Repository navigation
[Improve][Zeta] Share checkpoint trigger scheduling across active pipelines - #12165
Vivek1106-04 wants to merge 16 commits into
Conversation
0521939 to
0032ddc
Compare
DanielLeens
left a comment
There was a problem hiding this comment.
What Problem Does This PR Solve?
User pain point: Every CheckpointCoordinator built its own dedicated two-thread ScheduledExecutorService (CheckpointCoordinator.java:227-238 on dev), and CheckpointManager builds one coordinator per pipeline (CheckpointManager.java:119-168). Timer threads therefore grew linearly with the number of active checkpoint-enabled pipelines on a member: a member running 500 pipelines held up to 1000 mostly-sleeping timer threads, purely to re-arm a periodic trigger and arm a timeout watchdog.
Fix approach: Introduce one SharedCheckpointScheduler per member (owned by SeaTunnelEngineContext, closed with it) backed by two pools: a tiny fixed checkpoint-timer pool that only fires and hands off, and an elastic checkpoint-dispatcher pool that actually runs the body, so a slow checkpoint body never delays another pipeline's timer. Each CheckpointCoordinator now leases a PipelineCheckpointScheduler from CheckpointManager instead of owning a pool; the lease tracks only its own outstanding tasks so cancelAll() stays scoped to one pipeline, and — critically — the lease is reusable after cancelAll(), so a coordinator restored on master failover (restoreCoordinator() -> cleanPendingCheckpoint(CHECKPOINT_COORDINATOR_RESET)) keeps working without rebuilding a pool.
One-sentence summary: This replaces N dedicated per-pipeline timer pools with one shared member-wide timer + elastic dispatch pool, cutting a 500-pipeline member's checkpoint-timer thread count from ~1000 to 2, with no behavioral change beyond thread names in stack dumps.
Example: Before vs After
A member running 500 checkpoint-enabled pipelines, each with checkpoint.interval set:
- Before: 500
CheckpointCoordinatorinstances, each with its ownExecutors.newScheduledThreadPool(2, ...)namedcheckpoint-coordinator-<pipelineId>/<jobId>-> up to 1000 live timer threads sitting idle almost all the time. - After: 2
checkpoint-timer-%dthreads total for the whole member, plus acheckpoint-dispatcher-%dcached pool that only grows while checkpoint bodies are actually being handed off. This is exactly whatSharedCheckpointSchedulerTest#testTimerThreadCountStaysFlatAsPipelinesGrowasserts (500 leases scheduling a task keepgetTimerPoolSize() <= 2).
1. Code Change Review
1.1 Core Logic Analysis
Runtime flow that reaches the changed code:
CheckpointManager.addCoordinator(...) [per-pipeline coordinator construction]
-> new CheckpointCoordinator(manager, ...)
-> this.scheduler = manager.leaseCheckpointScheduler(pipelineId) // CheckpointCoordinator.java:229
-> CheckpointManager.leaseCheckpointScheduler(pipelineId)
-> this.checkpointScheduler.lease(jobId, pipelineId) // one lease per (jobId, pipelineId)
Periodic trigger path (steady state):
tryTriggerPendingCheckpoint() [synchronized(lock)]
-> scheduleTriggerPendingCheckpoint(type, delay)
-> scheduler.schedule(() -> tryTriggerPendingCheckpoint(type), delay, MILLISECONDS) // PipelineCheckpointScheduler.schedule()
-> new PipelineCheckpointTask(MDCTracer.tracing(command), outstanding::remove)
-> parent.dispatchAfter(task, delay, unit) // SharedCheckpointScheduler: timer.schedule(() -> submit(task), ...)
[checkpoint-timer-%d fires after `delay`]
-> dispatcher.execute(task) // hand-off only, no blocking work here
[checkpoint-dispatcher-%d actually runs task.run() -> body.run() -> tryTriggerPendingCheckpoint(...)]
Timeout watchdog path:
triggerCheckpoint(...) -> pendingCheckpoint.setCheckpointTimeOutFuture(scheduler.schedule(() -> { ... }, timeout, MILLISECONDS)) // CheckpointCoordinator.java:972-989
[re-checks pendingCheckpoints.get(id) != null && !isFullyAcknowledged() before firing CHECKPOINT_EXPIRED]
Reset / master-failover path:
restoreCoordinator(alreadyStarted) // CheckpointCoordinator.java:664
-> cleanPendingCheckpoint(CHECKPOINT_COORDINATOR_RESET)
-> scheduler.cancelAll() // CheckpointCoordinator.java:1196 (was: scheduler.shutdownNow() + rebuild pool)
-> shutdown = false // CheckpointCoordinator.java:673, same instance resumes scheduling on the SAME lease
Precise change: CheckpointCoordinator.scheduler goes from a mutable, self-owned ScheduledExecutorService (rebuilt on every cleanPendingCheckpoint()/reset via Executors.newScheduledThreadPool(2, ...), CheckpointCoordinator.java:227-238 and :1200-1211 on dev) to a final transient PipelineCheckpointScheduler obtained once at construction (CheckpointCoordinator.java:155,229) and never rebuilt; cleanPendingCheckpoint() now calls scheduler.cancelAll() (:1196) instead of shutdownNow() + reconstruction. Three new classes carry the mechanism: SharedCheckpointScheduler (member-wide, two real pools), PipelineCheckpointScheduler (per-pipeline lease, tracks its own outstanding ConcurrentHashMap.newKeySet()), PipelineCheckpointTask (a hand-rolled ScheduledFuture that crosses both pools and re-checks its own AtomicBoolean cancelled at run time because the timer-side future is already complete by the time the body runs on the dispatch pool).
Key findings from tracing the invariants that must survive this refactor:
- Single-pending-checkpoint guard is untouched.
tryTriggerPendingCheckpoint()still enterssynchronized (lock)and checksisCompleted() || isShutdown()before doing anything (verified atCheckpointCoordinator.java:774), and thependingCounter.get() > 0short-circuit at:791is unmodified. Sincelockis a per-coordinator (per-pipeline) instance field, moving the timer to a shared pool cannot introduce cross-pipeline contention on this guard — every dispatch thread that happens to run pipeline A's body only ever contends with other threads running pipeline A's body. - Master-failover restore/reuse is correctly preserved. I traced
restoreCoordinator()(:664) ->cleanPendingCheckpoint(CHECKPOINT_COORDINATOR_RESET)->scheduler.cancelAll()(:1196) ->shutdown = false(:673) and confirmed the SAMECheckpointCoordinatorinstance (and therefore the same lease) is reused after a reset, exactly matching the old semantics where the executor was destroyed and rebuilt in place. This is directly exercised bySharedCheckpointSchedulerTest#testLeaseIsReusableAfterCancelAll. - Timeout watchdog firing after a stale cancellation is harmless by construction. The watchdog body re-checks
pendingCheckpoints.get(pendingCheckpoint.getCheckpointId()) != null(CheckpointCoordinator.java:977-981) before acting, andpendingCheckpoints.clear()happens inside the samesynchronized(lock)block asscheduler.cancelAll()incleanPendingCheckpoint(). So even in the narrow race described below, a stale watchdog/trigger firing after a reset is a guaranteed no-op. PipelineCheckpointTaskcross-pool cancellation has a narrow, provably-benign race (see Issue 1) — worth calling out explicitly for an engine/checkpoint change even though it is not exploitable given the guards above.- MDC context propagation is equivalent, not just claimed. The old code wrapped the whole executor once via
MDCTracer.tracing(scheduler); the new code wraps each command at schedule time viaMDCTracer.tracing(command)insidePipelineCheckpointScheduler.schedule(). I checkedMDCTracer.tracing(Runnable)->tracing(MDCContext.current(), delegate)andMDCRunnable.run()(activates the context, runs delegate, closes it) — sincescheduleTriggerPendingCheckpoint()is itself invoked recursively from inside an already-activeMDCRunnable(the previous scheduled body),MDCContext.current()correctly resolves to this pipeline's context at every re-arm, not just the first one. Equivalent to the old behavior. - The claim about
createCoordinatorExecutor()is accurate. I verifiedCoordinatorService.java:287-298: it really is aThreadPoolExecutorover aSynchronousQueuewithThreadPoolStatus.RejectionCountingHandler, i.e. a bounded hand-off queue that can reject under saturation — this is a correct, verified justification for keeping checkpoint dispatch off that pool. - Only one
CheckpointManager/SeaTunnelEngineContextconstruction path exists in production (SeaTunnelServer.java:155builds the context once;CheckpointManager's constructor resolvesengineContext.getCheckpointScheduler()which is a non-nullfinalfield set unconditionally inSeaTunnelEngineContext's private constructor) — thesharedScheduler != null ? sharedScheduler : new SharedCheckpointScheduler()fallback inCheckpointManageris reachable only whenengineContextitself is a test double (e.g. a Mockito mock returningnull), never in production. Confirmed by grepping the whole tree forSharedCheckpointScheduler/PipelineCheckpointScheduler/PipelineCheckpointTaskusages — exactly the 8 files in this diff reference them, no other call site was missed. - Lifecycle wiring is complete:
SeaTunnelEngineContext.close()now callscheckpointScheduler.close()beforestateStores.close()(new), andSeaTunnelServer.java:256callsengineContext.close()on shutdown — so the shared pools are torn down exactly once, at member shutdown, not per-pipeline.
1.2 Compatibility Impact
Classification: Fully compatible.
- No config option added/removed/renamed, no default changed (verified: no new
Option/OptionRuleentries anywhere in the diff). - No checkpoint state, serialization format, or storage schema is touched —
ProtoStuffSerializer,PendingCheckpoint, and the checkpoint storage layer are untouched by this diff. A checkpoint/savepoint written by a pre-PR member restores identically on a post-PR member and vice versa, since nothing about what gets persisted changed, only how the in-memory timer is implemented. - The only externally observable difference is thread naming in stack dumps/profiles (
checkpoint-coordinator-<pipelineId>/<jobId>->checkpoint-timer-%d/checkpoint-dispatcher-%d), which I confirmed viagit grephas no other reference anywhere in the codebase (docs, tests, tooling) that depends on the old name. - Per-pipeline checkpoint isolation is preserved:
PipelineCheckpointScheduler.outstandingis a private, per-leaseConcurrentHashMap.newKeySet(), socancelAll()on one pipeline cannot affect a sibling's outstanding tasks — directly proven bySharedCheckpointSchedulerTest#testCancelAllOnOneLeaseLeavesSiblingPipelineRunning.
1.3 Performance / Side-Effect Analysis
- Positive, and the intended fix: timer-thread count drops from O(active pipelines) to a constant 2 per member. This is a real reduction in idle-thread memory/scheduling overhead on members hosting many small pipelines.
- New shared resource, worth flagging for a financial-grade review: the dispatch pool is
Executors.newCachedThreadPool(...)(SharedCheckpointSchedulerconstructor) — unbounded. Today this is safe because the dispatched body is documented and structurally verified to be non-blocking (the heavy barrier work is off-loaded again viathenApplyAsync(..., executorService); the dispatch-pool thread's job is just to invoketryTriggerPendingCheckpoint's synchronous, per-pipeline-locked portion and return). But because this pool is now shared across every pipeline on the member (previously each pipeline had its own worst-case-bounded 2-thread pool), a future change that makes the dispatched body block for any reason (e.g. a slow synchronous call added inside thesynchronized(lock)section) would no longer be bounded per pipeline — it could grow the shared pool by one thread per concurrently-blocked pipeline. See Issue 2. - No new locks are introduced;
lockremains a per-coordinator field, so no new cross-pipeline contention. RejectedExecutionExceptionfrom either pool during shutdown is caught and the task is cancelled rather than lost (SharedCheckpointScheduler.dispatchAfter/submit), so no silent task leak on shutdown.
1.4 Error Handling and Logging
- Rejection during shutdown is logged at
debug(SharedCheckpointScheduler.java,dispatchAfter/submit) — appropriate level, since this is an expected event during normal shutdown, not an error condition. - No sensitive data is logged anywhere in the new code (thread names, job/pipeline ids only).
- See Issue 1 for the one correctness-adjacent note in this area (not an error-handling gap, a scheduling race that is already absorbed by existing state guards).
2. Code Quality Assessment
2.1 Coding Standards
- All three new classes (
SharedCheckpointScheduler,PipelineCheckpointScheduler,PipelineCheckpointTask) carry clear class-level Javadoc explaining ownership, lifetime, and the two-pool split rationale. Non-trivial methods (lease,dispatchAfter,schedule,cancelAll,attachTimerFuture) all have Javadoc explaining the "why", not just restating the signature — e.g.attachTimerFuture's note about losing the race againstcancel(). This meets the bar for new core methods/fields; no missing-Javadoc issue to raise. CheckpointManager.checkpointSchedulerandSeaTunnelEngineContext.checkpointSchedulerfields both have Javadoc explaining ownership and why a fallback exists. Good.
2.2 Test Coverage and Test Stability
Coverage: SharedCheckpointSchedulerTest (new, 8 tests) directly proves every invariant claimed in the PR description: body runs on dispatch thread not timer thread; cancelAll() is pipeline-scoped; cancel-before-fire and cancel-after-fire both stop the body; a lease is reusable after cancelAll() (the master-failover path); finished tasks self-remove (no accumulation); schedule-after-close returns an already-cancelled future; and the acceptance criterion from the linked issue (500 leases keep the timer pool at 2 threads) is asserted directly rather than inferred. CheckpointCoordinatorTest is updated so all pre-existing coordinator tests continue to exercise real lease/cancel behavior instead of a manager mock returning null for leaseCheckpointScheduler (which would otherwise NPE on cleanup) — I confirmed every new CheckpointCoordinator(...) call site in that file (10 of them) is now backed either by the new mockCheckpointManager() helper or by a real SeaTunnelEngineContext obtained from a live test server (TestCheckpointManager, which was never a bare Mockito mock to begin with).
Test stability rating: Stable, with one Low-risk note:
SharedCheckpointSchedulerTest.testCancelBeforeTimerFiresStopsTheBody(assertingran.get()==falseafterThread.sleep(800)following a 500ms-delay cancel) usesThread.sleepwhere the rest of the suite consistently usesCountDownLatch/await. Because this is a negative assertion guarded by an upfront synchronouscancel()call, the only failure mode is a false failure under extreme host contention (the sleep window being too short relative to a slow test runner), never a false pass that hides a real regression — so I rate this Low risk rather than High, but it's still the one place in an otherwise very clean,Thread.sleep-free suite. See Issue 3.- All other 7 tests are deterministic:
CountDownLatch/await(10s)-based, fresh scheduler per test (@BeforeEach/@AfterEach), no shared mutable state, no order dependence, no floating-point comparisons. SupportMultiTableSinkWritercontract check: not applicable — this PR touches noSinkWriter/Sinkcode.
2.3 Documentation Updates
None needed, and none made. Confirmed via git grep across docs/en and docs/zh that no page references the old checkpoint-coordinator-<pipelineId>/<jobId> thread-naming pattern or any checkpoint-scheduler-specific config, so there is nothing to update. No new engine Option was introduced (a deliberate, well-reasoned choice per the PR description, to avoid a user-facing config contract for something that should not need tuning) — I agree with that call for this change's scope.
3. Architectural Soundness
3.1 Precise fix vs. workaround vs. long-term solution
This is the long-term/root-cause fix, not a workaround: the actual defect was "the timer pool's lifetime and cardinality is tied to the coordinator's lifetime and count," and this PR fixes exactly that by relocating pool ownership to the member (SeaTunnelEngineContext) while keeping cancellation scoped correctly via the lease abstraction. It does not paper over the symptom (e.g. by capping pipeline count or adding a config knob) — it removes the structural cause of thread growth.
3.2 Maintainability
The three-class split (SharedCheckpointScheduler / PipelineCheckpointScheduler / PipelineCheckpointTask) cleanly separates "member-wide pool ownership," "per-pipeline cancellation scope," and "cross-pool task lifecycle," which is easier to reason about than a single class trying to do all three. The explicit rationale comments (why not the coordinator executor, why a constant not a config) will save the next reader real investigation time.
3.3 Extensibility
The lease pattern (SharedCheckpointScheduler.lease(jobId, pipelineId)) generalizes cleanly if another per-pipeline periodic concern ever needs the same shared-timer/elastic-dispatch treatment; nothing here is checkpoint-specific except the class names and Javadoc.
3.4 Historical-Version Compatibility
No checkpoint/savepoint format, protocol, or serialization change — a job checkpointed on a pre-PR member restores identically on a post-PR member. No config default changed, so upgrading a running cluster to a build containing this PR requires no operator action. This is an internal-implementation-only change from the perspective of any persisted state or historical job.
4. Issue Summary
| Number | Issue | Location | Severity |
|---|---|---|---|
| 1 | PipelineCheckpointTask has a narrow, currently-benign race: a task already dequeued by the dispatch pool right as cancelAll() fires can still run its body once, because the cancellation check is a single unsynchronized read at the top of run() |
seatunnel-engine/seatunnel-engine-server/.../checkpoint/scheduler/PipelineCheckpointTask.java (run()/cancel()) |
Low |
| 2 | checkpoint-dispatcher pool is an unbounded newCachedThreadPool, now shared by every pipeline on the member instead of isolated per pipeline; safe today only because the dispatched body is non-blocking by design |
seatunnel-engine/seatunnel-engine-server/.../checkpoint/scheduler/SharedCheckpointScheduler.java (constructor) |
Low |
| 3 | One new test uses Thread.sleep(800) for a negative assertion instead of the CountDownLatch/Awaitility style used by the rest of the suite; false-failure risk only, not a false-pass risk |
seatunnel-engine/seatunnel-engine-server/.../checkpoint/scheduler/SharedCheckpointSchedulerTest.java (testCancelBeforeTimerFiresStopsTheBody) |
Low |
All three are non-blocking observations from a deep engine/checkpoint-level review; none of them represent an active correctness, compatibility, or data-loss risk given the guards already in place elsewhere in CheckpointCoordinator.
5. Merge Recommendation
Conclusion: Ready to merge
- Blockers — must be fixed
None.
-
Recommended fixes — non-blocking
-
Add a short code comment on
PipelineCheckpointTask.run()/cancel()documenting that the residual cross-pool cancellation race (Issue 1) is intentionally tolerated because every caller re-validates coordinator/checkpoint state before acting, so a future contributor does not "fix" it in a way that changes the cancellation contract. -
Consider a
maximumPoolSizebound (with a sane default) on thecheckpoint-dispatcherpool as defense-in-depth (Issue 2), since it is now a member-wide shared resource rather than an isolated per-pipeline pool; document the tradeoff either way. -
Replace the
Thread.sleep(800)intestCancelBeforeTimerFiresStopsTheBody(Issue 3) with an Awaitility-based negative-assertion idiom for consistency with the rest of the suite.
Overall assessment: This is an excellent first contribution — a real, well-diagnosed production concern (unbounded per-pipeline timer-thread growth), fixed at the correct root cause, with a genuinely thoughtful design (splitting timer-fire from body-execution across two pools specifically so a slow checkpoint never delays another pipeline's timer) and a test suite that proves the actual claims (thread-count-stays-flat, pipeline-scoped cancellation, reuse-after-reset) rather than just re-asserting output. I traced every invariant that matters for an engine/checkpoint change — the single-pending-checkpoint guard, master-failover restore/reuse, and MDC context propagation — and all of them are correctly preserved. Welcome to Apache SeaTunnel, and thank you for the very clear PR description and the honesty about the two deliberate design tradeoffs — that made this review considerably easier to do thoroughly. I'll take another look once CI (currently still queued on the fork) has run.
nzw921rx
left a comment
There was a problem hiding this comment.
Thank you for your wonderful speech. I have a question. What happens when the API calls savepoint and the PipeCheckpointScheduler only triggers?
|
@nzw921rx Thanks — good question, and worth spelling out since it's exactly the kind of interaction an engine/checkpoint reviewer should check on this PR. Short answer: the savepoint API path does not go through the periodic re-arm loop ( Tracing it on the current head (
So in concrete terms: if you call the savepoint API while a periodic checkpoint is already pending on that pipeline, the savepoint call blocks (sleeping 500ms at a time) until |
|
@DanielLeens Thanks for the very thorough review — tracing the single-pending-checkpoint guard, the master-failover restore path and the MDC propagation was exactly what I was hoping a reviewer would check on this one. First, a CI update. You mentioned you'd look again once CI had run: it did, and it caught a real problem in this PR that I've now fixed.
Root cause is a direct consequence of the change this PR makes. The fix treats both pools as system threads in On your three non-blocking notes — I agree with all three and plan to address them in follow-up commits on this branch rather than leave them as TODOs:
@nzw921rx thanks for the savepoint question, and @DanielLeens thanks for the detailed trace in reply — that matches my understanding: |
|
@Vivek1106-04 Thank you for your reply. Version 3.0 is about to be released, and I will carefully review it after completion. Currently, I think the direction of this PR is very good. My suggestion is that you can incorporate the graphics and logic in the video into the MD of the PR. This will help me better understand your design concept🚀 |
|
@nzw921rx Added a How it works section to the description with the flow diagrams, as suggested. Six diagrams, rendered inline with mermaid so there is nothing external to open: pool ownership before and after, what is member-scoped vs. pipeline-scoped, the timer/dispatch split, savepoint vs. periodic trigger, failover lease reuse, and task cancellation across the two pools. No rush on the review, and good luck with the 3.0 release. |
DanielLeens
left a comment
There was a problem hiding this comment.
Thanks for the update — I re-reviewed the latest head from scratch rather than just the delta, since a checkpoint-scheduling change in the engine deserves a full pass every time new commits land, not just a diff check.
What Problem Does This PR Solve?
Every CheckpointCoordinator built its own two-thread scheduled pool (CheckpointCoordinator.java:227-238 on dev), and CheckpointManager builds one coordinator per pipeline. Timer threads therefore grew linearly with the number of active checkpoint-enabled pipelines on a member: a member running 500 pipelines held up to 1000 mostly-sleeping timer threads, purely to re-arm a periodic trigger and arm a timeout watchdog.
This PR introduces one SharedCheckpointScheduler per member (owned by SeaTunnelEngineContext, closed with it) backed by two pools: a tiny fixed checkpoint-timer pool that only fires and hands off, and an elastic checkpoint-dispatcher pool that actually runs the body, so a slow checkpoint body never delays another pipeline's timer. Each CheckpointCoordinator now leases a PipelineCheckpointScheduler from CheckpointManager instead of owning a pool, and the lease is reusable after cancelAll(), so a coordinator restored on master failover keeps working without rebuilding a pool.
Example: a member running 500 checkpoint-enabled pipelines goes from up to 1000 live per-pipeline timer threads to 2 shared checkpoint-timer-%d threads plus an elastic checkpoint-dispatcher-%d pool that only grows while checkpoint bodies are actually being handed off. This is exactly what SharedCheckpointSchedulerTest#testTimerThreadCountStaysFlatAsPipelinesGrow asserts.
1. Code Change Review
1.1 Core Logic Analysis
What changed since my last review, verified precisely: I diffed my previously-reviewed head against the current one commit by commit. Two new commits landed:
85efedd— swapsPipelineCheckpointTask'sCompletableFutureimport fromjava.util.concurrent.CompletableFuturetoorg.apache.seatunnel.engine.common.utils.concurrent.CompletableFuture. I checked this shim class: it overridescancel,isCancelled,isDone,get,get(timeout, unit),complete,completeExceptionallyas plain delegating calls tosuper(the JDK class) with identical semantics, so this is behavior-neutral. I also confirmed it is the established convention in this exact package —CheckpointCoordinator.java,CheckpointManager.java,PendingCheckpoint.java,StateStoreCheckpointIDCounter.java,StandaloneCheckpointIDCounter.javaall already import the same shim. This is a legitimate consistency fix, not a functional change.fd2841fd7— addscheckpoint-timer-andcheckpoint-dispatcher-as whitelisted system-thread prefixes inSeaTunnelContainer#isSystemThread(e2e test infra). This is a real CI-driven fix: I confirmed it directly addresses the exact failure the author described (CheckpointRestoreWithStopIT,SavepointRestoreITand other e2e jobs failing the "no leaked threads" check because the shared pools now correctly outlive an individual job and are only torn down at member shutdown, not job completion). The fix is precise and minimal, matching the actual architectural change in this PR — it doesn't touch anything else.
I re-verified via git diff that CheckpointCoordinator.java, CheckpointManager.java, PipelineCheckpointScheduler.java, SeaTunnelEngineContext.java, CheckpointCoordinatorTest.java, and SharedCheckpointSchedulerTest.java are byte-for-byte identical to the version I reviewed and approved previously, so every invariant I traced then still holds on the current head. I re-walked them directly against the current source rather than trusting my earlier notes:
Runtime flow that reaches the changed code (unchanged from before, reconfirmed):
CheckpointManager.addCoordinator(...) -> new CheckpointCoordinator(...)
-> scheduler = manager.leaseCheckpointScheduler(pipelineId) // one lease per (jobId, pipelineId)
Periodic trigger (steady state):
tryTriggerPendingCheckpoint() [synchronized(lock)]
-> scheduleTriggerPendingCheckpoint(type, delay)
-> scheduler.schedule(...) -> PipelineCheckpointTask -> parent.dispatchAfter(...)
[checkpoint-timer-%d fires after delay] -> dispatcher.execute(task) [hand-off only]
[checkpoint-dispatcher-%d runs task.run() -> body.run() -> tryTriggerPendingCheckpoint(...)]
Timeout watchdog: triggerCheckpoint(...) -> scheduler.schedule(..., timeout, ...) [re-checks pendingCheckpoints.get(id)!=null before firing CHECKPOINT_EXPIRED]
Master-failover reset: restoreCoordinator(alreadyStarted) -> cleanPendingCheckpoint(CHECKPOINT_COORDINATOR_RESET) -> scheduler.cancelAll() -> shutdown = false [SAME instance, SAME lease resumes]
Key findings, all reconfirmed directly against the current head:
- Single-pending-checkpoint guard is untouched.
tryTriggerPendingCheckpoint()still enterssynchronized (lock)and checksisCompleted() || isShutdown()before doing anything, and thependingCounter.get() > 0short-circuit is unmodified.lockis a per-coordinator field, so sharing the timer cannot introduce cross-pipeline contention on this guard. - Master-failover restore/reuse is correctly preserved.
restoreCoordinator()->cleanPendingCheckpoint(CHECKPOINT_COORDINATOR_RESET)->scheduler.cancelAll()->shutdown = falsereuses the SAMECheckpointCoordinatorinstance and therefore the SAME lease after a reset, exactly matching the old semantics. Directly exercised bySharedCheckpointSchedulerTest#testLeaseIsReusableAfterCancelAll. - No permanent scheduler wedge from one pipeline's failure — I traced this specifically given the task brief's emphasis on it.
SharedCheckpointScheduler.dispatchAfterschedules() -> submit(task)on the timer pool, andsubmit()itself wrapsdispatcher.execute(task)in a try/catch forRejectedExecutionException, so the lambda running on a timer thread can never throw uncaught.PipelineCheckpointTask.run()(which can rethrow aThrowablefrom a misbehaving checkpoint body) only ever executes on a dispatcher-pool thread, never a timer thread. A dispatcher-pool thread dying from an uncaught exception is ordinary, self-healingThreadPoolExecutorbehavior (the pool spawns a fresh worker on the next submission) — it cannot wedge the shared timer that every other pipeline depends on. This confirms one pipeline's misbehaving checkpoint body cannot silently stall or drop another pipeline's periodic trigger. - Savepoint vs. periodic-trigger interaction — this was asked by @nzw921rx after my last review, and I re-verified my own answer against the current head rather than just restating it.
startSavepoint()busy-waits under the per-pipelinelockwhilependingCounter.get() > 0, then callscreatePendingCheckpoint(...)andstartTriggerPendingCheckpoint(savepoint)directly — it never enterstryTriggerPendingCheckpoint(), so it never asks the scheduler to re-arm a periodic trigger. The only place a savepoint touchesschedulerat all is insidestartTriggerPendingCheckpoint(), shared by every checkpoint type, to arm that checkpoint's timeout watchdog. SincependingCounter(untouched by this PR) already guarantees only one pending checkpoint per pipeline at a time, a savepoint's watchdog and a periodic checkpoint's watchdog are never both live for the same pipeline simultaneously. Confirmed correct. - MDC context propagation is equivalent, not just claimed — each command is wrapped via
MDCTracer.tracing(command)at schedule time insidePipelineCheckpointScheduler.schedule(), and since re-arming is invoked recursively from inside an already-activeMDCRunnable,MDCContext.current()resolves correctly at every re-arm. - Lifecycle wiring is complete and unchanged:
SeaTunnelEngineContext.close()callscheckpointScheduler.close(), called once at member shutdown viaSeaTunnelServer.java.
1.2 Compatibility Impact
Classification: Fully compatible. No config option added/removed/renamed, no default changed. No checkpoint state, serialization format, or storage schema touched — a checkpoint/savepoint written by a pre-PR member restores identically on a post-PR member and vice versa. The only externally observable difference is thread naming in stack dumps/profiles, and the e2e thread-leak whitelist fix in the newest commit is exactly the adjustment needed to keep test infrastructure honest about that naming change. Per-pipeline checkpoint isolation is preserved (PipelineCheckpointScheduler.outstanding is a private per-lease set).
One coordination note worth surfacing for the merge decision rather than a code defect in this PR: the PR description itself flags that #12149 and #12096 are open against the same constructor and the same cleanPendingCheckpoint() lines. gh pr view currently reports this PR as MERGEABLE against dev, so there is no live textual conflict today, but whichever of these three PRs merges last will need a careful rebase against the others' changes to that exact method. Worth a maintainer decision on sequencing before merge, not a blocker on this PR's own correctness.
1.3 Performance / Side-Effect Analysis
- Timer-thread count drops from O(active pipelines) to a constant 2 per member — the intended fix, and a real reduction in idle-thread overhead.
- The dispatch pool (
Executors.newCachedThreadPool) is unbounded and now shared across every pipeline on the member rather than isolated per pipeline. Safe today because the dispatched body is non-blocking by design (heavy barrier work already offloaded viathenApplyAsync), but this remains a structural change in blast radius if a future change ever makes the dispatched body block (see carried-over Issue 2 below). RejectedExecutionExceptionfrom either pool during shutdown is caught and the task cancelled rather than lost, so no silent task leak on shutdown.
1.4 Error Handling and Logging
Rejection during shutdown is logged at debug, an appropriate level since it's expected during normal shutdown. No sensitive data logged. See the carried-over issues below for the one scheduling-race note (already absorbed by existing state guards, not a correctness gap).
Re-verification of previously-raised issues against the current head (all three were raised by me in my prior review; the author agreed with all three in a follow-up comment and said they planned to address them in follow-up commits on this branch — I checked the actual diff, and none of the three have been pushed yet, so I'm carrying them forward as still-open, still non-blocking, Low-severity items):
- Issue 1 (carryover, still open):
PipelineCheckpointTaskcross-pool cancellation race.run()checkscancelled.get()once at the top; a task already dequeued by the dispatch pool right ascancelAll()fires can still run its body once. This remains provably benign — every caller (periodic trigger, watchdog) re-validates coordinator/checkpoint state under the samelockbefore acting — but the author agreed to add a documenting comment onrun()/cancel()so a future contributor doesn't "fix" this in a way that changes the cancellation contract. Not yet in the diff. - Issue 2 (carryover, still open): unbounded
checkpoint-dispatcherpool.Executors.newCachedThreadPool(...)inSharedCheckpointScheduler's constructor has nomaximumPoolSizebound, and it is now a member-wide shared resource. The author agreed to add a bound as defense-in-depth. Not yet in the diff. - Issue 3 (carryover, still open):
Thread.sleep(800)inSharedCheckpointSchedulerTest#testCancelBeforeTimerFiresStopsTheBody. Still the oneThread.sleep-based negative assertion in an otherwiseAwaitility/CountDownLatch-based suite. False-failure risk only under host contention, not a false-pass risk that would hide a real regression. The author agreed to switch it to an Awaitility-based idiom. Not yet in the diff.
None of these three block merge on their own — I want to be clear I'm not escalating their severity, just accurately reporting that the code doesn't yet reflect the author's stated intent, so nobody assumes they're done.
2. Code Quality Assessment
2.1 Coding Standards
All three new classes (SharedCheckpointScheduler, PipelineCheckpointScheduler, PipelineCheckpointTask) carry clear class-level Javadoc explaining ownership, lifetime, and the two-pool split rationale, and non-trivial methods (lease, dispatchAfter, schedule, cancelAll, attachTimerFuture) all explain the "why". No missing-Javadoc issue to raise. CheckpointManager.checkpointScheduler and SeaTunnelEngineContext.checkpointScheduler fields both have Javadoc explaining ownership and the test-double fallback. The new SeaTunnelContainer whitelist entries carry an explanatory comment about why these threads are member-scoped rather than job-scoped — good practice for an easy-to-misread test-infra change.
2.2 Test Coverage and Test Stability
SharedCheckpointSchedulerTest (8 tests) directly proves every invariant claimed: body runs on dispatch thread not timer thread; cancelAll() is pipeline-scoped; cancel-before-fire and cancel-after-fire both stop the body; a lease is reusable after cancelAll() (the master-failover path); finished tasks self-remove; schedule-after-close returns an already-cancelled future; and the 500-lease/2-timer-thread acceptance criterion is asserted directly. CheckpointCoordinatorTest correctly threads real lease/cancel behavior through all new CheckpointCoordinator(...) call sites instead of relying on a manager mock that would otherwise NPE on leaseCheckpointScheduler.
Test stability rating: Stable, with the one Low-risk carryover noted above (Issue 3, Thread.sleep(800)) — unchanged assessment from my last review since the test file hasn't been touched.
2.3 Documentation Updates
None needed, none made — correctly reasoned in the PR description: no new engine Option, and a git grep across docs/en/docs/zh confirms nothing references the old per-pipeline thread-naming pattern. I'd also call out that the author's "How it works" section added to the PR description since my last review (six Mermaid diagrams covering pool ownership, the timer/dispatch split, the savepoint interaction, failover lease reuse, and cross-pool cancellation) is unusually good reviewer-facing documentation — I checked each diagram's claims against the actual source and they're accurate, not just illustrative.
3. Architectural Soundness
3.1 Elegance of the Solution
Precise fix, not a workaround: the actual defect was "the timer pool's lifetime and cardinality is tied to the coordinator's lifetime and count," and this PR fixes exactly that by relocating pool ownership to the member while keeping cancellation scoped correctly via the lease abstraction.
3.2 Maintainability
The three-class split cleanly separates "member-wide pool ownership," "per-pipeline cancellation scope," and "cross-pool task lifecycle." The explicit rationale comments (why not the coordinator executor, why a constant not a config) will save the next reader real investigation time.
3.3 Extensibility
The lease pattern (SharedCheckpointScheduler.lease(jobId, pipelineId)) generalizes cleanly if another per-pipeline periodic concern ever needs the same shared-timer/elastic-dispatch treatment.
3.4 Historical-Version Compatibility
No checkpoint/savepoint format, protocol, or serialization change — a job checkpointed on a pre-PR member restores identically on a post-PR member. No config default changed, so upgrading a running cluster to a build containing this PR requires no operator action.
4. Issue Summary
| Number | Issue | Location | Severity | Raised by another reviewer |
|---|---|---|---|---|
| 1 | Carryover: PipelineCheckpointTask cross-pool cancellation race is benign but undocumented as intentional |
seatunnel-engine/seatunnel-engine-server/.../checkpoint/scheduler/PipelineCheckpointTask.java (run()/cancel()) |
Low | No (mine, still open) |
| 2 | Carryover: unbounded checkpoint-dispatcher pool is now a member-wide shared resource |
seatunnel-engine/seatunnel-engine-server/.../checkpoint/scheduler/SharedCheckpointScheduler.java (constructor) |
Low | No (mine, still open) |
| 3 | Carryover: Thread.sleep(800) in one negative-assertion test, inconsistent with the rest of the suite |
seatunnel-engine/seatunnel-engine-server/.../checkpoint/scheduler/SharedCheckpointSchedulerTest.java (testCancelBeforeTimerFiresStopsTheBody) |
Low | No (mine, still open) |
5. Merge Recommendation
Conclusion: Ready to merge after fixes
-
Blockers — must be fixed
- CI is currently failing on the head commit (
fd2841fd7) and must go green before merge. I pulled the fork's actual job logs (the apache-sideBuildcheck is only a pointer to the fork run) and found four failing jobs:all-connectors-it-6(DatabendCDCSinkIT, container-level error),all-connectors-it-1(NebulaGraphIT), andtransform-v2-it-part-1on both JDK 8 and JDK 11 (TestFilterRowKindIT#testFilterRowKindMultiTable, a data-assertion failure on the Flink-engine test container). I traced all four against this PR's actual diff (Zeta checkpoint-scheduler internals plus one e2e thread-whitelist entry) and none of them touch code paths this PR modifies — Databend/NebulaGraph are unrelated connector containers, andTestFilterRowKindITexercises Flink's own translation layer and the Filter transform, neither of which this PR touches. Given the PR is also 35 commits behinddevand diverged, my recommendation is to sync withdevand get one clean CI run before merging, rather than assuming these are environmental based on code-path analysis alone.
- CI is currently failing on the head commit (
-
Recommended fixes — non-blocking
- Issues 1-3 above (carried over from my last review): document the intentional cancellation race, bound the dispatcher pool's
maximumPoolSize, and replace the oneThread.sleep(800)with an Awaitility-based idiom. The author already agreed with all three in a follow-up comment; they just haven't landed in the diff yet. - Worth a maintainer decision on sequencing against #12149 and #12096, which touch the same
cleanPendingCheckpoint()method, before merging any of the three.
- Issues 1-3 above (carried over from my last review): document the intentional cancellation race, bound the dispatcher pool's
Overall assessment: The core design is sound and I found nothing new to walk back from my previous approval — I re-traced every invariant that matters for an engine/checkpoint change (single-pending-checkpoint guard, master-failover restore/reuse, MDC propagation, and specifically the "can one pipeline wedge the shared scheduler" and "does a savepoint fight the periodic trigger" questions this task calls out) and all of them hold on the current head. The two commits since my last review are both good: a real consistency fix and a precise, correctly-scoped CI fix for a leak-check assertion that needed to catch up with the new member-scoped thread lifetime. The only reason I'm not re-approving outright this round is that CI is genuinely red on the commit I'm reviewing, and I'd rather see a clean run than assert from code-reading alone that four e2e job failures are unrelated. Once CI is green on a synced head, and ideally once the three carried-over Low-severity nits land, this is very close to done — nice work continuing to sweat the details here.
| * Timer threads only dispatch, so a small fixed pool is enough regardless of pipeline count. | ||
| * Two threads keep one slow dispatch hand-off from stalling the other timers. | ||
| */ | ||
| private static final int TIMER_THREAD_NUM = 2; |
There was a problem hiding this comment.
I think it can be configured here, otherwise the more tasks there will be a lot of backlog, and the limited space for users to adjust. We should give users this right
There was a problem hiding this comment.
Good question — worth separating the two pools here, since they behave very differently under load.
TIMER_THREAD_NUM = 2 (this line) is not a size backlog could ever pressure: ScheduledThreadPoolExecutor's internal delay queue is unbounded by JDK design, and these timer threads do nothing but a single dispatcher.execute(task) hand-off (SharedCheckpointScheduler.java:105-114) — no blocking work ever runs on them. Two threads exist only so one slow hand-off can't stall another pipeline's timer momentarily; they are never a throughput bottleneck regardless of pipeline count.
The pool that actually does the work is dispatcher (lines 76-81), and it's Executors.newCachedThreadPool() — also effectively unbounded (maximumPoolSize = Integer.MAX_VALUE, backed by a SynchronousQueue), so it grows threads on demand instead of queuing or backlogging. So today there genuinely is no user-adjustable "queue size" — but there's also no queue to size: both pools degrade by growing (threads) or queuing indefinitely (timer), never by throttling or rejecting under load.
The real, honest tradeoff here (the opposite direction from what you're worried about) is that dispatcher being unbounded means a member running many pipelines with slow checkpoint bodies could grow an unbounded number of dispatch threads. That's already tracked as a known Low-severity, non-blocking follow-up from my 2026-09-10 review (Issue #2: bound dispatcher's maximumPoolSize and make it configurable). I agree that's worth doing, but I don't think it should block this PR — "grow threads" is a strictly safer default than "reject/queue when saturated" would be, and the author already agreed to take it as a fast-follow.
There was a problem hiding this comment.
@nzw921rx Late reply from me on this one, and the code has moved since, so here is where it stands.
The dispatcher is no longer unbounded. Since b0bc24603 it is a ThreadPoolExecutor with core equal to maximum at max(8, availableProcessors * 2), an unbounded LinkedBlockingQueue, and allowCoreThreadTimeOut(true) - so it grows on demand, reaps idle threads, is bounded in thread count, and never rejects. The reason it cannot use a bounded queue is that a rejection here is a dropped checkpoint trigger or timeout watchdog, which is a correctness problem rather than a resource one, so the bound is paid for in delay instead.
On making it configurable, which was your actual ask: I measured it rather than argued it. #12418 adds a scheduling-delay benchmark, and the comparison is on #12339. The short version is that scheduling delay is flat at about 10 us from 1 to 500 pipelines here against a knee between 100 and 500 for the per-pipeline model, and flat through 31% dispatch utilization - but past saturation it collapses to a p50 of 461 ms, so the pool size does matter in a regime the default interval does not reach.
Both @DanielLeens and @SEZ9 read that as: no checkpoint.scheduler-dispatch-thread-num option in this STIP, and scheduling-delay observability ahead of any tuning knob, since the metric is what would tell an operator they are approaching the regime where the option would help. I have taken that as the decision for this PR. If you want the option anyway, I would rather add it on the back of that metric than on its own, and I am happy to do both as a follow-up.
| try { | ||
| dispatcher.execute(task); | ||
| } catch (RejectedExecutionException e) { | ||
| log.debug("Shared checkpoint dispatcher is shut down, dropping checkpoint task", e); |
There was a problem hiding this comment.
Should users feel rejected here?
There was a problem hiding this comment.
What if it fails here? Is the task status corresponding to it a failure or a silent no longer triggering checkpoint?
There was a problem hiding this comment.
No — and the code here specifically guards against that reading. Both catch (RejectedExecutionException e) blocks in this file (the timer-side one around line 108-113, and this submit() one at line 119-122) are only reachable after close() has begun: closed = true then timer.shutdownNow() / dispatcher.shutdownNow() (SharedCheckpointScheduler.java:137-139). The class's own inline comments say this explicitly — "Only reachable once close() has begun; the coordinator is stopping anyway" / "Shared checkpoint dispatcher is shut down." Neither pool has a bounded queue that could saturate under normal load (see my reply on the line-59 thread about TIMER_THREAD_NUM), so there is no live-traffic path that reaches RejectedExecutionException at all — only an intentional-shutdown path.
So "rejected" here really means "the member (or this scheduler instance) is already shutting down," which is why it's logged at debug rather than warn/error — it's an expected, non-actionable event during teardown, not a user-facing failure.
There was a problem hiding this comment.
Continuing from my reply above: in this specific path, "fails" means "the scheduler is already closing," and task.cancel(false) (line 111/121) marks the PipelineCheckpointTask cancelled and completes its internal future via cancel() (PipelineCheckpointTask.java:82-93) — genuinely cancelled, not exceptionally-failed. That's the correct terminal state for "we stopped because we're shutting down," and nothing downstream treats it as a checkpoint failure, because by the time close() runs, the owning CheckpointCoordinator/pipeline is also being torn down — there's no live consumer left to silently miss a checkpoint.
There IS a real "silent no longer triggering checkpoint" risk in this design, but it's not in this rejection path — it's in the checkpoint body itself. tryTriggerPendingCheckpoint() (CheckpointCoordinator.java:722) is not wrapped in try/catch, and it's the same method that re-arms the next periodic trigger (scheduleTriggerPendingCheckpoint(...) at line 802) before returning. If it throws for an unrelated reason (bug, NPE, etc.) before reaching that line, the periodic loop breaks and checkpointing silently stops for that one pipeline.
I checked whether this PR introduces or worsens that, and it doesn't — I diffed against the pre-PR code on dev, where CheckpointCoordinator called scheduler.schedule(runnable, ...) directly on a real ScheduledThreadPoolExecutor. That path is actually more silent than today's: ScheduledThreadPoolExecutor.schedule() wraps the runnable in an internal future-backed task whose run() catches Throwable and stores it via setException(), with zero stderr output, since the returned future was never queried. Today's dispatcher.execute(task) runs PipelineCheckpointTask as a plain Runnable (not future-wrapped), so an uncaught exception now propagates to the worker thread's default uncaught-exception handler, which prints a stack trace to stderr — strictly more visible than before, even though it's still not routed through SeaTunnel's own logger or any alert.
This is a real, pre-existing gap — not something this PR needs to fix, and not something it makes worse — but I think it's worth a dedicated follow-up: wrap the scheduled body (or PipelineCheckpointTask.run()) with a catch that logs at WARN/ERROR through SeaTunnel's logger, since checkpoint triggering going silent is exactly the kind of failure an operator needs visibility into. I would not treat it as a blocker for this PR specifically, since it predates it and isn't made worse by it, but it's a legitimate item for a follow-up issue.
There was a problem hiding this comment.
@nzw921rx To answer both of your questions on this thread directly, with the code as it stands today.
Can a user hit this rejection? No. Both RejectedExecutionException catches in this class are reachable only after close() has begun, which happens when the member's engine context shuts down. The dispatch pool is bounded in threads but backed by an unbounded queue and never rejects under load, and the timer's delay queue is unbounded by JDK design, so there is no live-traffic path into either catch. That is why they log at debug: it means "this member is stopping", not "a checkpoint was refused".
Is the task then failed, or silently no longer triggering? Neither, in that path. task.cancel(false) puts the PipelineCheckpointTask in a genuinely cancelled terminal state, and the owning coordinator is being torn down at the same time, so there is no live consumer left to miss a checkpoint.
Your underlying worry - work disappearing without anyone being told - was the right thing to ask about, and it does have a real instance elsewhere, which I have now fixed in da0cbbf52. The checkpoint timeout watchdog used to run the whole expiry inline on a dispatch thread, including an IMap retry loop and a wait on the coordinator lock. It now hands that to the coordinator executor. That executor is bounded over a SynchronousQueue and does abort when saturated, and there a dropped task would be exactly the silent failure you are describing - the checkpoint would stay pending with nothing left to fail it - so the rejection is caught and the expiry runs inline instead, with a WARN rather than a debug line. Covered by CheckpointCoordinatorTest#testCheckpointExpiryHandlingNeverRunsOnTheDispatchThread.
The separate gap @DanielLeens identified on this thread - tryTriggerPendingCheckpoint not being wrapped in try/catch, so an unrelated throw could break the periodic re-arm - predates this PR and is not made worse by it. I agree it deserves a logged catch and will file it as a follow-up rather than widen this diff.
There was a problem hiding this comment.
Filed as #12442, as promised above - an unexpected throw in tryTriggerPendingCheckpoint leaving a pipeline with no armed timer, logged nowhere through SeaTunnel's own logger. Pre-existing on dev and not made worse by this PR, so it is tracked rather than fixed here.
| return new PipelineCheckpointScheduler(this, jobId, pipelineId); | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
How can we ensure the completeness of his lifecycle here? For example, can there only be one jobId+pipelineId?
There was a problem hiding this comment.
Good catch on the ambiguity — SharedCheckpointScheduler itself keeps no registry at all, so there is no code-level assertion that only one PipelineCheckpointScheduler can exist per (jobId, pipelineId). lease(jobId, pipelineId) (lines 95-97) just returns a brand-new, unregistered object on every call; uniqueness is not enforced by this class.
What actually makes this safe in practice is the calling discipline one layer up, which I traced end-to-end:
CheckpointCoordinator.scheduleris afinalfield, assigned exactly once in the constructor (CheckpointCoordinator.java:155,229) viamanager.leaseCheckpointScheduler(pipelineId).CheckpointManagerbuilds exactly oneCheckpointCoordinatorperpipelineIdin itscoordinatorMap(CheckpointManager.java:132-181, keyed byCheckpointCoordinator::getPipelineId), so exactly one lease is created per pipeline for thatCheckpointManager's lifetime.- Critically, on master-failover reset the SAME
CheckpointCoordinatorinstance is reused:cleanPendingCheckpoint(CHECKPOINT_COORDINATOR_RESET)callsscheduler.cancelAll()(CheckpointCoordinator.java:1196) on the existing lease and flipsshutdown = false— it does not callleaseCheckpointScheduler()again. So restart/failover doesn't create a second lease for the same(jobId, pipelineId); it reuses the first one.
So there's no "replace vs. leak vs. exception" branch to worry about, because re-registration never happens on the paths I found. A second lease for the same (jobId, pipelineId) would only appear if a brand-new CheckpointManager were constructed for the same pipeline while an old one was still alive — which would itself be a bug in the job-restart lifecycle outside this PR's scope, not something SharedCheckpointScheduler needs to guard against. And because leases hold no shared state (each just has its own local outstanding set, PipelineCheckpointScheduler.java:45), even a hypothetical duplicate lease wouldn't corrupt the shared pools — it would just be two independent cancellation domains, a correctness smell rather than a resource leak, since nothing is centrally registered to go stale.
If you'd like a hard guarantee here (a defensive check-and-throw on duplicate lease, or an explicit registry with dispose()), I'd treat that as a legitimate hardening ask — but I don't see a live code path today that would actually trigger the failure mode you're describing.
There was a problem hiding this comment.
@nzw921rx On lifecycle and uniqueness: @DanielLeens traced this correctly, and I can confirm it against the current head.
SharedCheckpointScheduler deliberately keeps no registry. lease(jobId, pipelineId) returns a new object each call and registers nothing, which is the reason dropping a lease when its job ends leaks nothing - there is no central map to go stale.
Uniqueness comes from the layer above, not from this class: CheckpointCoordinator.scheduler is final and assigned once in the constructor, and CheckpointManager builds exactly one coordinator per pipelineId in its coordinatorMap. On a master-failover reset the same coordinator instance is reused - cleanPendingCheckpoint(CHECKPOINT_COORDINATOR_RESET) calls scheduler.cancelAll() on the existing lease and leaves the lease usable - so a reset does not create a second lease for the same (jobId, pipelineId).
A duplicate lease would therefore require a second CheckpointManager for the same pipeline while the first is still alive, which would be a job-restart lifecycle bug outside this class. Even then the blast radius is small, because leases share no state: each holds only its own outstanding set, so two leases would be two independent cancellation domains rather than a corrupted shared one.
SharedCheckpointSchedulerTest covers the parts that matter here: cancellation scoped to one lease leaving other leases untouched, a lease still usable after cancelAll, and cancellation working both before the timer fires and after the body has been dispatched. If you want a hard guarantee on top - a check-and-throw on a duplicate lease, or an explicit registry with dispose() - I am happy to add it, but I would rather not introduce a registry whose only purpose is to be a thing that can leak.
|
I have another question. If thread scheduling slows down due to IO or a certain jobId, the CK of subsequent tasks will be delayed. Do we have any better solutions? |
|
CI error, please rebase upstream/dev |
nzw921rx
left a comment
There was a problem hiding this comment.
Have you considered excessive solutions on your end? For example, clearly defining it as experimental ability? I am concerned about the scheduling efficiency and latency of large-scale tasks, and if users upgrade, there will be uncertainty involved. I am very worried.
|
Thank you for such a detailed design. I think it should comply with the STIP principle. You can refer to the following link to initiate STIP🔥 |
|
Replying to your latest round of comments (review comment, and three issue-level comments from today) together, since they're all design/process-level rather than line-specific. On "excessive solutions" / marking this experimental (review comment)
I don't think an experimental flag fits this particular change, for a structural reason rather than a dismissal of the concern: this PR has no new That said, I do share the underlying worry about validating this carefully before it reaches everyone, which is exactly why my CHANGES_REQUESTED review is still blocking on a clean CI run rather than approving from code-reading alone (see CI note below). On scheduling efficiency / large-scale latency (review comment) and head-of-line blocking (issue comment)
This is exactly the tradeoff the timer/dispatch split is built to avoid for the "arm the next trigger" step. The shared That said, it is still a shared JVM-wide resource in a broader sense: if enough pipelines are simultaneously blocked on slow IO at very large scale, you could see member-wide resource pressure (thread count, context-switching, memory) even though no single mutex or bounded queue is shared between jobs. That's the same tradeoff already tracked as Issue #2 (Low, non-blocking) in my CHANGES_REQUESTED review — bounding and making On CI / rebase (issue comment)
Agreed, and this matches the actual blocker in my 2026-09-10 CHANGES_REQUESTED review: CI was red on the head commit for reasons I traced back to being unrelated to this PR's own diff, and the branch was already well behind On STIP (issue comment)
Reasonable suggestion given the scope — this does touch core, node-wide checkpoint-scheduling internals, so going through STIP for broader design visibility and consensus makes sense to me. That said, whether to require it is a process call for the author and the PMC/maintainers to make, not something I can gate as a reviewer. |
fd2841f to
7310dd7
Compare
|
@nzw921rx Thanks for the review. Answering your three points. 1. RebasedRebased onto current 2. "If thread scheduling slows down due to IO or a certain jobId, the CK of subsequent tasks will be delayed"This is the right thing to check, and it is the reason the scheduler is split into two pools rather than one. A timer thread never runs checkpoint work. In So a checkpoint that blocks on IO or a slow RPC occupies a dispatcher thread, not a timer thread. The next periodic trigger for every other pipeline still fires on schedule, and so does the next one for the slow pipeline itself. Only two things are ever scheduled, both in
Both of those bodies run on the dispatcher. Worth noting this is stricter than what The honest caveat: the dispatch pool is unbounded. If N pipelines all block at once, up to N dispatcher threads exist for as long as they block, and idle ones are reaped after the cached pool's 60s keep-alive. That worst case is no worse than I deliberately did not bound the dispatch pool. A bounded pool over a 3. Experimental flag@DanielLeens already covered this in #12165 (comment), and I agree with their reading, so this is just me confirming it as the author rather than a new argument. I do not think a flag fits, for a structural reason. The PR adds no new If the concern is that the scheduling change should be reversible in the field, the cleaner form of that is the STIP discussion below rather than a config toggle. 4. STIPAgreed. This touches node-wide checkpoint scheduling internals in the engine, which fits the "engine-level change" bar. I will open |
|
@nzw921rx Opened the STIP as requested: [STIP-40][Improve][Zeta] Member-wide shared checkpoint trigger scheduling - #12339 It covers Background, Motivation, Goals, Non-Goals, Design, Compatibility, Test Plan, Alternatives and References, and carries the thread-ownership and dispatch diagrams. Two things I flagged there rather than smoothed over, since they are the parts that need the list's opinion:
Two asks:
Treating this PR as the candidate implementation for STIP-40, not as an accepted design. Happy to hold it until the STIP discussion concludes. |
|
Thanks @nzw921rx for dispatching the benchmark on top of #12896 and attaching the raw data, and thanks @Vivek1106-04 for syncing. I read the three comments against the unchanged head What the numbers say (median of 2 alternating runs on the same worker,
My reading (an inference from the posted table, not a measurement of mine): the candidate adds roughly 16 to 43 microseconds of trigger delay per due trigger, which is consistent with the extra timer-to-dispatcher hop this design introduces. That is tiny next to a checkpoint interval measured in seconds, and the PR's goal is the checkpoint-timer thread count, not trigger latency, so I do not read this as a functional regression. But it is a real, repeatable direction in the data, so it should be explained in the PR rather than left as a bare table. The 10-pipeline row also has a candidate CV of 12.42% with only 2 runs, so that row is not a stable estimate yet. The Concrete next steps for @Vivek1106-04:
CI: the apache-side |
|
@nzw921rx thanks for running the benchmark on top of #12896 and attaching the raw data. @DanielLeens thanks for the read. I went through the raw data and then traced the slowdown down to the line that causes it. Short version: the whole difference comes from the timer -> dispatcher hand-off, which I'm keeping deliberately. Details and evidence below. What the raw data showsThe run used 1 fork x 5 iterations x 10 s, which is about 100 samples per iteration. In those JSON files there are two separate effects:
Where the time goesI rebuilt exactly the two commits you compared ( Run 40061505634 (JDK 11 + 17, P=10/100/500) reproduced both effects:
A scheduler trace (bpftrace on To confirm the cause rather than just correlate it, run 40071856278 adds a third build:
Over all ~4,000 triggers per trial in the trace, p99.9 is 0.2-0.4 ms for dev and for the no-hand-off build (one no-hand-off trial: 1.4 ms), and 0.65-2.1 ms for the PR. The same results on both runner CPUs this run got (EPYC 7763 and 9V74). So the shared timer itself costs nothing, and the whole delta is the hand-off. Why I'm keeping the hand-offThe hand-off is there so timer threads never run checkpoint bodies. Those bodies take the coordinator lock, and on The benefit, measured in the same tracesFrom the safepoint logs of the JDK 17 run (
The scheduler thread count is 1,000 on dev at P=500, and on the PR it is 2 timer threads plus at most The
|
…atch thread PipelineCheckpointTask.run() rethrew a body failure after completing its future exceptionally. The rethrow ended the dispatch worker and reported the error only through the default uncaught-exception handler on stderr, outside the engine log. The failure is already carried by the task's future, so log it with the job and pipeline id and keep the worker.
DanielLeens
left a comment
There was a problem hiding this comment.
Thanks @Vivek1106-04 for the new commit and for the very thorough benchmark investigation. The no-hand-off control build, the run-queue (run_delay) evidence and the safepoint table answer my 2026-10-23 questions well. I accept that the extra ~25 us per trigger comes from the timer-to-dispatcher hand-off, which is a deliberate trade-off, and that the benefit is the flat thread count and time-to-safepoint. This is a full re-read at head 8766ee4dbc71. Since my last review at 771fce2212 there is one new commit, 8766ee4dbc ("Log a failing checkpoint task instead of killing its dispatch thread"). It fixes my previous Issue 2, and the PR description now describes the bounded pool and the cost of the hand-off, which closes my previous Issue 4. Thank you!
While re-checking the branch against current dev, though, I found one new blocker. It did not exist at my last full review: it comes from #12448, which merged into dev after that review and after your last sync (details in Issue 1).
What Problem Does This PR Solve?
- User pain point: every
CheckpointCoordinatorbuilt its own two-thread scheduled pool, andCheckpointManagerbuilds one coordinator per pipeline, so the number of checkpoint-timer threads grew linearly with active pipelines. - Fix approach: one member-wide
SharedCheckpointSchedulerowned bySeaTunnelEngineContext. It has twocheckpoint-timerthreads that only fire, and a boundedcheckpoint-dispatcherpool that runs the body. Each coordinator leases aPipelineCheckpointScheduler, socancelAll()stays pipeline-scoped. - One-sentence summary: replace N per-pipeline timer pools with one shared timer plus a bounded dispatch pool, with no config, checkpoint-format or default-behaviour change.
1. Code Change Review
1.1 Core Logic Analysis
New commit 8766ee4dbc:
// PipelineCheckpointTask.java:99-115, before
} catch (Throwable t) {
completion.completeExceptionally(t);
throw t; // ended the dispatch worker, error only on stderr
}
// after
} catch (Throwable t) {
log.error("Checkpoint task failed for job {} pipeline {}", jobId, pipelineId, t);
completion.completeExceptionally(t);
}This now matches what dev effectively did: ScheduledThreadPoolExecutor's FutureTask also captured the throwable and kept the worker alive. The PR adds a log line with job/pipeline context through the engine logger. jobId / pipelineId are passed from PipelineCheckpointScheduler.schedule, and the lease already holds both, so the change is small. The new testFailingBodyCompletesExceptionallyAndKeepsItsDispatchThread asserts both that the exception is propagated through the future (assertSame(failure, thrown.getCause())) and that the worker thread survives. If the throw t were restored, the worker would reach TERMINATED and the test would fail, so it can catch the regression.
Semantic interaction with dev (#12448): the branch is diverged (ahead 10, behind 17). GitHub reports no textual conflict, and my trial merge also has none. But #12448 (df94fb07db) added CheckpointCoordinatorScheduledTriggerTest, which builds a coordinator on a plain Mockito.mock(CheckpointManager.class):
// dev: CheckpointCoordinatorScheduledTriggerTest.java:44, 77, 87
CheckpointManager checkpointManager = Mockito.mock(CheckpointManager.class);
...
coordinator.scheduleTriggerPendingCheckpoint(CheckpointType.CHECKPOINT_TYPE, 0);
...
coordinator.cancelCheckpoint();On this branch the coordinator's constructor does this.scheduler = manager.leaseCheckpointScheduler(pipelineId) (CheckpointCoordinator.java:242). A plain mock returns null for it, so scheduleTriggerPendingCheckpoint hits scheduler.schedule(...) (CheckpointCoordinator.java:572) and throws NullPointerException before the test can observe the coordinator failure. The finally block's cancelCheckpoint() then reaches scheduler.cancelAll() (line 1283) and throws again. This is exactly the mocked-manager case that your own CheckpointCoordinatorTest helper (mockCheckpointManager(), lines 103-110) was written for. The fork CI runs only the branch head, which does not contain #12448, so it cannot show this. It would show up on the merged result (merge queue or dev).
The production side of #12448 merges cleanly in the trial merge: its try/catch lambda body is still passed to scheduler.schedule(...), which has the same (Runnable, long, TimeUnit) signature on the lease. So only the test needs adapting.
Runtime path (unchanged apart from the logging change):
SeaTunnelServer -> SeaTunnelEngineContext (owns SharedCheckpointScheduler)
-> CheckpointManager.leaseCheckpointScheduler(pipelineId) -> CheckpointCoordinator.scheduler
-> scheduleTriggerPendingCheckpoint: lease.schedule -> checkpoint-timer fires
-> hand-off to checkpoint-dispatcher -> PipelineCheckpointTask.run (cancel check, body, log on failure)
-> coordinator close/reset: lease.cancelAll() (pipeline-scoped); context close: both pools shut down
1.2 Compatibility Impact
Fully compatible. No option, default, checkpoint/savepoint format or storage schema is touched. The observable difference is thread naming, and an ERROR log now appears where dev stayed silent for a failing scheduled body.
1.3 Performance / Side-Effect Analysis
Same as before, now backed by your measurements: about 25 us of hand-off per trigger, plus an occasional wait of up to one scheduler time slice when every CPU is busy. In exchange the thread count and time-to-safepoint stay flat across pipelines. The new log fires only when a body throws, and a failing trigger does not re-arm itself, so there is no log loop.
1.4 Error Handling and Logging
Issue 1: the PR breaks dev's new CheckpointCoordinatorScheduledTriggerTest once merged (semantic merge conflict with #12448)
- Location:
CheckpointCoordinator.java:242and:572(this PR) versusseatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinatorScheduledTriggerTest.java:44-87(ondev) - Problem: the plain
CheckpointManagermock returns anulllease, so the test fails with an NPE instead of reaching itsFAILEDassertion. - Risk: the merged result has a failing engine unit test. The merge queue would reject it, or
devwould go red, and the branch's own CI cannot detect it. - Best improvement: merge the latest
dev, then give that test a real lease the same wayCheckpointCoordinatorTest.mockCheckpointManager()does, for exampleMockito.when(checkpointManager.leaseCheckpointScheduler(Mockito.anyInt())).thenAnswer(...)backed by a test-scopedSharedCheckpointSchedulerthat is closed infinally. Keep every assertion of the #12448 test as it is. With the lease in place, the test also checks that #12448's wrapped body still fails the coordinator through the shared dispatcher. - Severity: High
- Raised by another reviewer: No
Issue 2: residual dispatch-pool coupling through savepoint lock-holding (carryover, tracked)
- Location:
CheckpointCoordinator.javastartSavepointversustryTriggerPendingCheckpoint'ssynchronized (lock) - Problem / Risk: enough pipelines savepointing at the same time can occupy the bounded dispatch pool and delay other pipelines' triggers. The delay is bounded and there is no data loss.
- Best improvement: tracked in #12441 / #12511.
- Severity: Low
- Raised by another reviewer: Yes, related to @nzw921rx's head-of-line concern.
Issue 3: no guard on the thenAcceptAsync hop if the coordinator executor is ever bounded (carryover)
- Location:
CheckpointCoordinator.javastartTriggerPendingCheckpoint - Problem / Risk: not reachable today. This is a note for whoever bounds the coordinator executor later.
- Severity: Low
- Raised by another reviewer: No
Issue 4: complete the future before logging (new, optional)
- Location:
PipelineCheckpointTask.java:109-110 - Problem:
log.error(...)runs beforecompletion.completeExceptionally(t). If logging itself throws (for example underOutOfMemoryError, the case wheretis most likely anError), the future is never completed and a waiter onget()would hang. - Best improvement: swap the two lines, so the future is completed first and the log line comes after.
- Severity: Low
- Raised by another reviewer: No
2. Code Quality Assessment
2.1 Coding Standards
Good. The new fields are self-explanatory and the comment in run() explains why the rethrow was removed.
2.2 Test Coverage and Test Stability
SharedCheckpointSchedulerTest now covers the throwing-body case and CheckpointCoordinatorTest is unchanged. The new test waits on a thread-state condition with Awaitility (10 s bound) and has no fixed sleeps. The pool's 60 s keep-alive cannot turn a live idle worker into TERMINATED within that window. Stability rating: Stable. The dev test from Issue 1 needs adapting as described.
2.3 Documentation Updates
No user-facing option or behaviour change, so no docs are needed. The PR description has been refreshed.
3. Architectural Soundness
3.1 Elegance
Long-term solution. Leasing at the CheckpointManager/Coordinator boundary keeps pipeline-scoped cancellation and gives an orderly member shutdown.
3.2 Maintainability
Good. Lifecycle, cancellation races and the hand-off trade-off are documented in the code and the description.
3.3 Extensibility
Moving the periodic trigger back onto the timer thread after #12511 is a one-line change, as you noted.
3.4 Historical-Version Compatibility
No state or format impact. A rolling upgrade only changes thread names.
4. Issue Summary
| Number | Issue | Location | Severity |
|---|---|---|---|
| 1 | Merged result breaks dev's CheckpointCoordinatorScheduledTriggerTest (null lease from plain mock) |
CheckpointCoordinator.java:242, :572; CheckpointCoordinatorScheduledTriggerTest.java:44-87 |
High |
| 2 | Dispatch-pool coupling via savepoint lock (tracked #12441) | CheckpointCoordinator.java startSavepoint |
Low |
| 3 | No guard if coordinator executor is bounded later | CheckpointCoordinator.java |
Low |
| 4 | Complete the future before logging | PipelineCheckpointTask.java:109-110 |
Low |
5. Merge Recommendation
Conclusion: Ready to merge after fixes
Blockers (must fix)
- Issue 1: merge the latest
dev(it brings in #12448 and #12387) and giveCheckpointCoordinatorScheduledTriggerTesta real scheduler lease, then get a green forkBuildon that new head.
CI: the fork Build run for this head (Vivek1106-04/seatunnel run 40075717724) is still in progress. It has no failed job yet, and one all-connectors-it-3 (11) leg was cancelled. Because the head does not contain #12448, a green result here does not cover Issue 1.
Recommended fixes (non-blocking): Issue 4 (a two-line swap). Issues 2 and 3 stay as tracked notes.
Overall assessment: the core design has been solid for several rounds, and this round's fix and benchmark analysis are excellent. The one remaining item is an integration gap with a test that landed on dev after your last sync, and it should be a small test-only change. A committer's final approval is still needed for the merge itself.
…ler lease CheckpointCoordinatorScheduledTriggerTest (apache#12448) builds the coordinator on a plain CheckpointManager mock, which returns a null scheduler lease on this branch. Back the lease with a test-scoped SharedCheckpointScheduler, closed in finally, and keep the test's assertions unchanged.
If logging itself throws, for example under OutOfMemoryError, the future would never complete and a waiter on get() would hang.
|
Thanks @DanielLeens, good catch on the semantic conflict with #12448. Both items are addressed at head Issue 1 (blocker): merged the latest Issue 4: Local, JDK 17: Fork Build on the new head (still running, I will follow up with the result): https://github.com/Vivek1106-04/seatunnel/actions/runs/40161631838 |
DanielLeens
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround, @Vivek1106-04! Both items from my last review are addressed. I re-read the new head 29ab7519b483 (a dev merge plus two small commits) and re-traced the changed paths. This is a code-reading review only; nothing was executed locally.
What Problem Does This PR Solve?
- User pain point: every
CheckpointCoordinatorcreated its own two-thread scheduled pool, andCheckpointManagercreates one coordinator per pipeline, so checkpoint-timer threads grew linearly with active pipelines. - Fix approach: one member-wide
SharedCheckpointScheduler(owned bySeaTunnelEngineContext) with twocheckpoint-timerthreads that only fire, and a boundedcheckpoint-dispatcherpool that runs the body. Each coordinator leases aPipelineCheckpointScheduler, socancelAll()stays pipeline-scoped. - One-sentence summary: replace N per-pipeline timer pools with one shared timer plus a bounded dispatch pool, with no config, checkpoint-format or default-behaviour change.
1. Code Change Review
1.1 Core Logic Analysis
Changes since my last review (8766ee4dbc71):
f6224fc3b(my Issue 1, was High):CheckpointCoordinatorScheduledTriggerTest(added todevby #12448) now stubsleaseCheckpointScheduler(anyInt())to return a lease from a test-scopedSharedCheckpointScheduler, closed infinally. All #12448 assertions are unchanged, so the test again proves that a throwing periodic trigger body fails the coordinator (FAILEDplushandleCheckpointError(1, false)) when it runs through the shared dispatcher.29ab7519b(my Issue 4): inPipelineCheckpointTask.run()the order is nowcompletion.completeExceptionally(t)first, thenlog.error(...). If logging itself throws (for example under OOM), waiters on the future are no longer left hanging, andonSettledstill runs fromfinally.- A merge of
dev(feedd2506) that brings in #12448 and #12387 with no textual conflict.
Runtime path (unchanged):
SeaTunnelServer -> SeaTunnelEngineContext (owns SharedCheckpointScheduler)
-> CheckpointManager.leaseCheckpointScheduler(pipelineId) -> CheckpointCoordinator.scheduler
-> scheduleTriggerPendingCheckpoint -> lease.schedule -> checkpoint-timer fires
-> hand-off to checkpoint-dispatcher -> PipelineCheckpointTask.run (cancel check, body, complete future, log on failure)
-> coordinator close/reset: lease.cancelAll() (pipeline-scoped); context close: both pools shut down
I re-checked PipelineCheckpointTask.run() and cancel() at head: the cancel flag is read before the body, the late-body race is documented and safe because both scheduled bodies re-validate state under the coordinator lock, and cancel() is idempotent via CAS.
1.2 Compatibility Impact
Fully compatible. No Option, default, checkpoint/savepoint format, storage or RPC change. The visible differences are thread names and an ERROR log line where dev stayed silent for a failing scheduled body.
1.3 Performance / Side-Effect Analysis
Unchanged from the last round: roughly 25 us extra per trigger from the timer-to-dispatcher hand-off (documented with your benchmark data), in exchange for a flat thread count and flat time-to-safepoint. The log line only fires when a body throws, and a failing trigger does not re-arm itself, so there is no log loop.
1.4 Error Handling and Logging
No new issues. Remaining items are the same tracked notes:
Issue 1 (carryover, tracked): dispatch-pool coupling through savepoint lock-holding
- Location:
CheckpointCoordinator.javastartSavepointversustryTriggerPendingCheckpoint'ssynchronized (lock) - Problem / Risk: many pipelines savepointing at once can occupy the bounded dispatch pool and delay other pipelines' triggers. The delay is bounded and there is no data loss.
- Best fix: tracked in #12441 / #12511.
- Severity: Low
- Raised by another reviewer: Yes (related to @nzw921rx's head-of-line concern)
Issue 2 (carryover): no guard on the thenAcceptAsync hop if the coordinator executor is ever bounded
- Location:
CheckpointCoordinator.javastartTriggerPendingCheckpoint - Problem / Risk: not reachable today; a note for whoever bounds that executor later.
- Severity: Low
- Raised by another reviewer: No
2. Code Quality Assessment
2.1 Coding Standards
Good. The new classes carry thorough Javadoc on lifecycle, cancellation and the deliberate best-effort race. Formatting is clean.
2.2 Test Coverage and Test Stability
Rating: Stable. SharedCheckpointSchedulerTest covers lease isolation, cancellation, a failing body keeping the worker alive, and shutdown. The repaired CheckpointCoordinatorScheduledTriggerTest now uses a real lease, so removing the lease wiring turns it red with the NPE you reproduced. CheckpointCoordinatorTest uses a real lease through mockCheckpointManager(). Tests close their scheduler in finally; I saw no sleeps used as assertions and no fixed ports in the tests I read.
2.3 Documentation Updates
No user-facing option or behaviour change, so no docs are required. The PR description documents the bounded pool and the hand-off cost.
3. Architectural Soundness
3.1 Elegance
Long-term solution: one owner for timers, a lease abstraction that keeps per-pipeline cancel semantics, and a clean separation of "fire" from "run".
3.2 Maintainability
Clear boundaries (SharedCheckpointScheduler / PipelineCheckpointScheduler / PipelineCheckpointTask) with the cancellation contract documented where it matters.
3.3 Extensibility
Pool sizes live in one class; the open note is only about bounded coordinator executors (Issue 2).
3.4 Historical-Version Compatibility
No impact on existing jobs, checkpoint data or savepoints. Rolling upgrades are unaffected because nothing crosses the wire.
4. Issue Summary
| Number | Issue | Location | Severity |
|---|---|---|---|
| 1 | Dispatch-pool coupling via savepoint lock (tracked #12441 / #12511) | CheckpointCoordinator.java startSavepoint |
Low |
| 2 | No guard if the coordinator executor is bounded later | CheckpointCoordinator.java |
Low |
5. Merge Recommendation
Conclusion: Ready to merge
Blockers (must fix): none. My previous blocker (semantic conflict with #12448) is resolved.
Recommended fixes (non-blocking): Issues 1 and 2 stay as tracked notes.
CI: the real build runs in the fork: Build run https://github.com/Vivek1106-04/seatunnel/actions/runs/40161631838 is success for head 29ab7519b483, and this head contains #12448, so it also covers the merged-test interaction I raised. The apache-side PR signal run is action_required, which is only the first-run approval gate. The branch is 3 commits behind dev with no conflicts, so no further sync is needed.
Overall assessment: the design has been solid for several rounds, and both of this round's fixes are exactly what was asked. I have no remaining source-level objections. @nzw921rx's earlier concerns (savepoint interaction, scale/latency, upgrade uncertainty) are answered by the savepoint-lock note, the published benchmark data and the no-format-change statement; whether to label the feature experimental is a maintainer call. A committer's final approval is still needed for the merge itself.
DanielLeens
left a comment
There was a problem hiding this comment.
Thanks for fixing the merged trigger-test fixture and completing the task future before logging. I re-read the entire current implementation and every changed test, including the original timer/dispatch design. The current-head fork Build is now green, so the earlier pending/red CI guidance is superseded. Sorry for missing the stale expiry-handoff boundary in the previous full review. Issue 5 below is an omission carried over from that implementation, not a newly introduced problem in your two latest fixes.
What problem this PR solves
- User pain: Each active pipeline previously owned its own two-thread checkpoint timer pool, increasing member thread count as pipeline count grows.
- Fix approach: Share member-owned timing and bounded dispatch pools while giving every coordinator a reusable pipeline-local cancellation lease.
- One-line summary: Reduce checkpoint timer thread growth while retaining per-pipeline scheduling and cleanup.
Example: Five hundred active pipelines share two timer threads plus a host-sized dispatch pool instead of owning five hundred separate scheduled executors. Cancelling one pipeline cancels its timers without stopping another pipeline's lease.
1. Code-change review
1.1 Core logic analysis
All ten files (+1101/-43), including the complete three new scheduler classes, coordinator/manager/context changes, container thread classification, scheduled-trigger integration test and both entire changed test bodies, were read. The complete target-relative diff was read at 29ab7519b483425e30faffb2bacc4043a7fc11a9, not just the latest commit. A member-level resource-ownership change; source/checkpoint/barrier contracts require special attention at asynchronous recovery boundaries.
Before / after:
Before: each CheckpointCoordinator creates a two-thread ScheduledThreadPoolExecutor; cleanup shuts it down and creates another.
After: EngineContext owns SharedCheckpointScheduler -> coordinator leases PipelineCheckpointScheduler -> timer fires -> bounded dispatcher executes PipelineCheckpointTask.
Trigger barrier continuation: thenAccept -> thenAcceptAsync(executorService).
Timeout: dispatch-side pending/ACK check -> expireCheckpoint -> coordinator-executor runnable calling handleCoordinatorError, with inline rejection fallback.
Task failure: completeExceptionally first, then contextual ERROR log, no rethrow; finally unregister task.
Complete relevant execution chain:
SeaTunnelServer builds SeaTunnelEngineContext -> member-scoped SharedCheckpointScheduler
-> JobMaster.initCheckPointManager -> CheckpointManager stores shared scheduler
-> coordinator construction -> leaseCheckpointScheduler(pipelineId), one final lease per coordinator
Periodic: scheduleTriggerPendingCheckpoint (570) wraps failures -> lease.schedule -> capture MDC / outstanding set -> timer.schedule handoff
-> dispatcher -> PipelineCheckpointTask.run (97): cancellation check -> body -> completed future -> unregister
-> tryTriggerPendingCheckpoint (785): readiness/interval/min-pause -> lock -> shutdown/pending/schema-change guards
-> createPendingCheckpoint -> async barrier continuation (1004) on coordinator executor -> triggerCheckpoint RPCs -> ACK/complete/storage
-> arm watchdog (1073) on lease -> pending identity lookup + notFullyAcknowledged (1078) -> expireCheckpoint (363)
-> coordinator executor -> handleCoordinatorError (376): current coordinator future/status -> fail manager -> cleanPendingCheckpoint
Savepoint: startSavepoint takes the existing pipeline lock and waits for pending work; direct trigger still shares watchdog lease, not periodic re-arm policy
Reset: CheckpointManager.restoreCoordinator (300) reuses coordinator -> restoreCoordinator (726) replaces future/error state -> clean old checkpoints and lease.cancelAll -> resume same lease
Terminal: lease.cancelAll affects one pipeline; server shutdown ends coordinator services, then EngineContext.close shuts shared timer/dispatcher before state stores.
Key findings:
- The normal checkpoint path reaches the shared scheduler; barriers/storage remain on the coordinator executor. Savepoints retain their direct trigger path and current serialization contract.
- The latest dev trigger test now receives a real scoped lease and closes its scheduler; #12448's FAILED/handleCheckpointError assertions remain intact. Previous Issue 1 is closed.
- Task failure completes the future before logging and keeps its worker alive; previous Issue 4 is closed. MDC is captured per scheduled body, not assumed from a member-wide thread name.
- The bounded dispatcher uses an unbounded queue, so saturation delays rather than rejects. Existing savepoint lock-holding can occupy all dispatch capacity; this remains the explicitly accepted follow-up, not a latency bound.
- A watchdog expiry that has already left the lease is no longer cancelled by cancelAll. Its executor runnable has neither the expired PendingCheckpoint nor a coordinator generation, so it can act on a subsequently reset coordinator (Issue 5).
Community context: +1 to @nzw921rx's lifecycle, saturation and benchmark questions: leases are cancellation domains rather than a global registry; the manager owns one coordinator per pipeline; bounded worker count does not bound queue latency. I agree with @SEZ9's recorded acceptance of the documented savepoint-contention follow-up (#12441/#12511), not a claim that it is implemented here. The author's measured hand-off cost and no-hand-off control explain the latency tradeoff; these are author/maintainer measurements, not runs I performed. The posted benchmark rejection stack was against a terminated fixture executor; the current trigger wrapper and contextual task logging must be evaluated separately from benchmark throughput. @SEZ9's comments state no further source changes on the older head; the current head is independently re-read here. There is no current write-capable approval I can replace or dismiss.
1.2 Compatibility impact
Compatible for public configuration, checkpoint/savepoint formats and protocols. Existing user options/defaults, barrier payloads and persistent state layouts are unchanged. Observable thread ownership/names and failure logging change. Resource isolation changes from per-pipeline timers to shared dispatch capacity, which is the explicit design tradeoff. No checkpoint migration is needed. Repair the stale reset action in Issue 5 before relying on recovery compatibility.
1.3 Performance / side effects
The production cost and ownership implications are described below. Two member timer threads replace pipeline-scaled pools; dispatch workers are bounded by max(8, 2*availableProcessors), idle timeout 60 seconds. Each handoff adds scheduling/allocation overhead; reported tens-of-microseconds medians and occasional run-queue tails are not zero overhead. Queue memory and latency remain unbounded under sustained saturation; this is acknowledged rather than hidden behind the thread bound. Timers only submit work. Barrier/expiry distributed-map work normally hops to the coordinator executor; rejected expiry runs inline with WARN to avoid silently losing failure handling. Per-lease outstanding tasks remove themselves on completion/cancel, timer remove-on-cancel is enabled, and member close shuts both pools. No extra network request or checkpoint serialization step is added. Savepoint lock contention remains a cross-pipeline coupling until the separate fix lands.
1.4 Error handling and logging
The existing exception and logging boundaries remain in place. The scheduled trigger wrapper from dev still transitions the coordinator to FAILED on an unexpected throw. PipelineCheckpointTask completes its future before logging with job/pipeline context, catches task failures and unregisters in finally. Shutdown-only submission rejection cancels the task rather than claiming success. Expiry rejection is logged and handled inline. The missing checkpoint/generation context in the asynchronous expiry action is the correctness issue below; a current coordinatorFuture.isDone check alone does not establish that an old timeout belongs to that future.
Issue 5: Fence an already-dispatched expiry against coordinator reset (High, blocking; carryover omission).
- Location:
seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinator.java:363-372,:1078-1086,:726-735. - Description: The watchdog validates the old pending checkpoint on the shared dispatch thread, then expireCheckpoint submits a parameterless runnable that later calls handleCoordinatorError. restoreCoordinator reuses the same coordinator, replaces checkpointCoordinatorFuture/error state and clears old pending checkpoints. cancelAll only owns PipelineCheckpointTasks; after the watchdog has submitted this separate coordinator-executor runnable and returned, cancelling that lease cannot cancel or invalidate the submitted expiry. If reset occurs before that runnable executes, it fails the new coordinator future and cleans its new state for an old checkpoint. handleCoordinatorError checks the current future, not the generation that timed out.
- Risk: A delayed old watchdog can fail a recovered pipeline and abort newly started checkpoint work. This is a concrete permitted interleaving at the new asynchronous handoff, not a claim that the green CI run reproduced it. It is distinct from the accepted savepoint dispatch-capacity tradeoff.
- Best improvement: Carry the expired checkpoint identity plus a coordinator generation/future token into the submitted action. Revalidate ownership under a reset-compatible fence at the point where failure mutates coordinator state; a check only before executor submission is insufficient. Preserve the inline rejection fallback with the same guard. Add a deterministic controlled-executor test: let the old watchdog hand off, hold its action, reset/restart the coordinator, release the old action and assert the new generation remains healthy; retain a test proving a current-generation genuine expiry still fails it.
- Already raised by another reviewer: No identical point in the reviewed comments. This asynchronous expiry hop existed in the previous reviewed implementation; apologies for missing it then.
Issue 6: Wait for task settlement rather than a worker thread state (Medium, non-blocking test risk).
- Location:
seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/checkpoint/scheduler/SharedCheckpointSchedulerTest.java:298-313;PipelineCheckpointTask.java:109-114. - Description: The future is now completed before log.error and before finally removes the outstanding task. The test then waits for the worker to be non-RUNNABLE and immediately asserts outstandingCount==0. A worker can be BLOCKED or WAITING inside the logging path while the task is still outstanding; non-RUNNABLE does not mean run() reached finally. This can produce a timing-dependent failure, and it is not a reliable proof that the worker has returned to the dispatch queue.
- Risk: Intermittent test failure under logging contention; the production completion-before-log ordering is correct and should remain.
- Best improvement: Await outstandingCount==0 directly, as testCompletedTasksDoNotAccumulate already does. To prove worker survival, submit a latch-controlled follow-up task to that worker or otherwise observe completed dispatch work, rather than infer it from a transient Thread.State.
- Already raised by another reviewer: No identical point. The completion-before-log fix makes the already weak thread-state proxy especially visible; no production ordering rollback is requested.
Previous Issues 2/3 (Low, unchanged): savepoint lock-holding/shared-capacity coupling remains tracked in #12441/#12511; future changes to coordinator executor policy must preserve failure handling for the asynchronous continuation. Neither replaces Issue 5's recovery-generation guard.
2. Code-quality assessment
2.1 Code conventions
New scheduler files carry ASF headers and detailed lifecycle/cancellation/ownership comments; methods/fields have meaningful contract documentation. Existing coordinator/test classes are extended rather than duplicating the lifecycle fixture. The scoped whitespace check produced no entries.
2.2 Test coverage and stability
Stability rating: Risk present, non-blocking (Issue 6). No local build, unit/integration test, container or service was run. The 325-line scheduler test covers dispatch thread selection, sibling cancellation isolation, cancellation before/after handoff, 500-pipeline timer bounds, reusable leases, settlement, close, dispatch bounds, spare-capacity progress and failed-body futures. Most use latches/atomics/Awaitility and release blocked bodies in finally; the fixed sleep has been replaced by a negative during window. CheckpointCoordinatorTest:443/507 exercises the already-completed-future asynchronous trigger and expiry executor ownership. The merged ScheduledTriggerTest:45-92 now uses a scoped real lease and retains dev's assertions. Issue 6 identifies the remaining weak settlement signal. The current tests do not hold an already-submitted expiry across reset, which is the missing correctness regression in Issue 5. The high-core dispatch-bound test scales workers with availableProcessors, an explicit resource cost rather than an environment-independent constant.
Exact current-head fork evidence at 29ab7519b483425e30faffb2bacc4043a7fc11a9: Build 40161631838: completed/success. Apache required-check and review gates remain independent of the fork run. Queued/in-progress/cancelled is not success; the earlier-head result does not transfer to this head.
2.3 Documentation
No new user option or checkpoint format requires bilingual configuration docs. The class Javadocs and PR description must retain the measured handoff cost, bounded-worker/unbounded-queue tradeoff and accepted savepoint contention. Avoid claiming unrelated pipeline execution is always unaffected or that savepoint duration is a guaranteed upper latency bound.
3. Architectural soundness
3.1 Solution quality
Member ownership and pipeline leases are a coherent long-term resource design. Keep timers free of lock-taking bodies; removing the handoff solely to improve benchmark medians would reintroduce timer-wide blocking.
3.2 Maintainability
Shared ownership and reusable cancellation domains reduce pool churn. Expiry actions need explicit lifetime identity when they cross a second executor; comments about safe late lease bodies do not cover a separate stale action after that body has returned.
3.3 Extensibility
Scheduling-delay/queue observability is a useful follow-up before tuning knobs. Additional control paths should carry the same generation/ownership contract instead of relying on lease cancellation after handoff.
3.4 Historical compatibility
No wire/state migration is needed. Existing savepoint/min-pause/pending guards remain. Recovery correctness must include stale asynchronous actions on reused coordinators, as requested in Issue 5.
4. Findings summary
| No. | Finding | Location | Severity |
|---|---|---|---|
| 5 (carryover omission) | Old expiry action can fail a reset coordinator | CheckpointCoordinator.java:363; :726; :1078 | High |
| 6 | Worker state is not task settlement | SharedCheckpointSchedulerTest.java:298-313 | Medium, non-blocking |
| 2/3 (previous) | Accepted contention / executor-policy follow-ups | Coordinator savepoint/continuation | Low |
5. Merge conclusion
Conclusion: Can merge after fixes
- Blocking items: Issue 5: bind deferred expiry to its checkpoint/coordinator generation and add the controlled-reset regression. Current-head CI is green, but does not cover this interleaving. Effective write-capable maintainer approval remains required.
- Non-blocking recommendations: Issue 6: replace the thread-state settlement proxy. Keep the accepted savepoint/observability work bounded to its follow-up and do not remove the timer handoff to chase medians.
The latest fixture fix and completion-before-log change address the previous round, and the exact-head Build succeeds. The remaining correctness request is a narrow stale-action guard at the expiry handoff; it does not reopen the accepted shared-thread design or invalidate the benchmark measurements.
Compare against dev: diverged, ahead 13 / behind 8, merge conflict=False. Drift is recorded independently; it does not itself justify a sync-first comment without an unrelated CI failure.
Daniel has READ permission. This COMMENT is a technical assessment, not an effective write-level approval. A write-capable maintainer must confirm repository gates and supply the effective approval.
Local evidence: exact-head fetch/checkout on seatunnel-review-12165, complete diff and source call-chain reads cited above. No runtime verification was claimed.
…dinator The timeout watchdog hands the expiry to the coordinator executor, and restoreCoordinator() can reuse the coordinator before that action runs. The action carried no identity, so it failed the restored generation's future and cleaned its new pending state for an old checkpoint. The expiry now carries the expired PendingCheckpoint and the coordinator future that was current when it was triggered. Its ownership check and FAILED transition hold a dedicated generationLock, which also guards the future swap in restoreCoordinator(), so a stale expiry is dropped. The inline rejection fallback takes the same path. handleCheckpointError() runs after the lock is released because it takes the pipeline monitor, which a restoring pipeline holds while calling restoreCoordinator().
…rker The failing-body test inferred that the worker had returned to the queue from it leaving RUNNABLE, but the task completes its future before it logs and unregisters, so the worker can be blocked in the logger while the task is still outstanding. Wait for outstandingCount() to reach zero first, then for the worker to park in the queue's timed poll.
|
@DanielLeens thanks for the full re-read, and for catching the expiry hand-off. You are right: once the watchdog has handed the parameterless runnable to the coordinator executor, Issue 5 (stale expiry fails a restored coordinator) -
Tests in
Red runs, made by changing only the production code:
Issue 6 (thread state is not task settlement) -
Verification (JDK 17, local)
Fork Build for |
DanielLeens
left a comment
There was a problem hiding this comment.
Thank you @Vivek1106-04 for the fast, precise turnaround. The head is now a932e27a7 (commits 3cc4dea45 and a932e27a7 on top of 29ab7519b). I re-read both commits in full, the complete failExpiredCheckpoint / restoreCoordinator / cleanPendingCheckpoint / startSavepoint paths at this head, and the new tests. This is a static review; nothing was built or run locally, and the runtime evidence is the fork CI.
Status of my earlier points (verified in the head code):
- Issue 5 (an already-dispatched expiry could fail a reset coordinator): resolved. The generation token is read in
startTriggerPendingCheckpoint(CheckpointCoordinator.java:1060, while the caller holdslock), carried intoexpireCheckpoint(expired, owner)(:375), and re-validated infailExpiredCheckpoint(:403-424) undergenerationLockagainstowner == checkpointCoordinatorFuture,!owner.isDone()and the identity of the pending checkpoint.restoreCoordinatorswaps the future under the same monitor (:780-784), so the expiry either fails the old generation first or sees the new one and is dropped. The inline-rejection fallback runs the same guarded action. Thank you, and sorry again for missing this on earlier rounds. - Issue 6 (thread state is not task settlement): resolved. The test now waits for
lease.outstandingCount() == 0first, then forTIMED_WAITING/TERMINATED, and assertsTIMED_WAITING. - Issues 2/3 (savepoint contention and executor-policy notes): unchanged, still tracked in #12441/#12511.
What Problem Does This PR Solve?
- Pain point: every
CheckpointCoordinatorowned a two-thread scheduled pool, so timer threads grew linearly with active pipelines. - Fix approach: one member-wide
SharedCheckpointScheduler(two timer threads plus a bounded dispatch pool) and a per-pipeline cancellation lease for each coordinator. - Summary: replace N per-pipeline timer pools with shared timers and a bounded dispatcher, with no config, wire or state-format change.
1. Code Change Review
1.1 Core Logic Analysis
trigger (under lock): owner = checkpointCoordinatorFuture [generation token]
-> pending checkpoint created -> watchdog armed on the lease (1143)
watchdog (dispatch thread): pending still tracked && !fullyAcknowledged
-> expireCheckpoint(expired, owner) -> coordinator executor (or inline if rejected)
-> failExpiredCheckpoint: synchronized(generationLock)
owner current? not done? pendingCheckpoints.get(id) == expired?
yes: errorByPhysicalVertex, updateStatus(FAILED), owner.complete(FAILED), cleanPendingCheckpoint
no : INFO log, drop
then (outside the lock) checkpointManager.handleCheckpointError(pipelineId, false)
restoreCoordinator: synchronized(generationLock){ new error ref, new future, RUNNING } -> clean (RESET) -> resume
I checked the points that matter for this kind of change:
- Lock order:
generationLock -> this (updateStatus) -> lock (clean). Nothing that holdslockor the coordinator monitor ever waits forgenerationLock(completePendingCheckpointandtryTriggeronly read the volatile field), so I find no cycle.startSavepointcan holdlockin its sleep-poll, butcleanPendingCheckpointsetsshutdown = truebefore takinglock, which ends that poll (:983-986).handleCheckpointErroris correctly called after the lock is released, because it takes the SubPlan monitor. - Reordering: the expiry path now cleans pending state before notifying the manager. The old comment's reason (do not kill the pool first) no longer applies, since
cleanPendingCheckpointonly callsscheduler.cancelAll()(:1352). - Side benefit: the pending-identity check also drops an expiry for a checkpoint that completed between the watchdog decision and execution, which the old unconditional
handleCoordinatorErrorwould have failed. - Reset semantics:
PendingCheckpoint.abortCheckpointcompletes withnullforCHECKPOINT_COORDINATOR_RESET, so aborting old pending checkpoints during a restore does not trigger the error handler on the new generation.
1.2 Compatibility Impact
Fully compatible. No Option, default, checkpoint/savepoint format, storage layout or RPC change. Visible differences are thread names and an ERROR log line for a failing scheduled body.
1.3 Performance / Side-effects
Unchanged from the last round (timer-to-dispatcher hop, bounded workers, unbounded queue by design). The new generationLock is only taken on expiry and on restore, so there is no hot-path cost.
1.4 Error handling and logging
Issue 1 - Medium (non-blocking, pre-existing on dev, same class as Issue 5): other deferred actions still use handleCoordinatorError without a generation token.
- Location:
CheckpointCoordinator.java:1071-1079and:1109-1115(completion/barrier-trigger failure handlers in thethenAcceptAsynccontinuation, includingallOf(...).get()at:1107), and the scheduled-trigger wrapper at:620-640. - Problem: these run later on the coordinator executor or a dispatch thread and call
handleCoordinatorError, which checks only the currentcheckpointCoordinatorFuture. A barrier RPC that fails because a member went away, observed afterrestoreCoordinatorswapped the future, would fail the restored generation in the same way Issue 5 described. Your PR description is right that synchronous callers are unaffected; these are the asynchronous ones. - Risk: a narrow recovery race that exists on
devtoday, so not introduced here and not a blocker for this PR. - Best improvement: a follow-up that threads the same
ownertoken through these handlers (a small private overload ofhandleCoordinatorError(owner, reason, e)that applies thegenerationLockcheck). Worth filing next to #12441. - Severity: Medium. Raised by another reviewer: No.
Issue 2 - Low: generationLock now spans distributed IMap I/O.
- Location:
CheckpointCoordinator.java:406-423(updateStatusretries againstrunningJobStateIMap,cleanPendingCheckpointremoves IMap keys and notifies the monitor) whilerestoreCoordinator(:780) waits on the same lock, typically holding the SubPlan monitor. - Problem/Risk: a slow IMap during an expiry delays a concurrent restore by the retry budget. Before this change
restoreCoordinatoronly waited on the coordinator monitor forupdateStatus, so this is a small extension, not a new hazard class. No test exercises expiry racing a concurrent savepoint, which is the interaction the lock-choice comment relies on. - Best improvement: keep as is; optionally add one controlled test that holds the
lockin a savepoint-style poll while an expiry runs, to pin the "shutdown ends the poll" argument. - Severity: Low. Raised by another reviewer: No.
Issue 3 - Low: new tests reach private members by reflection.
- Location:
CheckpointCoordinatorTest.javahelpersexpire(...)(invokes privateexpireCheckpoint(PendingCheckpoint, CompletableFuture)),coordinatorFuture(...),trackPendingCheckpoint(...)(read private fields). - Problem: the signature of a private method is now part of the test contract. This matches the existing style of the class, so I treat it as a nit.
- Best improvement: make
expireCheckpointpackage-private with@VisibleForTesting, asscheduleTriggerPendingCheckpointalready is. - Severity: Low. Raised by another reviewer: No.
2. Code Quality
2.1 Coding standards
The new generationLock field, expireCheckpoint and failExpiredCheckpoint carry Javadoc that states why it is not lock and why handleCheckpointError runs outside the lock. checkpointCoordinatorFuture is now volatile for the token read. ASF headers on the new scheduler files were already in place.
2.2 Test coverage and stability
Rating: Stable. testExpiryHeldAcrossCoordinatorRestoreDoesNotFailTheRestoredGeneration holds the coordinator executor with a latch, restores the coordinator, re-tracks the same pending checkpoint so only the generation check can drop the expiry, and asserts the restored future is not done and handleCheckpointError is never called; removing the owner condition makes it red (you demonstrated this). testExpiryOfTheCurrentGenerationFailsTheCoordinator guards against over-fencing. testFailingBodyCompletesExceptionallyAndKeepsItsDispatchThread is now settlement-based (SharedCheckpointSchedulerTest.java:307-322): after outstandingCount() == 0, the only timed wait on the worker's path is the dispatcher's keep-alive poll, so TIMED_WAITING is a sound signal. No sleeps as assertions, bounded Awaitility, executors shut down in finally. No existing assertion was weakened.
CI on a932e27a7 (fork run 40463705801): unit-test (ubuntu 11/17, windows 11/17), engine-v2-it (11/17), Code style, License header and 80+ other jobs are green. One job failed, rocketmq-connector-it (17), and all-connectors-it-4 (11) was cancelled. This PR touches no connector code, so I read the RocketMQ failure as unrelated (a connector IT already known to be flaky); a job-level rerun is the cheapest confirmation. The Apache side is BLOCKED on required review; the branch is 32 commits behind dev and ahead 15, with no conflicts reported.
2.3 Documentation
No user-facing option or format change, so no docs are required; the Javadoc and PR description carry the tradeoffs.
3. Architectural Soundness
3.1 Fix quality
Member-owned timers, bounded dispatch and per-pipeline leases remain a coherent long-term design, and the generation token is the right shape of fence: cheap, local, and scoped to the one asynchronous hop that outlives lease cancellation.
3.2 Maintainability
Clear boundaries between scheduler, lease and task, with the cancellation contract documented where it matters.
3.3 Extensibility
Issue 1 is the natural next step: make the token a general part of deferred coordinator error handling.
3.4 Historical compatibility
No impact on existing jobs, checkpoints or savepoints; nothing crosses the wire.
4. Issue Summary
| # | Issue | Location | Severity |
|---|---|---|---|
| 1 | Other deferred handleCoordinatorError callers lack a generation token (pre-existing) |
CheckpointCoordinator.java:1071-1115, :620-640 |
Medium (non-blocking) |
| 2 | generationLock spans IMap I/O; no savepoint-interaction test |
CheckpointCoordinator.java:406-423 |
Low |
| 3 | Tests invoke a private method by reflection | CheckpointCoordinatorTest.java expire(...) |
Low |
5. Merge Recommendation
Conclusion: Ready to merge
Blockers: none on the source side. Both items from my last review are fixed and verified. Remaining gates: CI on this head (one unrelated connector IT to rerun) and a write-capable maintainer approval, which my account cannot provide.
Recommended non-blocking fixes: Issue 1 as a follow-up PR, Issues 2 and 3 if you touch these files again. Thank you for the very careful engineering on this one.
|
Thanks for the contribution. The CI fact cited in my previous conclusion has changed; this follow-up updates that gate on the unchanged reviewed head, without issuing another full source review. Gate snapshot (2026-10-28T15:56:42+00:00), head The blocking jobs are Run / all-connectors-it-4 (11, ubuntu-latest) The failed job list is observed, but its causal error is not established by the available summary. Please inspect the linked failed step/report before choosing a source change; no verified upstream sync-only cure is established in this follow-up. My previous source conclusion remains clear for the reviewed scope. CI is not being passed by inference, and this follow-up is not an effective approval. Comparison recorded in this scan: base |
|
Thanks @DanielLeens for the re-review and for confirming Issues 5 and 6. CI on
I re-ran the failed jobs (attempt 2) and will post the result here. Issue 1 (other deferred |
|
@DanielLeens Issue 1 is now tracked in #13052. The fix, together with Issues 2 and 3, is ready on top of this branch. I will open its PR once this one merges, so its diff only contains that change. |
|
Thanks @Vivek1106-04 for digging into the logs on run 40463705801. Your read of both non-green jobs (runner shutdown mid-job for Keeping this PR at its current head while that is resolved is the right call; please do not push anything unless the re-run points at a real source problem. Concrete asks:
On #13052: agreed with the sequencing. Tracking the remaining |
|
Thanks @Vivek1106-04 for tracing the runner shutdown/time limit and tracking the deferred generation-owner work in #13052. I verified that issue is open and the current-head Build is now successful. The prior failed/cancelled-job guidance is superseded. Keep this PR's reviewed scope unchanged; the planned follow-up owns the remaining optional callers/savepoint interaction coverage. My technical source conclusion stays clear; effective maintainer approval is separate. Checked 2026-10-29T17:13:58+00:00; head Comparison: base Daniel has READ repository permission. This is a discussion/CI clarification, not a new complete source review, an effective APPROVED event or dismissal of historical requested changes. A write-capable maintainer must perform any required final approval/dismissal. Thanks for keeping the follow-up focused. |
|
Thanks @Vivek1106-04. Tracking Issue 1 separately and bundling Issues 2 and 3 into the same follow-up PR, opened once this one merges, keeps this PR's reviewed scope unchanged, which works for me. Nothing new from my side beyond my last comment: once the branch is synced with the latest base and the conflicts are resolved, please leave a note here so the new head can be checked. Final approval rests with a write-capable maintainer. |
|
Thanks @SEZ9. I agree with keeping the follow-up scope separate. The current reviewed head is source-clear and Build-green, but GitHub still reports actual merge conflicts. The contributor must sync the latest base and resolve those conflicts while preserving both sides, then obtain CI on the resulting new head for complete re-review. Final effective approval belongs to a write-capable maintainer. Checked 2026-10-30T03:30:37.151775+00:00; head This is an unchanged-head discussion/CI clarification, not another full source review or an effective approval. Daniel has READ repository permission. |
|
Thanks @Vivek1106-04 for tracing the runner shutdown and the job time limit on the two non-green jobs in run 40463705801 on Keeping the owner-checked One remaining item: GitHub reports the branch as |
…-checkpoint-scheduler # Conflicts: # seatunnel-engine/seatunnel-engine-server/src/main/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinator.java # seatunnel-engine/seatunnel-engine-server/src/test/java/org/apache/seatunnel/engine/server/checkpoint/CheckpointCoordinatorTest.java
|
@SEZ9 @DanielLeens I merged the latest The conflicts came from #12042 (idle reader state after a master switch), which touched the same two files:
Local, JDK 17, Fork Build for |
DanielLeens
left a comment
There was a problem hiding this comment.
Thank you @Vivek1106-04 for syncing with dev and for describing the conflict resolution so precisely. I re-reviewed the current head b48e2101972853a13914547d659e55b0e64f6ffb from scratch, not just the merge. My previous round (a932e27a7) already found no source blockers, and nothing in this merge reopens that. Both items I raised earlier (the stale-expiry generation fence and the thread-state test) are still resolved at this head. This is a static review; nothing was built or run locally, and runtime evidence comes from the fork CI.
What Problem Does This PR Solve?
- User pain point: every
CheckpointCoordinatorowned a two-thread scheduled pool, so timer threads grew as 2 x active checkpoint-enabled pipelines (500 pipelines: up to 1000 mostly idle threads). - Fix approach: one member-wide
SharedCheckpointScheduler(owned bySeaTunnelEngineContext): 2 timer threads that only fire and hand off, plus a bounded dispatch pool that runs the body. Each coordinator leases aPipelineCheckpointSchedulerthat tracks only its own tasks, socancelAll()is per pipeline and the lease survives a master-failover reset. - One-sentence summary: replaces N per-pipeline timer pools with one shared timer plus a bounded dispatcher, with no config, wire or checkpoint-format change.
1. Code Change Review
1.1 Core Logic Analysis
What is new since my last review. The head is a merge of dev (3ac2d1cec6a, which is also the merge base, so the effective PR diff is clean). I diffed the engine main sources against my previously reviewed head: the only change is the import block plus the restoreCoordinator(alreadyStarted) idle-reader bookkeeping that arrived from #12042. That block is dev code and does not touch the scheduler. The PR's own main-source diff is byte-identical to what I reviewed before.
CheckpointManager.<init> -> engineContext.getCheckpointScheduler() CheckpointManager.java (ctor)
-> leaseCheckpointScheduler(pipelineId) -> SharedCheckpointScheduler.lease per (jobId, pipelineId)
tryTriggerPendingCheckpoint (under lock) -> scheduler.schedule(...)
-> timer thread: dispatchAfter -> submit -> dispatcher.execute(task) SharedCheckpointScheduler.java
-> dispatch thread: PipelineCheckpointTask.run() -> body (re-validates state under coordinator lock)
trigger body -> startTriggerPendingCheckpoint: owner = checkpointCoordinatorFuture (generation token)
-> thenAcceptAsync(..., executorService) barrier wait off the dispatcher
-> watchdog armed on the lease -> expireCheckpoint(expired, owner) -> failExpiredCheckpoint
restoreCoordinator: synchronized(generationLock){ new future, RUNNING } -> clean(RESET) -> scheduler.cancelAll()
I re-verified the merged result on these points:
- Interplay with #12042: the new
finishedGroupsread andclosedIdleTaskrebuild run inrestoreCoordinatorafter thegenerationLockblock and beforenotifyCompleted. They userunningJobStateIMaponly, take no scheduler lease and no new lock, so they cannot interact with the lease or the generation fence. Lock order staysgenerationLock -> coordinator monitor (updateStatus) -> lock (clean). - Cancellation contract:
PipelineCheckpointTask.cancelis a CAS, cancels the timer future and the completion once, andattachTimerFuturecloses the schedule-vs-cancel window. A body already on the dispatcher can still run once; both bodies re-validate under the coordinator's own state, and the stale expiry is additionally fenced by the generation token. Correct as documented. - Lifecycle:
SharedCheckpointScheduler.close()runs fromSeaTunnelEngineContext.close(); a closed scheduler cancels tasks instead of throwing, and daemon threads cannot pin the JVM.cleanPendingCheckpointnow only callscancelAll(), so no pool is rebuilt on every reset. - Restore: the lease is reusable after
cancelAll(), so the failover path (restoreCoordinator-> clean(RESET) -> trigger again) re-arms normally.
1.2 Compatibility Impact
Fully compatible. No Option, default, checkpoint or savepoint format, state-store layout or RPC change. The only visible differences are thread names (checkpoint-timer-*, checkpoint-dispatcher-*, replacing checkpoint-coordinator-<p>/<j>) and an ERROR log for a failing scheduled body. The E2E thread-leak whitelist was extended accordingly.
1.3 Performance / Side-effects
One extra timer-to-dispatcher hop per trigger (microseconds against a seconds-scale checkpoint interval). Thread count is capped at 2 + max(8, 2 x cores), idle dispatchers are reaped after 60 s. The unbounded queue is deliberate (a rejection would mean a dropped trigger or watchdog), and the one path that can hold a dispatcher for long (startSavepoint holding lock in its poll) is documented in the class Javadoc and tracked in #12441. For the 10-billion-row-per-job and trillion-per-day baseline this change adds no per-record cost; it only touches per-checkpoint control-plane scheduling, so I see no OOM, leak or backpressure amplification path.
1.4 Error handling and logging
No new blocking issue. Two nits and the carried-over follow-up:
Issue 1 - Low: dead fallback creates an unmanaged scheduler.
- Location:
CheckpointManager.javaconstructor (sharedScheduler != null ? sharedScheduler : new SharedCheckpointScheduler()). - Problem:
engineContextis dereferenced a few lines later (engineContext.getStateStores()), so a null context already fails before this fallback matters. If a context ever returned a null scheduler, the private instance would never be closed and would leave two timer threads plus dispatcher threads behind per job. - Best improvement: drop the fallback (the context constructor always creates one), or keep it only if you add a close path.
- Severity: Low. Raised by another reviewer: No.
Issue 2 - Low: a plain Mockito.mock(CheckpointManager.class) yields a null lease.
- Location:
CheckpointCoordinatorTest.java(~line 1705, the parallelism-remap test) still uses a bare mock, while every other construction site now usesmockCheckpointManager(). - Problem: it passes today because that test only calls
restoreTaskState, but any future use of the scheduler in that path would NPE with a confusing message. - Best improvement: use
mockCheckpointManager()there too. - Severity: Low. Raised by another reviewer: No.
Carried over, non-blocking, agreed with @SEZ9 to ship as a follow-up PR: the owner-checked handleCoordinatorError overload for the other deferred callers, the package-private expireCheckpoint, and the savepoint-poll interaction test. Scope stays as reviewed.
2. Code Quality
2.1 Coding standards
New classes carry ASF headers and Javadoc that explain why (two-pool split, unbounded queue, best-effort cancellation, generationLock vs lock). @Slf4j is used consistently; the shim CompletableFuture convention is followed.
2.2 Test coverage and stability
Rating: Stable. SharedCheckpointSchedulerTest covers flat timer thread count at 500 leases, per-lease cancellation, cancel-after-dispatch, failing-body survival and the dispatcher bound, with Awaitility instead of sleeps; CheckpointCoordinatorTest covers the stale-expiry fence in both directions (old generation dropped, current generation still fails). The merge only adjusted dev's new idle-reader test to build its coordinator via mockCheckpointManager(), which keeps its assertions intact. No existing assertion was weakened.
CI on b48e21019 (fork run 41170040575): 95 of 96 jobs succeeded, including unit-test on ubuntu 11/17 and windows 11 and the engine ITs. The single failure, unit-test (17, windows-latest), ended after 36 seconds in the "run all modules unit test (Windows)" step with 403 Forbidden while downloading the Maven wrapper (ClassNotFoundException: MavenWrapperMain), so no test executed on that leg. That is runner infrastructure, not this PR. A job-level rerun (gh run rerun 41170040575 --job 124446368937) is the cheapest confirmation; I would still like to see that leg green before merge. The Apache-side Build pointer had not reported when I checked, and the Apache side shows BLOCKED on required review only (mergeable, no conflicts).
2.3 Documentation
No user-facing option or format change, so no docs/en or docs/zh update is needed. Trade-offs are in Javadoc and the PR description.
3. Architectural Soundness
- Fix quality: this fixes the root cause (pool-per-pipeline), not a symptom. Member-owned threads, per-pipeline cancellation scope and a generation fence on the one async hop that outlives lease cancellation is a coherent design.
- Maintainability: clear scheduler / lease / task boundaries with the cancellation contract documented where it matters.
- Extensibility: the follow-up generation-owner work extends the same token naturally.
- Historical compatibility: nothing crosses the wire or touches persisted state, so rolling upgrades,
2.6 -> 3.xstyle upgrades and savepoint/checkpoint restores are unaffected.
4. Issue Summary
| # | Issue | Location | Severity |
|---|---|---|---|
| 1 | Dead fallback creates an unmanaged SharedCheckpointScheduler |
CheckpointManager.java ctor |
Low |
| 2 | One test still builds a bare CheckpointManager mock (null lease) |
CheckpointCoordinatorTest.java ~L1705 |
Low |
| - | Follow-up: owner-checked error overload, package-private expireCheckpoint, savepoint-poll test (agreed, separate PR) |
CheckpointCoordinator.java |
Medium, non-blocking |
5. Merge Recommendation
Conclusion: Ready to merge
Blockers: none on the source side. Remaining gates are mechanical: a rerun of the one infrastructure-failed Windows 17 job, the Apache Build result for this head, and approval from a write-capable maintainer (my account cannot provide that).
Recommended non-blocking fixes: Issues 1 and 2 (or fold them into the agreed follow-up PR).
Overall assessment: a careful, well-documented and well-tested change; the merge with #12042 was resolved correctly and preserved both sides. Thank you for the thorough work on this one.
|
@SEZ9 @DanielLeens Here is the fork Build result for The first attempt of run 41170040575 failed on one job only: The two Low nits from the last review are the dead fallback scheduler and the bare |
DanielLeens
left a comment
There was a problem hiding this comment.
Thanks @Vivek1106-04 for rerunning the Windows leg and posting the result. I confirmed it on the unchanged head b48e2101972853a13914547d659e55b0e64f6ffb: fork run 41170040575 is success on attempt 2, with all 96 jobs either success (85) or skipped (11), so the 403 Maven-wrapper download on unit-test (17, windows-latest) was indeed runner infrastructure.
Folding the two Low nits (the dead new SharedCheckpointScheduler() fallback in the CheckpointManager constructor and the bare CheckpointManager mock in CheckpointCoordinatorTest) into the agreed follow-up #13052 is fine with me; keeping this head unchanged avoids another review and CI round for no behavioural gain.
With CI green and no source blockers, I am approving this head. The Apache-side check rollup for this SHA is still empty, so a write-capable maintainer needs to reconcile the required Build check and give the final approval for the merge gate. Thanks again for the careful work on this one.
|
Thanks @Vivek1106-04 for posting the fork result for On your question: please keep the two Low nits (the dead fallback scheduler and the bare Nothing further is needed from you on this one. A write-capable maintainer still needs to reconcile the required |
|
Thanks @SEZ9 for confirming the same-head retry result and the agreed scope. My technical approval stands. Keep the two low-priority scheduler/test nits in the agreed follow-up #13052; no additional commit is needed here for them. The fork result and Apache required-check adoption are separate, and a write-capable maintainer still owns the effective approval and merge. This is an unchanged-head discussion reply, not another complete source review. Daniel has READ permission; technical approval and GitHub's effective maintainer gate are separate. |
Purpose of this pull request
Closes #12122.
Every
CheckpointCoordinatorbuilt its own two-thread scheduled pool (CheckpointCoordinator.java:227-238), andCheckpointManagerbuilds one coordinator per entry ofcheckpointPlanMap(CheckpointManager.java:119-168). Timer threads therefore grew with the number of active checkpoint-enabled pipelines: a member running 500 of them held up to 1000 threads that spend nearly all their time asleep.The pool was only ever used as a timer. There are two call sites — re-arming the periodic trigger in
scheduleTriggerPendingCheckpoint()and arming the checkpoint-timeout watchdog stored viapendingCheckpoint.setCheckpointTimeOutFuture(...)— while the heavy barrier work already runs on the injectedexecutorServicethroughthenApplyAsync. That is what makes a shared timer safe here.This PR introduces a member-wide scheduler and gives each coordinator a per-pipeline lease.
What changed
checkpoint/scheduler/SharedCheckpointScheduler: one instance per member, owned bySeaTunnelEngineContext(built once atSeaTunnelServer.java:155) and closed with it. It is deliberately not a static singleton, because several members run in one JVM during unit and E2E tests.checkpoint-timer-%d, withsetRemoveOnCancelPolicy(true)) do nothing but hand the task to a bounded dispatch pool (checkpoint-dispatcher-%d, at mostmax(8, 2 x cores)threads, created on demand and reaped after 60 s idle, over an unbounded queue so excess work is delayed rather than dropped), so a checkpoint body that blocks on an RPC delays neither its own pipeline's next timer nor any other pipeline's.PipelineCheckpointScheduler: the lease, identified by(jobId, pipelineId). It holds only its own outstanding tasks, socancelAll()is scoped to one pipeline and a coordinator no longer shuts down a pool it does not own. The lease stays usable afterwards, so a coordinator restored after a master-failover reset schedules again without rebuilding any pool.PipelineCheckpointTask: theScheduledFuturehanded back to callers. Because the task crosses two pools, cancelling it has to work both before the timer fires and after the body has been handed to the dispatch pool; the body re-checks its own cancellation flag at run time rather than relying on the timer future, which is already complete by then.CheckpointCoordinatornow leases its timer fromCheckpointManagerinstead of constructing a pool, andcleanPendingCheckpoint()cancels this pipeline's tasks instead of callingshutdownNow()and building a replacement.Two deliberate choices worth reviewing
Checkpoint work is not dispatched onto the coordinator executor.
createCoordinatorExecutor()(CoordinatorService.java:287-298) is a boundedThreadPoolExecutorover aSynchronousQueuewith a rejection handler, so a saturated coordinator pool could silently drop a checkpoint trigger or a timeout watchdog. Keeping a separate dispatch pool also leaves this change independent of the executor-bounding work in #12117 and #12121.The timer pool size is a constant rather than a new engine config option. Adding an
Optionwould create a user-facing config contract for something that should not need tuning, and would require matchingdocs/enanddocs/zhupdates. Happy to add one if reviewers prefer it.Invariants preserved
The single-pending-checkpoint guard is untouched. The whole trigger path stays inside
synchronized (lock), and thependingCounter.get() > 0guard still short-circuits a redundant trigger. Coordinators previously already had two threads that could enter that path concurrently, so sharing the timer adds no new race: extra dispatch threads queue on the same lock and bail through the same guard.Note that the issue text cites this guard as
:802, which matches the commit it was written against; on currentdevline 802 is the re-schedule call and the guard sits at:791.How it works
1. Before and after
The problem in one picture: one pool per pipeline, so timer threads grew with the
pipeline count even though they spend nearly all their time asleep.
Now the member owns the threads, and each coordinator gets a lease on them.
flowchart LR subgraph BEFORE["Before: one pool per pipeline"] direction TB C1["coordinator<br/>pipeline 1"] --> P1["own timer pool<br/>2 threads"] C2["coordinator<br/>pipeline 2"] --> P2["own timer pool<br/>2 threads"] CN["coordinator<br/>pipeline N"] --> PN["own timer pool<br/>2 threads"] end subgraph AFTER["After: pools shared by the member"] direction TB D1["coordinator<br/>pipeline 1"] --> L1["lease 1"] D2["coordinator<br/>pipeline 2"] --> L2["lease 2"] DN["coordinator<br/>pipeline N"] --> LN["lease N"] L1 --> SH["shared scheduler<br/>2 timer threads<br/>bounded dispatch pool"] L2 --> SH LN --> SH endTwo timer threads plus a bounded dispatch pool instead of 2N threads, whatever the pipeline count.
2. Who owns what
The idea that makes a shared pool safe: threads belong to the member, cancellation
belongs to the pipeline.
A lease owns no threads. It borrows them, and it keeps its own list of scheduled tasks.
That list is the thing a coordinator controls.
flowchart TD SRV["SeaTunnel member"] --> CTX["engine context<br/>lives as long as the member"] CTX --> SHARED["shared checkpoint scheduler"] SHARED --> TIMER["timer pool<br/>2 daemon threads<br/>only fires and hands off"] SHARED --> DISP["dispatch pool<br/>bounded, reaps idle threads<br/>runs the checkpoint body"] CTX --> MGR["checkpoint manager"] MGR -->|"one lease per pipeline"| LEASE1["lease: pipeline 1<br/>own task list"] MGR --> LEASE2["lease: pipeline 2<br/>own task list"] LEASE1 --> COORD1["coordinator 1"] LEASE2 --> COORD2["coordinator 2"] LEASE1 -.->|"borrows threads"| TIMER LEASE2 -.->|"borrows threads"| TIMEREach lease keeps a private task set, so cancelling pipeline 1 can't touch pipeline 2's
timers.
3. The timer only fires; the dispatch pool does the work
This split is the reason a shared pool doesn't couple pipelines together. A timer thread
never runs checkpoint logic. It wakes up, hands the task to the dispatch pool, and goes
back to sleep.
So if a checkpoint blocks on a slow RPC, it holds a dispatch thread, not a timer thread.
No timer fires late because of it. It does hold a dispatch thread, and those are shared,
which is why what a dispatched body may block on matters: see
What this PR does not change.
sequenceDiagram autonumber participant L as lease participant T as timer thread participant D as dispatch thread L->>T: schedule the next trigger, after the checkpoint interval Note over T: waits out the delay T->>D: hand off the task Note over T: free again immediately D->>D: run the trigger, under this pipeline's lock D->>L: task done, drop it from the task list D->>T: schedule the next triggerThe lock in step 5 is per-coordinator, so a dispatch thread running pipeline A's
checkpoint only ever waits on other work for pipeline A. While it waits, though, it is a
shared thread that nobody else can use.
4. Savepoints don't queue behind the periodic timer
This came up in review: what happens when the savepoint API fires while the scheduler is
running the periodic trigger? A savepoint never enters the periodic loop.
The periodic path is the only one that asks the scheduler to re-arm itself. A savepoint
is triggered directly by the caller. It uses the scheduler for one thing only, the same
thing every checkpoint type uses it for: arming that checkpoint's timeout watchdog.
flowchart TD PER["periodic trigger<br/>tryTriggerPendingCheckpoint"] --> LOCK["take this pipeline's lock"] SAV["savepoint API<br/>startSavepoint"] --> LOCK LOCK --> BUSY{"a checkpoint already pending?"} BUSY -->|"periodic: yes"| REARM["give up, ask the timer<br/>to try again later"] BUSY -->|"savepoint: yes"| WAIT["wait here until it drains"] BUSY -->|"no"| FIRE["create and trigger the checkpoint"] WAIT --> FIRE FIRE --> WD["arm this checkpoint's<br/>timeout watchdog"] REARM -.->|"only the periodic path re-arms"| PERSo calling the savepoint API during a pending periodic checkpoint makes the savepoint
wait, then fire straight away. It never sits in the timer queue. And because a pipeline
allows only one pending checkpoint at a time — which this PR doesn't change — a
savepoint's watchdog and a periodic checkpoint's watchdog are never both live for the
same pipeline.
This PR changes where the timer threads live. It doesn't change when a checkpoint gets
triggered, or which kind wins.
One thing the diagram hides: "wait here until it drains" is a 500 ms sleep-poll inside
this pipeline's lock. That is existing
devbehaviour, and this PR does not change it.What does change is where a periodic trigger for that pipeline waits on it. See below.
5. Master failover reuses the lease
The old code threw its executor away on reset and built a replacement. The new code just
cancels that pipeline's scheduled tasks. The lease stays valid, so a restored
coordinator starts scheduling again with no pool to rebuild.
sequenceDiagram autonumber participant CO as coordinator participant LE as its lease participant SH as shared scheduler CO->>CO: restore after master failover CO->>LE: cancel everything this pipeline scheduled LE->>LE: clear the task list Note over SH: threads untouched, still 2 timer threads CO->>CO: clear pending checkpoints, mark itself running CO->>LE: schedule again on the same leaseSame coordinator, same lease, no thread churn.
6. Cancelling a task that has already been handed off
A task crosses two pools, so cancellation has to work in both halves. Cancelling the
timer entry is enough while the task is still waiting. Once the timer has fired, that
entry is already spent — so the task also checks a cancelled flag right before running
the body.
stateDiagram-v2 [*] --> Waiting: scheduled Waiting --> Cancelled: cancelled before the timer fires Waiting --> HandedOff: timer fires HandedOff --> Skipped: sees the cancelled flag,<br/>body never runs HandedOff --> Running: not cancelled Running --> Finished: body completes Running --> Failed: body throws Finished --> [*]: dropped from the task list Failed --> [*]: dropped from the task list Skipped --> [*]: dropped from the task list Cancelled --> [*]: dropped from the task listA body that throws completes the task's future exceptionally and is logged at ERROR with the
job and pipeline id. The dispatch thread survives and goes back to the pool.
There's a small window left: a task that already passed the flag check will finish its
body even if cancellation lands mid-run. That's harmless, because every body re-checks
the coordinator's state before doing anything. A periodic trigger bails out if the
coordinator is finished or shut down; a watchdog bails out if the checkpoint it was
watching is gone, and an expiry it already handed off is dropped if the coordinator was
restored in the meantime (see the watchdog section). Both checks share the same lock as the cancellation, so a late firing
does nothing.
Shutdown
The shared pools close once, with the engine context, when the member shuts down.
Anything scheduled in that window isn't lost quietly: the task is cancelled and the
rejection is logged at debug, since it is expected during shutdown rather than a fault.
Timeout watchdog: expiry runs off the dispatch thread
The watchdog used to run its expiry handling inline on the thread that fired it. That
handling updates the coordinator status in the IMap through a retry loop and takes the
coordinator lock through
cleanPendingCheckpoint, which is too much to run on a shareddispatch thread. Since
da0cbbf52the dispatch thread only checks whether the checkpointis still pending and hands the expiry to the coordinator executor. That executor aborts
when saturated, and a dropped expiry would leave the checkpoint pending with nothing left
to fail it, so a rejection falls back to running inline, with a WARN.
Because the expiry now runs later than the watchdog's check,
restoreCoordinator()canreuse the coordinator in between. Since
3cc4dea45the expiry carries the expiredPendingCheckpointand the coordinator future that was current when that checkpoint wastriggered (read in
startTriggerPendingCheckpointwhile the caller holdslock). It failsthe coordinator only if that future is still current and not done, and the checkpoint is
still the one tracked under its id; otherwise it is dropped with an INFO log. That check and
the FAILED transition hold a dedicated
generationLock, whichrestoreCoordinator()alsoholds while it swaps in the new future, so a stale expiry can never fail a restored
generation. It is not
lock, becausestartSavepointsleep-polls insidelockwaiting forthe very checkpoint that may be expiring.
handleCheckpointErroris called after the lock isreleased, since it takes the pipeline monitor that a restoring pipeline holds while it calls
restoreCoordinator().Cost of the hand-off
The timer -> dispatcher hand-off is not free, and the scheduling benchmark from #12896 measures
it. Full analysis and raw data: #12165 (comment).
trace shows the timer fires just as late on both sides (about 80 us at p50), and the whole
difference is the hop from timer thread to dispatcher thread.
thread's scheduler time slice to end. On the 4-vCPU CI runners the thread holding the CPU was
usually the benchmark's own busy-spinning JMH thread.
(
timer.schedule(task, ...)) scores the same asdevon JDK 11 and 17, with no tail(run 40071856278; builds alternated
on one runner, two rounds each).
The hand-off is kept on purpose. It keeps lock-taking checkpoint bodies off the two shared
timer threads, so one slow coordinator can't delay every pipeline's timers. The cost is
microseconds against checkpoint intervals of seconds, and it does not grow with the pipeline count.
What sharing buys, from the JDK 17 safepoint logs of run 40061505634:
dev's 2 timer threads perpipeline make safepoints slower as P grows. Time-to-safepoint p50 is 10 / 18 / 56 us at
P = 10 / 100 / 500, and the longest pause at P = 500 is 34-37 ms. This PR stays at about 8.5 us,
with a 6.6-6.9 ms longest pause, because its thread count does not depend on P.
What this PR does not change: savepoint lock holding (#12441)
startSavepointholds the coordinator lock across its 500 ms sleep-poll until thein-flight checkpoint drains, and
tryTriggerPendingCheckpointtakes the same lock. Aperiodic trigger that fires for that pipeline during a savepoint therefore blocks until
the savepoint gets going.
CheckpointManager.triggerSavePointsdoes this for everycoordinator of a job at once.
devthe blocked trigger holds one of that pipeline's own two threads. Here it holdsa shared dispatch thread. That is a new cross-pipeline coupling, and this PR does not
remove it.
pool, enough pipelines' trigger and savepoint windows have to coincide, and I have not
been able to demonstrate that.
dev's savepoint path, notthis design. The regression lands with that fix, written red against the current
blocking lock first: fill the dispatch capacity with savepoint-contended coordinators,
then assert that an unrelated pipeline still gets its trigger and watchdog. This split
was agreed on [STIP-40][Improve][Zeta] Member-wide shared checkpoint trigger scheduling #12339.
The same caveat is in the
SharedCheckpointSchedulerclass Javadoc and in theCorrection section of the STIP (#12339).
Does this PR introduce any user-facing change?
No.
No config option is added, removed, or renamed, and no default changes. The only externally observable difference is thread naming in stack dumps and profiles: the per-pipeline
checkpoint-coordinator-<pipelineId>/<jobId>threads are replaced by sharedcheckpoint-timer-%dandcheckpoint-dispatcher-%dthreads. Job and pipeline context is still available on those threads, because each scheduled body is wrapped withMDCTracer.tracing(...)at schedule time, which is what the previousMDCTracer.tracing(scheduler)wrapper did. No documented behavior depends on the old thread names.How was this patch tested?
New
SharedCheckpointSchedulerTestcovers the lease contract:cancelAll()on one pipeline's lease leaves a sibling pipeline's timer running;cancelAll(), which is the master-failover reset path;771fce221, where the rethrow terminated the worker). It waits for the task to settle (outstandingCount() == 0) and then for the worker to park in the dispatch queue's timed poll, rather than inferring settlement from the thread leaving RUNNABLE.The acceptance criterion from the issue is asserted directly: 500 leases each scheduling a task keep the timer pool at 2 threads.
CheckpointCoordinatorTestneeded one change. Nine tests build coordinators onMockito.mock(CheckpointManager.class); a mocked manager returnsnullfor the newleaseCheckpointScheduler(...), so those coordinators would NPE on cancel. They now go through a helper that hands out real leases from a test-scoped shared scheduler.CheckpointCoordinatorScheduledTriggerTest(from #12448 ondev) got the same treatment: its plain mock now hands out a lease from a test-scoped scheduler that is closed infinally. All of its assertions are unchanged, so it now also checks that #12448's wrapped trigger body still fails the coordinator when it runs on the shared dispatcher.CheckpointCoordinatorTest#testCheckpointExpiryHandlingNeverRunsOnTheDispatchThreadcovers the watchdog change: an expired checkpoint's handling runs on the coordinator executor, not on the dispatch thread that fired the watchdog. It was verified red withda0cbbf52reverted.CheckpointCoordinatorTest#testExpiryHeldAcrossCoordinatorRestoreDoesNotFailTheRestoredGenerationholds a handed-off expiry behind a latch on a single-thread coordinator executor, restores the coordinator, puts the samePendingCheckpointback so only the generation check can drop the expiry, then releases it and asserts the restored generation's future is not done andhandleCheckpointErrorwas never called. It was verified red twice: with the expiry reverted to the old unconditionalhandleCoordinatorError, and with only the owner-future check removed.testExpiryOfTheCurrentGenerationFailsTheCoordinatorasserts that a genuine expiry still completes the current future FAILED and callshandleCheckpointError(1, false).Local results at the current head
a932e27a7(after thedevmergefeedd2506), JDK 17:SharedCheckpointSchedulerTest: 11 tests, 0 failuresCheckpointCoordinatorTest: 20 tests, 0 failuresCheckpointCoordinatorScheduledTriggerTest: 1 test, 0 failures (it failed with an NPE on the null lease before the fix)StateTransitionCleanupTest: 13 tests, 0 failuresorg.apache.seatunnel.engine.server.checkpoint.**tests plusSavePointTestandCheckpointErrorRestoreEndTest: 69 tests, 0 failures, 2 skipped (existing@Disabled)From an earlier head of this PR, on JDK 11, before the watchdog and trigger-body commits; not re-run since:
*Checkpoint*TestplusCoordinatorServiceTest: 83 tests, 0 failures, 3 skippedseatunnel-engine-serverunit test suite: 513 tests, 0 failures, 0 errors, 5 skipped./mvnw spotless:applyapplied; the only formatting changes were whitespace in the files this PR already touches.E2E thread-leak check
SeaTunnelContainerasserts that a finished job leaves no non-system thread behind. Theold per-coordinator pool shut down with the job, so it never tripped that assertion. The
shared pools outlive the job by design, so they do. The first job in a container
creates
checkpoint-timer-%dandcheckpoint-dispatcher-%d, those threads are absentfrom the pre-job snapshot, and they are reported as leaked — which is why the failures
showed up in unrelated connector e2e jobs as well as in
CheckpointRestoreWithStopITand
SavepointRestoreIT. Fixed infd2841fd7by treating both prefixes as systemthreads in
isSystemThread(), alongside the existing member-scopedseatunnel-coordinator-serviceentry.Rebase note
#12149 is open on the same constructor and the same
cleanPendingCheckpoint()lines, and #12096 also touchescleanPendingCheckpoint(). This change is complementary rather than a duplicate: #12149 fixes the lifecycle of the per-coordinator pool, while this removes the per-coordinator pool. I kept the diff narrow specifically so that rebasing is cheap, and I am happy to rebase onto either of those once they land, or to have this sequenced behind them — whichever reviewers prefer.Check list
New License Guide — no new dependencies
incompatible-changes.mdto describe the incompatibility caused by this PR — no incompatible change