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 f58ea84310 [#13773] feat(tms): Add scheduler pools and soft-slot
occupancy claim (#13774)
f58ea84310 is described below
commit f58ea843106f95681e82c3b5093960de1389c67d
Author: MaSai <[email protected]>
AuthorDate: Wed Oct 14 13:55:12 2026 +0800
[#13773] feat(tms): Add scheduler pools and soft-slot occupancy claim
(#13774)
### What changes were proposed in this pull request?
Second subtask of Epic #13760 (depends on #13761 / #13762, merged).
- Add db-scheduler 16.12.0 and three isolated pools (expand / cron /
commit) over `scheduled_expand_tasks` / `scheduled_cron_tasks` /
`scheduled_commit_tasks`
- Add `TableMaintenanceConfig` for pool threads, poll / heartbeat /
missed-heartbeat, expand batch size, and unbound claim TTL
- Soft-slot occupancy on `table_maintenance_action`: `finished_at = 0`
as the exclusive in-flight slot (`UNIQUE(table_id, policy_id,
finished_at)`), unbound lease via `created_at` CAS reclaim, `bindJobId`
keeps the slot, `markFinished` sets `finished_at > 0`
- Claim / reclaim MetaService APIs and DDL for the soft slot
**Out of scope (follow-up):** `OccupancyGate`,
`TableMaintenanceRESTFeature` (pools do not auto-start until the Feature
lands), PolicyExpand / Spark submit, IRC commit-job listener, in-job S0
/ drift / recommend.
### Why are the changes needed?
Epic #13760 needs a multi-node-safe scheduler foundation and soft-slot
claim storage before occupancy gates and submit pipelines can land on
top.
Fix: #13773
### Does this PR introduce _any_ user-facing change?
Yes. New config keys:
- `gravitino.maintenance.scheduler.expand.threads` / `cron.threads` /
`commit.threads`
- `gravitino.maintenance.scheduler.pollingIntervalMs` /
`heartbeatIntervalMs` / `missedHeartbeatsLimit`
- `gravitino.maintenance.expand.enqueueBatchSize`
- `gravitino.maintenance.occupancy.claimTimeoutMs`
No public REST resources in this change. Enabling pools via
`gravitino.server.rest.extensionPackages` lands with the Feature
follow-up.
### How was this patch tested?
```bash
./gradlew :plugins:tms:spotlessApply :plugins:tms:test -PskipITs
```
---------
Co-authored-by: Cursor <[email protected]>
---
LICENSE.bin | 3 +
design-docs/table-maintenance-service-design.md | 51 ++--
gradle/libs.versions.toml | 2 +
plugins/tms/build.gradle.kts | 22 +-
.../maintenance/TableMaintenanceConfig.java | 263 +++++++++++++++++++++
.../scheduler/TableMaintenanceSchedulerPools.java | 152 ++++++++++++
.../mapper/TableMaintenanceActionMapper.java | 42 +++-
.../TableMaintenanceActionSQLProviderFactory.java | 64 ++++-
.../TableMaintenanceActionBaseSQLProvider.java | 131 +++++++++-
.../TableMaintenanceActionPostgreSQLProvider.java | 9 +-
.../storage/service/OccupancyClaim.java | 82 +++++++
.../service/TableMaintenanceActionMetaService.java | 183 +++++++++++++-
.../TestTableMaintenanceSchedulerPools.java | 55 +++++
.../TestTableMaintenanceActionMetaService.java | 44 +++-
scripts/h2/schema-2.0.0-h2.sql | 60 ++++-
scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql | 60 ++++-
scripts/mysql/schema-2.0.0-mysql.sql | 64 ++++-
scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql | 64 ++++-
scripts/postgresql/schema-2.0.0-postgresql.sql | 98 +++++++-
.../upgrade-1.3.0-to-2.0.0-postgresql.sql | 98 +++++++-
20 files changed, 1451 insertions(+), 96 deletions(-)
diff --git a/LICENSE.bin b/LICENSE.bin
index 9da550578c..899b0e1dbd 100644
--- a/LICENSE.bin
+++ b/LICENSE.bin
@@ -411,6 +411,9 @@
Fabric8 Kubernetes Client
Fabric8 Kubernetes Model
Fabric8 zjsonpatch
+ db-scheduler
+ cron-utils (shaded into db-scheduler)
+ JSpecify
This product bundles various third-party components also under the
Apache Software Foundation License 1.1
diff --git a/design-docs/table-maintenance-service-design.md
b/design-docs/table-maintenance-service-design.md
index f1b768e79b..d4487e8e12 100644
--- a/design-docs/table-maintenance-service-design.md
+++ b/design-docs/table-maintenance-service-design.md
@@ -91,10 +91,10 @@ submit). Industry options compared below:
| Cluster CAS + heartbeat | Yes | Yes
| Lock only | Yes (heavy) |
| Fits long-lived expand + one-shot submits | Yes | Yes
| No | Yes |
-**Chosen:** db-scheduler — Apache 2.0, one `scheduled_tasks` table with
built-in heartbeat, and it
-fits both long-lived expand leases and one-shot cron/commit submits (three
pools: expand / cron /
-commit). JobRunr is LGPL. ShedLock only provides a lock, not the scheduler TMS
needs. Quartz JDBC
-can do the job but is heavier than needed here.
+**Chosen:** db-scheduler — Apache 2.0, built-in heartbeat, and three isolated
pools each with its
+own task table (`scheduled_expand_tasks` / `scheduled_cron_tasks` /
`scheduled_commit_tasks`).
+JobRunr is LGPL. ShedLock only provides a lock, not the scheduler TMS needs.
Quartz JDBC can do
+the job but is heavier than needed here.
### 4.3 Snapshot drift
@@ -137,7 +137,7 @@ policy first, then scheduling.
| Store | Role
|
| -------------------------------------------------------------- |
------------------------------------------------------------ |
| `policy_meta` / `policy_relation_meta` / `policy_version_info` | Triggers +
`minIntervalMs` + `jobOptions` / `rewriteOptions` |
-| `scheduled_tasks` | Scheduler
rows: expand / cron-job / commit-job |
+| `scheduled_expand_tasks` / `scheduled_cron_tasks` / `scheduled_commit_tasks`
| Scheduler rows (one db-scheduler table per pool) |
| `table_maintenance_job` | Per-run
occupancy; before/after `snapshot_id` |
| `table_snapshot_metrics` | Metrics
keyed by Iceberg `snapshot_id` |
| `gravitino.conf` |
Cluster-static Gravitino / IRC client auth |
@@ -153,7 +153,8 @@ policy may enable both `onCommit` and `crontab`.
Triggers live in **`policy_version_info.content.schedule`**, next to
`jobOptions`,
`rewriteOptions`, and `minIntervalMs`. Operators edit that versioned content
through Policy
-APIs. `policy_meta` only holds identity and attachment. `scheduled_tasks` is
runtime state for db-scheduler — not where operators set triggers.
+APIs. `policy_meta` only holds identity and attachment. The
`scheduled_*_tasks` tables are runtime
+state for db-scheduler — not where operators set triggers.
TMS has two trigger modes. They never feed each other's queues:
@@ -224,16 +225,17 @@ each — it does not write secrets back into policy content:
### 5.2 Scheduling and commit
-db-scheduler runs **three** task kinds in `scheduled_tasks`:
+db-scheduler runs **three** isolated pools. Each pool needs its own physical
task table (upstream
+db-scheduler does not support sharing one table across Scheduler instances):
-| Kind | `task_name` | Role |
-| ---- | --------------- | ----------------------------------------------- |
-| 1 | `policy-expand` | Long-lived lease per policy; fans out cron-jobs |
-| 2 | `cron-job` | One-shot crontab Spark submit |
-| 3 | `commit-job` | One-shot commit-triggered chained Spark submit |
+| Pool | Table | `task_name` | Role
|
+| ------ | ------------------------- | --------------- |
----------------------------------------------- |
+| Expand | `scheduled_expand_tasks` | `policy-expand` | Long-lived lease per
policy; fans out cron-jobs |
+| Cron | `scheduled_cron_tasks` | `cron-job` | One-shot crontab
Spark submit |
+| Commit | `scheduled_commit_tasks` | `commit-job` | One-shot
commit-triggered chained Spark submit |
**policy-expand** stays after each pick. **cron-job** and **commit-job** are
deleted after a
-short `runJob`. Spark lifetime and in-flight checks use
`table_maintenance_job`, not those
+short `runJob`. Spark lifetime and in-flight checks use
`table_maintenance_action`, not those
one-shot rows.
Every node may poll; **exactly one claim wins** per due instance (CAS +
heartbeat). Missed
@@ -241,11 +243,12 @@ heartbeats free only a hung **short** callback — not a
long-running Spark Job.
| Pool | Registers | Default threads | conf key
(`gravitino.maintenance.`) |
| ------ | ------------- | --------------- |
----------------------------------- |
-| Expand | policy-expand | 4 | `scheduler.expand.threads`
|
-| Cron | cron-job | 8 | `scheduler.cron.threads`
|
-| Commit | commit-job | 4 | `scheduler.commit.threads`
|
+| Expand | policy-expand | 4 | `scheduler.expandThreads`
|
+| Cron | cron-job | 8 | `scheduler.cronThreads`
|
+| Commit | commit-job | 4 | `scheduler.commitThreads`
|
-PK is `(task_name, task_instance)`. `scheduled_tasks` uses upstream
db-scheduler DDL.
+PK is `(task_name, task_instance)`. Each table uses upstream db-scheduler DDL
(MySQL
+`DATETIME(6)` / PostgreSQL `TIMESTAMP WITH TIME ZONE` time columns).
**TMS tables used by cron-job and commit-job**
@@ -448,9 +451,9 @@ gravitino.server.rest.extensionPackages =
org.apache.gravitino.maintenance.web.r
gravitino.auxService.names = iceberg-rest
gravitino.eventListener.names = tms-commit-job
gravitino.eventListener.tms-commit-job.class =
org.apache.gravitino.maintenance.listener.IcebergUpdateTableEventListener
-gravitino.maintenance.scheduler.expand.threads = 4
-gravitino.maintenance.scheduler.cron.threads = 8
-gravitino.maintenance.scheduler.commit.threads = 4
+gravitino.maintenance.scheduler.expandThreads = 4
+gravitino.maintenance.scheduler.cronThreads = 8
+gravitino.maintenance.scheduler.commitThreads = 4
```
`extensionPackages` plus colocated IRC start the TMS Feature and db-scheduler
pools. The
@@ -462,9 +465,9 @@ onCommit does not. HTTP / Kafka commit ingress is out of
scope.
| Key | Default |
Description |
| ------------------------------------------------------- | ------- |
----------------------------- |
-| `gravitino.maintenance.scheduler.expand.threads` | `4` | Expand
pool |
-| `gravitino.maintenance.scheduler.cron.threads` | `8` | Cron
pool |
-| `gravitino.maintenance.scheduler.commit.threads` | `4` | Commit
pool |
+| `gravitino.maintenance.scheduler.expandThreads` | `4` | Expand
pool |
+| `gravitino.maintenance.scheduler.cronThreads` | `8` | Cron
pool |
+| `gravitino.maintenance.scheduler.commitThreads` | `4` | Commit
pool |
| `gravitino.maintenance.scheduler.pollingIntervalMs` | `10000` | Poll
interval |
| `gravitino.maintenance.scheduler.heartbeatIntervalMs` | `60000` |
Heartbeat while callback runs |
| `gravitino.maintenance.scheduler.missedHeartbeatsLimit` | `6` | Misses
before dead |
@@ -534,7 +537,7 @@ gravitino.maintenance.ircAuth.oauthCredential =
client_id:client_secret
| ----- |
---------------------------------------------------------------------------------
|
| 1 | `TableMaintenanceRESTFeature`; start/stop three db-scheduler pools
|
| 2 | `PolicyExpandPipeline` + `MaintenanceSparkSubmitPipeline` + gates
|
-| 3 | Migrations: `scheduled_tasks`, `table_maintenance_job`,
`table_snapshot_metrics` |
+| 3 | Migrations: `scheduled_*_tasks`, `table_maintenance_action`,
`table_snapshot_metrics` |
| 4 | IRC EventListener upserts commit-job; policy create → INSERT expand;
Job listener |
| 5 | Metrics, shutdown, docs; `gravitino.conf` client auth; privilege
docs |
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index cfe5bb7ce6..cb827cdacb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -17,6 +17,7 @@
# under the License.
#
[versions]
+db-scheduler = "16.12.0"
awssdk = "2.31.73"
azure-identity = "1.13.1"
azure-storage-file-datalake = "12.20.0"
@@ -184,6 +185,7 @@ aspectj = "1.9.24"
ql-expression = "4.0.3"
[libraries]
+db-scheduler = { group = "com.github.kagkarlsson", name = "db-scheduler",
version.ref = "db-scheduler" }
aspectj-aspectjrt = { group = "org.aspectj", name = "aspectjrt", version.ref =
"aspectj" }
aws-dynamodb = { group = "software.amazon.awssdk", name = "dynamodb",
version.ref = "awssdk" }
aws-glue = { group = "software.amazon.awssdk", name = "glue", version.ref =
"awssdk" }
diff --git a/plugins/tms/build.gradle.kts b/plugins/tms/build.gradle.kts
index 1e6d92d9ad..835eb0fea6 100644
--- a/plugins/tms/build.gradle.kts
+++ b/plugins/tms/build.gradle.kts
@@ -30,6 +30,7 @@ dependencies {
implementation(project(":common"))
implementation(project(":core"))
+ implementation(libs.db.scheduler)
implementation(libs.guava)
implementation(libs.mybatis)
@@ -40,7 +41,6 @@ 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)
@@ -53,11 +53,19 @@ dependencies {
}
tasks {
+ val runtimeJars by registering(Copy::class) {
+ from(configurations.runtimeClasspath)
+ into("build/libs")
+ }
+
val copyLibs by registering(Copy::class) {
- dependsOn(jar)
- from(layout.buildDirectory.dir("libs")) {
- include("gravitino-tms-*.jar")
+ dependsOn(jar, runtimeJars)
+ from("build/libs") {
exclude("*-javadoc.jar", "*-sources.jar")
+ exclude("guava-*.jar")
+ exclude("log4j-*.jar")
+ exclude("slf4j-*.jar")
+ exclude("error_prone_annotations-*.jar")
}
into("$rootDir/distribution/package/libs")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
@@ -65,10 +73,14 @@ tasks {
register("copyLibAndConfigs", Copy::class) {
group = "gravitino distribution"
- description = "Copy tms plugin jar into distribution package libs"
+ description = "Copy tms plugin jar and runtime dependencies into
distribution package libs"
dependsOn(copyLibs)
}
+ jar {
+ dependsOn(runtimeJars)
+ }
+
test {
useJUnitPlatform()
environment("GRAVITINO_HOME", rootDir.path)
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/TableMaintenanceConfig.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/TableMaintenanceConfig.java
new file mode 100644
index 0000000000..a69c04d6a2
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/TableMaintenanceConfig.java
@@ -0,0 +1,263 @@
+/*
+ * 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;
+
+import org.apache.gravitino.Config;
+import org.apache.gravitino.config.ConfigBuilder;
+import org.apache.gravitino.config.ConfigConstants;
+import org.apache.gravitino.config.ConfigEntry;
+
+/** Configuration for table maintenance scheduler pools (§7.2). */
+public final class TableMaintenanceConfig {
+
+ public static final ConfigEntry<Integer> SCHEDULER_EXPAND_THREADS =
+ new ConfigBuilder("gravitino.maintenance.scheduler.expandThreads")
+ .doc("Thread count for the policy-expand db-scheduler pool")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(4);
+
+ public static final ConfigEntry<Integer> SCHEDULER_CRON_THREADS =
+ new ConfigBuilder("gravitino.maintenance.scheduler.cronThreads")
+ .doc("Thread count for the cron-job db-scheduler pool")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(8);
+
+ public static final ConfigEntry<Integer> SCHEDULER_COMMIT_THREADS =
+ new ConfigBuilder("gravitino.maintenance.scheduler.commitThreads")
+ .doc("Thread count for the commit-job db-scheduler pool")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(4);
+
+ public static final ConfigEntry<Long> SCHEDULER_POLLING_INTERVAL_MS =
+ new ConfigBuilder("gravitino.maintenance.scheduler.pollingIntervalMs")
+ .doc("db-scheduler poll interval in milliseconds")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .longConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(10_000L);
+
+ public static final ConfigEntry<Long> SCHEDULER_HEARTBEAT_INTERVAL_MS =
+ new ConfigBuilder("gravitino.maintenance.scheduler.heartbeatIntervalMs")
+ .doc("Heartbeat interval while a scheduler callback runs")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .longConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(60_000L);
+
+ public static final ConfigEntry<Integer> SCHEDULER_MISSED_HEARTBEATS_LIMIT =
+ new
ConfigBuilder("gravitino.maintenance.scheduler.missedHeartbeatsLimit")
+ .doc("Missed heartbeats before a picked task is considered dead")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(6);
+
+ public static final ConfigEntry<Integer> EXPAND_ENQUEUE_BATCH_SIZE =
+ new ConfigBuilder("gravitino.maintenance.expand.enqueueBatchSize")
+ .doc("Maximum cron-jobs enqueued per policy-expand pick")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .intConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(100);
+
+ /** Default unbound claim TTL ({@code job_id} still null), matching
ShedLock-style short locks. */
+ public static final long DEFAULT_CLAIM_TIMEOUT_MS = 120_000L;
+
+ public static final ConfigEntry<Long> OCCUPANCY_CLAIM_TIMEOUT_MS =
+ new ConfigBuilder("gravitino.maintenance.occupancy.claimTimeoutMs")
+ .doc(
+ "Max age of an unbound table_maintenance_action claim (job_id
null) before another"
+ + " submitter may reclaim it")
+ .version(ConfigConstants.VERSION_2_0_0)
+ .longConf()
+ .checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
+ .createWithDefault(DEFAULT_CLAIM_TIMEOUT_MS);
+
+ private final int expandThreads;
+ private final int cronThreads;
+ private final int commitThreads;
+ private final long pollingIntervalMs;
+ private final long heartbeatIntervalMs;
+ private final int missedHeartbeatsLimit;
+ private final int expandEnqueueBatchSize;
+ private final long claimTimeoutMs;
+
+ private TableMaintenanceConfig(
+ int expandThreads,
+ int cronThreads,
+ int commitThreads,
+ long pollingIntervalMs,
+ long heartbeatIntervalMs,
+ int missedHeartbeatsLimit,
+ int expandEnqueueBatchSize,
+ long claimTimeoutMs) {
+ this.expandThreads = expandThreads;
+ this.cronThreads = cronThreads;
+ this.commitThreads = commitThreads;
+ this.pollingIntervalMs = pollingIntervalMs;
+ this.heartbeatIntervalMs = heartbeatIntervalMs;
+ this.missedHeartbeatsLimit = missedHeartbeatsLimit;
+ this.expandEnqueueBatchSize = expandEnqueueBatchSize;
+ this.claimTimeoutMs = claimTimeoutMs;
+ }
+
+ /**
+ * Reads scheduler settings from server configuration.
+ *
+ * @param config gravitino server config
+ * @return resolved maintenance config
+ */
+ public static TableMaintenanceConfig from(Config config) {
+ return new TableMaintenanceConfig(
+ config.get(SCHEDULER_EXPAND_THREADS),
+ config.get(SCHEDULER_CRON_THREADS),
+ config.get(SCHEDULER_COMMIT_THREADS),
+ config.get(SCHEDULER_POLLING_INTERVAL_MS),
+ config.get(SCHEDULER_HEARTBEAT_INTERVAL_MS),
+ config.get(SCHEDULER_MISSED_HEARTBEATS_LIMIT),
+ config.get(EXPAND_ENQUEUE_BATCH_SIZE),
+ config.get(OCCUPANCY_CLAIM_TIMEOUT_MS));
+ }
+
+ /**
+ * @return expand pool thread count
+ */
+ public int expandThreads() {
+ return expandThreads;
+ }
+
+ /**
+ * @return cron pool thread count
+ */
+ public int cronThreads() {
+ return cronThreads;
+ }
+
+ /**
+ * @return commit pool thread count
+ */
+ public int commitThreads() {
+ return commitThreads;
+ }
+
+ /**
+ * @return poll interval in milliseconds
+ */
+ public long pollingIntervalMs() {
+ return pollingIntervalMs;
+ }
+
+ /**
+ * @return heartbeat interval in milliseconds
+ */
+ public long heartbeatIntervalMs() {
+ return heartbeatIntervalMs;
+ }
+
+ /**
+ * @return missed heartbeat limit
+ */
+ public int missedHeartbeatsLimit() {
+ return missedHeartbeatsLimit;
+ }
+
+ /**
+ * @return expand enqueue batch size
+ */
+ public int expandEnqueueBatchSize() {
+ return expandEnqueueBatchSize;
+ }
+
+ /**
+ * @return unbound occupancy claim timeout in milliseconds
+ */
+ public long claimTimeoutMs() {
+ return claimTimeoutMs;
+ }
+
+ /**
+ * Test-only factory with explicit values.
+ *
+ * @param expandThreads expand pool threads
+ * @param cronThreads cron pool threads
+ * @param commitThreads commit pool threads
+ * @param pollingIntervalMs poll interval
+ * @param heartbeatIntervalMs heartbeat interval
+ * @param missedHeartbeatsLimit missed heartbeat limit
+ * @param expandEnqueueBatchSize expand batch size
+ * @return config instance
+ */
+ public static TableMaintenanceConfig forTest(
+ int expandThreads,
+ int cronThreads,
+ int commitThreads,
+ long pollingIntervalMs,
+ long heartbeatIntervalMs,
+ int missedHeartbeatsLimit,
+ int expandEnqueueBatchSize) {
+ return forTest(
+ expandThreads,
+ cronThreads,
+ commitThreads,
+ pollingIntervalMs,
+ heartbeatIntervalMs,
+ missedHeartbeatsLimit,
+ expandEnqueueBatchSize,
+ DEFAULT_CLAIM_TIMEOUT_MS);
+ }
+
+ /**
+ * Test-only factory with explicit values including claim timeout.
+ *
+ * @param expandThreads expand pool threads
+ * @param cronThreads cron pool threads
+ * @param commitThreads commit pool threads
+ * @param pollingIntervalMs poll interval
+ * @param heartbeatIntervalMs heartbeat interval
+ * @param missedHeartbeatsLimit missed heartbeat limit
+ * @param expandEnqueueBatchSize expand batch size
+ * @param claimTimeoutMs unbound claim timeout
+ * @return config instance
+ */
+ public static TableMaintenanceConfig forTest(
+ int expandThreads,
+ int cronThreads,
+ int commitThreads,
+ long pollingIntervalMs,
+ long heartbeatIntervalMs,
+ int missedHeartbeatsLimit,
+ int expandEnqueueBatchSize,
+ long claimTimeoutMs) {
+ return new TableMaintenanceConfig(
+ expandThreads,
+ cronThreads,
+ commitThreads,
+ pollingIntervalMs,
+ heartbeatIntervalMs,
+ missedHeartbeatsLimit,
+ expandEnqueueBatchSize,
+ claimTimeoutMs);
+ }
+}
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
new file mode 100644
index 0000000000..e60bbf9d18
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java
@@ -0,0 +1,152 @@
+/*
+ * 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;
+
+import com.github.kagkarlsson.scheduler.Scheduler;
+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.util.ArrayList;
+import java.util.List;
+import javax.sql.DataSource;
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Starts and stops three isolated db-scheduler pools (§5.2, §7.2), each with
its own task table
+ * ({@link #EXPAND_TASKS_TABLE}, {@link #CRON_TASKS_TABLE}, {@link
#COMMIT_TASKS_TABLE}).
+ *
+ * <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>Task handlers are intentionally minimal in this change; submit and
occupancy logic land in
+ * follow-up PRs.
+ */
+public class TableMaintenanceSchedulerPools implements AutoCloseable {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(TableMaintenanceSchedulerPools.class);
+
+ /** Expand-pool db-scheduler table. */
+ public static final String EXPAND_TASKS_TABLE = "scheduled_expand_tasks";
+
+ /** Cron-pool db-scheduler table. */
+ public static final String CRON_TASKS_TABLE = "scheduled_cron_tasks";
+
+ /** Commit-pool db-scheduler table. */
+ public static final String COMMIT_TASKS_TABLE = "scheduled_commit_tasks";
+
+ /** Long-lived policy expand lease ({@code task_name}). */
+ public static final String POLICY_EXPAND = "policy-expand";
+
+ /** One-shot crontab Spark submit ({@code task_name}). */
+ public static final String CRON_JOB = "cron-job";
+
+ /** One-shot on-commit chained Spark submit ({@code task_name}). */
+ public static final String COMMIT_JOB = "commit-job";
+
+ private final List<Scheduler> schedulers = new ArrayList<>();
+
+ /**
+ * Creates and starts expand, cron, and commit scheduler pools.
+ *
+ * <p>If any pool fails to start, already-started pools are stopped and this
instance returns to
+ * the idle state so {@link #start} may be retried.
+ *
+ * @param dataSource relational JDBC pool shared with the entity store
+ * @param config maintenance scheduler settings
+ */
+ public void start(DataSource dataSource, TableMaintenanceConfig config) {
+ Preconditions.checkState(schedulers.isEmpty(), "scheduler pools already
started");
+ Preconditions.checkNotNull(dataSource, "dataSource must not be null");
+
+ List<Scheduler> built =
+ List.of(
+ buildScheduler(
+ dataSource,
+ config,
+ config.expandThreads(),
+ EXPAND_TASKS_TABLE,
+ noopTask(POLICY_EXPAND)),
+ buildScheduler(
+ dataSource, config, config.cronThreads(), CRON_TASKS_TABLE,
noopTask(CRON_JOB)),
+ buildScheduler(
+ dataSource,
+ config,
+ config.commitThreads(),
+ COMMIT_TASKS_TABLE,
+ noopTask(COMMIT_JOB)));
+
+ try {
+ for (Scheduler scheduler : built) {
+ scheduler.start();
+ schedulers.add(scheduler);
+ }
+ } catch (RuntimeException e) {
+ stop();
+ throw e;
+ }
+ LOG.info("Started {} table maintenance scheduler pools",
schedulers.size());
+ }
+
+ /** Stops all scheduler pools. */
+ public void stop() {
+ for (Scheduler scheduler : schedulers) {
+ try {
+ scheduler.stop();
+ } catch (Exception e) {
+ LOG.warn("Failed to stop a table maintenance scheduler pool", e);
+ }
+ }
+ schedulers.clear();
+ LOG.info("Stopped table maintenance scheduler pools");
+ }
+
+ @Override
+ public void close() {
+ stop();
+ }
+
+ private static Scheduler buildScheduler(
+ DataSource dataSource,
+ TableMaintenanceConfig config,
+ int threads,
+ String tableName,
+ Task<Void> task) {
+ return Scheduler.create(dataSource, task)
+ .threads(threads)
+ .pollingInterval(Duration.ofMillis(config.pollingIntervalMs()))
+ .heartbeatInterval(Duration.ofMillis(config.heartbeatIntervalMs()))
+ .missedHeartbeatsLimit(config.missedHeartbeatsLimit())
+ .tableName(tableName)
+ .registerShutdownHook()
+ .build();
+ }
+
+ private static Task<Void> noopTask(String taskName) {
+ return Tasks.custom(taskName, Void.class)
+ .execute(
+ (taskInstance, executionContext) -> {
+ LOG.debug(
+ "TMS scheduler callback for {} instance {}", taskName,
taskInstance.getId());
+ return (executionComplete, executionOperations) -> {};
+ });
+ }
+}
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 ac14efa56a..2882964445 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
@@ -18,6 +18,7 @@
*/
package org.apache.gravitino.maintenance.storage.mapper;
+import java.util.List;
import javax.annotation.Nullable;
import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
import org.apache.ibatis.annotations.DeleteProvider;
@@ -36,8 +37,35 @@ public interface TableMaintenanceActionMapper {
@Options(useGeneratedKeys = true, keyProperty = "actionMeta.id", keyColumn =
"id")
void insertAction(@Param("actionMeta") TableMaintenanceActionPO actionMeta);
+ @UpdateProvider(
+ type = TableMaintenanceActionSQLProviderFactory.class,
+ method = "reclaimExpiredUnbound")
+ int reclaimExpiredUnbound(
+ @Param("tableId") long tableId,
+ @Param("policyId") long policyId,
+ @Param("claimTimeoutMs") long claimTimeoutMs);
+
+ @Nullable
+ @SelectProvider(
+ type = TableMaintenanceActionSQLProviderFactory.class,
+ method = "selectUnboundByTableAndPolicy")
+ TableMaintenanceActionPO selectUnboundByTableAndPolicy(
+ @Param("tableId") long tableId, @Param("policyId") long policyId);
+
+ @Nullable
+ @SelectProvider(
+ type = TableMaintenanceActionSQLProviderFactory.class,
+ method = "selectLiveUnboundByTableAndPolicy")
+ TableMaintenanceActionPO selectLiveUnboundByTableAndPolicy(
+ @Param("tableId") long tableId,
+ @Param("policyId") long policyId,
+ @Param("claimTimeoutMs") long claimTimeoutMs);
+
@UpdateProvider(type = TableMaintenanceActionSQLProviderFactory.class,
method = "bindJobId")
- int bindJobId(@Param("id") long id, @Param("jobId") long jobId);
+ int bindJobId(
+ @Param("id") long id,
+ @Param("jobId") long jobId,
+ @Param("expectedCreatedAt") long expectedCreatedAt);
@Nullable
@SelectProvider(type = TableMaintenanceActionSQLProviderFactory.class,
method = "selectById")
@@ -64,4 +92,16 @@ public interface TableMaintenanceActionMapper {
@DeleteProvider(type = TableMaintenanceActionSQLProviderFactory.class,
method = "deleteByJobId")
int deleteByJobId(@Param("jobId") long jobId);
+
+ @SelectProvider(
+ type = TableMaintenanceActionSQLProviderFactory.class,
+ method = "selectInFlightByTableAndPolicy")
+ List<TableMaintenanceActionPO> selectInFlightByTableAndPolicy(
+ @Param("tableId") long tableId, @Param("policyId") long policyId);
+
+ @Nullable
+ @SelectProvider(
+ type = TableMaintenanceActionSQLProviderFactory.class,
+ method = "selectMaxFinishedAt")
+ Long selectMaxFinishedAt(@Param("tableId") long tableId, @Param("policyId")
long policyId);
}
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 2bfe0b66ee..891dd120d7 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
@@ -56,13 +56,53 @@ public class TableMaintenanceActionSQLProviderFactory {
return getProvider().insertAction(actionMeta);
}
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param claimTimeoutMs unbound claim TTL in milliseconds
+ * @return UPDATE statement
+ */
+ public static String reclaimExpiredUnbound(
+ @Param("tableId") long tableId,
+ @Param("policyId") long policyId,
+ @Param("claimTimeoutMs") long claimTimeoutMs) {
+ return getProvider().reclaimExpiredUnbound(tableId, policyId,
claimTimeoutMs);
+ }
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return SELECT statement
+ */
+ public static String selectUnboundByTableAndPolicy(
+ @Param("tableId") long tableId, @Param("policyId") long policyId) {
+ return getProvider().selectUnboundByTableAndPolicy(tableId, policyId);
+ }
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param claimTimeoutMs unbound claim TTL in milliseconds
+ * @return SELECT statement
+ */
+ public static String selectLiveUnboundByTableAndPolicy(
+ @Param("tableId") long tableId,
+ @Param("policyId") long policyId,
+ @Param("claimTimeoutMs") long claimTimeoutMs) {
+ return getProvider().selectLiveUnboundByTableAndPolicy(tableId, policyId,
claimTimeoutMs);
+ }
+
/**
* @param id action id
* @param jobId job run id
+ * @param expectedCreatedAt claim lease start observed at acquire time
* @return UPDATE statement
*/
- public static String bindJobId(@Param("id") long id, @Param("jobId") long
jobId) {
- return getProvider().bindJobId(id, jobId);
+ public static String bindJobId(
+ @Param("id") long id,
+ @Param("jobId") long jobId,
+ @Param("expectedCreatedAt") long expectedCreatedAt) {
+ return getProvider().bindJobId(id, jobId, expectedCreatedAt);
}
/**
@@ -120,6 +160,26 @@ public class TableMaintenanceActionSQLProviderFactory {
return getProvider().deleteByJobId(jobId);
}
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return SELECT statement
+ */
+ public static String selectInFlightByTableAndPolicy(
+ @Param("tableId") long tableId, @Param("policyId") long policyId) {
+ return getProvider().selectInFlightByTableAndPolicy(tableId, policyId);
+ }
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return SELECT statement
+ */
+ public static String selectMaxFinishedAt(
+ @Param("tableId") long tableId, @Param("policyId") long policyId) {
+ return getProvider().selectMaxFinishedAt(tableId, policyId);
+ }
+
static TableMaintenanceActionBaseSQLProvider getProvider() {
String databaseId =
SqlSessionFactoryHelper.getInstance()
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 9b5c44b134..419a1987fb 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
@@ -21,37 +21,112 @@ package
org.apache.gravitino.maintenance.storage.mapper.provider.base;
import static
org.apache.gravitino.maintenance.storage.mapper.TableMaintenanceActionMapper.TABLE_NAME;
import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
+import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
import org.apache.ibatis.annotations.Param;
-/** Portable SQL for {@code table_maintenance_action}. */
+/** Portable SQL for {@code table_maintenance_action} (MySQL / H2 {@code
MODE=MYSQL}). */
public class TableMaintenanceActionBaseSQLProvider {
/**
- * Inserts an in-flight occupancy row before {@code runJob}. {@code job_id}
stays null until bound
- * after submission (§5.2).
+ * @return SQL expression for the database clock in epoch millis
+ */
+ protected String currentTimeMillisExpr() {
+ return DatabaseTimeSQL.MYSQL;
+ }
+
+ /**
+ * Inserts an unbound occupancy claim before {@code runJob}. {@code
finished_at = 0} holds the
+ * exclusive soft slot (same pattern as {@code deleted_at}); {@code
created_at} is the claim lease
+ * start from the database clock.
*
- * @param actionMeta row to insert
+ * @param actionMeta row to insert ({@code createdAt} is ignored; DB time is
used)
* @return INSERT statement
*/
public String insertAction(@Param("actionMeta") TableMaintenanceActionPO
actionMeta) {
return "INSERT INTO "
+ TABLE_NAME
- + " (table_id, policy_id, created_at)"
- + " VALUES (#{actionMeta.tableId}, #{actionMeta.policyId},
#{actionMeta.createdAt})";
+ + " (table_id, policy_id, created_at, finished_at)"
+ + " VALUES (#{actionMeta.tableId}, #{actionMeta.policyId}, "
+ + currentTimeMillisExpr()
+ + ", 0)";
}
/**
- * Binds the Gravitino job run id after {@code runJob} succeeds.
+ * Extends an expired unbound claim lease by CAS on {@code created_at}
(ShedLock-style). Lease age
+ * is evaluated against the database clock so every node shares one TTL
timeline.
+ *
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param claimTimeoutMs unbound claim TTL in milliseconds
+ * @return UPDATE statement
+ */
+ public String reclaimExpiredUnbound(
+ @Param("tableId") long tableId,
+ @Param("policyId") long policyId,
+ @Param("claimTimeoutMs") long claimTimeoutMs) {
+ String now = currentTimeMillisExpr();
+ return "UPDATE "
+ + TABLE_NAME
+ + " SET created_at = "
+ + now
+ + " WHERE table_id = #{tableId} AND policy_id = #{policyId}"
+ + " AND job_id IS NULL AND finished_at = 0"
+ + " AND created_at <= ("
+ + now
+ + ") - #{claimTimeoutMs}";
+ }
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return SELECT statement for the unbound claim row
+ */
+ public String selectUnboundByTableAndPolicy(
+ @Param("tableId") long tableId, @Param("policyId") long policyId) {
+ return selectColumns()
+ + " WHERE table_id = #{tableId} AND policy_id = #{policyId}"
+ + " AND job_id IS NULL AND finished_at = 0";
+ }
+
+ /**
+ * Live unbound claim still within TTL on the database clock.
+ *
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param claimTimeoutMs unbound claim TTL in milliseconds
+ * @return SELECT statement
+ */
+ public String selectLiveUnboundByTableAndPolicy(
+ @Param("tableId") long tableId,
+ @Param("policyId") long policyId,
+ @Param("claimTimeoutMs") long claimTimeoutMs) {
+ return selectColumns()
+ + " WHERE table_id = #{tableId} AND policy_id = #{policyId}"
+ + " AND job_id IS NULL AND finished_at = 0"
+ + " AND created_at > ("
+ + currentTimeMillisExpr()
+ + ") - #{claimTimeoutMs}";
+ }
+
+ /**
+ * Binds the Gravitino job run id after {@code runJob}. Keeps {@code
finished_at = 0} so the soft
+ * slot covers the whole run. {@code expectedCreatedAt} fences against a
concurrent reclaim that
+ * CAS-updated {@code created_at}.
*
* @param id action row id
* @param jobId {@code job_run_meta.job_run_id}
+ * @param expectedCreatedAt claim lease start observed at acquire time
* @return UPDATE statement
*/
- public String bindJobId(@Param("id") long id, @Param("jobId") long jobId) {
+ public String bindJobId(
+ @Param("id") long id,
+ @Param("jobId") long jobId,
+ @Param("expectedCreatedAt") long expectedCreatedAt) {
return "UPDATE "
+ TABLE_NAME
+ " SET job_id = #{jobId}"
- + " WHERE id = #{id} AND job_id IS NULL AND finished_at IS NULL";
+ + " WHERE id = #{id} AND job_id IS NULL AND finished_at = 0"
+ + " AND created_at = #{expectedCreatedAt}";
}
/**
@@ -82,15 +157,18 @@ public class TableMaintenanceActionBaseSQLProvider {
return "UPDATE "
+ TABLE_NAME
+ " SET before_snapshot_id = #{beforeSnapshotId}"
- + " WHERE job_id = #{jobId} AND before_snapshot_id IS NULL AND
finished_at IS NULL";
+ + " WHERE job_id = #{jobId} AND before_snapshot_id IS NULL AND
finished_at = 0";
}
/**
- * Sets terminal snapshot pointers when the Spark Job finishes.
+ * Sets terminal snapshot pointers when the Spark Job finishes and releases
the soft slot.
+ *
+ * <p>Only one in-flight row exists per {@code (table_id, policy_id)}
({@code finished_at = 0}),
+ * so finish timestamps do not contend across concurrent submitters.
*
* @param jobId Gravitino job run id
* @param afterSnapshotId terminal snapshot id
- * @param finishedAt finish time millis
+ * @param finishedAt finish time millis; must be {@code > 0}
* @return UPDATE statement
*/
public String markFinished(
@@ -100,7 +178,7 @@ public class TableMaintenanceActionBaseSQLProvider {
return "UPDATE "
+ TABLE_NAME
+ " SET after_snapshot_id = #{afterSnapshotId}, finished_at =
#{finishedAt}"
- + " WHERE job_id = #{jobId} AND finished_at IS NULL";
+ + " WHERE job_id = #{jobId} AND finished_at = 0";
}
/**
@@ -119,6 +197,33 @@ public class TableMaintenanceActionBaseSQLProvider {
return "DELETE FROM " + TABLE_NAME + " WHERE job_id = #{jobId}";
}
+ /**
+ * Returns the open row ({@code finished_at = 0}) for {@code (table_id,
policy_id)}, if any.
+ *
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return SELECT statement
+ */
+ public String selectInFlightByTableAndPolicy(
+ @Param("tableId") long tableId, @Param("policyId") long policyId) {
+ return selectColumns()
+ + " WHERE table_id = #{tableId} AND policy_id = #{policyId} AND
finished_at = 0";
+ }
+
+ /**
+ * Returns {@code MAX(finished_at)} among completed rows for {@code
(table_id, policy_id)}.
+ *
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return SELECT statement
+ */
+ public String selectMaxFinishedAt(
+ @Param("tableId") long tableId, @Param("policyId") long policyId) {
+ return "SELECT MAX(finished_at) FROM "
+ + TABLE_NAME
+ + " WHERE table_id = #{tableId} AND policy_id = #{policyId} AND
finished_at > 0";
+ }
+
private static String selectColumns() {
return "SELECT id, job_id AS jobId, table_id AS tableId, policy_id AS
policyId,"
+ " before_snapshot_id AS beforeSnapshotId, after_snapshot_id AS
afterSnapshotId,"
diff --git
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableMaintenanceActionPostgreSQLProvider.java
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableMaintenanceActionPostgreSQLProvider.java
index 49f89e7827..bcc32f49ea 100644
---
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableMaintenanceActionPostgreSQLProvider.java
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableMaintenanceActionPostgreSQLProvider.java
@@ -19,7 +19,14 @@
package org.apache.gravitino.maintenance.storage.mapper.provider.postgresql;
import
org.apache.gravitino.maintenance.storage.mapper.provider.base.TableMaintenanceActionBaseSQLProvider;
+import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
/** PostgreSQL SQL provider for {@code table_maintenance_action}. */
public class TableMaintenanceActionPostgreSQLProvider
- extends TableMaintenanceActionBaseSQLProvider {}
+ extends TableMaintenanceActionBaseSQLProvider {
+
+ @Override
+ protected String currentTimeMillisExpr() {
+ return DatabaseTimeSQL.POSTGRESQL;
+ }
+}
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
new file mode 100644
index 0000000000..adc4f5bcdd
--- /dev/null
+++
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/OccupancyClaim.java
@@ -0,0 +1,82 @@
+/*
+ * 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.storage.service;
+
+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.
+ *
+ * <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
+ * undo a submit that already succeeded after a crash between {@code runJob}
and bind.
+ */
+public final class OccupancyClaim {
+
+ private final long id;
+ private final long createdAt;
+
+ /**
+ * @param id {@code table_maintenance_action.id}
+ * @param createdAt claim lease start written at acquire / reclaim time
+ */
+ public OccupancyClaim(long id, long createdAt) {
+ this.id = id;
+ this.createdAt = createdAt;
+ }
+
+ /**
+ * @return action row id
+ */
+ public long id() {
+ return id;
+ }
+
+ /**
+ * @return {@code created_at} observed when this claim was acquired
+ */
+ public long createdAt() {
+ return createdAt;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof OccupancyClaim)) {
+ return false;
+ }
+ OccupancyClaim that = (OccupancyClaim) o;
+ return id == that.id && createdAt == that.createdAt;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, createdAt);
+ }
+
+ @Override
+ public String toString() {
+ return "OccupancyClaim{id=" + id + ", createdAt=" + createdAt + '}';
+ }
+}
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 ea5c570d3e..9814cc3c34 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
@@ -21,7 +21,13 @@ package org.apache.gravitino.maintenance.storage.service;
import static
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
import com.google.common.base.Preconditions;
+import java.sql.SQLIntegrityConstraintViolationException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
import javax.annotation.Nullable;
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
import
org.apache.gravitino.maintenance.storage.mapper.TableMaintenanceActionMapper;
import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
import org.apache.gravitino.metrics.Monitored;
@@ -43,48 +49,146 @@ public class TableMaintenanceActionMetaService {
}
/**
- * Inserts an in-flight occupancy row before {@code runJob}. {@code job_id}
is bound later via
- * {@link #bindJobId(long, long)}.
+ * Tries to acquire an unbound occupancy claim before {@code runJob}.
+ *
+ * <ol>
+ * <li>CAS-{@code UPDATE created_at} on an expired unbound row ({@code
finished_at = 0}), using
+ * the database clock for lease age
+ * <li>Otherwise {@code INSERT} with {@code finished_at = 0} and DB {@code
created_at}; unique
+ * conflict means lost race
+ * </ol>
+ *
+ * <p>{@code finished_at = 0} is the soft exclusive slot (like {@code
deleted_at}); it stays until
+ * {@link #markFinished}. {@code created_at} is the unbound claim lease
start from the database
+ * clock so multi-node TTL does not depend on JVM skew.
+ *
+ * <p><b>Submit contract:</b> {@link #bindJobId} only fences the DB bind. It
cannot undo a {@code
+ * runJob} that already succeeded. OccupancyGate / submit follow-ups must
make {@code runJob}
+ * idempotent on {@link OccupancyClaim#id()} (or use an outbox /
reconcile-before-reclaim) so a
+ * reclaim after a crash between submit and bind cannot double-submit Spark
work.
*
* @param tableId table entity id
* @param policyId policy entity id
- * @return generated action id
+ * @param claimTimeoutMs unbound claim TTL
+ * @return claim ({@code id} + DB {@code created_at}) when acquired, empty
when another live claim
+ * or open run holds the slot
*/
@Monitored(
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
- baseMetricName = "insertTableMaintenanceAction")
- public long insertInFlight(long tableId, long policyId) {
+ baseMetricName = "tryAcquireTableMaintenanceAction")
+ public Optional<OccupancyClaim> tryAcquireInFlight(
+ long tableId, long policyId, long claimTimeoutMs) {
+ Preconditions.checkArgument(claimTimeoutMs > 0, "claimTimeoutMs must be
positive");
+ int reclaimed =
+ SessionUtils.doWithCommitAndFetchResult(
+ TableMaintenanceActionMapper.class,
+ mapper -> mapper.reclaimExpiredUnbound(tableId, policyId,
claimTimeoutMs));
+ if (reclaimed == 1) {
+ return Optional.of(readUnboundClaim(tableId, policyId));
+ }
+
+ TableMaintenanceActionPO live =
+ SessionUtils.getWithoutCommit(
+ TableMaintenanceActionMapper.class,
+ mapper -> mapper.selectLiveUnboundByTableAndPolicy(tableId,
policyId, claimTimeoutMs));
+ if (live != null) {
+ return Optional.empty();
+ }
+
TableMaintenanceActionPO actionMeta =
TableMaintenanceActionPO.builder()
.withTableId(tableId)
.withPolicyId(policyId)
- .withCreatedAt(System.currentTimeMillis())
+ .withFinishedAt(0L)
.build();
- SessionUtils.doWithCommit(
- TableMaintenanceActionMapper.class, mapper ->
mapper.insertAction(actionMeta));
+ try {
+ SessionUtils.doWithCommit(
+ TableMaintenanceActionMapper.class, mapper ->
mapper.insertAction(actionMeta));
+ } catch (RuntimeException e) {
+ if (isUniqueViolation(e)) {
+ return Optional.empty();
+ }
+ throw e;
+ }
Preconditions.checkState(actionMeta.id() != null, "insert did not return
generated id");
- return actionMeta.id();
+ TableMaintenanceActionPO inserted =
+ SessionUtils.getWithoutCommit(
+ TableMaintenanceActionMapper.class, mapper ->
mapper.selectById(actionMeta.id()));
+ Preconditions.checkState(
+ inserted != null && inserted.createdAt() != null, "insert lost
created_at");
+ return Optional.of(new OccupancyClaim(inserted.id(),
inserted.createdAt()));
}
/**
- * Binds {@code job_run_meta.job_run_id} after a successful {@code runJob}.
+ * Acquires an unbound claim using {@link
TableMaintenanceConfig#claimTimeoutMs()} or fails.
*
- * @param id action row id
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param config maintenance config supplying the unbound claim TTL
+ * @return acquired claim
+ */
+ public OccupancyClaim insertInFlight(long tableId, long policyId,
TableMaintenanceConfig config) {
+ Preconditions.checkNotNull(config, "config must not be null");
+ return insertInFlight(tableId, policyId, config.claimTimeoutMs());
+ }
+
+ /**
+ * Acquires an unbound claim with the given claim timeout or fails.
+ *
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @param claimTimeoutMs unbound claim TTL
+ * @return acquired claim
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "insertTableMaintenanceAction")
+ public OccupancyClaim insertInFlight(long tableId, long policyId, long
claimTimeoutMs) {
+ Optional<OccupancyClaim> acquired = tryAcquireInFlight(tableId, policyId,
claimTimeoutMs);
+ Preconditions.checkState(acquired.isPresent(), "failed to acquire unbound
occupancy claim");
+ return acquired.get();
+ }
+
+ /**
+ * Binds {@code job_run_meta.job_run_id} after a successful {@code runJob}.
Does not release the
+ * soft slot ({@code finished_at} stays {@code 0} until {@link
#markFinished}).
+ *
+ * <p>Pass the {@link OccupancyClaim} from acquire / reclaim as-is. Do not
re-read {@code
+ * created_at} from the row: after another node reclaims, that would observe
the new fence and
+ * defeat reclaim fencing. The CAS matches zero rows for a stale claim.
+ *
+ * <p>This CAS does <em>not</em> prevent duplicate Spark submissions if
{@code runJob} already
+ * ran; see {@link #tryAcquireInFlight} submit contract.
+ *
+ * @param claim claim returned by {@link #tryAcquireInFlight} / {@link
#insertInFlight}
* @param jobId Gravitino job run id
*/
@Monitored(
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "bindTableMaintenanceActionJobId")
- public void bindJobId(long id, long jobId) {
+ public void bindJobId(OccupancyClaim claim, long jobId) {
+ Preconditions.checkNotNull(claim, "claim must not be null");
int updated =
SessionUtils.doWithCommitAndFetchResult(
- TableMaintenanceActionMapper.class, mapper -> mapper.bindJobId(id,
jobId));
+ TableMaintenanceActionMapper.class,
+ mapper -> mapper.bindJobId(claim.id(), jobId, claim.createdAt()));
Preconditions.checkState(
updated == 1,
"expected to bind job_id on exactly one in-flight action, updated=%s",
updated);
}
+ private static OccupancyClaim readUnboundClaim(long tableId, long policyId) {
+ TableMaintenanceActionPO unbound =
+ SessionUtils.getWithoutCommit(
+ TableMaintenanceActionMapper.class,
+ mapper -> mapper.selectUnboundByTableAndPolicy(tableId, policyId));
+ Preconditions.checkState(
+ unbound != null && unbound.id() != null && unbound.createdAt() != null,
+ "reclaim lost unbound row");
+ return new OccupancyClaim(unbound.id(), unbound.createdAt());
+ }
+
/**
* @param id action row id
* @return action row or null
@@ -134,6 +238,10 @@ public class TableMaintenanceActionMetaService {
/**
* Marks the action finished with the terminal snapshot id.
*
+ * <p>{@code UNIQUE(table_id, policy_id, finished_at)} exists so {@code
finished_at = 0} is a
+ * single soft slot per pair (like {@code deleted_at}). Only one in-flight
row can finish that
+ * pair at a time, so two completed rows cannot race to the same millisecond
{@code finished_at}.
+ *
* @param jobId Gravitino job run id
* @param afterSnapshotId terminal snapshot id
* @param finishedAt finish timestamp millis
@@ -142,6 +250,7 @@ public class TableMaintenanceActionMetaService {
metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
baseMetricName = "markTableMaintenanceActionFinished")
public void markFinished(long jobId, Long afterSnapshotId, long finishedAt) {
+ Preconditions.checkArgument(finishedAt > 0, "finishedAt must be positive");
int updated =
SessionUtils.doWithCommitAndFetchResult(
TableMaintenanceActionMapper.class,
@@ -175,4 +284,52 @@ public class TableMaintenanceActionMetaService {
SessionUtils.doWithCommit(
TableMaintenanceActionMapper.class, mapper ->
mapper.deleteByJobId(jobId));
}
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return open rows with {@code finished_at = 0} (at most one per
table/policy; may include a
+ * terminal job not yet marked finished)
+ */
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "listInFlightTableMaintenanceActions")
+ public List<TableMaintenanceActionPO> listInFlight(long tableId, long
policyId) {
+ List<TableMaintenanceActionPO> open =
+ SessionUtils.getWithoutCommit(
+ TableMaintenanceActionMapper.class,
+ mapper -> mapper.selectInFlightByTableAndPolicy(tableId,
policyId));
+ return open == null ? Collections.emptyList() : open;
+ }
+
+ /**
+ * @param tableId table entity id
+ * @param policyId policy entity id
+ * @return latest {@code finished_at}, or null when none finished yet
+ */
+ @Nullable
+ @Monitored(
+ metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+ baseMetricName = "getMaxFinishedAtTableMaintenanceAction")
+ public Long maxFinishedAt(long tableId, long policyId) {
+ return SessionUtils.getWithoutCommit(
+ TableMaintenanceActionMapper.class,
+ mapper -> mapper.selectMaxFinishedAt(tableId, policyId));
+ }
+
+ 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/test/java/org/apache/gravitino/maintenance/scheduler/TestTableMaintenanceSchedulerPools.java
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestTableMaintenanceSchedulerPools.java
new file mode 100644
index 0000000000..5de4f541b1
--- /dev/null
+++
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestTableMaintenanceSchedulerPools.java
@@ -0,0 +1,55 @@
+/*
+ * 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;
+
+import javax.sql.DataSource;
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+
+class TestTableMaintenanceSchedulerPools extends TestJDBCBackend {
+
+ private TableMaintenanceSchedulerPools pools;
+
+ @AfterEach
+ void tearDown() {
+ if (pools != null) {
+ pools.stop();
+ }
+ }
+
+ @TestTemplate
+ void testThreePoolsStartAndStop() {
+ DataSource dataSource =
+ SqlSessionFactoryHelper.getInstance()
+ .getSqlSessionFactory()
+ .getConfiguration()
+ .getEnvironment()
+ .getDataSource();
+ pools = new TableMaintenanceSchedulerPools();
+ Assertions.assertDoesNotThrow(
+ () ->
+ pools.start(dataSource, TableMaintenanceConfig.forTest(1, 1, 1,
500L, 1_000L, 4, 10)));
+ Assertions.assertDoesNotThrow(pools::stop);
+ }
+}
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 2921255cd4..855b073d49 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
@@ -19,6 +19,8 @@
package org.apache.gravitino.maintenance.storage.service;
+import java.util.Optional;
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
import org.apache.gravitino.storage.relational.TestJDBCBackend;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.TestTemplate;
@@ -32,15 +34,21 @@ class TestTableMaintenanceActionMetaService extends
TestJDBCBackend {
TableSnapshotMetricsMetaService metricsService =
TableSnapshotMetricsMetaService.getInstance();
long before = System.currentTimeMillis();
- long actionId = actionService.insertInFlight(10L, 20L);
+ OccupancyClaim claim =
+ actionService.insertInFlight(
+ 10L, 20L, TableMaintenanceConfig.forTest(1, 1, 1, 500L, 1_000L, 4,
10));
long after = System.currentTimeMillis();
- Assertions.assertNull(actionService.getById(actionId).jobId());
- Assertions.assertTrue(actionService.getById(actionId).createdAt() >=
before);
- Assertions.assertTrue(actionService.getById(actionId).createdAt() <=
after);
+ Assertions.assertNull(actionService.getById(claim.id()).jobId());
+ Assertions.assertEquals(0L,
actionService.getById(claim.id()).finishedAt());
+ // DB clock may skew slightly from the JVM; allow a small window.
+ Assertions.assertTrue(claim.createdAt() >= before - 5_000L);
+ Assertions.assertTrue(claim.createdAt() <= after + 5_000L);
+ Assertions.assertEquals(claim.createdAt(),
actionService.getById(claim.id()).createdAt());
long jobId = 999L;
- actionService.bindJobId(actionId, jobId);
+ actionService.bindJobId(claim, jobId);
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());
@@ -62,4 +70,30 @@ class TestTableMaintenanceActionMetaService extends
TestJDBCBackend {
Assertions.assertEquals(
"{\"mse\":2}", metricsService.getMetrics(10L, 20L,
200L).metricsValue());
}
+
+ @TestTemplate
+ void testTryAcquireRejectsLiveUnboundAndReclaimsExpired() throws Exception {
+ TableMaintenanceActionMetaService actions =
TableMaintenanceActionMetaService.getInstance();
+ long tableId = 11L;
+ long policyId = 21L;
+ long timeoutMs = 200L;
+
+ Optional<OccupancyClaim> first = actions.tryAcquireInFlight(tableId,
policyId, timeoutMs);
+ Assertions.assertTrue(first.isPresent());
+ Assertions.assertEquals(0L,
actions.getById(first.get().id()).finishedAt());
+ Assertions.assertTrue(actions.tryAcquireInFlight(tableId, policyId,
timeoutMs).isEmpty());
+
+ Thread.sleep(timeoutMs + 50L);
+
+ Optional<OccupancyClaim> reclaimed = actions.tryAcquireInFlight(tableId,
policyId, timeoutMs);
+ Assertions.assertTrue(reclaimed.isPresent());
+ Assertions.assertEquals(first.get().id(), reclaimed.get().id());
+ Assertions.assertTrue(reclaimed.get().createdAt() >=
first.get().createdAt());
+ Assertions.assertEquals(0L,
actions.getById(reclaimed.get().id()).finishedAt());
+
+ Assertions.assertThrows(
+ IllegalStateException.class, () -> actions.bindJobId(first.get(),
1001L));
+ actions.bindJobId(reclaimed.get(), 1002L);
+ Assertions.assertEquals(1002L,
actions.getById(reclaimed.get().id()).jobId());
+ }
}
diff --git a/scripts/h2/schema-2.0.0-h2.sql b/scripts/h2/schema-2.0.0-h2.sql
index ac7fb308d1..901f3c99cd 100644
--- a/scripts/h2/schema-2.0.0-h2.sql
+++ b/scripts/h2/schema-2.0.0-h2.sql
@@ -681,19 +681,71 @@ CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
CREATE INDEX IF NOT EXISTS `idx_state_updated` ON `iceberg_cleanup_job`
(`state`, `updated_at`);
CREATE INDEX IF NOT EXISTS `idx_object` ON `iceberg_cleanup_job`
(`catalog_id`, `namespace`, `table_name`, `state`);
+CREATE TABLE IF NOT EXISTS `scheduled_expand_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` TIMESTAMP(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` TIMESTAMP(6) COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` TIMESTAMP(6) COMMENT 'last successful execution time',
+ `last_failure` TIMESTAMP(6) COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`)
+) COMMENT='table maintenance expand schedule metadata';
+CREATE INDEX IF NOT EXISTS `scheduled_expand_tasks_execution_time_idx` ON
`scheduled_expand_tasks`(`execution_time`);
+CREATE INDEX IF NOT EXISTS `scheduled_expand_tasks_last_heartbeat_idx` ON
`scheduled_expand_tasks`(`last_heartbeat`);
+
+CREATE TABLE IF NOT EXISTS `scheduled_cron_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` TIMESTAMP(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` TIMESTAMP(6) COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` TIMESTAMP(6) COMMENT 'last successful execution time',
+ `last_failure` TIMESTAMP(6) COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`)
+) COMMENT='table maintenance cron schedule metadata';
+CREATE INDEX IF NOT EXISTS `scheduled_cron_tasks_execution_time_idx` ON
`scheduled_cron_tasks`(`execution_time`);
+CREATE INDEX IF NOT EXISTS `scheduled_cron_tasks_last_heartbeat_idx` ON
`scheduled_cron_tasks`(`last_heartbeat`);
+
+CREATE TABLE IF NOT EXISTS `scheduled_commit_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` TIMESTAMP(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` TIMESTAMP(6) COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` TIMESTAMP(6) COMMENT 'last successful execution time',
+ `last_failure` TIMESTAMP(6) COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`)
+) COMMENT='table maintenance commit schedule metadata';
+CREATE INDEX IF NOT EXISTS `scheduled_commit_tasks_execution_time_idx` ON
`scheduled_commit_tasks`(`execution_time`);
+CREATE INDEX IF NOT EXISTS `scheduled_commit_tasks_last_heartbeat_idx` ON
`scheduled_commit_tasks`(`last_heartbeat`);
+
CREATE TABLE IF NOT EXISTS `table_maintenance_action` (
`id` BIGINT AUTO_INCREMENT NOT NULL COMMENT 'auto increment id',
- `job_id` BIGINT COMMENT 'optional job run id mapped to
job_run_meta.job_run_id, null until bound after runJob',
+ `job_id` BIGINT COMMENT 'job run id, maps to job_run_meta.job_run_id',
`table_id` BIGINT NOT NULL COMMENT 'table id from table_meta',
`policy_id` BIGINT NOT NULL COMMENT 'policy id from policy_meta',
`before_snapshot_id` BIGINT COMMENT 'Iceberg snapshot id before
maintenance, null until sampled',
`after_snapshot_id` BIGINT COMMENT 'Iceberg snapshot id after maintenance,
null while pending',
- `finished_at` BIGINT COMMENT 'finish timestamp in milliseconds, null while
in flight',
+ `finished_at` BIGINT NOT NULL DEFAULT 0 COMMENT 'finish millis, 0 = soft
in-flight slot (one per table/policy)',
`created_at` BIGINT NOT NULL COMMENT 'creation timestamp in milliseconds',
PRIMARY KEY (`id`)
-) COMMENT='table maintenance action occupancy and run metadata';
+) COMMENT='table maintenance action metadata';
CREATE UNIQUE INDEX IF NOT EXISTS `uk_tma_job_id` ON
`table_maintenance_action`(`job_id`);
-CREATE INDEX IF NOT EXISTS `idx_tma_table_policy_finished` ON
`table_maintenance_action`(`table_id`, `policy_id`, `finished_at`);
+-- Soft slot: finished_at=0 is exclusive per table/policy (one in-flight
finish).
+CREATE UNIQUE INDEX IF NOT EXISTS `uk_tma_table_policy_finished` ON
`table_maintenance_action`(`table_id`, `policy_id`, `finished_at`);
CREATE INDEX IF NOT EXISTS `idx_tma_before_sid` ON
`table_maintenance_action`(`table_id`, `before_snapshot_id`);
CREATE INDEX IF NOT EXISTS `idx_tma_after_sid` ON
`table_maintenance_action`(`table_id`, `after_snapshot_id`);
CREATE INDEX IF NOT EXISTS `idx_tma_created_at` ON
`table_maintenance_action`(`created_at`);
diff --git a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
index fb4f838a66..ffc5b9ca11 100644
--- a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
+++ b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
@@ -102,19 +102,71 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
KEY `idx_smvi_sid` (`schema_id`)
) ENGINE=InnoDB COMMENT 'semantic model version information';
+CREATE TABLE IF NOT EXISTS `scheduled_expand_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` TIMESTAMP(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` TIMESTAMP(6) COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` TIMESTAMP(6) COMMENT 'last successful execution time',
+ `last_failure` TIMESTAMP(6) COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`)
+) COMMENT='table maintenance expand schedule metadata';
+CREATE INDEX IF NOT EXISTS `scheduled_expand_tasks_execution_time_idx` ON
`scheduled_expand_tasks`(`execution_time`);
+CREATE INDEX IF NOT EXISTS `scheduled_expand_tasks_last_heartbeat_idx` ON
`scheduled_expand_tasks`(`last_heartbeat`);
+
+CREATE TABLE IF NOT EXISTS `scheduled_cron_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` TIMESTAMP(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` TIMESTAMP(6) COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` TIMESTAMP(6) COMMENT 'last successful execution time',
+ `last_failure` TIMESTAMP(6) COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`)
+) COMMENT='table maintenance cron schedule metadata';
+CREATE INDEX IF NOT EXISTS `scheduled_cron_tasks_execution_time_idx` ON
`scheduled_cron_tasks`(`execution_time`);
+CREATE INDEX IF NOT EXISTS `scheduled_cron_tasks_last_heartbeat_idx` ON
`scheduled_cron_tasks`(`last_heartbeat`);
+
+CREATE TABLE IF NOT EXISTS `scheduled_commit_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` TIMESTAMP(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` TIMESTAMP(6) COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` TIMESTAMP(6) COMMENT 'last successful execution time',
+ `last_failure` TIMESTAMP(6) COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`)
+) COMMENT='table maintenance commit schedule metadata';
+CREATE INDEX IF NOT EXISTS `scheduled_commit_tasks_execution_time_idx` ON
`scheduled_commit_tasks`(`execution_time`);
+CREATE INDEX IF NOT EXISTS `scheduled_commit_tasks_last_heartbeat_idx` ON
`scheduled_commit_tasks`(`last_heartbeat`);
+
CREATE TABLE IF NOT EXISTS `table_maintenance_action` (
`id` BIGINT AUTO_INCREMENT NOT NULL COMMENT 'auto increment id',
- `job_id` BIGINT COMMENT 'optional job run id mapped to
job_run_meta.job_run_id, null until bound after runJob',
+ `job_id` BIGINT COMMENT 'job run id, maps to job_run_meta.job_run_id',
`table_id` BIGINT NOT NULL COMMENT 'table id from table_meta',
`policy_id` BIGINT NOT NULL COMMENT 'policy id from policy_meta',
`before_snapshot_id` BIGINT COMMENT 'Iceberg snapshot id before
maintenance, null until sampled',
`after_snapshot_id` BIGINT COMMENT 'Iceberg snapshot id after maintenance,
null while pending',
- `finished_at` BIGINT COMMENT 'finish timestamp in milliseconds, null while
in flight',
+ `finished_at` BIGINT NOT NULL DEFAULT 0 COMMENT 'finish millis, 0 = soft
in-flight slot (one per table/policy)',
`created_at` BIGINT NOT NULL COMMENT 'creation timestamp in milliseconds',
PRIMARY KEY (`id`)
-) COMMENT='table maintenance action occupancy and run metadata';
+) COMMENT='table maintenance action metadata';
CREATE UNIQUE INDEX IF NOT EXISTS `uk_tma_job_id` ON
`table_maintenance_action`(`job_id`);
-CREATE INDEX IF NOT EXISTS `idx_tma_table_policy_finished` ON
`table_maintenance_action`(`table_id`, `policy_id`, `finished_at`);
+-- Soft slot: finished_at=0 is exclusive per table/policy (one in-flight
finish).
+CREATE UNIQUE INDEX IF NOT EXISTS `uk_tma_table_policy_finished` ON
`table_maintenance_action`(`table_id`, `policy_id`, `finished_at`);
CREATE INDEX IF NOT EXISTS `idx_tma_before_sid` ON
`table_maintenance_action`(`table_id`, `before_snapshot_id`);
CREATE INDEX IF NOT EXISTS `idx_tma_after_sid` ON
`table_maintenance_action`(`table_id`, `after_snapshot_id`);
CREATE INDEX IF NOT EXISTS `idx_tma_created_at` ON
`table_maintenance_action`(`created_at`);
diff --git a/scripts/mysql/schema-2.0.0-mysql.sql
b/scripts/mysql/schema-2.0.0-mysql.sql
index 786356bcf1..db39e065ba 100644
--- a/scripts/mysql/schema-2.0.0-mysql.sql
+++ b/scripts/mysql/schema-2.0.0-mysql.sql
@@ -668,23 +668,74 @@ CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
KEY `idx_object` (`catalog_id`, `namespace`(255), `table_name`(128), `state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'async
Iceberg table cleanup jobs';
+CREATE TABLE IF NOT EXISTS `scheduled_expand_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` DATETIME(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` DATETIME(6) NULL COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` DATETIME(6) NULL COMMENT 'last successful execution time',
+ `last_failure` DATETIME(6) NULL COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`),
+ KEY `execution_time_idx` (`execution_time`),
+ KEY `last_heartbeat_idx` (`last_heartbeat`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
maintenance expand schedule metadata';
+
+CREATE TABLE IF NOT EXISTS `scheduled_cron_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` DATETIME(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` DATETIME(6) NULL COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` DATETIME(6) NULL COMMENT 'last successful execution time',
+ `last_failure` DATETIME(6) NULL COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`),
+ KEY `execution_time_idx` (`execution_time`),
+ KEY `last_heartbeat_idx` (`last_heartbeat`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
maintenance cron schedule metadata';
+
+CREATE TABLE IF NOT EXISTS `scheduled_commit_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` DATETIME(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` DATETIME(6) NULL COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` DATETIME(6) NULL COMMENT 'last successful execution time',
+ `last_failure` DATETIME(6) NULL COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`),
+ KEY `execution_time_idx` (`execution_time`),
+ KEY `last_heartbeat_idx` (`last_heartbeat`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
maintenance commit schedule metadata';
+
CREATE TABLE IF NOT EXISTS `table_maintenance_action` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
- `job_id` BIGINT(20) UNSIGNED NULL COMMENT 'optional job run id mapped to
job_run_meta.job_run_id, null until bound after runJob',
+ `job_id` BIGINT(20) UNSIGNED NULL COMMENT 'job run id, maps to
job_run_meta.job_run_id',
`table_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'table id from table_meta',
`policy_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'policy id from
policy_meta',
`before_snapshot_id` BIGINT(20) NULL COMMENT 'Iceberg snapshot id before
maintenance, null until sampled',
`after_snapshot_id` BIGINT(20) NULL COMMENT 'Iceberg snapshot id after
maintenance, null while pending',
- `finished_at` BIGINT(20) UNSIGNED NULL COMMENT 'finish timestamp in
milliseconds, null while in flight',
+ `finished_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'finish
millis, 0 = soft in-flight slot (one per table/policy)',
`created_at` BIGINT(20) UNSIGNED NOT NULL COMMENT 'creation timestamp in
milliseconds',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tma_job_id` (`job_id`),
- KEY `idx_tma_table_policy_finished` (`table_id`, `policy_id`,
`finished_at`),
+ -- Soft slot: finished_at=0 is exclusive per table/policy (one in-flight
finish).
+ UNIQUE KEY `uk_tma_table_policy_finished` (`table_id`, `policy_id`,
`finished_at`),
KEY `idx_tma_before_sid` (`table_id`, `before_snapshot_id`),
KEY `idx_tma_after_sid` (`table_id`, `after_snapshot_id`),
KEY `idx_tma_created_at` (`created_at`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
- COMMENT 'table maintenance action occupancy and run metadata';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
maintenance action metadata';
CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
@@ -696,5 +747,4 @@ CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tid_pid_sid` (`table_id`, `policy_id`, `snapshot_id`),
KEY `idx_tsm_created_at` (`created_at`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
- COMMENT 'table snapshot metrics metadata';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
snapshot metrics metadata';
diff --git a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
index c5108a79c9..26677f167e 100644
--- a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
+++ b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
@@ -549,23 +549,74 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
KEY `idx_smvi_sid` (`schema_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic
model version information';
+CREATE TABLE IF NOT EXISTS `scheduled_expand_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` DATETIME(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` DATETIME(6) NULL COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` DATETIME(6) NULL COMMENT 'last successful execution time',
+ `last_failure` DATETIME(6) NULL COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`),
+ KEY `execution_time_idx` (`execution_time`),
+ KEY `last_heartbeat_idx` (`last_heartbeat`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
maintenance expand schedule metadata';
+
+CREATE TABLE IF NOT EXISTS `scheduled_cron_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` DATETIME(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` DATETIME(6) NULL COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` DATETIME(6) NULL COMMENT 'last successful execution time',
+ `last_failure` DATETIME(6) NULL COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`),
+ KEY `execution_time_idx` (`execution_time`),
+ KEY `last_heartbeat_idx` (`last_heartbeat`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
maintenance cron schedule metadata';
+
+CREATE TABLE IF NOT EXISTS `scheduled_commit_tasks` (
+ `task_name` VARCHAR(100) NOT NULL COMMENT 'task name',
+ `task_instance` VARCHAR(100) NOT NULL COMMENT 'task instance',
+ `task_data` BLOB COMMENT 'serialized task data',
+ `execution_time` DATETIME(6) NOT NULL COMMENT 'next execution time',
+ `picked` BOOLEAN NOT NULL COMMENT 'whether the task is currently picked',
+ `picked_by` VARCHAR(50) COMMENT 'scheduler instance that picked the task',
+ `last_heartbeat` DATETIME(6) NULL COMMENT 'last heartbeat from the picker',
+ `version` BIGINT NOT NULL COMMENT 'optimistic lock version',
+ `consecutive_failures` INT COMMENT 'consecutive failure count',
+ `last_success` DATETIME(6) NULL COMMENT 'last successful execution time',
+ `last_failure` DATETIME(6) NULL COMMENT 'last failed execution time',
+ PRIMARY KEY (`task_name`, `task_instance`),
+ KEY `execution_time_idx` (`execution_time`),
+ KEY `last_heartbeat_idx` (`last_heartbeat`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
maintenance commit schedule metadata';
+
CREATE TABLE IF NOT EXISTS `table_maintenance_action` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
- `job_id` BIGINT(20) UNSIGNED NULL COMMENT 'optional job run id mapped to
job_run_meta.job_run_id, null until bound after runJob',
+ `job_id` BIGINT(20) UNSIGNED NULL COMMENT 'job run id, maps to
job_run_meta.job_run_id',
`table_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'table id from table_meta',
`policy_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'policy id from
policy_meta',
`before_snapshot_id` BIGINT(20) NULL COMMENT 'Iceberg snapshot id before
maintenance, null until sampled',
`after_snapshot_id` BIGINT(20) NULL COMMENT 'Iceberg snapshot id after
maintenance, null while pending',
- `finished_at` BIGINT(20) UNSIGNED NULL COMMENT 'finish timestamp in
milliseconds, null while in flight',
+ `finished_at` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'finish
millis, 0 = soft in-flight slot (one per table/policy)',
`created_at` BIGINT(20) UNSIGNED NOT NULL COMMENT 'creation timestamp in
milliseconds',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tma_job_id` (`job_id`),
- KEY `idx_tma_table_policy_finished` (`table_id`, `policy_id`,
`finished_at`),
+ -- Soft slot: finished_at=0 is exclusive per table/policy (one in-flight
finish).
+ UNIQUE KEY `uk_tma_table_policy_finished` (`table_id`, `policy_id`,
`finished_at`),
KEY `idx_tma_before_sid` (`table_id`, `before_snapshot_id`),
KEY `idx_tma_after_sid` (`table_id`, `after_snapshot_id`),
KEY `idx_tma_created_at` (`created_at`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
- COMMENT 'table maintenance action occupancy and run metadata';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
maintenance action metadata';
CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment
id',
@@ -577,8 +628,7 @@ CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tid_pid_sid` (`table_id`, `policy_id`, `snapshot_id`),
KEY `idx_tsm_created_at` (`created_at`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
- COMMENT 'table snapshot metrics metadata';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'table
snapshot metrics metadata';
-- Merge duplicate live owners left by concurrent assignments: the newest live
row
-- (largest id) wins, and older ones are soft-deleted.
diff --git a/scripts/postgresql/schema-2.0.0-postgresql.sql
b/scripts/postgresql/schema-2.0.0-postgresql.sql
index b64a8d7fbb..19868d56db 100644
--- a/scripts/postgresql/schema-2.0.0-postgresql.sql
+++ b/scripts/postgresql/schema-2.0.0-postgresql.sql
@@ -1176,6 +1176,93 @@ COMMENT ON COLUMN iceberg_cleanup_job.heartbeat_at IS
'last heartbeat from the w
COMMENT ON COLUMN iceberg_cleanup_job.created_by IS 'principal that requested
the drop (audit)';
COMMENT ON COLUMN iceberg_cleanup_job.updated_at IS 'last state change, drives
poll ordering and old finished-job cleanup';
+CREATE TABLE IF NOT EXISTS scheduled_expand_tasks (
+ task_name VARCHAR(100) NOT NULL,
+ task_instance VARCHAR(100) NOT NULL,
+ task_data BYTEA,
+ execution_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ picked BOOLEAN NOT NULL,
+ picked_by VARCHAR(50),
+ last_heartbeat TIMESTAMP WITH TIME ZONE,
+ version BIGINT NOT NULL,
+ consecutive_failures INT,
+ last_success TIMESTAMP WITH TIME ZONE,
+ last_failure TIMESTAMP WITH TIME ZONE,
+ PRIMARY KEY (task_name, task_instance)
+);
+CREATE INDEX IF NOT EXISTS scheduled_expand_tasks_execution_time_idx ON
scheduled_expand_tasks (execution_time);
+CREATE INDEX IF NOT EXISTS scheduled_expand_tasks_last_heartbeat_idx ON
scheduled_expand_tasks (last_heartbeat);
+COMMENT ON TABLE scheduled_expand_tasks IS 'table maintenance expand schedule
metadata';
+COMMENT ON COLUMN scheduled_expand_tasks.task_name IS 'task name';
+COMMENT ON COLUMN scheduled_expand_tasks.task_instance IS 'task instance';
+COMMENT ON COLUMN scheduled_expand_tasks.task_data IS 'serialized task data';
+COMMENT ON COLUMN scheduled_expand_tasks.execution_time IS 'next execution
time';
+COMMENT ON COLUMN scheduled_expand_tasks.picked IS 'whether the task is
currently picked';
+COMMENT ON COLUMN scheduled_expand_tasks.picked_by IS 'scheduler instance that
picked the task';
+COMMENT ON COLUMN scheduled_expand_tasks.last_heartbeat IS 'last heartbeat
from the picker';
+COMMENT ON COLUMN scheduled_expand_tasks.version IS 'optimistic lock version';
+COMMENT ON COLUMN scheduled_expand_tasks.consecutive_failures IS 'consecutive
failure count';
+COMMENT ON COLUMN scheduled_expand_tasks.last_success IS 'last successful
execution time';
+COMMENT ON COLUMN scheduled_expand_tasks.last_failure IS 'last failed
execution time';
+
+CREATE TABLE IF NOT EXISTS scheduled_cron_tasks (
+ task_name VARCHAR(100) NOT NULL,
+ task_instance VARCHAR(100) NOT NULL,
+ task_data BYTEA,
+ execution_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ picked BOOLEAN NOT NULL,
+ picked_by VARCHAR(50),
+ last_heartbeat TIMESTAMP WITH TIME ZONE,
+ version BIGINT NOT NULL,
+ consecutive_failures INT,
+ last_success TIMESTAMP WITH TIME ZONE,
+ last_failure TIMESTAMP WITH TIME ZONE,
+ PRIMARY KEY (task_name, task_instance)
+);
+CREATE INDEX IF NOT EXISTS scheduled_cron_tasks_execution_time_idx ON
scheduled_cron_tasks (execution_time);
+CREATE INDEX IF NOT EXISTS scheduled_cron_tasks_last_heartbeat_idx ON
scheduled_cron_tasks (last_heartbeat);
+COMMENT ON TABLE scheduled_cron_tasks IS 'table maintenance cron schedule
metadata';
+COMMENT ON COLUMN scheduled_cron_tasks.task_name IS 'task name';
+COMMENT ON COLUMN scheduled_cron_tasks.task_instance IS 'task instance';
+COMMENT ON COLUMN scheduled_cron_tasks.task_data IS 'serialized task data';
+COMMENT ON COLUMN scheduled_cron_tasks.execution_time IS 'next execution time';
+COMMENT ON COLUMN scheduled_cron_tasks.picked IS 'whether the task is
currently picked';
+COMMENT ON COLUMN scheduled_cron_tasks.picked_by IS 'scheduler instance that
picked the task';
+COMMENT ON COLUMN scheduled_cron_tasks.last_heartbeat IS 'last heartbeat from
the picker';
+COMMENT ON COLUMN scheduled_cron_tasks.version IS 'optimistic lock version';
+COMMENT ON COLUMN scheduled_cron_tasks.consecutive_failures IS 'consecutive
failure count';
+COMMENT ON COLUMN scheduled_cron_tasks.last_success IS 'last successful
execution time';
+COMMENT ON COLUMN scheduled_cron_tasks.last_failure IS 'last failed execution
time';
+
+CREATE TABLE IF NOT EXISTS scheduled_commit_tasks (
+ task_name VARCHAR(100) NOT NULL,
+ task_instance VARCHAR(100) NOT NULL,
+ task_data BYTEA,
+ execution_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ picked BOOLEAN NOT NULL,
+ picked_by VARCHAR(50),
+ last_heartbeat TIMESTAMP WITH TIME ZONE,
+ version BIGINT NOT NULL,
+ consecutive_failures INT,
+ last_success TIMESTAMP WITH TIME ZONE,
+ last_failure TIMESTAMP WITH TIME ZONE,
+ PRIMARY KEY (task_name, task_instance)
+);
+CREATE INDEX IF NOT EXISTS scheduled_commit_tasks_execution_time_idx ON
scheduled_commit_tasks (execution_time);
+CREATE INDEX IF NOT EXISTS scheduled_commit_tasks_last_heartbeat_idx ON
scheduled_commit_tasks (last_heartbeat);
+COMMENT ON TABLE scheduled_commit_tasks IS 'table maintenance commit schedule
metadata';
+COMMENT ON COLUMN scheduled_commit_tasks.task_name IS 'task name';
+COMMENT ON COLUMN scheduled_commit_tasks.task_instance IS 'task instance';
+COMMENT ON COLUMN scheduled_commit_tasks.task_data IS 'serialized task data';
+COMMENT ON COLUMN scheduled_commit_tasks.execution_time IS 'next execution
time';
+COMMENT ON COLUMN scheduled_commit_tasks.picked IS 'whether the task is
currently picked';
+COMMENT ON COLUMN scheduled_commit_tasks.picked_by IS 'scheduler instance that
picked the task';
+COMMENT ON COLUMN scheduled_commit_tasks.last_heartbeat IS 'last heartbeat
from the picker';
+COMMENT ON COLUMN scheduled_commit_tasks.version IS 'optimistic lock version';
+COMMENT ON COLUMN scheduled_commit_tasks.consecutive_failures IS 'consecutive
failure count';
+COMMENT ON COLUMN scheduled_commit_tasks.last_success IS 'last successful
execution time';
+COMMENT ON COLUMN scheduled_commit_tasks.last_failure IS 'last failed
execution time';
+
CREATE TABLE IF NOT EXISTS table_maintenance_action (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT,
@@ -1183,22 +1270,23 @@ CREATE TABLE IF NOT EXISTS table_maintenance_action (
policy_id BIGINT NOT NULL,
before_snapshot_id BIGINT,
after_snapshot_id BIGINT,
- finished_at BIGINT,
+ finished_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_tma_job_id ON table_maintenance_action
(job_id);
-CREATE INDEX IF NOT EXISTS idx_tma_table_policy_finished ON
table_maintenance_action (table_id, policy_id, finished_at);
+-- Soft slot: finished_at=0 is exclusive per table/policy (one in-flight
finish).
+CREATE UNIQUE INDEX IF NOT EXISTS uk_tma_table_policy_finished ON
table_maintenance_action (table_id, policy_id, finished_at);
CREATE INDEX IF NOT EXISTS idx_tma_before_sid ON table_maintenance_action
(table_id, before_snapshot_id);
CREATE INDEX IF NOT EXISTS idx_tma_after_sid ON table_maintenance_action
(table_id, after_snapshot_id);
CREATE INDEX IF NOT EXISTS idx_tma_created_at ON table_maintenance_action
(created_at);
-COMMENT ON TABLE table_maintenance_action IS 'table maintenance action
occupancy and run metadata';
+COMMENT ON TABLE table_maintenance_action IS 'table maintenance action
metadata';
COMMENT ON COLUMN table_maintenance_action.id IS 'auto increment id';
-COMMENT ON COLUMN table_maintenance_action.job_id IS 'optional job run id
mapped to job_run_meta.job_run_id, null until bound after runJob';
+COMMENT ON COLUMN table_maintenance_action.job_id IS 'job run id, maps to
job_run_meta.job_run_id';
COMMENT ON COLUMN table_maintenance_action.table_id IS 'table id from
table_meta';
COMMENT ON COLUMN table_maintenance_action.policy_id IS 'policy id from
policy_meta';
COMMENT ON COLUMN table_maintenance_action.before_snapshot_id IS 'Iceberg
snapshot id before maintenance, null until sampled';
COMMENT ON COLUMN table_maintenance_action.after_snapshot_id IS 'Iceberg
snapshot id after maintenance, null while pending';
-COMMENT ON COLUMN table_maintenance_action.finished_at IS 'finish timestamp in
milliseconds, null while in flight';
+COMMENT ON COLUMN table_maintenance_action.finished_at IS 'finish millis, 0 =
soft in-flight slot (one per table/policy)';
COMMENT ON COLUMN table_maintenance_action.created_at IS 'creation timestamp
in milliseconds';
CREATE TABLE IF NOT EXISTS table_snapshot_metrics (
diff --git a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
index 6fde2a8e27..b6deb5b273 100644
--- a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
+++ b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
@@ -151,6 +151,93 @@ COMMENT ON COLUMN semantic_model_version_info.properties
IS 'semantic model prop
COMMENT ON COLUMN semantic_model_version_info.audit_info IS 'semantic model
version audit info';
COMMENT ON COLUMN semantic_model_version_info.deleted_at IS 'version deleted
at';
+CREATE TABLE IF NOT EXISTS scheduled_expand_tasks (
+ task_name VARCHAR(100) NOT NULL,
+ task_instance VARCHAR(100) NOT NULL,
+ task_data BYTEA,
+ execution_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ picked BOOLEAN NOT NULL,
+ picked_by VARCHAR(50),
+ last_heartbeat TIMESTAMP WITH TIME ZONE,
+ version BIGINT NOT NULL,
+ consecutive_failures INT,
+ last_success TIMESTAMP WITH TIME ZONE,
+ last_failure TIMESTAMP WITH TIME ZONE,
+ PRIMARY KEY (task_name, task_instance)
+);
+CREATE INDEX IF NOT EXISTS scheduled_expand_tasks_execution_time_idx ON
scheduled_expand_tasks (execution_time);
+CREATE INDEX IF NOT EXISTS scheduled_expand_tasks_last_heartbeat_idx ON
scheduled_expand_tasks (last_heartbeat);
+COMMENT ON TABLE scheduled_expand_tasks IS 'table maintenance expand schedule
metadata';
+COMMENT ON COLUMN scheduled_expand_tasks.task_name IS 'task name';
+COMMENT ON COLUMN scheduled_expand_tasks.task_instance IS 'task instance';
+COMMENT ON COLUMN scheduled_expand_tasks.task_data IS 'serialized task data';
+COMMENT ON COLUMN scheduled_expand_tasks.execution_time IS 'next execution
time';
+COMMENT ON COLUMN scheduled_expand_tasks.picked IS 'whether the task is
currently picked';
+COMMENT ON COLUMN scheduled_expand_tasks.picked_by IS 'scheduler instance that
picked the task';
+COMMENT ON COLUMN scheduled_expand_tasks.last_heartbeat IS 'last heartbeat
from the picker';
+COMMENT ON COLUMN scheduled_expand_tasks.version IS 'optimistic lock version';
+COMMENT ON COLUMN scheduled_expand_tasks.consecutive_failures IS 'consecutive
failure count';
+COMMENT ON COLUMN scheduled_expand_tasks.last_success IS 'last successful
execution time';
+COMMENT ON COLUMN scheduled_expand_tasks.last_failure IS 'last failed
execution time';
+
+CREATE TABLE IF NOT EXISTS scheduled_cron_tasks (
+ task_name VARCHAR(100) NOT NULL,
+ task_instance VARCHAR(100) NOT NULL,
+ task_data BYTEA,
+ execution_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ picked BOOLEAN NOT NULL,
+ picked_by VARCHAR(50),
+ last_heartbeat TIMESTAMP WITH TIME ZONE,
+ version BIGINT NOT NULL,
+ consecutive_failures INT,
+ last_success TIMESTAMP WITH TIME ZONE,
+ last_failure TIMESTAMP WITH TIME ZONE,
+ PRIMARY KEY (task_name, task_instance)
+);
+CREATE INDEX IF NOT EXISTS scheduled_cron_tasks_execution_time_idx ON
scheduled_cron_tasks (execution_time);
+CREATE INDEX IF NOT EXISTS scheduled_cron_tasks_last_heartbeat_idx ON
scheduled_cron_tasks (last_heartbeat);
+COMMENT ON TABLE scheduled_cron_tasks IS 'table maintenance cron schedule
metadata';
+COMMENT ON COLUMN scheduled_cron_tasks.task_name IS 'task name';
+COMMENT ON COLUMN scheduled_cron_tasks.task_instance IS 'task instance';
+COMMENT ON COLUMN scheduled_cron_tasks.task_data IS 'serialized task data';
+COMMENT ON COLUMN scheduled_cron_tasks.execution_time IS 'next execution time';
+COMMENT ON COLUMN scheduled_cron_tasks.picked IS 'whether the task is
currently picked';
+COMMENT ON COLUMN scheduled_cron_tasks.picked_by IS 'scheduler instance that
picked the task';
+COMMENT ON COLUMN scheduled_cron_tasks.last_heartbeat IS 'last heartbeat from
the picker';
+COMMENT ON COLUMN scheduled_cron_tasks.version IS 'optimistic lock version';
+COMMENT ON COLUMN scheduled_cron_tasks.consecutive_failures IS 'consecutive
failure count';
+COMMENT ON COLUMN scheduled_cron_tasks.last_success IS 'last successful
execution time';
+COMMENT ON COLUMN scheduled_cron_tasks.last_failure IS 'last failed execution
time';
+
+CREATE TABLE IF NOT EXISTS scheduled_commit_tasks (
+ task_name VARCHAR(100) NOT NULL,
+ task_instance VARCHAR(100) NOT NULL,
+ task_data BYTEA,
+ execution_time TIMESTAMP WITH TIME ZONE NOT NULL,
+ picked BOOLEAN NOT NULL,
+ picked_by VARCHAR(50),
+ last_heartbeat TIMESTAMP WITH TIME ZONE,
+ version BIGINT NOT NULL,
+ consecutive_failures INT,
+ last_success TIMESTAMP WITH TIME ZONE,
+ last_failure TIMESTAMP WITH TIME ZONE,
+ PRIMARY KEY (task_name, task_instance)
+);
+CREATE INDEX IF NOT EXISTS scheduled_commit_tasks_execution_time_idx ON
scheduled_commit_tasks (execution_time);
+CREATE INDEX IF NOT EXISTS scheduled_commit_tasks_last_heartbeat_idx ON
scheduled_commit_tasks (last_heartbeat);
+COMMENT ON TABLE scheduled_commit_tasks IS 'table maintenance commit schedule
metadata';
+COMMENT ON COLUMN scheduled_commit_tasks.task_name IS 'task name';
+COMMENT ON COLUMN scheduled_commit_tasks.task_instance IS 'task instance';
+COMMENT ON COLUMN scheduled_commit_tasks.task_data IS 'serialized task data';
+COMMENT ON COLUMN scheduled_commit_tasks.execution_time IS 'next execution
time';
+COMMENT ON COLUMN scheduled_commit_tasks.picked IS 'whether the task is
currently picked';
+COMMENT ON COLUMN scheduled_commit_tasks.picked_by IS 'scheduler instance that
picked the task';
+COMMENT ON COLUMN scheduled_commit_tasks.last_heartbeat IS 'last heartbeat
from the picker';
+COMMENT ON COLUMN scheduled_commit_tasks.version IS 'optimistic lock version';
+COMMENT ON COLUMN scheduled_commit_tasks.consecutive_failures IS 'consecutive
failure count';
+COMMENT ON COLUMN scheduled_commit_tasks.last_success IS 'last successful
execution time';
+COMMENT ON COLUMN scheduled_commit_tasks.last_failure IS 'last failed
execution time';
+
CREATE TABLE IF NOT EXISTS table_maintenance_action (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT,
@@ -158,22 +245,23 @@ CREATE TABLE IF NOT EXISTS table_maintenance_action (
policy_id BIGINT NOT NULL,
before_snapshot_id BIGINT,
after_snapshot_id BIGINT,
- finished_at BIGINT,
+ finished_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_tma_job_id ON table_maintenance_action
(job_id);
-CREATE INDEX IF NOT EXISTS idx_tma_table_policy_finished ON
table_maintenance_action (table_id, policy_id, finished_at);
+-- Soft slot: finished_at=0 is exclusive per table/policy (one in-flight
finish).
+CREATE UNIQUE INDEX IF NOT EXISTS uk_tma_table_policy_finished ON
table_maintenance_action (table_id, policy_id, finished_at);
CREATE INDEX IF NOT EXISTS idx_tma_before_sid ON table_maintenance_action
(table_id, before_snapshot_id);
CREATE INDEX IF NOT EXISTS idx_tma_after_sid ON table_maintenance_action
(table_id, after_snapshot_id);
CREATE INDEX IF NOT EXISTS idx_tma_created_at ON table_maintenance_action
(created_at);
-COMMENT ON TABLE table_maintenance_action IS 'table maintenance action
occupancy and run metadata';
+COMMENT ON TABLE table_maintenance_action IS 'table maintenance action
metadata';
COMMENT ON COLUMN table_maintenance_action.id IS 'auto increment id';
-COMMENT ON COLUMN table_maintenance_action.job_id IS 'optional job run id
mapped to job_run_meta.job_run_id, null until bound after runJob';
+COMMENT ON COLUMN table_maintenance_action.job_id IS 'job run id, maps to
job_run_meta.job_run_id';
COMMENT ON COLUMN table_maintenance_action.table_id IS 'table id from
table_meta';
COMMENT ON COLUMN table_maintenance_action.policy_id IS 'policy id from
policy_meta';
COMMENT ON COLUMN table_maintenance_action.before_snapshot_id IS 'Iceberg
snapshot id before maintenance, null until sampled';
COMMENT ON COLUMN table_maintenance_action.after_snapshot_id IS 'Iceberg
snapshot id after maintenance, null while pending';
-COMMENT ON COLUMN table_maintenance_action.finished_at IS 'finish timestamp in
milliseconds, null while in flight';
+COMMENT ON COLUMN table_maintenance_action.finished_at IS 'finish millis, 0 =
soft in-flight slot (one per table/policy)';
COMMENT ON COLUMN table_maintenance_action.created_at IS 'creation timestamp
in milliseconds';
CREATE TABLE IF NOT EXISTS table_snapshot_metrics (