Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions sei-db/config/checkpoint_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package config

import (
"time"

"github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl"
)

// CheckpointConfig configures a CheckpointScheduler: how far apart the heights it picks are.
//
// A height has to clear every interval set above 0, so with both set the tighter one paces the
// cadence. A value of 0 or less is unused, and with neither set checkpointing is off.
type CheckpointConfig struct {
// TimeInterval is the wall-clock gap between checkpoints, measured from the last one completing.
TimeInterval time.Duration

// BlockInterval places checkpoints on multiples of itself: at 1000, heights 1000, 2000, 3000
// and so on are eligible.
BlockInterval int64
}

// DefaultCheckpointConfig returns a cadence mirroring the state-commit snapshot settings: a
// checkpoint every 10,000 blocks, and no more than one an hour.
func DefaultCheckpointConfig() CheckpointConfig {
return CheckpointConfig{
TimeInterval: time.Duration(memiavl.DefaultSnapshotMinTimeInterval) * time.Second,
BlockInterval: memiavl.DefaultSnapshotInterval,
}
}
213 changes: 145 additions & 68 deletions sei-db/controller/checkpoint_scheduler.go
Comment thread
yzang2019 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,91 +4,168 @@
package controller

import (
"errors"
"fmt"
"sync"
"time"

"github.com/sei-protocol/sei-chain/sei-db/db_engine/types"
"github.com/sei-protocol/seilog"

"github.com/sei-protocol/sei-chain/sei-db/config"
)

// CheckpointScheduler coordinates checkpoints for stores with in-flight writes.
var checkpointLogger = seilog.NewLogger("db", "checkpoint")

