This is an automated email from the ASF dual-hosted git repository. roryqi pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit c4ab2879a70bc7f284a8598be6a456d32b88b5c7 Author: remilu <[email protected]> AuthorDate: Mon Oct 26 07:55:09 2026 +0800 [#14217] feat(tms): Relational cron-job enqueue with snapshot batches Fixes #14217. Squashed from fork #76 (B6). --- .../scheduler/TableMaintenanceSchedulerPools.java | 41 +- .../maintenance/scheduler/job/cron/CronJobIds.java | 206 ++++++++++ .../scheduler/job/cron/CronJobScheduler.java | 5 +- .../job/cron/PendingCronExpandOccupancyGate.java | 51 +++ .../job/cron/RelationalCronJobScheduler.java | 427 +++++++++++++++++++++ .../scheduler/job/cron/TestCronJobIds.java | 58 +++ .../scheduler/job/cron/TestCronJobScheduler.java | 325 ++++++++++++++++ 7 files changed, 1090 insertions(+), 23 deletions(-) diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java index ebf608129e..88582781e1 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java @@ -19,6 +19,7 @@ package org.apache.gravitino.maintenance.scheduler; import com.github.kagkarlsson.scheduler.Scheduler; +import com.github.kagkarlsson.scheduler.serializer.JacksonSerializer; import com.github.kagkarlsson.scheduler.task.Task; import com.github.kagkarlsson.scheduler.task.helper.Tasks; import com.google.common.base.Preconditions; @@ -26,8 +27,8 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import javax.sql.DataSource; +import org.apache.gravitino.json.JsonUtils; import org.apache.gravitino.maintenance.TableMaintenanceConfig; -import org.apache.gravitino.maintenance.policy.PolicyExpandLifecycleHook; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,10 +39,8 @@ import org.slf4j.LoggerFactory; * <p>db-scheduler requires a dedicated {@code tableName} per Scheduler instance for multi-pool * isolation; sharing one physical table across pools is unsupported (see design §5.2). * - * <p>Cron and commit pools poll. {@code policy-expand} rows are written with a {@link - * com.github.kagkarlsson.scheduler.SchedulerClient}; expand polling starts with fan-out. {@link - * #policyExpandTask()} reschedules to the next cron after a pick, so a run does not leave the row - * picked. + * <p>Cron enqueue uses {@link #cronJobTask()} with Jackson {@code task_data}; pick submit lands in + * a follow-up. */ public class TableMaintenanceSchedulerPools implements AutoCloseable { @@ -68,11 +67,7 @@ public class TableMaintenanceSchedulerPools implements AutoCloseable { private final List<Scheduler> schedulers = new ArrayList<>(); /** - * Creates and starts the cron and commit scheduler pools. - * - * <p>The expand pool stays stopped. Due {@code policy-expand} rows would otherwise be picked by - * an empty handler and block policy alter and delete. Expand polling starts when fan-out can both - * run and reschedule the row. + * 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. @@ -87,7 +82,13 @@ public class TableMaintenanceSchedulerPools implements AutoCloseable { List<Scheduler> built = List.of( buildScheduler( - dataSource, config, config.cronThreads(), CRON_TASKS_TABLE, noopTask(CRON_JOB)), + dataSource, + config, + config.expandThreads(), + EXPAND_TASKS_TABLE, + noopTask(POLICY_EXPAND)), + buildScheduler( + dataSource, config, config.cronThreads(), CRON_TASKS_TABLE, cronJobTask()), buildScheduler( dataSource, config, @@ -130,12 +131,13 @@ public class TableMaintenanceSchedulerPools implements AutoCloseable { TableMaintenanceConfig config, int threads, String tableName, - Task<Void> task) { + Task<?> task) { return Scheduler.create(dataSource, task) .threads(threads) .pollingInterval(Duration.ofMillis(config.pollingIntervalMs())) .heartbeatInterval(Duration.ofMillis(config.heartbeatIntervalMs())) .missedHeartbeatsLimit(config.missedHeartbeatsLimit()) + .serializer(new JacksonSerializer(JsonUtils.anyFieldMapper())) .tableName(tableName) .commitWhenAutocommitDisabled(true) .registerShutdownHook() @@ -143,20 +145,15 @@ public class TableMaintenanceSchedulerPools implements AutoCloseable { } /** - * Expand task definition shared by the registration client and a future expand pool. - * - * <p>{@link #start} does not poll this task. When a run does happen, completion reschedules to - * the next cron or removes the row, so the execution does not stay picked. + * Cron-pool task stub: removes the one-shot row after dequeue. Real submit lands in a follow-up. * - * @return the {@code policy-expand} task + * @return the {@code cron-job} task */ - public static Task<Void> policyExpandTask() { - return Tasks.custom(POLICY_EXPAND, Void.class) + public static Task<MaintenanceJobSpec> cronJobTask() { + return Tasks.custom(CRON_JOB, MaintenanceJobSpec.class) .execute( (taskInstance, executionContext) -> - (executionComplete, executionOperations) -> - PolicyExpandLifecycleHook.completePick( - taskInstance.getId(), executionComplete, executionOperations)); + (executionComplete, executionOperations) -> executionOperations.remove()); } private static Task<Void> noopTask(String taskName) { diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronJobIds.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronJobIds.java new file mode 100644 index 0000000000..bc976e0920 --- /dev/null +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronJobIds.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +import java.util.Objects; +import java.util.Optional; +import javax.annotation.Nullable; + +/** + * Builds and parses db-scheduler instance ids for one-shot {@code cron-job} rows (§5.2.2). + * + * <p>Most types use {@code table:{table_id}:{policy_id}}. Batched snapshot-expiry uses {@code + * batch:{batch_id}:{policy_id}} where {@code batch_id} is a per-policy sequence ({@code 0}, {@code + * 1}, …) so concurrent batch names stay distinct under one {@code policy_id}. + */ +public final class CronJobIds { + + private static final String TABLE_PREFIX = "table:"; + private static final String BATCH_PREFIX = "batch:"; + + private CronJobIds() {} + + /** + * @param tableId table entity id + * @param policyId policy entity id + * @return cron-job instance id + */ + public static String forTable(long tableId, long policyId) { + return TABLE_PREFIX + tableId + ":" + policyId; + } + + /** + * @param batchId per-policy batch sequence id ({@code 0}, {@code 1}, …) + * @param policyId policy entity id + * @return cron-job instance id {@code batch:{batch_id}:{policy_id}} + */ + public static String forBatch(long batchId, long policyId) { + return BATCH_PREFIX + batchId + ":" + policyId; + } + + /** + * Parses a single-table cron-job instance id. + * + * @param instanceId db-scheduler task instance id + * @return table and policy ids when the id matches {@code table:{tableId}:{policyId}} + */ + public static Optional<TablePolicyIds> parseTable(String instanceId) { + if (instanceId == null || !instanceId.startsWith(TABLE_PREFIX)) { + return Optional.empty(); + } + String rest = instanceId.substring(TABLE_PREFIX.length()); + int sep = rest.indexOf(':'); + if (sep <= 0 || sep == rest.length() - 1) { + return Optional.empty(); + } + try { + long tableId = Long.parseLong(rest.substring(0, sep)); + long policyId = Long.parseLong(rest.substring(sep + 1)); + return Optional.of(new TablePolicyIds(tableId, policyId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** + * Parses a batched snapshot-expiry cron-job instance id. + * + * @param instanceId db-scheduler task instance id + * @return batch and policy ids when the id matches {@code batch:{batchId}:{policyId}} + */ + public static Optional<BatchPolicyIds> parseBatch(String instanceId) { + if (instanceId == null || !instanceId.startsWith(BATCH_PREFIX)) { + return Optional.empty(); + } + String rest = instanceId.substring(BATCH_PREFIX.length()); + int sep = rest.indexOf(':'); + if (sep <= 0 || sep == rest.length() - 1) { + return Optional.empty(); + } + try { + long batchId = Long.parseLong(rest.substring(0, sep)); + long policyId = Long.parseLong(rest.substring(sep + 1)); + return Optional.of(new BatchPolicyIds(batchId, policyId)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } + + /** Parsed {@code table:{tableId}:{policyId}} ids. */ + public static final class TablePolicyIds { + private final long tableId; + private final long policyId; + + /** + * @param tableId table entity id + * @param policyId policy entity id + */ + public TablePolicyIds(long tableId, long policyId) { + this.tableId = tableId; + this.policyId = policyId; + } + + /** + * @return table entity id + */ + public long tableId() { + return tableId; + } + + /** + * @return policy entity id + */ + public long policyId() { + return policyId; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TablePolicyIds)) { + return false; + } + TablePolicyIds that = (TablePolicyIds) o; + return tableId == that.tableId && policyId == that.policyId; + } + + @Override + public int hashCode() { + return Objects.hash(tableId, policyId); + } + + @Override + public String toString() { + return forTable(tableId, policyId); + } + } + + /** Parsed {@code batch:{batchId}:{policyId}} ids. */ + public static final class BatchPolicyIds { + private final long batchId; + private final long policyId; + + /** + * @param batchId per-policy batch sequence id + * @param policyId policy entity id + */ + public BatchPolicyIds(long batchId, long policyId) { + this.batchId = batchId; + this.policyId = policyId; + } + + /** + * @return batch sequence id + */ + public long batchId() { + return batchId; + } + + /** + * @return policy entity id + */ + public long policyId() { + return policyId; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof BatchPolicyIds)) { + return false; + } + BatchPolicyIds that = (BatchPolicyIds) o; + return batchId == that.batchId && policyId == that.policyId; + } + + @Override + public int hashCode() { + return Objects.hash(batchId, policyId); + } + + @Override + public String toString() { + return forBatch(batchId, policyId); + } + } +} diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronJobScheduler.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronJobScheduler.java index f16cc3febd..dfd6b2ca12 100644 --- a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronJobScheduler.java +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/CronJobScheduler.java @@ -30,6 +30,9 @@ import org.apache.gravitino.meta.PolicyEntity; * batch:{batch_id}:{policy_id}} rows; incomplete batches are filled via {@code * SchedulerClient#getScheduledExecution} / {@code reschedule} before a new {@code batch_id} is * allocated (§5.2.2). + * + * <p>Enqueue builds a {@link org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec} once + * (policy → jobConf) into {@code task_data}; pick consumes that spec. */ @FunctionalInterface public interface CronJobScheduler { @@ -37,7 +40,7 @@ public interface CronJobScheduler { /** * Enqueues due-now cron jobs for the given expand candidates. * - * @param policy effective maintenance policy + * @param policy effective maintenance policy (used to build the jobConf spec) * @param candidates enqueueable tables with winner levels * @param batchCapacity max tables per snapshot-expiry batch row (and max candidates considered) * @return number of newly inserted cron-job rows (merges into existing batches do not count) diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/PendingCronExpandOccupancyGate.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/PendingCronExpandOccupancyGate.java new file mode 100644 index 0000000000..cb9fd6a65a --- /dev/null +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/PendingCronExpandOccupancyGate.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +import com.google.common.base.Preconditions; +import javax.sql.DataSource; +import org.apache.gravitino.maintenance.scheduler.policy.expand.ExpandOccupancyGate; + +/** + * Decorates an {@link ExpandOccupancyGate} so tables already covered by a queued {@code cron-job} + * row are skipped and do not consume the expand batch budget. + */ +public final class PendingCronExpandOccupancyGate implements ExpandOccupancyGate { + + private final ExpandOccupancyGate delegate; + private final DataSource dataSource; + + /** + * @param delegate in-flight / minInterval gate + * @param dataSource entity-store JDBC pool used by the cron scheduler table + */ + public PendingCronExpandOccupancyGate(ExpandOccupancyGate delegate, DataSource dataSource) { + this.delegate = Preconditions.checkNotNull(delegate, "delegate"); + this.dataSource = Preconditions.checkNotNull(dataSource, "dataSource"); + } + + @Override + public boolean shouldSkip(long tableId, long policyId, long minIntervalMs, long nowMs) { + if (delegate.shouldSkip(tableId, policyId, minIntervalMs, nowMs)) { + return true; + } + return RelationalCronJobScheduler.isQueuedForPolicy( + RelationalCronJobScheduler.client(dataSource), tableId, policyId); + } +} diff --git a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/RelationalCronJobScheduler.java b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/RelationalCronJobScheduler.java new file mode 100644 index 0000000000..1fd173ec0d --- /dev/null +++ b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/RelationalCronJobScheduler.java @@ -0,0 +1,427 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +import com.github.kagkarlsson.scheduler.ScheduledExecution; +import com.github.kagkarlsson.scheduler.SchedulerClient; +import com.github.kagkarlsson.scheduler.serializer.JacksonSerializer; +import com.github.kagkarlsson.scheduler.task.TaskInstance; +import com.github.kagkarlsson.scheduler.task.TaskInstanceId; +import com.google.common.base.Preconditions; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import javax.sql.DataSource; +import org.apache.gravitino.Config; +import org.apache.gravitino.json.JsonUtils; +import org.apache.gravitino.maintenance.scheduler.AutocommitDataSources; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.TableTarget; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpecFactory; +import org.apache.gravitino.maintenance.scheduler.TableMaintenanceSchedulerPools; +import org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceTableRefs.ResolvedTableRef; +import org.apache.gravitino.maintenance.scheduler.policy.expand.ExpandTableCandidate; +import org.apache.gravitino.meta.PolicyEntity; +import org.apache.gravitino.policy.Policy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Schedules {@code cron-job} rows through a db-scheduler {@link SchedulerClient} on the + * entity-store DataSource. + * + * <p>Follows the usual client pattern: {@link SchedulerClient#scheduleIfNotExists} for new instance + * ids, {@link SchedulerClient#getScheduledExecutionsForTask} to inspect open rows, and {@link + * SchedulerClient#reschedule(TaskInstanceId, Instant, Object)} to replace {@code task_data} when + * filling an incomplete snapshot-expiry batch (same due time, new table list + shared jobConf). + * + * <p>The entity store disables JDBC autocommit; connections are wrapped via {@link + * AutocommitDataSources} so each client call commits. {@code task_data} is JSON via {@link + * JacksonSerializer + JsonUtils.anyFieldMapper()}. + */ +public final class RelationalCronJobScheduler implements CronJobScheduler { + + private static final Logger LOG = LoggerFactory.getLogger(RelationalCronJobScheduler.class); + + private final DataSource dataSource; + private final MaintenanceJobSpecFactory specFactory; + private final Function<List<Long>, List<ResolvedTableRef>> tableRefResolver; + + /** + * @param dataSource relational JDBC pool shared with the entity store + * @param gravitinoConfig server config for jobConf overlay + */ + public RelationalCronJobScheduler(DataSource dataSource, Config gravitinoConfig) { + this(dataSource, gravitinoConfig, MaintenanceTableRefs::resolve); + } + + /** + * Visible for tests that inject table-id resolution without a JDBC catalog store. + * + * @param dataSource relational JDBC pool + * @param gravitinoConfig server config + * @param tableRefResolver resolves entity ids to catalog + schema.table + */ + RelationalCronJobScheduler( + DataSource dataSource, + Config gravitinoConfig, + Function<List<Long>, List<ResolvedTableRef>> tableRefResolver) { + this.dataSource = Preconditions.checkNotNull(dataSource); + this.specFactory = new MaintenanceJobSpecFactory(Preconditions.checkNotNull(gravitinoConfig)); + this.tableRefResolver = Preconditions.checkNotNull(tableRefResolver); + } + + @Override + public int enqueueDueNow( + PolicyEntity policy, List<ExpandTableCandidate> candidates, int batchCapacity) { + if (policy == null || candidates == null || candidates.isEmpty()) { + return 0; + } + if (MaintenanceJobTemplates.templateName(policy.policyType()).isEmpty()) { + LOG.warn( + "Skipping cron-job enqueue for policy {} with unsupported type {}", + policy.id(), + policy.policyType()); + return 0; + } + int capacity = Math.max(1, batchCapacity); + SchedulerClient client = client(dataSource); + Instant due = Instant.now(); + + if (policy.policyType() == Policy.BuiltInType.ICEBERG_SNAPSHOT_EXPIRATION) { + return enqueueSnapshotExpiry(client, policy, candidates, capacity, due); + } + int scheduled = 0; + int limit = Math.min(candidates.size(), capacity); + for (ExpandTableCandidate candidate : candidates.subList(0, limit)) { + if (scheduleTable(client, policy, candidate.tableId(), due)) { + scheduled++; + } + } + return scheduled; + } + + /** + * Builds a SchedulerClient that can commit against the entity-store DataSource. + * + * @param dataSource entity-store pool + * @return client bound to {@link TableMaintenanceSchedulerPools#CRON_TASKS_TABLE} + */ + static SchedulerClient client(DataSource dataSource) { + return SchedulerClient.Builder.create( + AutocommitDataSources.wrap(dataSource), TableMaintenanceSchedulerPools.cronJobTask()) + .serializer(new JacksonSerializer(JsonUtils.anyFieldMapper())) + .tableName(TableMaintenanceSchedulerPools.CRON_TASKS_TABLE) + .build(); + } + + private int enqueueSnapshotExpiry( + SchedulerClient client, + PolicyEntity policy, + List<ExpandTableCandidate> slice, + int batchCapacity, + Instant due) { + List<Long> batchable = new ArrayList<>(); + int scheduled = 0; + for (ExpandTableCandidate candidate : slice) { + if (candidate.batchableForSnapshotExpiry()) { + if (!isQueuedForPolicy(client, candidate.tableId(), policy.id())) { + batchable.add(candidate.tableId()); + } + } else if (scheduleTable(client, policy, candidate.tableId(), due)) { + scheduled++; + } + } + scheduled += packIntoBatches(client, policy, batchable, batchCapacity, due); + return scheduled; + } + + private int packIntoBatches( + SchedulerClient client, + PolicyEntity policy, + List<Long> tableIds, + int batchCapacity, + Instant due) { + if (tableIds.isEmpty()) { + return 0; + } + // Same-catalog batches only: mixed catalogs would reuse wrong spark.sql.catalog.* props. + Map<String, List<Long>> byCatalog = groupByCatalog(tableIds); + int newRows = 0; + for (Map.Entry<String, List<Long>> entry : byCatalog.entrySet()) { + newRows += + packSameCatalog(client, policy, entry.getKey(), entry.getValue(), batchCapacity, due); + } + return newRows; + } + + private Map<String, List<Long>> groupByCatalog(List<Long> tableIds) { + List<ResolvedTableRef> resolved = tableRefResolver.apply(tableIds); + Map<Long, ResolvedTableRef> byId = new LinkedHashMap<>(); + for (ResolvedTableRef ref : resolved) { + byId.put(ref.tableId(), ref); + } + Map<String, List<Long>> byCatalog = new LinkedHashMap<>(); + for (Long tableId : tableIds) { + ResolvedTableRef ref = byId.get(tableId); + if (ref == null) { + LOG.warn("Skipping unresolved table {} from snapshot-expiry batch packing", tableId); + continue; + } + byCatalog.computeIfAbsent(ref.catalogName(), ignored -> new ArrayList<>()).add(tableId); + } + return byCatalog; + } + + private int packSameCatalog( + SchedulerClient client, + PolicyEntity policy, + String catalogName, + List<Long> tableIds, + int batchCapacity, + Instant due) { + List<Long> remaining = new ArrayList<>(tableIds); + int newRows = 0; + while (!remaining.isEmpty()) { + Optional<OpenBatch> incomplete = + findIncompleteBatch(client, policy.id(), batchCapacity, catalogName); + if (incomplete.isPresent()) { + OpenBatch open = incomplete.get(); + int room = batchCapacity - open.spec.tableIds().size(); + List<Long> chunk = new ArrayList<>(remaining.subList(0, Math.min(room, remaining.size()))); + if (mergeIntoBatch(client, open, chunk)) { + remaining.subList(0, chunk.size()).clear(); + continue; + } + } + int end = Math.min(batchCapacity, remaining.size()); + List<Long> chunk = new ArrayList<>(remaining.subList(0, end)); + if (scheduleBatch(client, policy, chunk, due)) { + newRows++; + remaining.subList(0, end).clear(); + continue; + } + // Fail-closed: do not drop a whole multi-table chunk on build failure — peel one id. + Long first = remaining.remove(0); + if (chunk.size() > 1 && scheduleBatch(client, policy, List.of(first), due)) { + newRows++; + } else { + LOG.warn( + "Dropping table {} from snapshot-expiry enqueue for policy {} after failed batch build", + first, + policy.id()); + } + } + return newRows; + } + + private boolean scheduleTable( + SchedulerClient client, PolicyEntity policy, long tableId, Instant due) { + Optional<MaintenanceJobSpec> spec = buildSpec(policy, List.of(tableId)); + if (spec.isEmpty()) { + return false; + } + String id = CronJobIds.forTable(tableId, policy.id()); + return client.scheduleIfNotExists( + new TaskInstance<>(TableMaintenanceSchedulerPools.CRON_JOB, id, spec.get()), due); + } + + private boolean scheduleBatch( + SchedulerClient client, PolicyEntity policy, List<Long> tableIds, Instant due) { + Optional<MaintenanceJobSpec> spec = buildSpec(policy, tableIds); + if (spec.isEmpty()) { + return false; + } + long batchId = nextBatchId(client, policy.id()); + String id = CronJobIds.forBatch(batchId, policy.id()); + return client.scheduleIfNotExists( + new TaskInstance<>(TableMaintenanceSchedulerPools.CRON_JOB, id, spec.get()), due); + } + + private boolean mergeIntoBatch(SchedulerClient client, OpenBatch open, List<Long> extraTableIds) { + Map<Long, TableTarget> byId = new LinkedHashMap<>(); + for (TableTarget target : open.spec.getTables()) { + byId.put(target.getTableId(), target); + } + String openCatalog = open.spec.getTables().get(0).catalogName(); + if (openCatalog == null || openCatalog.isEmpty()) { + LOG.warn( + "Could not merge tables into cron-job {}: open batch missing catalog name", + open.instanceId.getId()); + return false; + } + List<ResolvedTableRef> resolved = tableRefResolver.apply(extraTableIds); + // Require every requested id so packIntoBatches does not drop unresolved tables from + // remaining after a partial merge. + if (resolved.size() != extraTableIds.size()) { + LOG.warn( + "Could not merge tables into cron-job {}: resolved {} of {} ids {}", + open.instanceId.getId(), + resolved.size(), + extraTableIds.size(), + extraTableIds); + return false; + } + String metalake = open.spec.metalake(); + if (metalake == null) { + LOG.warn( + "Could not merge tables into cron-job {}: missing metalake on open spec", + open.instanceId.getId()); + return false; + } + boolean added = false; + for (ResolvedTableRef ref : resolved) { + if (!openCatalog.equals(ref.catalogName())) { + LOG.warn( + "Could not merge table {} into cron-job {}: catalog {} != open batch catalog {}", + ref.tableId(), + open.instanceId.getId(), + ref.catalogName(), + openCatalog); + return false; + } + if (!byId.containsKey(ref.tableId())) { + byId.put(ref.tableId(), MaintenanceJobSpecFactory.toTarget(metalake, ref)); + added = true; + } + } + if (!added) { + return true; + } + MaintenanceJobSpec merged = + new MaintenanceJobSpec( + new ArrayList<>(byId.values()), + open.spec.getOperations(), + open.spec.getJobConf()); + try { + return client.reschedule(open.instanceId, open.executionTime, merged); + } catch (RuntimeException e) { + LOG.debug( + "Could not merge tables into cron-job {}: {}", open.instanceId.getId(), e.toString()); + return false; + } + } + + private Optional<MaintenanceJobSpec> buildSpec(PolicyEntity policy, List<Long> tableIds) { + List<ResolvedTableRef> resolved = tableRefResolver.apply(tableIds); + if (resolved.size() != tableIds.size()) { + LOG.warn( + "Skipping cron-job enqueue for policy {}: resolved {} of {} table ids {}", + policy.id(), + resolved.size(), + tableIds.size(), + tableIds); + return Optional.empty(); + } + try { + return Optional.of(specFactory.forCron(policy, resolved)); + } catch (RuntimeException e) { + LOG.warn( + "Skipping cron-job enqueue for policy {}: failed to build jobConf / MaintenanceJobSpec: {}", + policy.id(), + e.toString()); + return Optional.empty(); + } + } + + private static Optional<OpenBatch> findIncompleteBatch( + SchedulerClient client, long policyId, int batchCapacity, String catalogName) { + return listOpenBatches(client, policyId).stream() + .filter(b -> !b.picked && b.spec.tableIds().size() < batchCapacity) + .filter(b -> catalogName.equals(b.spec.getTables().get(0).catalogName())) + .min(Comparator.comparingLong(b -> b.batchId)); + } + + private static List<OpenBatch> listOpenBatches(SchedulerClient client, long policyId) { + List<OpenBatch> open = new ArrayList<>(); + for (ScheduledExecution<MaintenanceJobSpec> execution : + client.getScheduledExecutionsForTask( + TableMaintenanceSchedulerPools.CRON_JOB, MaintenanceJobSpec.class)) { + Optional<CronJobIds.BatchPolicyIds> parsed = + CronJobIds.parseBatch(execution.getTaskInstance().getId()); + if (parsed.isEmpty() || parsed.get().policyId() != policyId) { + continue; + } + MaintenanceJobSpec data = execution.getData(); + if (data == null || !data.isValid()) { + continue; + } + open.add( + new OpenBatch( + TaskInstanceId.of( + TableMaintenanceSchedulerPools.CRON_JOB, execution.getTaskInstance().getId()), + parsed.get().batchId(), + execution.getExecutionTime(), + data, + execution.isPicked())); + } + return open; + } + + static long nextBatchId(SchedulerClient client, long policyId) { + long max = -1L; + for (OpenBatch batch : listOpenBatches(client, policyId)) { + max = Math.max(max, batch.batchId); + } + return max + 1L; + } + + /** True when a table already has a queued {@code table:} or {@code batch:} cron-job. */ + static boolean isQueuedForPolicy(SchedulerClient client, long tableId, long policyId) { + if (client + .getScheduledExecution( + TaskInstanceId.of( + TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forTable(tableId, policyId))) + .isPresent()) { + return true; + } + for (OpenBatch batch : listOpenBatches(client, policyId)) { + if (batch.spec.tableIds().contains(tableId)) { + return true; + } + } + return false; + } + + private static final class OpenBatch { + private final TaskInstanceId instanceId; + private final long batchId; + private final Instant executionTime; + private final MaintenanceJobSpec spec; + private final boolean picked; + + private OpenBatch( + TaskInstanceId instanceId, + long batchId, + Instant executionTime, + MaintenanceJobSpec spec, + boolean picked) { + this.instanceId = instanceId; + this.batchId = batchId; + this.executionTime = executionTime; + this.spec = spec; + this.picked = picked; + } + } +} diff --git a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestCronJobIds.java b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestCronJobIds.java new file mode 100644 index 0000000000..57f89116af --- /dev/null +++ b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestCronJobIds.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TestCronJobIds { + + @Test + void testForTableAndParseRoundTrip() { + String id = CronJobIds.forTable(10L, 20L); + Assertions.assertEquals("table:10:20", id); + Optional<CronJobIds.TablePolicyIds> parsed = CronJobIds.parseTable(id); + Assertions.assertTrue(parsed.isPresent()); + Assertions.assertEquals(10L, parsed.get().tableId()); + Assertions.assertEquals(20L, parsed.get().policyId()); + } + + @Test + void testForBatchAndParseRoundTrip() { + Assertions.assertEquals("batch:0:9", CronJobIds.forBatch(0L, 9L)); + Assertions.assertEquals("batch:12:9", CronJobIds.forBatch(12L, 9L)); + Optional<CronJobIds.BatchPolicyIds> parsed = CronJobIds.parseBatch("batch:12:9"); + Assertions.assertTrue(parsed.isPresent()); + Assertions.assertEquals(12L, parsed.get().batchId()); + Assertions.assertEquals(9L, parsed.get().policyId()); + } + + @Test + void testParseRejectsInvalid() { + Assertions.assertTrue(CronJobIds.parseTable(null).isEmpty()); + Assertions.assertTrue(CronJobIds.parseTable("batch:1:2").isEmpty()); + Assertions.assertTrue(CronJobIds.parseTable("table:").isEmpty()); + Assertions.assertTrue(CronJobIds.parseTable("table:1").isEmpty()); + Assertions.assertTrue(CronJobIds.parseTable("table:a:2").isEmpty()); + Assertions.assertTrue(CronJobIds.parseBatch(null).isEmpty()); + Assertions.assertTrue(CronJobIds.parseBatch("table:1:2").isEmpty()); + Assertions.assertTrue(CronJobIds.parseBatch("batch:pending:9").isEmpty()); + } +} diff --git a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestCronJobScheduler.java b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestCronJobScheduler.java new file mode 100644 index 0000000000..57a4c17596 --- /dev/null +++ b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestCronJobScheduler.java @@ -0,0 +1,325 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.gravitino.maintenance.scheduler.job.cron; + +import com.github.kagkarlsson.scheduler.Scheduler; +import com.github.kagkarlsson.scheduler.SchedulerClient; +import com.github.kagkarlsson.scheduler.serializer.JacksonSerializer; +import com.github.kagkarlsson.scheduler.task.TaskInstanceId; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import javax.sql.DataSource; +import org.apache.gravitino.Config; +import org.apache.gravitino.MetadataObject; +import org.apache.gravitino.json.JsonUtils; +import org.apache.gravitino.maintenance.policy.MaintenanceAuthConfigs; +import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec; +import org.apache.gravitino.maintenance.scheduler.TableMaintenanceSchedulerPools; +import org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceTableRefs.ResolvedTableRef; +import org.apache.gravitino.maintenance.scheduler.policy.expand.ExpandTableCandidate; +import org.apache.gravitino.meta.AuditInfo; +import org.apache.gravitino.meta.PolicyEntity; +import org.apache.gravitino.policy.IcebergDataCompactionContent; +import org.apache.gravitino.policy.Policy; +import org.apache.gravitino.policy.PolicyContents; +import org.apache.gravitino.storage.relational.TestJDBCBackend; +import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper; +import org.apache.gravitino.utils.NamespaceUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.TestTemplate; +import org.apache.gravitino.maintenance.scheduler.policy.expand.ExpandOccupancyGate; + +class TestCronJobScheduler extends TestJDBCBackend { + + private static final String GRAVITINO_URI = "http://localhost:8090"; + + @TestTemplate + void testEnqueueIsIdempotentAndRespectsBatchSize() { + DataSource dataSource = dataSource(); + CronJobScheduler scheduler = scheduler(dataSource); + PolicyEntity policy = compactionPolicy(7L); + + List<ExpandTableCandidate> three = + List.of( + candidate(1L, MetadataObject.Type.TABLE), + candidate(2L, MetadataObject.Type.TABLE), + candidate(3L, MetadataObject.Type.TABLE)); + Assertions.assertEquals(2, scheduler.enqueueDueNow(policy, three, 2)); + Assertions.assertEquals( + 0, + scheduler.enqueueDueNow( + policy, + List.of( + candidate(1L, MetadataObject.Type.TABLE), candidate(2L, MetadataObject.Type.TABLE)), + 2)); + + SchedulerClient client = RelationalCronJobScheduler.client(dataSource); + Assertions.assertTrue( + client + .getScheduledExecution( + TaskInstanceId.of( + TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forTable(1L, 7L))) + .isPresent()); + Assertions.assertTrue( + client + .getScheduledExecution( + TaskInstanceId.of( + TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forTable(2L, 7L))) + .isPresent()); + Assertions.assertTrue( + client + .getScheduledExecution( + TaskInstanceId.of( + TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forTable(3L, 7L))) + .isEmpty()); + + MaintenanceJobSpec spec = + (MaintenanceJobSpec) + client + .getScheduledExecution( + TaskInstanceId.of( + TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forTable(1L, 7L))) + .get() + .getData(); + Assertions.assertTrue(spec.isValid()); + Assertions.assertTrue(spec.getJobConf().containsKey("updater_options")); + Assertions.assertEquals(List.of(1L), spec.tableIds()); + } + + @TestTemplate + void testSnapshotExpiryFillsIncompleteBatchBeforeAllocatingNextId() { + DataSource dataSource = dataSource(); + CronJobScheduler scheduler = scheduler(dataSource); + PolicyEntity policy = expirePolicy(9L); + + Assertions.assertEquals( + 1, + scheduler.enqueueDueNow( + policy, + List.of( + candidate(10L, MetadataObject.Type.SCHEMA), + candidate(11L, MetadataObject.Type.SCHEMA)), + 10)); + + Assertions.assertEquals( + 0, + scheduler.enqueueDueNow( + policy, + List.of( + candidate(10L, MetadataObject.Type.SCHEMA), + candidate(11L, MetadataObject.Type.SCHEMA), + candidate(12L, MetadataObject.Type.CATALOG), + candidate(13L, MetadataObject.Type.SCHEMA)), + 10)); + + SchedulerClient client = RelationalCronJobScheduler.client(dataSource); + TaskInstanceId batch0 = + TaskInstanceId.of(TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forBatch(0L, 9L)); + TaskInstanceId batch1 = + TaskInstanceId.of(TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forBatch(1L, 9L)); + Assertions.assertTrue(client.getScheduledExecution(batch0).isPresent()); + Assertions.assertTrue(client.getScheduledExecution(batch1).isEmpty()); + MaintenanceJobSpec data = + (MaintenanceJobSpec) client.getScheduledExecution(batch0).get().getData(); + Assertions.assertEquals(List.of(10L, 11L, 12L, 13L), data.tableIds()); + Assertions.assertEquals(1, data.getOperations().size()); + Assertions.assertTrue(data.getJobConf().containsKey("updater_options")); + } + + @TestTemplate + void testSnapshotExpiryAllocatesNextBatchWhenCapacityFull() { + DataSource dataSource = dataSource(); + CronJobScheduler scheduler = scheduler(dataSource); + PolicyEntity policy = expirePolicy(9L); + + Assertions.assertEquals( + 1, + scheduler.enqueueDueNow( + policy, + List.of( + candidate(1L, MetadataObject.Type.SCHEMA), + candidate(2L, MetadataObject.Type.SCHEMA)), + 2)); + Assertions.assertEquals( + 1, + scheduler.enqueueDueNow( + policy, + List.of( + candidate(1L, MetadataObject.Type.SCHEMA), + candidate(2L, MetadataObject.Type.SCHEMA), + candidate(3L, MetadataObject.Type.SCHEMA)), + 2)); + + SchedulerClient client = RelationalCronJobScheduler.client(dataSource); + MaintenanceJobSpec batch0 = + (MaintenanceJobSpec) + client + .getScheduledExecution( + TaskInstanceId.of( + TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forBatch(0L, 9L))) + .get() + .getData(); + MaintenanceJobSpec batch1 = + (MaintenanceJobSpec) + client + .getScheduledExecution( + TaskInstanceId.of( + TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forBatch(1L, 9L))) + .get() + .getData(); + Assertions.assertEquals(List.of(1L, 2L), batch0.tableIds()); + Assertions.assertEquals(List.of(3L), batch1.tableIds()); + } + + @TestTemplate + void testPendingCronGateSkipsQueuedTables() { + DataSource dataSource = dataSource(); + CronJobScheduler scheduler = scheduler(dataSource); + Assertions.assertEquals( + 1, + scheduler.enqueueDueNow( + expirePolicy(9L), + List.of( + candidate(10L, MetadataObject.Type.SCHEMA), + candidate(11L, MetadataObject.Type.SCHEMA)), + 10)); + + PendingCronExpandOccupancyGate gate = + new PendingCronExpandOccupancyGate((t, p, m, n) -> false, dataSource); + Assertions.assertTrue(gate.shouldSkip(10L, 9L, 0L, System.currentTimeMillis())); + Assertions.assertFalse(gate.shouldSkip(12L, 9L, 0L, System.currentTimeMillis())); + } + + @TestTemplate + void testCronJobTaskRemovesRowAfterPick() throws Exception { + DataSource dataSource = dataSource(); + CronJobScheduler enqueue = scheduler(dataSource); + Assertions.assertEquals( + 1, + enqueue.enqueueDueNow( + compactionPolicy(9L), List.of(candidate(99L, MetadataObject.Type.TABLE)), 1)); + + TaskInstanceId instanceId = + TaskInstanceId.of(TableMaintenanceSchedulerPools.CRON_JOB, CronJobIds.forTable(99L, 9L)); + SchedulerClient client = RelationalCronJobScheduler.client(dataSource); + Assertions.assertTrue(client.getScheduledExecution(instanceId).isPresent()); + + Scheduler cronPool = + Scheduler.create(dataSource, TableMaintenanceSchedulerPools.cronJobTask()) + .threads(1) + .pollingInterval(Duration.ofMillis(100)) + .serializer(new JacksonSerializer(JsonUtils.anyFieldMapper())) + .tableName(TableMaintenanceSchedulerPools.CRON_TASKS_TABLE) + .commitWhenAutocommitDisabled(true) + .build(); + cronPool.start(); + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (client.getScheduledExecution(instanceId).isPresent()) { + Assertions.assertTrue( + System.nanoTime() < deadline, "cron-job row was not removed after pick"); + Thread.sleep(100L); + } + } finally { + cronPool.stop(); + } + + Assertions.assertEquals( + 1, + enqueue.enqueueDueNow( + compactionPolicy(9L), List.of(candidate(99L, MetadataObject.Type.TABLE)), 1)); + Assertions.assertTrue(client.getScheduledExecution(instanceId).isPresent()); + } + + private static CronJobScheduler scheduler(DataSource dataSource) { + return new RelationalCronJobScheduler( + dataSource, + configWithUri(), + ids -> + ids.stream() + .map(id -> new ResolvedTableRef(id, "iceberg", "db.t" + id)) + .collect(Collectors.toList())); + } + + private static DataSource dataSource() { + return SqlSessionFactoryHelper.getInstance() + .getSqlSessionFactory() + .getConfiguration() + .getEnvironment() + .getDataSource(); + } + + private static Config configWithUri() { + Config config = new Config(false) {}; + config.loadFromMap( + Map.of( + MaintenanceAuthConfigs.GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_URI.getKey(), + GRAVITINO_URI), + t -> true); + return config; + } + + private static ExpandTableCandidate candidate(long tableId, MetadataObject.Type level) { + return new ExpandTableCandidate(tableId, level); + } + + private static PolicyEntity compactionPolicy(long id) { + return PolicyEntity.builder() + .withId(id) + .withName("p-" + id) + .withNamespace(NamespaceUtil.ofPolicy("ml")) + .withPolicyType(Policy.BuiltInType.ICEBERG_COMPACTION) + .withEnabled(true) + .withContent( + PolicyContents.icebergDataCompaction( + IcebergDataCompactionContent.DEFAULT_MIN_DATA_FILE_MSE, + IcebergDataCompactionContent.DEFAULT_MIN_DELETE_FILE_NUMBER, + IcebergDataCompactionContent.DEFAULT_DATA_FILE_MSE_WEIGHT, + IcebergDataCompactionContent.DEFAULT_DELETE_FILE_NUMBER_WEIGHT, + IcebergDataCompactionContent.DEFAULT_MAX_PARTITION_NUM, + "binpack", + "", + Map.of(), + null, + true, + null, + Map.of("spark_executor_memory", "4g"), + Map.of())) + .withAuditInfo( + AuditInfo.builder().withCreator("test").withCreateTime(Instant.EPOCH).build()) + .build(); + } + + private static PolicyEntity expirePolicy(long id) { + return PolicyEntity.builder() + .withId(id) + .withName("e-" + id) + .withNamespace(NamespaceUtil.ofPolicy("ml")) + .withPolicyType(Policy.BuiltInType.ICEBERG_SNAPSHOT_EXPIRATION) + .withEnabled(true) + .withContent(PolicyContents.icebergSnapshotExpiration()) + .withAuditInfo( + AuditInfo.builder().withCreator("test").withCreateTime(Instant.EPOCH).build()) + .build(); + } +}
