This is an automated email from the ASF dual-hosted git repository. roryqi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit 116c974ad0c53f6eae38a1c7ca08ccbfaeac1ddc Author: remilu <[email protected]> AuthorDate: Mon Oct 26 07:55:09 2026 +0800 [#14020] feat(tms): Cron-pool submit pipeline and scheduler pool wiring Fixes #14020. Squashed from fork #77 (B7). --- .../scheduler/TableMaintenanceSchedulerPools.java | 136 +++++++-- .../scheduler/job/cron/CronSubmitGate.java | 34 +++ .../job/cron/MaintenanceJobSubmitter.java | 73 +++++ .../job/cron/MaintenanceSparkSubmitPipeline.java | 214 ++++++++++++++ .../job/cron/RelationalCronJobScheduler.java | 4 +- .../mapper/TableMaintenanceActionMapper.java | 5 + .../TableMaintenanceActionSQLProviderFactory.java | 10 + .../TableMaintenanceActionBaseSQLProvider.java | 16 + .../storage/service/OccupancyClaim.java | 3 +- .../service/TableMaintenanceActionMetaService.java | 22 ++ .../rest/feature/TableMaintenanceRESTFeature.java | 2 +- .../TestTableMaintenanceSchedulerPools.java | 7 +- .../cron/TestMaintenanceSparkSubmitPipeline.java | 321 +++++++++++++++++++++ .../TestTableMaintenanceActionMetaService.java | 30 ++ 14 files changed, 848 insertions(+), 29 deletions(-) diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java index 88582781e1..fc1140e922 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java @@ -24,13 +24,29 @@ import com.github.kagkarlsson.scheduler.task.Task; import com.github.kagkarlsson.scheduler.task.helper.Tasks; import com.google.common.base.Preconditions; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Supplier; import javax.sql.DataSource; +import org.apache.gravitino.Config; import org.apache.gravitino.json.JsonUtils; import org.apache.gravitino.maintenance.TableMaintenanceConfig; +import org.apache.gravitino.maintenance.occupancy.OccupancyGate; +import org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobConfBuilder; +import org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobSubmitter; +import org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceSparkSubmitPipeline; +import org.apache.gravitino.maintenance.scheduler.job.cron.PendingCronExpandOccupancyGate; +import org.apache.gravitino.maintenance.scheduler.job.cron.RelationalCronJobScheduler; +import org.apache.gravitino.maintenance.scheduler.policy.expand.NearestPolicyTableEnumerator; +import org.apache.gravitino.maintenance.scheduler.policy.expand.PolicyExpandPipeline; +import org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.gravitino.maintenance.scheduler.policy.expand.ExpandOccupancyGate; /** * Starts and stops three isolated db-scheduler pools (§5.2, §7.2), each with its own task table @@ -39,8 +55,9 @@ import org.slf4j.LoggerFactory; * <p>db-scheduler requires a dedicated {@code tableName} per Scheduler instance for multi-pool * isolation; sharing one physical table across pools is unsupported (see design §5.2). * - * <p>Cron enqueue uses {@link #cronJobTask()} with Jackson {@code task_data}; pick submit lands in - * a follow-up. + * <p>The expand pool fans a due {@code policy-expand} lease into {@code cron-job} rows with an + * enqueue-time {@link MaintenanceJobSpec}. The cron pool consumes that spec, claims a soft slot, + * and submits Spark. The commit pool still only removes its one-shot row. */ public class TableMaintenanceSchedulerPools implements AutoCloseable { @@ -64,6 +81,21 @@ public class TableMaintenanceSchedulerPools implements AutoCloseable { /** One-shot on-commit chained Spark submit ({@code task_name}). */ public static final String COMMIT_JOB = "commit-job"; + /** + * Expand pipeline supplier used by {@link #policyExpandTask()}. {@link #start} installs the + * relational implementation. + */ + private static final AtomicReference<Supplier<PolicyExpandPipeline>> EXPAND_PIPELINE = + new AtomicReference<>( + () -> { + throw new IllegalStateException("policy-expand pipeline is not configured"); + }); + + /** Cron submit handler used by {@link #cronJobTask()}; bound in {@link #start}. */ + static final AtomicReference<BiConsumer<String, MaintenanceJobSpec>> CRON_SUBMIT = + new AtomicReference<>(unconfiguredSubmit("cron-job")); + + private final List<Scheduler> schedulers = new ArrayList<>(); /** @@ -74,27 +106,43 @@ public class TableMaintenanceSchedulerPools implements AutoCloseable { * * @param dataSource relational JDBC pool shared with the entity store * @param config maintenance scheduler settings + * @param gravitinoConfig server config for {@code minIntervalMs} fallbacks and jobConf overlay */ - public void start(DataSource dataSource, TableMaintenanceConfig config) { + public void start(DataSource dataSource, TableMaintenanceConfig config, Config gravitinoConfig) { Preconditions.checkState(schedulers.isEmpty(), "scheduler pools already started"); Preconditions.checkNotNull(dataSource, "dataSource must not be null"); + Preconditions.checkNotNull(gravitinoConfig, "gravitinoConfig must not be null"); + + OccupancyGate occupancyGate = + new OccupancyGate(TableMaintenanceActionMetaService.getInstance(), config.claimTimeoutMs()); + ExpandOccupancyGate expandGate = + new PendingCronExpandOccupancyGate(occupancyGate::shouldSkip, dataSource); + PolicyExpandPipeline pipeline = + new PolicyExpandPipeline( + new NearestPolicyTableEnumerator(), + new RelationalCronJobScheduler(dataSource, gravitinoConfig), + expandGate, + gravitinoConfig, + config.expandEnqueueBatchSize()); + EXPAND_PIPELINE.set(() -> pipeline); + + MaintenanceSparkSubmitPipeline cronSubmit = + new MaintenanceSparkSubmitPipeline( + occupancyGate::isInFlight, + TableMaintenanceActionMetaService.getInstance(), + MaintenanceJobSubmitter.fromEnv(), + new MaintenanceJobConfBuilder(gravitinoConfig), + config.claimTimeoutMs()); + CRON_SUBMIT.set(cronSubmit::submit); List<Scheduler> built = List.of( buildScheduler( - dataSource, - config, - config.expandThreads(), - EXPAND_TASKS_TABLE, - noopTask(POLICY_EXPAND)), + dataSource, config, config.expandThreads(), EXPAND_TASKS_TABLE, policyExpandTask()), buildScheduler( dataSource, config, config.cronThreads(), CRON_TASKS_TABLE, cronJobTask()), buildScheduler( - dataSource, - config, - config.commitThreads(), - COMMIT_TASKS_TABLE, - noopTask(COMMIT_JOB))); + dataSource, config, config.commitThreads(), COMMIT_TASKS_TABLE, commitJobTask())); try { for (Scheduler scheduler : built) { @@ -118,6 +166,11 @@ public class TableMaintenanceSchedulerPools implements AutoCloseable { } } schedulers.clear(); + EXPAND_PIPELINE.set( + () -> { + throw new IllegalStateException("policy-expand pipeline is not configured"); + }); + CRON_SUBMIT.set(unconfiguredSubmit("cron-job")); LOG.info("Stopped table maintenance scheduler pools"); } @@ -145,24 +198,61 @@ public class TableMaintenanceSchedulerPools implements AutoCloseable { } /** - * Cron-pool task stub: removes the one-shot row after dequeue. Real submit lands in a follow-up. + * Expand-pool task definition used by the running expand scheduler. + * + * <p>On each due pick the handler expands the policy into {@code cron-job} rows and reschedules + * (or removes) the expand lease. + * + * @return the {@code policy-expand} task + */ + public static Task<Void> policyExpandTask() { + return Tasks.custom(POLICY_EXPAND, Void.class) + .execute( + (taskInstance, executionContext) -> { + long policyId = Long.parseLong(taskInstance.getId()); + Optional<Instant> nextDue = EXPAND_PIPELINE.get().get().expand(policyId); + return (executionComplete, executionOperations) -> { + if (nextDue.isEmpty()) { + executionOperations.remove(); + } else { + executionOperations.reschedule(executionComplete, nextDue.get()); + } + }; + }); + } + + /** + * Cron-pool task definition. Consumes the enqueue-time spec, claims a soft slot, submits Spark, + * then removes the one-shot row so a later expand pick can schedule again. * * @return the {@code cron-job} task */ public static Task<MaintenanceJobSpec> cronJobTask() { return Tasks.custom(CRON_JOB, MaintenanceJobSpec.class) .execute( - (taskInstance, executionContext) -> - (executionComplete, executionOperations) -> executionOperations.remove()); + (taskInstance, executionContext) -> { + try { + CRON_SUBMIT.get().accept(taskInstance.getId(), taskInstance.getData()); + } catch (RuntimeException e) { + LOG.warn( + "cron-job submit failed for instance {}; removing one-shot row", + taskInstance.getId(), + e); + } + return (executionComplete, executionOperations) -> executionOperations.remove(); + }); } - private static Task<Void> noopTask(String taskName) { - return Tasks.custom(taskName, Void.class) + + /** + * Commit-pool task definition. Removes the one-shot row. Chained submit lands in a follow-up. + * + * @return the {@code commit-job} task + */ + public static Task<MaintenanceJobSpec> commitJobTask() { + return Tasks.custom(COMMIT_JOB, MaintenanceJobSpec.class) .execute( - (taskInstance, executionContext) -> { - LOG.debug( - "TMS scheduler callback for {} instance {}", taskName, taskInstance.getId()); - return (executionComplete, executionOperations) -> {}; - }); + (taskInstance, executionContext) -> + (executionComplete, executionOperations) -> executionOperations.remove()); } } diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronSubmitGate.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronSubmitGate.java new file mode 100644 index 0000000000..e2b42a7d75 --- /dev/null +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronSubmitGate.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +/** + * In-flight check for cron-job pick. {@code minIntervalMs} was already applied at expand enqueue; + * pick only blocks on a live soft slot. + */ +@FunctionalInterface +public interface CronSubmitGate { + + /** + * @param tableId table entity id + * @param policyId policy entity id + * @return true when that policy still holds a soft slot for the table + */ + boolean isInFlight(long tableId, long policyId); +} diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobSubmitter.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobSubmitter.java new file mode 100644 index 0000000000..54525187cb --- /dev/null +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobSubmitter.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +import java.util.Map; +import org.apache.gravitino.GravitinoEnv; +import org.apache.gravitino.job.JobHandle; +import org.apache.gravitino.meta.JobEntity; + +/** + * Submits a Gravitino Job for a TMS cron-job callback (§5.2.2). + * + * <p>Production wiring uses {@link GravitinoEnv#jobOperationDispatcher()}; tests inject a fake. + */ +public interface MaintenanceJobSubmitter { + + /** + * Runs a job template and returns the persisted job-run id for occupancy bind. + * + * @param metalake metalake name + * @param jobTemplateName built-in or registered job template name + * @param jobConf runtime job configuration (policy jobOptions + auth overlay) + * @return {@link JobEntity#id()} + */ + long runJob(String metalake, String jobTemplateName, Map<String, String> jobConf); + + /** + * Best-effort cancel after a successful {@link #runJob} when occupancy bind fails. + * + * @param metalake metalake name + * @param jobId numeric Gravitino job run id + */ + void cancelJob(String metalake, long jobId); + + /** + * @return submitter that delegates to {@link GravitinoEnv#jobOperationDispatcher()} + */ + static MaintenanceJobSubmitter fromEnv() { + return new MaintenanceJobSubmitter() { + @Override + public long runJob(String metalake, String jobTemplateName, Map<String, String> jobConf) { + JobEntity job = + GravitinoEnv.getInstance() + .jobOperationDispatcher() + .runJob(metalake, jobTemplateName, jobConf); + return job.id(); + } + + @Override + public void cancelJob(String metalake, long jobId) { + GravitinoEnv.getInstance() + .jobOperationDispatcher() + .cancelJob(metalake, JobHandle.JOB_ID_PREFIX + jobId); + } + }; + } +} diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceSparkSubmitPipeline.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceSparkSubmitPipeline.java new file mode 100644 index 0000000000..10eabbb8b9 --- /dev/null +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceSparkSubmitPipeline.java @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +import com.google.common.base.Preconditions; +import java.util.Map; +import java.util.Optional; +import javax.annotation.Nullable; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.Operation; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.TableTarget; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpecFactory; +import org.apache.gravitino.maintenance.storage.service.OccupancyClaim; +import org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Cron-pool control-plane submit (§5.2.2): consume enqueue-time {@link MaintenanceJobSpec} → + * in-flight gate → soft-slot claim → {@code runJob} → bind job id. + * + * <p>Single-table {@code table:...} and snapshot-expiry {@code batch:...} rows share the same spec + * shape; batch pick overlays per-table keys and submits one Spark job per table. If any spec table + * is in-flight, the whole pick stops without partial submits (one-shot row is still removed by the + * caller). + * + * <p>Does not sample metrics or evaluate trigger-expr (both run inside Spark from enqueue-time + * {@code rules} in jobConf). Policy / jobConf are not recomputed here. + */ +public final class MaintenanceSparkSubmitPipeline { + + private static final Logger LOG = LoggerFactory.getLogger(MaintenanceSparkSubmitPipeline.class); + + private final CronSubmitGate occupancyGate; + private final TableMaintenanceActionMetaService actionMetaService; + private final MaintenanceJobSubmitter jobSubmitter; + private final MaintenanceJobConfBuilder jobConfBuilder; + private final long claimTimeoutMs; + + /** + * @param occupancyGate in-flight gate (minInterval already applied at enqueue) + * @param actionMetaService soft-slot claim / bind + * @param jobSubmitter Gravitino Job submitter + * @param jobConfBuilder resolves enqueue-time conf-key refs before {@code runJob} + * @param claimTimeoutMs unbound claim TTL + */ + public MaintenanceSparkSubmitPipeline( + CronSubmitGate occupancyGate, + TableMaintenanceActionMetaService actionMetaService, + MaintenanceJobSubmitter jobSubmitter, + MaintenanceJobConfBuilder jobConfBuilder, + long claimTimeoutMs) { + this.occupancyGate = Preconditions.checkNotNull(occupancyGate); + this.actionMetaService = Preconditions.checkNotNull(actionMetaService); + this.jobSubmitter = Preconditions.checkNotNull(jobSubmitter); + this.jobConfBuilder = Preconditions.checkNotNull(jobConfBuilder); + Preconditions.checkArgument(claimTimeoutMs > 0, "claimTimeoutMs must be positive"); + this.claimTimeoutMs = claimTimeoutMs; + } + + /** + * Runs one cron-job pick for {@code instanceId}. + * + * @param instanceId db-scheduler cron-job instance id + * @param spec enqueue-time payload; required + */ + public void submit(String instanceId, @Nullable MaintenanceJobSpec spec) { + if (spec == null || !spec.isValid()) { + LOG.warn("Skipping cron-job submit for {} with missing/invalid task_data spec", instanceId); + return; + } + + Optional<CronJobIds.TablePolicyIds> tableIds = CronJobIds.parseTable(instanceId); + Optional<CronJobIds.BatchPolicyIds> batchIds = CronJobIds.parseBatch(instanceId); + if (tableIds.isEmpty() && batchIds.isEmpty()) { + LOG.warn("Skipping cron-job submit for unparseable instance id {}", instanceId); + return; + } + + Operation step = spec.getOperations().get(0); + long policyId = step.getPolicyId(); + long expectedPolicyId = + tableIds.isPresent() ? tableIds.get().policyId() : batchIds.get().policyId(); + if (policyId != expectedPolicyId) { + LOG.warn( + "Skipping cron-job submit for {}: spec policyId {} != instance policyId {}", + instanceId, + policyId, + expectedPolicyId); + return; + } + if (tableIds.isPresent()) { + long expectedTableId = tableIds.get().tableId(); + if (spec.getTables().size() != 1 || spec.getTables().get(0).getTableId() != expectedTableId) { + LOG.warn( + "Skipping cron-job submit for {}: spec tables {} do not match instance tableId {}", + instanceId, + spec.tableIds(), + expectedTableId); + return; + } + } + + // minInterval was gated at expand enqueue; pick only blocks on in-flight. Any in-flight + // member aborts the whole pick so a batch row is not partially submitted then deleted. + for (TableTarget target : spec.getTables()) { + if (occupancyGate.isInFlight(target.getTableId(), policyId)) { + LOG.info( + "Skipping cron-job {} entirely (table {} in-flight for policy {})", + instanceId, + target.getTableId(), + policyId); + return; + } + } + for (TableTarget target : spec.getTables()) { + submitOne(spec, target, policyId); + } + } + + private void submitOne(MaintenanceJobSpec spec, TableTarget target, long policyId) { + Optional<OccupancyClaim> claim = + actionMetaService.tryAcquireInFlight(target.getTableId(), policyId, claimTimeoutMs); + if (claim.isEmpty()) { + LOG.info( + "Skipping cron-job submit for table {} policy {} (lost soft-slot race)", + target.getTableId(), + policyId); + return; + } + + Map<String, String> jobConf; + try { + jobConf = + jobConfBuilder.resolveForSubmit( + MaintenanceJobSpecFactory.jobConfForTable(spec.getJobConf(), target)); + } catch (RuntimeException e) { + releaseClaimQuietly(claim.get()); + LOG.warn( + "Skipping cron-job submit for table {} policy {}: invalid spec table target: {}", + target.getTableId(), + policyId, + e.toString()); + return; + } + runAndBind(spec.metalake(), jobConf, claim.get(), target.getTableId(), policyId); + } + + private void runAndBind( + String metalake, + Map<String, String> jobConf, + OccupancyClaim claim, + long tableId, + long policyId) { + Long jobId = null; + try { + jobId = + jobSubmitter.runJob( + metalake, MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE, jobConf); + actionMetaService.bindJobId(claim, jobId); + LOG.info("Submitted cron-job for table {} policy {} as job {}", tableId, policyId, jobId); + } catch (RuntimeException e) { + if (jobId != null) { + cancelJobQuietly(metalake, jobId); + } + releaseClaimQuietly(claim); + LOG.warn( + "Failed cron-job submit for table {} policy {}: {}", tableId, policyId, e.toString()); + } + } + + private void cancelJobQuietly(String metalake, long jobId) { + try { + jobSubmitter.cancelJob(metalake, jobId); + } catch (RuntimeException cancelError) { + LOG.warn( + "Failed to cancel orphaned cron Spark job {} under metalake {} after bind failure", + jobId, + metalake, + cancelError); + } + } + + private void releaseClaimQuietly(OccupancyClaim claim) { + try { + // Fence on (id, created_at) + unbound so a concurrent reclaim's live claim is not deleted. + if (!actionMetaService.deleteUnboundClaim(claim)) { + LOG.debug( + "Occupancy claim {} created_at={} was not released (reclaimed or already bound)", + claim.id(), + claim.createdAt()); + } + } catch (RuntimeException releaseError) { + LOG.warn( + "Failed to release occupancy claim {} after submit failure", claim.id(), releaseError); + } + } +} diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/RelationalCronJobScheduler.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/RelationalCronJobScheduler.java index 1fd173ec0d..863025715c 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/RelationalCronJobScheduler.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/RelationalCronJobScheduler.java @@ -311,9 +311,7 @@ public final class RelationalCronJobScheduler implements CronJobScheduler { } MaintenanceJobSpec merged = new MaintenanceJobSpec( - new ArrayList<>(byId.values()), - open.spec.getOperations(), - open.spec.getJobConf()); + new ArrayList<>(byId.values()), open.spec.getOperations(), open.spec.getJobConf()); try { return client.reschedule(open.instanceId, open.executionTime, merged); } catch (RuntimeException e) { diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java index c7313611ee..0453d0cd0a 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java @@ -122,6 +122,11 @@ public interface TableMaintenanceActionMapper { @DeleteProvider(type = TableMaintenanceActionSQLProviderFactory.class, method = "deleteById") int deleteById(@Param("id") long id); + @DeleteProvider( + type = TableMaintenanceActionSQLProviderFactory.class, + method = "deleteUnboundClaim") + int deleteUnboundClaim(@Param("id") long id, @Param("expectedCreatedAt") long expectedCreatedAt); + @DeleteProvider(type = TableMaintenanceActionSQLProviderFactory.class, method = "deleteByJobId") int deleteByJobId(@Param("jobId") long jobId); diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java index 784da61e70..f4a0aaf3fe 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java @@ -207,6 +207,16 @@ public class TableMaintenanceActionSQLProviderFactory { return getProvider().deleteById(id); } + /** + * @param id action id + * @param expectedCreatedAt claim lease start + * @return DELETE statement + */ + public static String deleteUnboundClaim( + @Param("id") long id, @Param("expectedCreatedAt") long expectedCreatedAt) { + return getProvider().deleteUnboundClaim(id, expectedCreatedAt); + } + /** * @param jobId job run id * @return DELETE statement diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java index a18f685fbd..e29ca6c454 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java @@ -258,6 +258,22 @@ public class TableMaintenanceActionBaseSQLProvider { return "DELETE FROM " + TABLE_NAME + " WHERE id = #{id}"; } + /** + * Deletes an unbound claim only when the lease {@code created_at} still matches. A concurrent + * reclaim that CAS-updated {@code created_at} leaves the new owner's row intact. + * + * @param id action row id + * @param expectedCreatedAt claim lease start observed at acquire time + * @return DELETE statement + */ + public String deleteUnboundClaim( + @Param("id") long id, @Param("expectedCreatedAt") long expectedCreatedAt) { + return "DELETE FROM " + + TABLE_NAME + + " WHERE id = #{id} AND job_id IS NULL AND finished_at = 0" + + " AND created_at = #{expectedCreatedAt}"; + } + /** * @param jobId Gravitino job run id * @return DELETE statement diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/OccupancyClaim.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/OccupancyClaim.java index adc4f5bcdd..5f927329f2 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/OccupancyClaim.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/OccupancyClaim.java @@ -24,7 +24,8 @@ import java.util.Objects; * Immutable occupancy claim returned by acquire. * * <p>Callers must pass {@link #createdAt()} to {@link TableMaintenanceActionMetaService#bindJobId} - * without re-reading {@code created_at} from the row. + * and {@link TableMaintenanceActionMetaService#deleteUnboundClaim} without re-reading {@code + * created_at} from the row. * * <p>{@link #id()} is the durable claim token for OccupancyGate follow-ups: make {@code runJob} * idempotent on this id (or outbox / reconcile-before-reclaim). Binding {@code job_id} alone cannot diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java index 454243bb10..f73ebfd59c 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java @@ -429,6 +429,28 @@ public class TableMaintenanceActionMetaService { SessionUtils.doWithCommit(TableMaintenanceActionMapper.class, mapper -> mapper.deleteById(id)); } + /** + * Releases an unbound occupancy claim when submit / bind fails. + * + * <p>Deletes only when {@code (id, created_at)} still match and the row is unbound. A concurrent + * reclaim that CAS-updated {@code created_at} leaves the new owner's live claim intact (returns + * {@code false}). + * + * @param claim claim returned by {@link #tryAcquireInFlight} / {@link #insertInFlight} + * @return {@code true} when this claim lease was deleted + */ + @Monitored( + metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME, + baseMetricName = "deleteUnboundTableMaintenanceActionClaim") + public boolean deleteUnboundClaim(OccupancyClaim claim) { + Preconditions.checkNotNull(claim, "claim must not be null"); + int deleted = + SessionUtils.doWithCommitAndFetchResult( + TableMaintenanceActionMapper.class, + mapper -> mapper.deleteUnboundClaim(claim.id(), claim.createdAt())); + return deleted == 1; + } + /** * Deletes the {@code table_maintenance_action} row keyed by job run id. Does not delete metrics. * diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/web/rest/feature/TableMaintenanceRESTFeature.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/web/rest/feature/TableMaintenanceRESTFeature.java index 1b9473553b..0930ed3d61 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/web/rest/feature/TableMaintenanceRESTFeature.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/web/rest/feature/TableMaintenanceRESTFeature.java @@ -86,7 +86,7 @@ public class TableMaintenanceRESTFeature implements Feature { DataSource dataSource = resolveEntityStoreDataSource(); TableMaintenanceSchedulerPools pools = new TableMaintenanceSchedulerPools(); - pools.start(dataSource, TableMaintenanceConfig.from(config)); + pools.start(dataSource, TableMaintenanceConfig.from(config), config); if (!SCHEDULER_POOLS.compareAndSet(null, pools)) { pools.stop(); LOG.warn("Table maintenance scheduler pools were started concurrently; discarded duplicate"); diff --git a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestTableMaintenanceSchedulerPools.java b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestTableMaintenanceSchedulerPools.java index f654612bbb..ce90bf342b 100644 --- a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestTableMaintenanceSchedulerPools.java +++ b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestTableMaintenanceSchedulerPools.java @@ -20,6 +20,7 @@ package org.apache.gravitino.maintenance.scheduler; import javax.sql.DataSource; +import org.apache.gravitino.Config; import org.apache.gravitino.maintenance.TableMaintenanceConfig; import org.apache.gravitino.storage.relational.TestJDBCBackend; import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper; @@ -47,9 +48,13 @@ class TestTableMaintenanceSchedulerPools extends TestJDBCBackend { .getEnvironment() .getDataSource(); pools = new TableMaintenanceSchedulerPools(); + Config gravitinoConfig = new Config(false) {}; Assertions.assertDoesNotThrow( () -> - pools.start(dataSource, TableMaintenanceConfig.forTest(1, 1, 1, 500L, 1_000L, 4, 10))); + pools.start( + dataSource, + TableMaintenanceConfig.forTest(1, 1, 1, 500L, 1_000L, 4, 10), + gravitinoConfig)); Assertions.assertDoesNotThrow(pools::stop); } } diff --git a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceSparkSubmitPipeline.java b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceSparkSubmitPipeline.java new file mode 100644 index 0000000000..ff9867772e --- /dev/null +++ b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceSparkSubmitPipeline.java @@ -0,0 +1,321 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.apache.gravitino.Config; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.Operation; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.TableTarget; +import org.apache.gravitino.maintenance.storage.service.OccupancyClaim; +import org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class TestMaintenanceSparkSubmitPipeline { + + private static final long TABLE_ID = 101L; + private static final long POLICY_ID = 202L; + private static final String INSTANCE = CronJobIds.forTable(TABLE_ID, POLICY_ID); + private static final long CLAIM_TIMEOUT_MS = 60_000L; + + @Test + void testUnparseableInstanceSkipsSubmit() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + pipeline(mockGate(false), actions, submitter).submit("not-a-cron-id", spec()); + verify(submitter, never()).runJob(anyString(), anyString(), anyMap()); + verify(actions, never()).tryAcquireInFlight(anyLong(), anyLong(), anyLong()); + } + + @Test + void testNullSpecSkipsSubmit() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + pipeline(mockGate(false), actions, submitter).submit(INSTANCE, null); + verify(submitter, never()).runJob(anyString(), anyString(), anyMap()); + } + + @Test + void testInFlightDoesNotClaimOrSubmit() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + pipeline(mockGate(true), actions, submitter).submit(INSTANCE, spec()); + verify(actions, never()).tryAcquireInFlight(anyLong(), anyLong(), anyLong()); + verify(submitter, never()).runJob(anyString(), anyString(), anyMap()); + } + + @Test + void testBatchInFlightAbortsEntirePick() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + CronSubmitGate gate = (tableId, policyId) -> tableId == 11L; + + Map<String, String> shared = new LinkedHashMap<>(); + shared.put(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY, "expire_snapshots"); + shared.put("updater_options", "{\"gravitino_uri\":\"http://localhost:8090\"}"); + MaintenanceJobSpec batch = + new MaintenanceJobSpec( + List.of( + new TableTarget(10L, List.of("metalake", "iceberg", "db", "t1")), + new TableTarget(11L, List.of("metalake", "iceberg", "db", "t2"))), + List.of(new Operation(POLICY_ID, "expire_snapshots")), + shared); + + pipeline(gate, actions, submitter).submit(CronJobIds.forBatch(0L, POLICY_ID), batch); + + verify(actions, never()).tryAcquireInFlight(anyLong(), anyLong(), anyLong()); + verify(submitter, never()).runJob(anyString(), anyString(), anyMap()); + } + + @Test + void testLostClaimRaceSkipsSubmit() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + when(actions.tryAcquireInFlight(TABLE_ID, POLICY_ID, CLAIM_TIMEOUT_MS)) + .thenReturn(Optional.empty()); + + pipeline(mockGate(false), actions, submitter).submit(INSTANCE, spec()); + + verify(submitter, never()).runJob(anyString(), anyString(), anyMap()); + } + + @Test + void testSuccessfulSubmitBindsJobId() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + when(submitter.runJob( + eq("metalake"), eq(MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE), anyMap())) + .thenReturn(999L); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + OccupancyClaim claim = new OccupancyClaim(7L, 1_000L); + when(actions.tryAcquireInFlight(TABLE_ID, POLICY_ID, CLAIM_TIMEOUT_MS)) + .thenReturn(Optional.of(claim)); + + pipeline(mockGate(false), actions, submitter).submit(INSTANCE, spec()); + + verify(actions).bindJobId(claim, 999L); + verify(actions, never()).deleteUnboundClaim(any()); + } + + @Test + void testSubmitFailureReleasesClaim() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + when(submitter.runJob(anyString(), anyString(), anyMap())) + .thenThrow(new RuntimeException("submit failed")); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + OccupancyClaim claim = new OccupancyClaim(8L, 2_000L); + when(actions.tryAcquireInFlight(TABLE_ID, POLICY_ID, CLAIM_TIMEOUT_MS)) + .thenReturn(Optional.of(claim)); + when(actions.deleteUnboundClaim(claim)).thenReturn(true); + + Assertions.assertDoesNotThrow( + () -> pipeline(mockGate(false), actions, submitter).submit(INSTANCE, spec())); + + verify(actions).deleteUnboundClaim(claim); + verify(actions, never()).bindJobId(any(), anyLong()); + verify(submitter, never()).cancelJob(anyString(), anyLong()); + } + + @Test + void testBindFailureCancelsJobAndReleasesClaim() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + when(submitter.runJob( + eq("metalake"), eq(MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE), anyMap())) + .thenReturn(777L); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + OccupancyClaim claim = new OccupancyClaim(9L, 3_000L); + when(actions.tryAcquireInFlight(TABLE_ID, POLICY_ID, CLAIM_TIMEOUT_MS)) + .thenReturn(Optional.of(claim)); + doThrow(new RuntimeException("bind failed")).when(actions).bindJobId(claim, 777L); + when(actions.deleteUnboundClaim(claim)).thenReturn(true); + + Assertions.assertDoesNotThrow( + () -> pipeline(mockGate(false), actions, submitter).submit(INSTANCE, spec())); + + verify(submitter).cancelJob("metalake", 777L); + verify(actions).deleteUnboundClaim(claim); + } + + @Test + void testBindFailureSkipsReleaseWhenClaimAlreadyReclaimed() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + when(submitter.runJob( + eq("metalake"), eq(MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE), anyMap())) + .thenReturn(778L); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + OccupancyClaim staleClaim = new OccupancyClaim(10L, 4_000L); + when(actions.tryAcquireInFlight(TABLE_ID, POLICY_ID, CLAIM_TIMEOUT_MS)) + .thenReturn(Optional.of(staleClaim)); + doThrow(new RuntimeException("bind lost lease")).when(actions).bindJobId(staleClaim, 778L); + // Concurrent reclaim already moved created_at — fenced delete matches zero rows. + when(actions.deleteUnboundClaim(staleClaim)).thenReturn(false); + + Assertions.assertDoesNotThrow( + () -> pipeline(mockGate(false), actions, submitter).submit(INSTANCE, spec())); + + verify(submitter).cancelJob("metalake", 778L); + verify(actions).deleteUnboundClaim(staleClaim); + verify(actions, never()).deleteById(anyLong()); + } + + @Test + void testBatchSubmitOneSparkJobPerTable() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + when(submitter.runJob( + eq("metalake"), eq(MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE), anyMap())) + .thenReturn(555L, 556L); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + OccupancyClaim claimA = new OccupancyClaim(1L, 10L); + OccupancyClaim claimB = new OccupancyClaim(2L, 20L); + when(actions.tryAcquireInFlight(10L, POLICY_ID, CLAIM_TIMEOUT_MS)) + .thenReturn(Optional.of(claimA)); + when(actions.tryAcquireInFlight(11L, POLICY_ID, CLAIM_TIMEOUT_MS)) + .thenReturn(Optional.of(claimB)); + + Map<String, String> shared = new LinkedHashMap<>(); + shared.put(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY, "expire_snapshots"); + shared.put("updater_options", "{\"gravitino_uri\":\"http://localhost:8090\"}"); + + MaintenanceJobSpec batch = + new MaintenanceJobSpec( + List.of( + new TableTarget(10L, List.of("metalake", "iceberg", "db", "t1")), + new TableTarget(11L, List.of("metalake", "iceberg", "db", "t2"))), + List.of(new Operation(POLICY_ID, "expire_snapshots")), + shared); + + pipeline(mockGate(false), actions, submitter).submit(CronJobIds.forBatch(0L, POLICY_ID), batch); + + @SuppressWarnings("unchecked") + ArgumentCaptor<Map<String, String>> confCaptor = ArgumentCaptor.forClass(Map.class); + verify(submitter, times(2)) + .runJob( + eq("metalake"), + eq(MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE), + confCaptor.capture()); + List<Map<String, String>> confs = confCaptor.getAllValues(); + Assertions.assertEquals("iceberg", confs.get(0).get("catalog_name")); + Assertions.assertNull(confs.get(0).get("table_identifier")); + Assertions.assertTrue(confs.get(0).get("tables").contains("\"table_identifier\":\"db.t1\"")); + Assertions.assertTrue(confs.get(0).get("tables").contains("\"table_id\":10")); + Assertions.assertFalse(confs.get(0).containsKey("tms_table_id")); + Assertions.assertEquals("expire_snapshots", confs.get(0).get("maintenance_types")); + Assertions.assertEquals("iceberg", confs.get(1).get("catalog_name")); + Assertions.assertNull(confs.get(1).get("table_identifier")); + Assertions.assertTrue(confs.get(1).get("tables").contains("\"table_identifier\":\"db.t2\"")); + Assertions.assertTrue(confs.get(1).get("tables").contains("\"table_id\":11")); + Assertions.assertFalse(confs.get(1).containsKey("tms_table_id")); + Assertions.assertFalse(confs.get(0).containsKey("tms_policy_id")); + verify(actions).bindJobId(claimA, 555L); + verify(actions).bindJobId(claimB, 556L); + } + + @Test + void testConsumesSpecJobConfWithoutRebuild() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + when(submitter.runJob(anyString(), anyString(), anyMap())).thenReturn(1L); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + when(actions.tryAcquireInFlight(TABLE_ID, POLICY_ID, CLAIM_TIMEOUT_MS)) + .thenReturn(Optional.of(new OccupancyClaim(1L, 1L))); + + Map<String, String> jobConf = new LinkedHashMap<>(); + jobConf.put(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY, "rewrite_data_files"); + jobConf.put("strategy", "binpack"); + jobConf.put( + "updater_options", + "{\"gravitino_uri\":\"http://localhost:8090\",\"metalake\":\"metalake\"}"); + jobConf.put("spark_conf", "{\"spark.sql.catalog.iceberg.rest.auth.type\":\"basic\"}"); + + pipeline(mockGate(false), actions, submitter).submit(INSTANCE, spec(jobConf)); + + @SuppressWarnings("unchecked") + ArgumentCaptor<Map<String, String>> confCaptor = ArgumentCaptor.forClass(Map.class); + verify(submitter) + .runJob( + eq("metalake"), + eq(MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE), + confCaptor.capture()); + Map<String, String> conf = confCaptor.getValue(); + Assertions.assertEquals("iceberg", conf.get("catalog_name")); + Assertions.assertNull(conf.get("table_identifier")); + Assertions.assertTrue(conf.get("tables").contains("\"table_identifier\":\"db.t\"")); + Assertions.assertTrue(conf.get("tables").contains("\"table_id\":" + TABLE_ID)); + Assertions.assertFalse(conf.containsKey("tms_table_id")); + Assertions.assertEquals("rewrite_data_files", conf.get("maintenance_types")); + Assertions.assertFalse(conf.containsKey("tms_policy_id")); + Assertions.assertEquals("binpack", conf.get("strategy")); + Assertions.assertTrue(conf.get("updater_options").contains("gravitino_uri")); + Assertions.assertTrue(conf.get("spark_conf").contains("basic")); + } + + @Test + void testPolicyIdMismatchSkipsSubmit() { + MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class); + TableMaintenanceActionMetaService actions = mock(TableMaintenanceActionMetaService.class); + pipeline(mockGate(false), actions, submitter) + .submit(CronJobIds.forTable(TABLE_ID, POLICY_ID + 1), spec()); + verify(submitter, never()).runJob(anyString(), anyString(), anyMap()); + } + + private static MaintenanceSparkSubmitPipeline pipeline( + CronSubmitGate gate, + TableMaintenanceActionMetaService actions, + MaintenanceJobSubmitter submitter) { + return new MaintenanceSparkSubmitPipeline( + gate, + actions, + submitter, + new MaintenanceJobConfBuilder(new Config(false) {}), + CLAIM_TIMEOUT_MS); + } + + private static CronSubmitGate mockGate(boolean inFlight) { + return (tableId, policyId) -> inFlight; + } + + private static MaintenanceJobSpec spec() { + Map<String, String> jobConf = new LinkedHashMap<>(); + jobConf.put(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY, "rewrite_data_files"); + jobConf.put("updater_options", "{\"gravitino_uri\":\"http://localhost:8090\"}"); + return spec(jobConf); + } + + private static MaintenanceJobSpec spec(Map<String, String> jobConf) { + return new MaintenanceJobSpec( + List.of(new TableTarget(TABLE_ID, List.of("metalake", "iceberg", "db", "t"))), + List.of(new Operation(POLICY_ID, "rewrite_data_files")), + jobConf); + } +} diff --git a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java index ec54a63786..c5c694ae7b 100644 --- a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java +++ b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java @@ -259,4 +259,34 @@ class TestTableMaintenanceActionMetaService extends TestJDBCBackend { actions.bindJobId(reclaimed.get(), 1002L); Assertions.assertEquals(1002L, actions.getById(reclaimed.get().id()).jobId()); } + + @TestTemplate + void testDeleteUnboundClaimFencesAgainstReclaim() throws Exception { + TableMaintenanceActionMetaService actions = TableMaintenanceActionMetaService.getInstance(); + long tableId = 12L; + long policyId = 22L; + long timeoutMs = 200L; + + Optional<OccupancyClaim> first = actions.tryAcquireInFlight(tableId, policyId, timeoutMs); + Assertions.assertTrue(first.isPresent()); + Assertions.assertTrue(actions.deleteUnboundClaim(first.get())); + Assertions.assertNull(actions.getById(first.get().id())); + + Optional<OccupancyClaim> second = actions.tryAcquireInFlight(tableId, policyId, timeoutMs); + Assertions.assertTrue(second.isPresent()); + Thread.sleep(timeoutMs + 50L); + Optional<OccupancyClaim> reclaimed = actions.tryAcquireInFlight(tableId, policyId, timeoutMs); + Assertions.assertTrue(reclaimed.isPresent()); + Assertions.assertEquals(second.get().id(), reclaimed.get().id()); + Assertions.assertTrue(reclaimed.get().createdAt() >= second.get().createdAt()); + + // Stale submitter cleanup must not delete the reclaimed live claim. + Assertions.assertFalse(actions.deleteUnboundClaim(second.get())); + Assertions.assertNotNull(actions.getById(reclaimed.get().id())); + Assertions.assertNull(actions.getById(reclaimed.get().id()).jobId()); + Assertions.assertEquals(0L, actions.getById(reclaimed.get().id()).finishedAt()); + + Assertions.assertTrue(actions.deleteUnboundClaim(reclaimed.get())); + Assertions.assertNull(actions.getById(reclaimed.get().id())); + } }