// CheckpointScheduler picks the heights every store on a node checkpoints at.
//
// Stores ask ShouldCheckpoint at each live commit and call MarkCheckpointComplete when that
// checkpoint finishes, success or failure. Replay, WAL catch-up, and state-sync must not ask.
// ShouldCheckpoint will register the store.
//
// The goal is that every registered store either checkpoints on the exact same height, or none of them do.
// That is achieved by holding a yes until every registered store has taken the checkpoint on the same height.
// So a lagging store is still told yes at that height, or no if the faster stores rejected that height already.
//
// A store that passes a held height without taking the checkpoint — one whose version jumped over it — is
// released from that height when it asks next time, so it costs that store one checkpoint rather than
// stopping the whole schedule. A store that stops asking altogether could hold the height indefinitely.
//
// The engine-side capabilities this builds on — types.Checkpointable,
// types.DrainBarrier and types.CheckpointVersionSetter — stay with the engines
// that implement them. What lives here is the decision of when a checkpoint runs
// and what version it is labeled with.
type CheckpointScheduler interface {
SupportsCheckpoint() bool
ScheduleCheckpoint(destDir string, shouldRun func() bool, done func(error))
SetCheckpointVersion(destDir string, version int64) error
// A time interval, a block interval, or both may be set. Both set means both must hold; neither disables checkpointing.
type CheckpointScheduler struct {
mu sync.Mutex
config config.CheckpointConfig

// registered holds every store that has asked, which is how a store joins the schedule.
registered map[string]struct{}
// awaiting maps each store nextCheckpointVersion is held for to whether that store has taken
// the height yet. Empty means every store has checkpointed this height.
awaiting map[string]bool

// nextCheckpointVersion is the height stores checkpoint at, 0 before the first one is picked.
// It stays set once complete, so a store that has yet to reach it is still given that height.
nextCheckpointVersion int64
// checkpointedAt is when the last checkpoint completed, the scheduler's creation before the first.
checkpointedAt time.Time
// rejectedVersion is the highest height answered no. A later ask at it is answered no again
// rather than turning yes once an interval elapses under it.
rejectedVersion int64
}

// ErrCheckpointCanceled reports that a queued checkpoint was canceled before
// it started.
var ErrCheckpointCanceled = errors.New("state store checkpoint canceled")

// SupportsCheckpoint reports whether db carries every engine capability a scheduled checkpoint needs.
// A store built from several engines answers for the set: one engine short of the capabilities makes
// the whole snapshot unpublishable.
func SupportsCheckpoint(db types.StateStore) bool {
_, checkpointable := db.(types.Checkpointable)
_, barrier := db.(types.DrainBarrier)
_, versionSetter := db.(types.CheckpointVersionSetter)
return checkpointable && barrier && versionSetter
// NewCheckpointScheduler returns a scheduler holding every store that asks it to one cadence.
func NewCheckpointScheduler(cfg config.CheckpointConfig) *CheckpointScheduler {
checkpointLogger.Info("checkpoint scheduler created",
"timeInterval", cfg.TimeInterval, "blockInterval", cfg.BlockInterval)
return &CheckpointScheduler{
config: cfg,
registered: make(map[string]struct{}),
awaiting: make(map[string]bool),
checkpointedAt: time.Now(),
Comment thread
seidroid[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Seeding checkpointedAt with time.Now() restarts the time interval on every process start, so a node that restarts more often than TimeInterval never checkpoints at all. With DefaultCheckpointConfig that is a 1-hour window: a node cycling every ~50 minutes produces no checkpoints and logs nothing beyond checkpoint scheduler created.

The analogous memiavl path deliberately avoids this — db.go:283 seeds lastSnapshotTime from the existing snapshot directory's mtime (getSnapshotModTime) rather than from process start. Since DefaultCheckpointConfig is documented as mirroring those settings, consider giving the scheduler the same recovery: seed checkpointedAt from the newest existing checkpoint when the caller can supply one, rather than unconditionally from construction time.

}
}

// FanIn returns a report callback for n parallel branches. Each branch calls it once, and the last
// call passes done the first error any branch reported, or nil.
func FanIn(n int, done func(error)) func(error) {
var (
mu sync.Mutex
remaining = n
firstErr error
)
return func(err error) {
mu.Lock()
if err != nil && firstErr == nil {
firstErr = err
}
remaining--
isLast := remaining == 0
// Read under the lock: a peer branch may report between the unlock and the call to done.
outcome := firstErr
mu.Unlock()
if isLast {
done(outcome)
}
// ShouldCheckpoint reports whether version is a height for store to checkpoint at, registering
// store with the schedule when this is its first ask. Call it only from the live commit path,
// never during replay, WAL catch-up, or state-sync forward-fill.
func (s *CheckpointScheduler) ShouldCheckpoint(store string, version int64) bool {
if !s.checkpointEnabled() || version <= 0 {
return false
}

s.mu.Lock()
defer s.mu.Unlock()
s.registered[store] = struct{}{}

if version == s.nextCheckpointVersion {
s.holdFor(store)
return true
}
if version > s.nextCheckpointVersion {
s.skipPastHeight(store)
}
if s.alreadyRejected(version) {
return false
}
if !s.allStoresCheckpointed() || !s.hasReachedNextInterval(version) {
s.rejectedVersion = version
return false
}
s.pickCheckpointHeight(store, version)
return true
}

// ScheduleCheckpoint checkpoints an engine after all writes already enqueued
// on it have been applied.
func ScheduleCheckpoint(db types.StateStore, destDir string, shouldRun func() bool, done func(error)) {
cp, ok := db.(types.Checkpointable)
if !ok {
done(fmt.Errorf("state store backend %T does not support checkpoints", db))
// MarkCheckpointComplete records that store has finished the height it was given. Both intervals
// start once every store that height is held for has reported it. Any other version is ignored, as
// is a repeat from the same store.
//
// A store that took a height must report it on every path out of the checkpoint, a failed one
// included, which in practice means deferring the call. No further height is picked while one store
// is unreported, so a missed call stops the node checkpointing until it restarts.
func (s *CheckpointScheduler) MarkCheckpointComplete(store string, version int64) {
s.mu.Lock()
defer s.mu.Unlock()

if version != s.nextCheckpointVersion {
return
}
barrier, ok := db.(types.DrainBarrier)
if !ok {
done(fmt.Errorf("state store backend %T does not support ordered checkpoint barriers", db))
if _, awaited := s.awaiting[store]; !awaited {
return
}
delete(s.awaiting, store)
s.updateCheckpointTime()
}

func (s *CheckpointScheduler) checkpointEnabled() bool {
return s.config.TimeInterval > 0 || s.config.BlockInterval > 0
}

func (s *CheckpointScheduler) allStoresCheckpointed() bool {
return len(s.awaiting) == 0
}

// alreadyRejected reports whether version has been turned down already, either by trailing
// nextCheckpointVersion or by being asked and refused. Standing by that no is what stops two stores
// asking at different moments from splitting on one version.
func (s *CheckpointScheduler) alreadyRejected(version int64) bool {
return version < s.nextCheckpointVersion || version <= s.rejectedVersion
}

// hasReachedNextInterval reports whether version satisfies every configured interval.
func (s *CheckpointScheduler) hasReachedNextInterval(version int64) bool {
timeElapsed := s.config.TimeInterval <= 0 || time.Since(s.checkpointedAt) >= s.config.TimeInterval
onBlockBoundary := s.config.BlockInterval <= 0 || version%s.config.BlockInterval == 0
return timeElapsed && onBlockBoundary
}

// pickCheckpointHeight sets nextCheckpointVersion to version and holds it for the stores registered
// now, of which store is the one that has taken it. One registering later is not held for here, at
// a version it may already have passed, and is held for from the moment it takes a height.
func (s *CheckpointScheduler) pickCheckpointHeight(store string, version int64) {
s.nextCheckpointVersion = version
s.awaiting = make(map[string]bool, len(s.registered))
for name := range s.registered {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] registered only ever grows — ShouldCheckpoint adds and nothing removes — and every pick clones the whole set into awaiting. A store that asks once and then stops asking (closed, disabled by a mode switch, or its commit loop wedged) is therefore re-added as false at every subsequent pick and holds the height forever, which stops checkpointing for all stores permanently. TestAStalledStoreStopsTheScheduler pins this as intended, and skipPastHeight only covers the store that keeps asking, so the stalled case has no recovery short of a restart.

Since nothing implements the caller side yet, this is cheap to close now: either an explicit deregistration call for a store shutting down, or a staleness rule (drop a store from awaiting once it has failed to ask for N picks / some multiple of the interval) with a warn naming it.

s.awaiting[name] = false
}
s.awaiting[store] = true
}

// holdFor records that store has taken nextCheckpointVersion, which is held until store reports it.
func (s *CheckpointScheduler) holdFor(store string) {
s.awaiting[store] = true
}

// skipPastHeight releases the hold store has on nextCheckpointVersion, for a store asking above
// that height and so past it for good. A store that took the height keeps its hold: it may be
// writing that checkpoint while it commits later versions, and the intervals wait for it.
func (s *CheckpointScheduler) skipPastHeight(store string) {
if took, awaited := s.awaiting[store]; !awaited || took {
return
}
barrier.ScheduleAtDrain(func() {
if shouldRun != nil && !shouldRun() {
done(ErrCheckpointCanceled)
return
}
done(cp.Checkpoint(destDir))
})
delete(s.awaiting, store)
s.updateCheckpointTime()
}

// SetCheckpointVersion makes a completed checkpoint self-describing without
// changing the live database.
func SetCheckpointVersion(db types.StateStore, destDir string, version int64) error {
setter, ok := db.(types.CheckpointVersionSetter)
if !ok {
return fmt.Errorf("state store backend %T cannot set checkpoint version", db)
// updateCheckpointTime records when nextCheckpointVersion completed, once every store has
// checkpointed this height.
func (s *CheckpointScheduler) updateCheckpointTime() {
if s.allStoresCheckpointed() {
s.checkpointedAt = time.Now()
}
return setter.SetCheckpointVersion(destDir, version)
}
Loading
Loading