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
The following commit(s) were added to refs/heads/main by this push:
new 189ba4dfcd [#13777] feat(tms): In-job S0 gates, OccupancyGate, and
REST Feature lifecycle (#13841)
189ba4dfcd is described below
commit 189ba4dfcd53936893c3f6b7972eae79b26cdca6
Author: MaSai <[email protected]>
AuthorDate: Thu Oct 15 10:10:09 2026 +0800
[#13777] feat(tms): In-job S0 gates, OccupancyGate, and REST Feature
lifecycle (#13841)
### What changes were proposed in this pull request?
Combines in-job gates and OccupancyGate / REST Feature work on top of
merged [#13774](https://github.com/apache/gravitino/pull/13774)
(formerly tracked as #13816, folded into #13777):
**In-job gates**
- Package `maintenance.injob`: S0 sample → snapshot-drift → recommend
gates
- `TableMaintenanceInJobSampler` / `TableMaintenanceInJobPipeline`
(`gateCronJob` / `gateCommitChainType`)
- `InJobRecommender` + `RecommendDecision` (injected in tests; no
production placeholder recommender)
- Persist sampled metrics via idempotent `insertMetrics`
(first-writer-wins on unique race) and set `before_snapshot_id` (same-S0
retry safe)
- Unit tests bind soft-slot rows with `OccupancyClaim` (`insertInFlight`
+ `bindJobId`)
**OccupancyGate + REST Feature**
- `OccupancyGate` over soft-slot occupancy: unbound TTL evaluated on the
**database** clock (`hasLiveUnboundClaim`); bound rows consult
`JobMetaService` with no `finished_at` backfill
- `TableMaintenanceRESTFeature` starts the three db-scheduler pools when
the TMS Jersey extension package is enabled, and stops them on Jersey
`DESTROY_FINISHED` (before entity-store `DataSource` close);
configure-after-stop re-registers the destroy listener
- Jersey / `server-common` dependencies for the Feature SPI
- Unit tests for `OccupancyGate` (including DB-clock unbound expiry) and
Feature destroy listener
**Out of scope:** Four maintenance type executors, OCC commit-chain,
PolicyExpand / Spark submit / IRC listener pipelines.
### Why are the changes needed?
In-job S0/drift/recommend gates and control-plane occupancy / Feature
lifecycle for Epic #13760.
Fix: #13777
### Does this PR introduce _any_ user-facing change?
No public REST resources yet. Enabling the TMS extension package starts
in-process scheduler pools via `TableMaintenanceRESTFeature`.
### How was this patch tested?
```bash
./gradlew :plugins:tms:test --tests
'org.apache.gravitino.maintenance.injob.*' \
--tests 'org.apache.gravitino.maintenance.occupancy.*' \
--tests 'org.apache.gravitino.maintenance.web.rest.feature.*' \
--tests 'org.apache.gravitino.maintenance.storage.service.*' -PskipITs
```
---------
Co-authored-by: Cursor <[email protected]>
---
plugins/tms/build.gradle.kts | 4 +
.../maintenance/injob/InJobGateOutcome.java | 99 +++++++++
.../maintenance/injob/InJobRecommender.java | 39 ++++
.../maintenance/injob/RecommendDecision.java | 33 +++
.../gravitino/maintenance/injob/SnapshotHead.java | 33 +++
.../injob/TableMaintenanceInJobPipeline.java | 105 ++++++++++
.../injob/TableMaintenanceInJobSampler.java | 105 ++++++++++
.../maintenance/occupancy/OccupancyGate.java | 183 +++++++++++++++++
.../TableMaintenanceActionBaseSQLProvider.java | 3 +-
.../service/TableMaintenanceActionMetaService.java | 43 +++-
.../service/TableSnapshotMetricsMetaService.java | 32 ++-
.../rest/feature/TableMaintenanceRESTFeature.java | 130 ++++++++++++
.../injob/TestTableMaintenanceInJobPipeline.java | 99 +++++++++
.../injob/TestTableMaintenanceInJobSampler.java | 107 ++++++++++
.../maintenance/occupancy/TestOccupancyGate.java | 221 +++++++++++++++++++++
.../TestTableMaintenanceActionMetaService.java | 3 +
.../feature/TestTableMaintenanceRESTFeature.java | 40 ++++
17 files changed, 1272 insertions(+), 7 deletions(-)
diff --git a/plugins/tms/build.gradle.kts b/plugins/tms/build.gradle.kts
index 835eb0fea6..b12be69734 100644
--- a/plugins/tms/build.gradle.kts
+++ b/plugins/tms/build.gradle.kts
@@ -29,10 +29,13 @@ dependencies {
implementation(project(":api"))
implementation(project(":common"))
implementation(project(":core"))
+ implementation(project(":server-common"))
+ implementation(libs.bundles.jersey)
implementation(libs.db.scheduler)
implementation(libs.guava)
implementation(libs.mybatis)
+ implementation(libs.servlet)
compileOnly(libs.lombok)
compileOnly(libs.slf4j.api)
@@ -41,6 +44,7 @@ dependencies {
testImplementation(project(":core", "testArtifacts"))
testImplementation(project(":integration-test-common", "testArtifacts"))
testImplementation(project(":server"))
+ testImplementation(project(":server-common"))
testImplementation(libs.junit.jupiter.api)
testImplementation(libs.junit.jupiter.params)
testImplementation(libs.mockito.core)
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/InJobGateOutcome.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/InJobGateOutcome.java
new file mode 100644
index 0000000000..61cb521881
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/InJobGateOutcome.java
@@ -0,0 +1,99 @@
+/*
+ * 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.injob;
+
+import com.google.common.base.Preconditions;
+import javax.annotation.Nullable;
+
+/** Result of the in-job sample / drift / recommend gates (§5.2.2). */
+public final class InJobGateOutcome {
+
+ /** Why maintenance should not continue. */
+ public enum StopReason {
+ /** HEAD snapshot id differs from {@code S0}; do not re-sample in a loop.
*/
+ SNAPSHOT_DRIFT,
+ /** Recommender decided not to maintain; end early without running work. */
+ RECOMMENDER_SKIP
+ }
+
+ private final boolean continueMaintenance;
+ @Nullable private final StopReason stopReason;
+ private final long sampledSnapshotId;
+ private final long headSnapshotId;
+
+ private InJobGateOutcome(
+ boolean continueMaintenance,
+ @Nullable StopReason stopReason,
+ long sampledSnapshotId,
+ long headSnapshotId) {
+ this.continueMaintenance = continueMaintenance;
+ this.stopReason = stopReason;
+ this.sampledSnapshotId = sampledSnapshotId;
+ this.headSnapshotId = headSnapshotId;
+ }
+
+ /**
+ * @param sampledSnapshotId {@code S0}
+ * @return outcome when maintenance may proceed
+ */
+ public static InJobGateOutcome continueMaintenance(long sampledSnapshotId) {
+ return new InJobGateOutcome(true, null, sampledSnapshotId,
sampledSnapshotId);
+ }
+
+ /**
+ * @param reason why the job or type should stop
+ * @param sampledSnapshotId {@code S0}
+ * @param headSnapshotId current HEAD when stopping
+ * @return outcome when maintenance must not continue
+ */
+ public static InJobGateOutcome stop(
+ StopReason reason, long sampledSnapshotId, long headSnapshotId) {
+ Preconditions.checkNotNull(reason);
+ return new InJobGateOutcome(false, reason, sampledSnapshotId,
headSnapshotId);
+ }
+
+ /**
+ * @return whether the Spark Job should run work for this type
+ */
+ public boolean continueMaintenance() {
+ return continueMaintenance;
+ }
+
+ /**
+ * @return stop reason when {@link #continueMaintenance()} is false
+ */
+ @Nullable
+ public StopReason stopReason() {
+ return stopReason;
+ }
+
+ /**
+ * @return sampled snapshot id ({@code S0})
+ */
+ public long sampledSnapshotId() {
+ return sampledSnapshotId;
+ }
+
+ /**
+ * @return HEAD snapshot id observed for the gate
+ */
+ public long headSnapshotId() {
+ return headSnapshotId;
+ }
+}
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/InJobRecommender.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/InJobRecommender.java
new file mode 100644
index 0000000000..dd05f934e7
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/InJobRecommender.java
@@ -0,0 +1,39 @@
+/*
+ * 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.injob;
+
+/**
+ * Decides whether maintenance should run after metrics were sampled at {@code
S0}.
+ *
+ * <p>Production implementations come with the four maintenance executors;
callers inject the
+ * strategy for the current type.
+ */
+@FunctionalInterface
+public interface InJobRecommender {
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param sampledSnapshotId Iceberg snapshot id at sample time
+ * @param metricsJson serialized metrics for that snapshot
+ * @return maintain or skip
+ */
+ RecommendDecision recommend(
+ long tableId, long policyId, long sampledSnapshotId, String metricsJson);
+}
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/RecommendDecision.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/RecommendDecision.java
new file mode 100644
index 0000000000..eff0d58650
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/RecommendDecision.java
@@ -0,0 +1,33 @@
+/*
+ * 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.injob;
+
+/**
+ * Whether the in-job path should run maintenance work after a successful
{@code S0} sample
+ * (§5.2.2).
+ *
+ * <p>Type-specific strategy recommenders are wired in a follow-up subtask;
this enum only models
+ * the gate outcome.
+ */
+public enum RecommendDecision {
+ /** Proceed to execute maintenance work. */
+ MAINTAIN,
+ /** End this job or type early; do not run maintenance work. */
+ SKIP
+}
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/SnapshotHead.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/SnapshotHead.java
new file mode 100644
index 0000000000..04e6f089b8
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/SnapshotHead.java
@@ -0,0 +1,33 @@
+/*
+ * 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.injob;
+
+/**
+ * Supplies the Iceberg table HEAD snapshot id from inside a Spark Job (§4.3,
§5.2.2).
+ *
+ * <p>Implementations typically delegate to {@code Table#currentSnapshot()}.
+ */
+@FunctionalInterface
+public interface SnapshotHead {
+
+ /**
+ * @return current Iceberg snapshot id at table HEAD
+ */
+ long currentSnapshotId();
+}
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/TableMaintenanceInJobPipeline.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/TableMaintenanceInJobPipeline.java
new file mode 100644
index 0000000000..9cef15ca4e
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/TableMaintenanceInJobPipeline.java
@@ -0,0 +1,105 @@
+/*
+ * 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.injob;
+
+import com.google.common.base.Preconditions;
+
+/**
+ * Cron / commit-chain in-job gates: sample at {@code S0}, stop on snapshot
drift, then apply the
+ * recommend maintain? gate (§5.2.2). Does not execute the four maintenance
types.
+ */
+public class TableMaintenanceInJobPipeline {
+
+ private final TableMaintenanceInJobSampler sampler;
+ private final InJobRecommender recommender;
+
+ /**
+ * @param sampler S0 sample + drift gate
+ * @param recommender maintain vs skip after a clean sample
+ */
+ public TableMaintenanceInJobPipeline(
+ TableMaintenanceInJobSampler sampler, InJobRecommender recommender) {
+ this.sampler = Preconditions.checkNotNull(sampler);
+ this.recommender = Preconditions.checkNotNull(recommender);
+ }
+
+ /**
+ * Runs sample → drift → recommend for a cron Spark Job.
+ *
+ * @param jobId Gravitino {@code job_run_meta.job_run_id}
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param sampledSnapshotId {@code S0}
+ * @param metricsJson metrics payload
+ * @param head current HEAD supplier after persistence
+ * @return continue only when HEAD matches {@code S0} and recommender says
maintain
+ */
+ public InJobGateOutcome gateCronJob(
+ long jobId,
+ long tableId,
+ long policyId,
+ long sampledSnapshotId,
+ String metricsJson,
+ SnapshotHead head) {
+ InJobGateOutcome afterSample =
+ sampler.sampleAndGateCronJob(
+ jobId, tableId, policyId, sampledSnapshotId, metricsJson, head);
+ if (!afterSample.continueMaintenance()) {
+ return afterSample;
+ }
+
+ RecommendDecision decision =
+ recommender.recommend(tableId, policyId, sampledSnapshotId,
metricsJson);
+ if (decision == RecommendDecision.SKIP) {
+ return InJobGateOutcome.stop(
+ InJobGateOutcome.StopReason.RECOMMENDER_SKIP,
+ sampledSnapshotId,
+ afterSample.headSnapshotId());
+ }
+ return afterSample;
+ }
+
+ /**
+ * Drift then recommend for one type inside a commit-chain job (§5.2.3).
Caller ends only the
+ * current type on stop; does not re-sample.
+ *
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param sampledSnapshotId {@code S0} for this type
+ * @param metricsJson metrics for this type's sample
+ * @param head current HEAD after this type's sample persistence
+ * @return continue vs stop for this type
+ */
+ public InJobGateOutcome gateCommitChainType(
+ long tableId, long policyId, long sampledSnapshotId, String metricsJson,
SnapshotHead head) {
+ InJobGateOutcome afterDrift =
sampler.gateCommitChainType(sampledSnapshotId, head);
+ if (!afterDrift.continueMaintenance()) {
+ return afterDrift;
+ }
+ RecommendDecision decision =
+ recommender.recommend(tableId, policyId, sampledSnapshotId,
metricsJson);
+ if (decision == RecommendDecision.SKIP) {
+ return InJobGateOutcome.stop(
+ InJobGateOutcome.StopReason.RECOMMENDER_SKIP,
+ sampledSnapshotId,
+ afterDrift.headSnapshotId());
+ }
+ return afterDrift;
+ }
+}
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/TableMaintenanceInJobSampler.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/TableMaintenanceInJobSampler.java
new file mode 100644
index 0000000000..9c96638434
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/injob/TableMaintenanceInJobSampler.java
@@ -0,0 +1,105 @@
+/*
+ * 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.injob;
+
+import com.google.common.base.Preconditions;
+import
org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService;
+import
org.apache.gravitino.maintenance.storage.service.TableSnapshotMetricsMetaService;
+
+/**
+ * In-Spark sampling path for TMS cron jobs (§5.2.2): sample once at {@code
S0}, persist metrics,
+ * record {@code before_snapshot_id}, then gate on snapshot drift without
re-sampling.
+ */
+public class TableMaintenanceInJobSampler {
+
+ private final TableSnapshotMetricsMetaService metricsMetaService;
+ private final TableMaintenanceActionMetaService actionMetaService;
+
+ /** Creates a sampler using the default relational meta services. */
+ public TableMaintenanceInJobSampler() {
+ this(
+ TableSnapshotMetricsMetaService.getInstance(),
+ TableMaintenanceActionMetaService.getInstance());
+ }
+
+ /**
+ * @param metricsMetaService metrics DAO
+ * @param actionMetaService maintenance action DAO
+ */
+ public TableMaintenanceInJobSampler(
+ TableSnapshotMetricsMetaService metricsMetaService,
+ TableMaintenanceActionMetaService actionMetaService) {
+ this.metricsMetaService = Preconditions.checkNotNull(metricsMetaService);
+ this.actionMetaService = Preconditions.checkNotNull(actionMetaService);
+ }
+
+ /**
+ * Persists metrics for {@code S0}, sets {@code before_snapshot_id} on the
maintenance action row,
+ * and returns whether the cron Spark Job should continue.
+ *
+ * <p>If HEAD has drifted after the sample, the caller must end the job
early and must not
+ * re-sample in a loop (§4.3).
+ *
+ * @param jobId Gravitino {@code job_run_meta.job_run_id} for the in-flight
maintenance row
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param sampledSnapshotId Iceberg snapshot id at sample time ({@code S0})
+ * @param metricsJson serialized metrics payload for {@code
table_snapshot_metrics}
+ * @param head supplies current HEAD after persistence
+ * @return continue vs drift stop outcome
+ */
+ public InJobGateOutcome sampleAndGateCronJob(
+ long jobId,
+ long tableId,
+ long policyId,
+ long sampledSnapshotId,
+ String metricsJson,
+ SnapshotHead head) {
+ Preconditions.checkNotNull(metricsJson, "metricsJson");
+ Preconditions.checkNotNull(head, "head");
+
+ metricsMetaService.insertMetrics(tableId, policyId, sampledSnapshotId,
metricsJson);
+ actionMetaService.recordBeforeSnapshot(jobId, sampledSnapshotId);
+
+ long headSnapshotId = head.currentSnapshotId();
+ if (sampledSnapshotId != headSnapshotId) {
+ return InJobGateOutcome.stop(
+ InJobGateOutcome.StopReason.SNAPSHOT_DRIFT, sampledSnapshotId,
headSnapshotId);
+ }
+ return InJobGateOutcome.continueMaintenance(sampledSnapshotId);
+ }
+
+ /**
+ * Evaluates drift for a commit-chain maintenance type after that type's
sample at {@code S0}
+ * (§5.2.3). When drift is detected the caller ends only the current type
and continues the chain.
+ *
+ * @param sampledSnapshotId {@code S0} for the current type
+ * @param head supplies current HEAD after persistence for this type
+ * @return continue vs drift stop outcome
+ */
+ public InJobGateOutcome gateCommitChainType(long sampledSnapshotId,
SnapshotHead head) {
+ Preconditions.checkNotNull(head, "head");
+ long headSnapshotId = head.currentSnapshotId();
+ if (sampledSnapshotId != headSnapshotId) {
+ return InJobGateOutcome.stop(
+ InJobGateOutcome.StopReason.SNAPSHOT_DRIFT, sampledSnapshotId,
headSnapshotId);
+ }
+ return InJobGateOutcome.continueMaintenance(sampledSnapshotId);
+ }
+}
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/occupancy/OccupancyGate.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/occupancy/OccupancyGate.java
new file mode 100644
index 0000000000..156d75047f
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/occupancy/OccupancyGate.java
@@ -0,0 +1,183 @@
+/*
+ * 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.occupancy;
+
+import com.google.common.base.Preconditions;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
+import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
+import
org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService;
+import org.apache.gravitino.meta.JobEntity;
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.TableMetaMapper;
+import org.apache.gravitino.storage.relational.po.MetalakePO;
+import org.apache.gravitino.storage.relational.po.TablePO;
+import org.apache.gravitino.storage.relational.service.JobMetaService;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+
+/**
+ * Expand / submit occupancy checks against {@code table_maintenance_action}
(§5.2).
+ *
+ * <p>Unbound rows ({@code job_id} null, {@code finished_at = 0}) are a short
claim: occupied while
+ * still within TTL on the <strong>database</strong> clock (same timeline as
acquire/reclaim). Bound
+ * rows consult {@link JobMetaService} and do <strong>not</strong> backfill
{@code finished_at}.
+ */
+public final class OccupancyGate {
+
+ private static final Set<JobHandle.Status> ACTIVE =
+ EnumSet.of(JobHandle.Status.QUEUED, JobHandle.Status.STARTED,
JobHandle.Status.CANCELLING);
+
+ private final TableMaintenanceActionMetaService actionMetaService;
+ private final long claimTimeoutMs;
+
+ /**
+ * @param actionMetaService maintenance action DAO
+ * @param claimTimeoutMs unbound claim TTL
+ */
+ public OccupancyGate(TableMaintenanceActionMetaService actionMetaService,
long claimTimeoutMs) {
+ Preconditions.checkArgument(claimTimeoutMs > 0, "claimTimeoutMs must be
positive");
+ this.actionMetaService = Preconditions.checkNotNull(actionMetaService);
+ this.claimTimeoutMs = claimTimeoutMs;
+ }
+
+ /**
+ * @param actionMetaService maintenance action DAO
+ */
+ public OccupancyGate(TableMaintenanceActionMetaService actionMetaService) {
+ this(actionMetaService, TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS);
+ }
+
+ /** Uses {@link TableMaintenanceActionMetaService#getInstance()} and default
claim timeout. */
+ public OccupancyGate() {
+ this(TableMaintenanceActionMetaService.getInstance());
+ }
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return true when any open row is a live unbound claim or an active
Gravitino job
+ */
+ public boolean isInFlight(long tableId, long policyId) {
+ if (actionMetaService.hasLiveUnboundClaim(tableId, policyId,
claimTimeoutMs)) {
+ return true;
+ }
+ for (TableMaintenanceActionPO open :
actionMetaService.listInFlight(tableId, policyId)) {
+ if (open.jobId() == null) {
+ continue;
+ }
+ JobEntity job = loadJob(open.jobId(), tableId);
+ if (job != null && ACTIVE.contains(job.status())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param minIntervalMs resolved minimum interval
+ * @param nowMs current epoch millis used only for cooldown (not unbound TTL)
+ * @return true when the last known finish is still inside the cooldown
window
+ */
+ public boolean withinMinInterval(long tableId, long policyId, long
minIntervalMs, long nowMs) {
+ if (minIntervalMs <= 0) {
+ return false;
+ }
+ Long lastFinished = lastFinishedAt(tableId, policyId);
+ return lastFinished != null && nowMs - lastFinished < minIntervalMs;
+ }
+
+ /**
+ * Expand / submit should skip when occupied or still cooling down.
+ *
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param minIntervalMs resolved minimum interval
+ * @param nowMs current epoch millis used only for cooldown (not unbound TTL)
+ * @return true when enqueue / submit must be skipped
+ */
+ public boolean shouldSkip(long tableId, long policyId, long minIntervalMs,
long nowMs) {
+ return isInFlight(tableId, policyId)
+ || withinMinInterval(tableId, policyId, minIntervalMs, nowMs);
+ }
+
+ /**
+ * Prefer {@code table_maintenance_action.finished_at}; if that column is
still {@code 0} but a
+ * Gravitino job is already terminal, use {@link JobEntity#finishedAt()}
without writing back.
+ */
+ @Nullable
+ private Long lastFinishedAt(long tableId, long policyId) {
+ Long latest = actionMetaService.maxFinishedAt(tableId, policyId);
+ for (TableMaintenanceActionPO open :
actionMetaService.listInFlight(tableId, policyId)) {
+ if (open.jobId() == null) {
+ continue;
+ }
+ JobEntity job = loadJob(open.jobId(), tableId);
+ if (job == null || ACTIVE.contains(job.status())) {
+ continue;
+ }
+ Long fromJob = job.finishedAt() != null && job.finishedAt() > 0 ?
job.finishedAt() : null;
+ if (fromJob == null) {
+ continue;
+ }
+ if (latest == null || fromJob > latest) {
+ latest = fromJob;
+ }
+ }
+ return latest;
+ }
+
+ @Nullable
+ private static JobEntity loadJob(long jobId, long tableId) {
+ String metalake = metalakeNameForTable(tableId);
+ if (metalake == null) {
+ return null;
+ }
+ try {
+ return JobMetaService.getInstance()
+ .getJobByIdentifier(NameIdentifierUtil.ofJob(metalake,
JobHandle.JOB_ID_PREFIX + jobId));
+ } catch (NoSuchEntityException e) {
+ return null;
+ }
+ }
+
+ @Nullable
+ private static String metalakeNameForTable(long tableId) {
+ List<TablePO> tables =
+ SessionUtils.getWithoutCommit(
+ TableMetaMapper.class,
+ mapper ->
mapper.listTablePOsByTableIds(Collections.singletonList(tableId)));
+ if (tables == null || tables.isEmpty()) {
+ return null;
+ }
+ MetalakePO metalake =
+ SessionUtils.getWithoutCommit(
+ MetalakeMetaMapper.class,
+ mapper ->
mapper.selectMetalakeMetaById(tables.get(0).getMetalakeId()));
+ return metalake == null ? null : metalake.getMetalakeName();
+ }
+}
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 419a1987fb..4ced139557 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
@@ -146,7 +146,8 @@ public class TableMaintenanceActionBaseSQLProvider {
}
/**
- * Sets {@code before_snapshot_id} once when {@code S0} is sampled.
+ * Sets {@code before_snapshot_id} when {@code S0} is sampled ({@code IS
NULL} first write).
+ * Callers treat an already-matching value as an idempotent no-op in Java.
*
* @param jobId Gravitino job run id
* @param beforeSnapshotId sampled snapshot id
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 9814cc3c34..ae1cd03e11 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
@@ -216,7 +216,10 @@ public class TableMaintenanceActionMetaService {
}
/**
- * Records the sampled Iceberg snapshot id ({@code S0}) once for a running
action.
+ * Records the sampled Iceberg snapshot id ({@code S0}) for a running action.
+ *
+ * <p>Safe to retry with the same {@code beforeSnapshotId} (cron / in-job
re-entry after a partial
+ * sample). A different snapshot id on an already-set row fails.
*
* @param jobId Gravitino job run id
* @param beforeSnapshotId sampled snapshot id
@@ -229,10 +232,19 @@ public class TableMaintenanceActionMetaService {
SessionUtils.doWithCommitAndFetchResult(
TableMaintenanceActionMapper.class,
mapper -> mapper.updateBeforeSnapshot(jobId, beforeSnapshotId));
+ if (updated == 1) {
+ return;
+ }
+ TableMaintenanceActionPO current = getByJobId(jobId);
Preconditions.checkState(
- updated == 1,
- "expected to set before_snapshot_id once on an unfinished action,
updated=%s",
- updated);
+ current != null && current.finishedAt() != null &&
current.finishedAt() == 0L,
+ "expected an unfinished action for job_id=%s",
+ jobId);
+ Preconditions.checkState(
+ current.beforeSnapshotId() != null && current.beforeSnapshotId() ==
beforeSnapshotId,
+ "before_snapshot_id already set to %s, refusing %s",
+ current.beforeSnapshotId(),
+ beforeSnapshotId);
}
/**
@@ -302,6 +314,29 @@ public class TableMaintenanceActionMetaService {
return open == null ? Collections.emptyList() : open;
}
+ /**
+ * Whether an unbound claim is still within TTL on the
<strong>database</strong> clock.
+ *
+ * <p>Must not be evaluated with {@code System.currentTimeMillis()} against
{@code created_at}:
+ * acquire/reclaim already use DB time, and JVM skew would disagree with
that lease.
+ *
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param claimTimeoutMs unbound claim TTL
+ * @return true when a live unbound claim holds the soft slot
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "hasLiveUnboundTableMaintenanceAction")
+ public boolean hasLiveUnboundClaim(long tableId, long policyId, long
claimTimeoutMs) {
+ Preconditions.checkArgument(claimTimeoutMs > 0, "claimTimeoutMs must be
positive");
+ TableMaintenanceActionPO live =
+ SessionUtils.getWithoutCommit(
+ TableMaintenanceActionMapper.class,
+ mapper -> mapper.selectLiveUnboundByTableAndPolicy(tableId,
policyId, claimTimeoutMs));
+ return live != null;
+ }
+
/**
* @param tableId table entity id
* @param policyId policy entity id
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableSnapshotMetricsMetaService.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableSnapshotMetricsMetaService.java
index 392c846b94..fe7b0719d2 100644
---
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableSnapshotMetricsMetaService.java
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableSnapshotMetricsMetaService.java
@@ -20,6 +20,8 @@ package org.apache.gravitino.maintenance.storage.service;
import static
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
+import java.sql.SQLIntegrityConstraintViolationException;
+import java.util.Locale;
import javax.annotation.Nullable;
import
org.apache.gravitino.maintenance.storage.mapper.TableSnapshotMetricsMapper;
import org.apache.gravitino.maintenance.storage.po.TableSnapshotMetricsPO;
@@ -45,6 +47,9 @@ public class TableSnapshotMetricsMetaService {
* Idempotently stores metrics JSON for a table/policy/snapshot triple. If a
row already exists,
* this is a no-op (does not overwrite) so sample / listener retries are
safe.
*
+ * <p>A concurrent insert that loses the unique-key race is treated as
success (first writer
+ * wins).
+ *
* @param tableId table id
* @param policyId policy id
* @param snapshotId Iceberg snapshot id
@@ -65,8 +70,15 @@ public class TableSnapshotMetricsMetaService {
.withMetricsValue(metricsJson)
.withCreatedAt(System.currentTimeMillis())
.build();
- SessionUtils.doWithCommit(
- TableSnapshotMetricsMapper.class, mapper ->
mapper.insertMetrics(metricsMeta));
+ try {
+ SessionUtils.doWithCommit(
+ TableSnapshotMetricsMapper.class, mapper ->
mapper.insertMetrics(metricsMeta));
+ } catch (RuntimeException e) {
+ if (isUniqueViolation(e)) {
+ return;
+ }
+ throw e;
+ }
}
/**
@@ -84,4 +96,20 @@ public class TableSnapshotMetricsMetaService {
TableSnapshotMetricsMapper.class,
mapper -> mapper.selectMetrics(tableId, policyId, snapshotId));
}
+
+ private static boolean isUniqueViolation(Throwable throwable) {
+ for (Throwable t = throwable; t != null; t = t.getCause()) {
+ if (t instanceof SQLIntegrityConstraintViolationException) {
+ return true;
+ }
+ String message = t.getMessage();
+ if (message != null) {
+ String lower = message.toLowerCase(Locale.ROOT);
+ if (lower.contains("unique") || lower.contains("duplicate")) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
}
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
new file mode 100644
index 0000000000..ec9b9511bc
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/web/rest/feature/TableMaintenanceRESTFeature.java
@@ -0,0 +1,130 @@
+/*
+ * 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.web.rest.feature;
+
+import java.util.concurrent.atomic.AtomicReference;
+import javax.sql.DataSource;
+import javax.ws.rs.core.Feature;
+import javax.ws.rs.core.FeatureContext;
+import javax.ws.rs.ext.Provider;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
+import
org.apache.gravitino.maintenance.scheduler.TableMaintenanceSchedulerPools;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.glassfish.jersey.server.monitoring.ApplicationEvent;
+import org.glassfish.jersey.server.monitoring.ApplicationEventListener;
+import org.glassfish.jersey.server.monitoring.RequestEvent;
+import org.glassfish.jersey.server.monitoring.RequestEventListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Jersey feature that starts table maintenance db-scheduler pools when the
extension package is
+ * enabled (§7.1).
+ *
+ * <p>TMS does not register public REST resources in this change; the feature
only owns server
+ * lifecycle for in-process scheduling. Pools stop on Jersey {@code
DESTROY_FINISHED}, which runs
+ * during Jetty shutdown before {@code GravitinoEnv} closes the entity-store
{@code DataSource}.
+ *
+ * <p>After {@link #stopSchedulers()}, a later {@link #configure} may start
pools again and
+ * re-register the destroy listener (needed for tests / embedded restarts in
the same JVM).
+ */
+@Provider
+public class TableMaintenanceRESTFeature implements Feature {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(TableMaintenanceRESTFeature.class);
+
+ /** Package name for {@link Configs#REST_API_EXTENSION_PACKAGES}. */
+ public static final String EXTENSION_PACKAGE =
TableMaintenanceRESTFeature.class.getPackageName();
+
+ private static final AtomicReference<TableMaintenanceSchedulerPools>
SCHEDULER_POOLS =
+ new AtomicReference<>();
+
+ @Override
+ public boolean configure(FeatureContext context) {
+ if (SCHEDULER_POOLS.get() != null) {
+ LOG.warn("Table maintenance scheduler pools are already running;
skipping reconfigure");
+ return true;
+ }
+
+ Config config = GravitinoEnv.getInstance().config();
+ if
(!Configs.RELATIONAL_ENTITY_STORE.equals(config.get(Configs.ENTITY_STORE))) {
+ throw new IllegalStateException(
+ "Table maintenance requires gravitino.entity.store = relational");
+ }
+
+ DataSource dataSource = resolveEntityStoreDataSource();
+ TableMaintenanceSchedulerPools pools = new
TableMaintenanceSchedulerPools();
+ pools.start(dataSource, TableMaintenanceConfig.from(config));
+ if (!SCHEDULER_POOLS.compareAndSet(null, pools)) {
+ pools.stop();
+ LOG.warn("Table maintenance scheduler pools were started concurrently;
discarded duplicate");
+ return true;
+ }
+
+ // Register only after a successful CAS so a later configure-after-stop
can attach a listener
+ // again on the new FeatureContext (listener is scoped to the Jersey
Application).
+ context.register(new SchedulerShutdownListener());
+ LOG.info("Table maintenance scheduler pools are running");
+ return true;
+ }
+
+ /** Stops scheduler pools. Invoked from Jersey destroy and tests. */
+ public static void stopSchedulers() {
+ TableMaintenanceSchedulerPools pools = SCHEDULER_POOLS.getAndSet(null);
+ if (pools != null) {
+ pools.stop();
+ LOG.info("Table maintenance scheduler pools stopped");
+ }
+ }
+
+ private static DataSource resolveEntityStoreDataSource() {
+ Object dataSource =
+ SqlSessionFactoryHelper.getInstance()
+ .getSqlSessionFactory()
+ .getConfiguration()
+ .getEnvironment()
+ .getDataSource();
+ if (!(dataSource instanceof DataSource)) {
+ throw new IllegalStateException("Entity store data source is not
available");
+ }
+ return (DataSource) dataSource;
+ }
+
+ /**
+ * Stops TMS scheduler pools when the Jersey application is destroyed (Jetty
stop), before the
+ * relational entity store shuts down.
+ */
+ static final class SchedulerShutdownListener implements
ApplicationEventListener {
+
+ @Override
+ public void onEvent(ApplicationEvent event) {
+ if (event.getType() == ApplicationEvent.Type.DESTROY_FINISHED) {
+ stopSchedulers();
+ }
+ }
+
+ @Override
+ public RequestEventListener onRequest(RequestEvent requestEvent) {
+ return null;
+ }
+ }
+}
diff --git
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/injob/TestTableMaintenanceInJobPipeline.java
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/injob/TestTableMaintenanceInJobPipeline.java
new file mode 100644
index 0000000000..c4cfbe9e56
--- /dev/null
+++
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/injob/TestTableMaintenanceInJobPipeline.java
@@ -0,0 +1,99 @@
+/*
+ * 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.injob;
+
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
+import
org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService;
+import
org.apache.gravitino.maintenance.storage.service.TableSnapshotMetricsMetaService;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestTemplate;
+
+class TestTableMaintenanceInJobPipeline extends TestJDBCBackend {
+
+ private static final String METRICS_JSON = "{\"mse\":1}";
+
+ private TableMaintenanceActionMetaService actionMetaService;
+ private TableMaintenanceInJobPipeline maintainPipeline;
+ private TableMaintenanceInJobPipeline skipPipeline;
+
+ @BeforeEach
+ void setUp() {
+ actionMetaService = TableMaintenanceActionMetaService.getInstance();
+ TableMaintenanceInJobSampler sampler =
+ new TableMaintenanceInJobSampler(
+ TableSnapshotMetricsMetaService.getInstance(), actionMetaService);
+ maintainPipeline =
+ new TableMaintenanceInJobPipeline(
+ sampler, (tableId, policyId, snapshotId, metricsJson) ->
RecommendDecision.MAINTAIN);
+ skipPipeline =
+ new TableMaintenanceInJobPipeline(
+ sampler, (tableId, policyId, snapshotId, metricsJson) ->
RecommendDecision.SKIP);
+ }
+
+ @TestTemplate
+ void testContinuesWhenNoDriftAndRecommenderMaintains() {
+ long jobId = 2001L;
+ insertBoundAction(jobId, 10L, 20L);
+ InJobGateOutcome outcome =
+ maintainPipeline.gateCronJob(jobId, 10L, 20L, 100L, METRICS_JSON, ()
-> 100L);
+ Assertions.assertTrue(outcome.continueMaintenance());
+ Assertions.assertNull(outcome.stopReason());
+ }
+
+ @TestTemplate
+ void testStopsOnDriftBeforeRecommend() {
+ long jobId = 2002L;
+ insertBoundAction(jobId, 11L, 21L);
+ InJobGateOutcome outcome =
+ maintainPipeline.gateCronJob(jobId, 11L, 21L, 200L, METRICS_JSON, ()
-> 201L);
+ Assertions.assertFalse(outcome.continueMaintenance());
+ Assertions.assertEquals(InJobGateOutcome.StopReason.SNAPSHOT_DRIFT,
outcome.stopReason());
+ }
+
+ @TestTemplate
+ void testStopsWhenRecommenderSkips() {
+ long jobId = 2003L;
+ insertBoundAction(jobId, 12L, 22L);
+ InJobGateOutcome outcome =
+ skipPipeline.gateCronJob(jobId, 12L, 22L, 300L, METRICS_JSON, () ->
300L);
+ Assertions.assertFalse(outcome.continueMaintenance());
+ Assertions.assertEquals(InJobGateOutcome.StopReason.RECOMMENDER_SKIP,
outcome.stopReason());
+ }
+
+ @TestTemplate
+ void testCommitChainTypeRecommendGate() {
+ InJobGateOutcome maintain =
+ maintainPipeline.gateCommitChainType(1L, 2L, 400L, METRICS_JSON, () ->
400L);
+ Assertions.assertTrue(maintain.continueMaintenance());
+
+ InJobGateOutcome skip =
+ skipPipeline.gateCommitChainType(1L, 2L, 401L, METRICS_JSON, () ->
401L);
+ Assertions.assertEquals(InJobGateOutcome.StopReason.RECOMMENDER_SKIP,
skip.stopReason());
+ }
+
+ private void insertBoundAction(long jobId, long tableId, long policyId) {
+ actionMetaService.bindJobId(
+ actionMetaService.insertInFlight(
+ tableId, policyId,
TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS),
+ jobId);
+ }
+}
diff --git
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/injob/TestTableMaintenanceInJobSampler.java
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/injob/TestTableMaintenanceInJobSampler.java
new file mode 100644
index 0000000000..8be445761a
--- /dev/null
+++
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/injob/TestTableMaintenanceInJobSampler.java
@@ -0,0 +1,107 @@
+/*
+ * 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.injob;
+
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
+import org.apache.gravitino.maintenance.storage.po.TableSnapshotMetricsPO;
+import
org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService;
+import
org.apache.gravitino.maintenance.storage.service.TableSnapshotMetricsMetaService;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestTemplate;
+
+class TestTableMaintenanceInJobSampler extends TestJDBCBackend {
+
+ private static final String METRICS_JSON = "{\"mse\":3}";
+
+ private TableMaintenanceInJobSampler sampler;
+ private TableMaintenanceActionMetaService actionMetaService;
+ private TableSnapshotMetricsMetaService metricsMetaService;
+
+ @BeforeEach
+ void setUp() {
+ actionMetaService = TableMaintenanceActionMetaService.getInstance();
+ metricsMetaService = TableSnapshotMetricsMetaService.getInstance();
+ sampler = new TableMaintenanceInJobSampler(metricsMetaService,
actionMetaService);
+ }
+
+ @TestTemplate
+ void testCronJobSamplePersistsAndContinuesWhenNoDrift() {
+ long jobId = 1001L;
+ insertBoundAction(jobId, 1L, 2L);
+ InJobGateOutcome outcome =
+ sampler.sampleAndGateCronJob(jobId, 1L, 2L, 100L, METRICS_JSON, () ->
100L);
+
+ Assertions.assertTrue(outcome.continueMaintenance());
+ Assertions.assertNull(outcome.stopReason());
+ TableSnapshotMetricsPO stored = metricsMetaService.getMetrics(1L, 2L,
100L);
+ Assertions.assertNotNull(stored);
+ Assertions.assertEquals(METRICS_JSON, stored.metricsValue());
+ Assertions.assertEquals(100L,
actionMetaService.getByJobId(jobId).beforeSnapshotId());
+ }
+
+ @TestTemplate
+ void testCronJobEndsEarlyWhenSnapshotDriftsAfterSample() {
+ long jobId = 1002L;
+ insertBoundAction(jobId, 3L, 4L);
+ InJobGateOutcome outcome =
+ sampler.sampleAndGateCronJob(jobId, 3L, 4L, 200L, METRICS_JSON, () ->
201L);
+
+ Assertions.assertFalse(outcome.continueMaintenance());
+ Assertions.assertEquals(InJobGateOutcome.StopReason.SNAPSHOT_DRIFT,
outcome.stopReason());
+ Assertions.assertEquals(200L, outcome.sampledSnapshotId());
+ Assertions.assertEquals(201L, outcome.headSnapshotId());
+ Assertions.assertNotNull(metricsMetaService.getMetrics(3L, 4L, 200L));
+ }
+
+ @TestTemplate
+ void testCronJobSampleRetryWithSameS0IsIdempotent() {
+ long jobId = 1003L;
+ insertBoundAction(jobId, 5L, 6L);
+ InJobGateOutcome first =
+ sampler.sampleAndGateCronJob(jobId, 5L, 6L, 300L, METRICS_JSON, () ->
300L);
+ InJobGateOutcome retry =
+ sampler.sampleAndGateCronJob(jobId, 5L, 6L, 300L, "{\"mse\":9}", () ->
300L);
+
+ Assertions.assertTrue(first.continueMaintenance());
+ Assertions.assertTrue(retry.continueMaintenance());
+ Assertions.assertEquals(
+ METRICS_JSON, metricsMetaService.getMetrics(5L, 6L,
300L).metricsValue());
+ Assertions.assertEquals(300L,
actionMetaService.getByJobId(jobId).beforeSnapshotId());
+ }
+
+ @TestTemplate
+ void testCommitChainTypeGateWithoutReSample() {
+ InJobGateOutcome ok = sampler.gateCommitChainType(300L, () -> 300L);
+ Assertions.assertTrue(ok.continueMaintenance());
+
+ InJobGateOutcome drift = sampler.gateCommitChainType(300L, () -> 301L);
+ Assertions.assertFalse(drift.continueMaintenance());
+ Assertions.assertEquals(InJobGateOutcome.StopReason.SNAPSHOT_DRIFT,
drift.stopReason());
+ }
+
+ private void insertBoundAction(long jobId, long tableId, long policyId) {
+ actionMetaService.bindJobId(
+ actionMetaService.insertInFlight(
+ tableId, policyId,
TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS),
+ jobId);
+ }
+}
diff --git
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/occupancy/TestOccupancyGate.java
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/occupancy/TestOccupancyGate.java
new file mode 100644
index 0000000000..f63532af35
--- /dev/null
+++
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/occupancy/TestOccupancyGate.java
@@ -0,0 +1,221 @@
+/*
+ * 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.occupancy;
+
+import java.io.IOException;
+import java.util.function.Function;
+import org.apache.gravitino.Namespace;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
+import org.apache.gravitino.maintenance.storage.service.OccupancyClaim;
+import
org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService;
+import org.apache.gravitino.meta.JobEntity;
+import org.apache.gravitino.meta.JobTemplateEntity;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.storage.relational.service.JobMetaService;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+
+class TestOccupancyGate extends TestJDBCBackend {
+
+ @TestTemplate
+ void testInFlightReadsJobMetaServiceWithoutBackfillingFinishedAt() throws
IOException {
+ String metalakeName = "tms_occ_ml";
+ createAndInsertMakeLake(metalakeName);
+ createAndInsertCatalog(metalakeName, "cat");
+ createAndInsertSchema(metalakeName, "cat", "sch");
+ TableEntity table = createAndInsertTableEntity(Namespace.of(metalakeName,
"cat", "sch"), "tbl");
+ JobTemplateEntity template =
+ createAndInsertShellJobTemplateEntity("occ-template", "comment",
metalakeName);
+
+ TableMaintenanceActionMetaService actions =
TableMaintenanceActionMetaService.getInstance();
+ OccupancyGate gate = new OccupancyGate(actions);
+ long policyId = 22L;
+
+ Assertions.assertFalse(gate.isInFlight(table.id(), policyId));
+
+ OccupancyClaim claim =
+ actions.insertInFlight(
+ table.id(), policyId,
TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS);
+ Assertions.assertTrue(gate.isInFlight(table.id(), policyId));
+
+ JobEntity started =
+ JobEntity.builder()
+ .withId(7001L)
+ .withJobExecutionId("exec-7001")
+ .withNamespace(NamespaceUtil.ofJob(metalakeName))
+ .withJobTemplateName(template.name())
+ .withStatus(JobHandle.Status.STARTED)
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(System.currentTimeMillis())
+ .withFinishedAt(0L)
+ .build();
+ backend.insert(started, false);
+ actions.bindJobId(claim, started.id());
+
+ Assertions.assertTrue(gate.isInFlight(table.id(), policyId));
+ Assertions.assertTrue(
+ gate.shouldSkip(table.id(), policyId, 3_600_000L,
System.currentTimeMillis()));
+ Assertions.assertEquals(0L, actions.getByJobId(started.id()).finishedAt());
+
+ long finishedAt = 1_700_000_000_000L;
+ JobMetaService.getInstance()
+ .updateJob(
+ NameIdentifierUtil.ofJob(metalakeName, started.name()),
+ (Function<JobEntity, JobEntity>)
+ current ->
+ JobEntity.builder()
+ .withId(current.id())
+ .withJobExecutionId(current.jobExecutionId())
+ .withNamespace(current.namespace())
+ .withJobTemplateName(current.jobTemplateName())
+ .withStatus(JobHandle.Status.SUCCEEDED)
+ .withAuditInfo(current.auditInfo())
+ .withStartedAt(current.startedAt())
+ .withFinishedAt(finishedAt)
+ .build());
+
+ Assertions.assertFalse(gate.isInFlight(table.id(), policyId));
+ Assertions.assertEquals(0L, actions.getByJobId(started.id()).finishedAt());
+ Assertions.assertTrue(
+ gate.withinMinInterval(table.id(), policyId, 3_600_000L, finishedAt +
1_000L));
+ Assertions.assertFalse(
+ gate.withinMinInterval(table.id(), policyId, 3_600_000L, finishedAt +
3_600_001L));
+ }
+
+ @TestTemplate
+ void testMinIntervalUsesMarkedFinishedAt() throws IOException {
+ String metalakeName = "tms_occ_ml2";
+ createAndInsertMakeLake(metalakeName);
+ createAndInsertCatalog(metalakeName, "cat");
+ createAndInsertSchema(metalakeName, "cat", "sch");
+ TableEntity table = createAndInsertTableEntity(Namespace.of(metalakeName,
"cat", "sch"), "tbl");
+
+ TableMaintenanceActionMetaService actions =
TableMaintenanceActionMetaService.getInstance();
+ OccupancyGate gate = new OccupancyGate(actions);
+ long policyId = 44L;
+ long jobId = 7002L;
+ long finishedAt = 1_700_000_100_000L;
+
+ OccupancyClaim claim =
+ actions.insertInFlight(
+ table.id(), policyId,
TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS);
+ actions.bindJobId(claim, jobId);
+ actions.markFinished(jobId, 200L, finishedAt);
+ Assertions.assertFalse(gate.isInFlight(table.id(), policyId));
+ Assertions.assertTrue(
+ gate.withinMinInterval(table.id(), policyId, 3_600_000L, finishedAt +
1_000L));
+ }
+
+ @TestTemplate
+ void testSoftSlotBlocksNewAcquireUntilMarkFinished() throws IOException {
+ String metalakeName = "tms_occ_ml3";
+ createAndInsertMakeLake(metalakeName);
+ createAndInsertCatalog(metalakeName, "cat");
+ createAndInsertSchema(metalakeName, "cat", "sch");
+ TableEntity table = createAndInsertTableEntity(Namespace.of(metalakeName,
"cat", "sch"), "tbl");
+ JobTemplateEntity template =
+ createAndInsertShellJobTemplateEntity("occ-template-3", "comment",
metalakeName);
+
+ TableMaintenanceActionMetaService actions =
TableMaintenanceActionMetaService.getInstance();
+ OccupancyGate gate = new OccupancyGate(actions);
+ long policyId = 55L;
+ long finishedAt = 1_700_000_200_000L;
+
+ JobEntity succeeded =
+ JobEntity.builder()
+ .withId(7003L)
+ .withJobExecutionId("exec-7003")
+ .withNamespace(NamespaceUtil.ofJob(metalakeName))
+ .withJobTemplateName(template.name())
+ .withStatus(JobHandle.Status.SUCCEEDED)
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(finishedAt - 1_000L)
+ .withFinishedAt(finishedAt)
+ .build();
+ backend.insert(succeeded, false);
+ OccupancyClaim stale =
+ actions.insertInFlight(
+ table.id(), policyId,
TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS);
+ actions.bindJobId(stale, succeeded.id());
+ Assertions.assertEquals(0L,
actions.getByJobId(succeeded.id()).finishedAt());
+ Assertions.assertFalse(gate.isInFlight(table.id(), policyId));
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () ->
+ actions.insertInFlight(
+ table.id(), policyId,
TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS));
+
+ actions.markFinished(succeeded.id(), 200L, finishedAt);
+ Assertions.assertEquals(0, actions.listInFlight(table.id(),
policyId).size());
+
+ JobEntity started =
+ JobEntity.builder()
+ .withId(7004L)
+ .withJobExecutionId("exec-7004")
+ .withNamespace(NamespaceUtil.ofJob(metalakeName))
+ .withJobTemplateName(template.name())
+ .withStatus(JobHandle.Status.STARTED)
+ .withAuditInfo(AUDIT_INFO)
+ .withStartedAt(System.currentTimeMillis())
+ .withFinishedAt(0L)
+ .build();
+ backend.insert(started, false);
+ OccupancyClaim active =
+ actions.insertInFlight(
+ table.id(), policyId,
TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS);
+ actions.bindJobId(active, started.id());
+
+ Assertions.assertEquals(1, actions.listInFlight(table.id(),
policyId).size());
+ Assertions.assertTrue(gate.isInFlight(table.id(), policyId));
+ Assertions.assertTrue(
+ gate.shouldSkip(table.id(), policyId, 3_600_000L, finishedAt +
3_600_001L));
+ }
+
+ @TestTemplate
+ void testExpiredUnboundClaimIsNotInFlightOnDbClock() throws Exception {
+ String metalakeName = "tms_occ_ml4";
+ createAndInsertMakeLake(metalakeName);
+ createAndInsertCatalog(metalakeName, "cat");
+ createAndInsertSchema(metalakeName, "cat", "sch");
+ TableEntity table = createAndInsertTableEntity(Namespace.of(metalakeName,
"cat", "sch"), "tbl");
+
+ TableMaintenanceActionMetaService actions =
TableMaintenanceActionMetaService.getInstance();
+ long claimTimeoutMs = 200L;
+ OccupancyGate gate = new OccupancyGate(actions, claimTimeoutMs);
+ long policyId = 66L;
+
+ OccupancyClaim claim = actions.insertInFlight(table.id(), policyId,
claimTimeoutMs);
+ Assertions.assertTrue(actions.hasLiveUnboundClaim(table.id(), policyId,
claimTimeoutMs));
+ Assertions.assertTrue(gate.isInFlight(table.id(), policyId));
+
+ Thread.sleep(claimTimeoutMs + 50L);
+
+ // Unbound TTL is evaluated in the DAO against the database clock, so
OccupancyGate agrees with
+ // acquire/reclaim even when the JVM clock would disagree (multi-node
skew).
+ Assertions.assertFalse(actions.hasLiveUnboundClaim(table.id(), policyId,
claimTimeoutMs));
+ Assertions.assertFalse(gate.isInFlight(table.id(), policyId));
+ Assertions.assertTrue(
+ actions.tryAcquireInFlight(table.id(), policyId,
claimTimeoutMs).isPresent());
+ Assertions.assertEquals(claim.id(), actions.getById(claim.id()).id());
+ }
+}
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 855b073d49..5979eef5d8 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
@@ -50,6 +50,9 @@ class TestTableMaintenanceActionMetaService extends
TestJDBCBackend {
Assertions.assertEquals(jobId, actionService.getByJobId(jobId).jobId());
Assertions.assertEquals(0L, actionService.getByJobId(jobId).finishedAt());
+ actionService.recordBeforeSnapshot(jobId, 100L);
+ Assertions.assertEquals(100L,
actionService.getByJobId(jobId).beforeSnapshotId());
+ // Same S0 is idempotent (in-job / cron retry after a partial sample).
actionService.recordBeforeSnapshot(jobId, 100L);
Assertions.assertEquals(100L,
actionService.getByJobId(jobId).beforeSnapshotId());
Assertions.assertThrows(
diff --git
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/web/rest/feature/TestTableMaintenanceRESTFeature.java
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/web/rest/feature/TestTableMaintenanceRESTFeature.java
new file mode 100644
index 0000000000..bd90aa2f77
--- /dev/null
+++
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/web/rest/feature/TestTableMaintenanceRESTFeature.java
@@ -0,0 +1,40 @@
+/*
+ * 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.web.rest.feature;
+
+import org.glassfish.jersey.server.monitoring.ApplicationEvent;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+class TestTableMaintenanceRESTFeature {
+
+ @Test
+ void testDestroyFinishedStopsSchedulers() {
+ TableMaintenanceRESTFeature.stopSchedulers();
+ ApplicationEvent event = Mockito.mock(ApplicationEvent.class);
+
Mockito.when(event.getType()).thenReturn(ApplicationEvent.Type.DESTROY_FINISHED);
+
+ TableMaintenanceRESTFeature.SchedulerShutdownListener listener =
+ new TableMaintenanceRESTFeature.SchedulerShutdownListener();
+ Assertions.assertDoesNotThrow(() -> listener.onEvent(event));
+ // stop is idempotent so configure-after-stop can start pools again and
re-register a listener
+ Assertions.assertDoesNotThrow(TableMaintenanceRESTFeature::stopSchedulers);
+ }
+}