Add global checkpoint scheduler - #4026
Conversation
PR SummaryMedium Risk Overview Refactor: Per-engine checkpoint orchestration ( Adds broad unit tests for scheduler cadence, gating, and lifecycle ( Reviewed by Cursor Bugbot for commit 15f04a7. Bugbot is set up for automated code reviews on this repo. Configure here. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4026 +/- ##
==========================================
- Coverage 61.24% 60.34% -0.90%
==========================================
Files 2153 2068 -85
Lines 188379 178213 -10166
==========================================
- Hits 115371 107542 -7829
+ Misses 62276 60784 -1492
+ Partials 10732 9887 -845
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 445e090. Configure here.
There was a problem hiding this comment.
Clean relocation of the SS checkpoint helpers into ss/snapshot plus a new global CheckpointScheduler; the move is complete and consistent, and the scheduler is thoroughly unit-tested. The scheduler is not yet wired into production, and its store contract leaves checkpoint completion inferred rather than reported, which is worth pinning down before implementations land.
Findings: 0 blocking | 4 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- [suggestion] The new global scheduler is not reachable from production: nothing implements
CheckpointableStore(grepforCheckpointInProgressfinds no implementation outsidesei-db/controller), andNewCheckpointScheduleris called only fromcheckpoint_scheduler_test.go. SS still constructs the existing per-composite coordinator insei-db/state_db/ss/composite/snapshot.go, so this PR adds the mechanism without enabling global checkpoint scheduling. Fine as a staged change — worth stating in the description that a follow-up wires it up, so it is not mistaken for a live feature. - [suggestion]
CheckpointableStoregives a store no way to report that a checkpoint failed. A failed checkpoint clearsCheckpointInProgress()and the store's version still advances past the target, so the scheduler reads it as a success: it starts the min-time gate and moves to the next boundary. Combined with the fact that only dispatch is logged (checkpoint scheduled), a node can silently stop producing usable checkpoints while the scheduler reports healthy activity. Consider a completion/error signal on the interface, or at minimum a failure counter the stores increment. - 2 suggestion(s)/nit(s) flagged inline on specific lines.
|
@seidroid review |
There was a problem hiding this comment.
The relocation of the SS checkpoint helpers into ss/snapshot and the Scheduler→Checkpointer rename are complete and consistent (no stale references to the removed controller symbols remain), and the new global CheckpointScheduler is well tested. Two non-blocking issues remain in the scheduler: a dispatch that can be silently dropped by a store yet counted as a completed checkpoint, and a first cycle that runs before shutdown/cancellation is observed.
Findings: 0 blocking | 3 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- [suggestion]
checkpointPollIntervalis a hard-coded 10s while the rest of the cadence is configurable, andCheckpointConfig.Validatedoes not relate the two. The scheduler can therefore dispatch at most one boundary per 10s, so a smallCheckpointInterval(or fast blocks) silently yields a longer effective cadence than configured. Worth either validating/warning when the configured interval is short relative to the poll rate, or deriving the poll interval from the config. - 2 suggestion(s)/nit(s) flagged inline on specific lines.
|
|
||
| targetVersion := nextCheckpointVersion(s.stores, s.config.CheckpointInterval) | ||
| for _, store := range s.stores { | ||
| store.ScheduleCheckpoint(targetVersion) |
There was a problem hiding this comment.
[suggestion] A dispatch a store drops is indistinguishable here from one it accepted. nextCheckpointVersion samples LatestVersion() and can return a target only one version above it (whenever latest ≡ interval-1 (mod interval)); if a store commits that version between the sample and this call, the CheckpointableStore contract says it silently ignores the request. The scheduler still sets scheduledVersion = targetVersion, and the next poll finds !CheckpointInProgress() and allStoresCommitted(target+1), so it records a completed checkpoint, starts the min-time gate, and moves to the next boundary. The result is stores holding checkpoints at different versions — the exact invariant this scheduler exists to enforce — with nothing in the logs distinguishing it from a healthy cycle. The window is narrow, but since nothing implements the interface yet the fix is cheap: have ScheduleCheckpoint report whether it accepted the target (bool or error) and have the scheduler log and re-target on rejection. This also covers the more general case the earlier review raised, where a store's checkpoint fails outright and completion is still inferred.
| for { | ||
| // Ahead of the first wait, not after it: there is already a boundary to announce by the time | ||
| // Start returns, and waiting out a poll interval only delays the first checkpoint. | ||
| s.scheduleNextCheckpoint() |
There was a problem hiding this comment.
[suggestion] The cycle runs before the loop ever observes stopCh or ctx.Done(), so Start() immediately followed by Close() — or Start() on an already-cancelled context — still dispatches one target to every store, contradicting the Start godoc ("begins dispatching targets until Close is called or ctx is cancelled"). Close then blocks in wg.Wait() until that dispatch completes. Dispatching ahead of the first wait is the right call for latency; a non-blocking select on stopCh/ctx.Done() before scheduleNextCheckpoint keeps that while making shutdown authoritative.
| // MinTimeBetweenCheckpoints is the shortest wall-clock gap allowed between one checkpoint | ||
| // finishing and the next being scheduled, which bounds how fast a node replaying blocks | ||
| // checkpoints. 0 leaves CheckpointInterval as the only pacing. | ||
| MinTimeBetweenCheckpoints time.Duration |
There was a problem hiding this comment.
It seems odd to specify a time between checkpoints instead of specifying the target rate of checkpoints. By specifying time between checkpoints, nodes will have different checkpoint frequencies depending on how fast they can checkpoint.
An alternate strategy might be to do the following:
- specify checkpoint period, e.g. "once per 10 minutes"
- if a checkpoint takes longer than 10 minutes, then do not start a new checkpoint until the old one is finished
- Two possible protocols for handling long checkpoints:
- Option 1: if a checkpoint takes longer than the checkpoint period, start the next checkpoint right away when the previous one finishes
- Option 2: if a checkpoint takes longer than the checkpoint period, don't write another checkpoint until we hit the next time that is a clean multiple of the checkpoint period
| const checkpointPollInterval = 10 * time.Second | ||
|
|
||
| // CheckpointConfig is the cadence a CheckpointScheduler holds every registered store to. | ||
| type CheckpointConfig struct { |
There was a problem hiding this comment.
Optional suggestion, feel free to ignore if you don't agree.
In many other places we split configs into their own files. Does it make sense to do so here as well?
There was a problem hiding this comment.
Are we calling the on disk snapshots "checkpoints" now? Changed the name of the in-memory stuff to View so not to conflict with the name "snapshot". I don't mind calling them one thing or the other, I just want to make sure we settle on a uniform name in the code.
| // scheduleNextCheckpoint runs one cycle: it hands the next boundary to every store, or does nothing | ||
| // when a store is still writing the last checkpoint, has not committed past its scheduled version, or | ||
| // the last one finished too recently. | ||
| func (s *CheckpointScheduler) scheduleNextCheckpoint() { |
There was a problem hiding this comment.
One nice to have property would be if this utility ensured that different checkpoint stores checkpoint the same block number. Not required for correctness of course, we're intentionally designing a system that doesn't break when checkpoints don't align (since a crash can cause us to be missing some checkpoints, even if we intend to checkpoint the same block). But its still nice to align them when we can.
If we wanted this property, we'd have to flip the model a little. Instead of the scheduler saying "it's time to checkpoint your current block", we'd have each of the stores ask the scheduler "should I checkpoint block X?". Via this mechanism we could ensure that we don't have an off-by-one block height difference when we snapshot our different stores.

Describe your changes and provide context
Two main changes in this PR:
This PR introduce the interface of new global scheduler, which is not reachable from production yet, nothing implements it. A follow-up PR will wire it up with existing stores.
Testing performed to validate your change