snapshotoffload

package
v0.0.0-...-3ab43b7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 14, 2026 License: AGPL-3.0 Imports: 31 Imported by: 0

Documentation

Index

Constants

View Source
const (
	// DefaultMinGenerations is the floor on manifests kept per group,
	// independent of age. Restores are rare and usually urgent, so the
	// default keeps a fallback generation when the newest snapshot
	// turns out to be unusable.
	DefaultMinGenerations = 3

	// DefaultMaxAge keeps every manifest published inside this window
	// even when that exceeds MinGenerations.
	DefaultMaxAge = 14 * 24 * time.Hour

	// DefaultMinMarkAge is the default two-pass sweep delay. It
	// matches DefaultPayloadGrace because both bound the same thing:
	// the longest plausible time between a publisher touching a
	// payload and its manifest committing.
	DefaultMinMarkAge = 24 * time.Hour

	// DefaultPayloadGrace is the §5 phase-2 delay before an
	// unreferenced payload may be reclaimed. It covers the window in
	// which a payload has been uploaded (payload-first publish) but
	// its manifest has not yet committed: during that window the
	// payload is legitimately unreferenced and must NOT be collected.
	// The grace period must therefore exceed the longest plausible
	// publish duration.
	DefaultPayloadGrace = 24 * time.Hour
)

Design §5 retention/GC.

GC is deliberately two-phase and asymmetric in what it trusts:

Phase 1 deletes MANIFEST objects that fall outside the policy.
Phase 2 deletes PAYLOAD objects that no surviving manifest names.

The asymmetry exists because payloads are content-addressed and therefore SHARED: two groups (or two generations within a group) that snapshot identical bytes converge on one payload object. A payload may only be reclaimed once the live set has been rebuilt from EVERY surviving manifest in the whole prefix — not just the group being trimmed. Getting this wrong deletes live data, so every ambiguity below resolves toward keeping the object.

View Source
const (
	DefaultSchedulerInterval       = 15 * time.Minute
	DefaultSchedulerConcurrency    = 1
	DefaultSchedulerPublishTimeout = 10 * time.Minute
)

Default scheduling parameters from §4.

View Source
const (
	ManifestSchemaVersion = 1
)

Variables

View Source
var (
	ErrInvalidOptions = errors.New("snapshot offload: invalid options")

	// ErrRestoreGroupMismatch reports a restore whose manifest belongs to a
	// different Raft group than the operator named. It is deliberately its
	// own sentinel: an operator repeating the restore command across groups
	// needs to see "wrong group", not a generic invalid-options error.
	ErrRestoreGroupMismatch = errors.New("snapshot offload: manifest group does not match the requested group")

	// ErrRestoreSourceClusterMismatch reports a restore whose manifest was
	// published by a different cluster than the operator named. Its own
	// sentinel, like the group mismatch: "another cluster's backup" and
	// "another group's backup" are different operator mistakes with
	// different fixes.
	ErrRestoreSourceClusterMismatch = errors.New("snapshot offload: manifest source cluster does not match the requested cluster")
	ErrIntegrity                    = errors.New("snapshot offload: integrity check failed")
	ErrObjectConflict               = errors.New("snapshot offload: object conflict")
	ErrObjectNotFound               = errors.New("snapshot offload: object not found")
	ErrObjectClaimed                = errors.New("snapshot offload: object claimed by another operation")

	// ErrObjectModified is returned by DeleteObjectIfUnmodified when
	// the object changed after the caller validated it. For retention
	// this is not a failure: it means a concurrent publish changed the
	// target object, so the correct response is to leave it alone. The
	// target may be either a payload or a manifest.
	ErrObjectModified = errors.New("snapshot offload: object modified since validation")

	// ErrNoPersistedSnapshot reports that the LOCAL data dir has no
	// persisted snapshot yet. It is deliberately distinct from
	// ErrObjectNotFound: a young group that has not snapshotted is a
	// normal scan outcome, whereas an object vanishing from the store
	// mid-publish is a real failure, and collapsing the two would
	// silence the second.
	ErrNoPersistedSnapshot = errors.New("snapshot offload: no persisted snapshot available")

	// ErrSnapshotNotNewer reports that the persisted snapshot is not
	// newer than the caller's high-water mark, so nothing was
	// published. It is a normal outcome for a scheduler tick over an
	// unchanged snapshot, not a failure.
	ErrSnapshotNotNewer = errors.New("snapshot offload: persisted snapshot is not newer than the last published index")
)

Functions

func CleanStaleSpoolFiles

func CleanStaleSpoolFiles(spoolDir string, olderThan time.Time) ([]string, error)

CleanStaleSpoolFiles removes spool files left behind by a previous process.

spoolExport removes its file with a defer, which never runs if the process is killed or the host loses power mid-publish. Nothing else removed them, so repeated crashes during large snapshots accumulated full payload copies until the spool volume filled.

olderThan guards against deleting a file a CONCURRENTLY RUNNING publish is still writing: only files whose mtime predates the cutoff are removed. A caller at startup must still use a real stale-age cutoff because another process may share the directory and be publishing concurrently.

Returns the files removed. Individual failures are collected rather than aborting, because one undeletable file must not leave the rest to accumulate.

func SpoolDirFor

func SpoolDirFor(spoolDir, dataDir string) string

SpoolDirFor reports the spool directory a publish with these options uses, so a caller can clean it without re-deriving the default.

func ValidateKMSKeyID

func ValidateKMSKeyID(value string) error

ValidateKMSKeyID accepts canonical key ARNs and bare key IDs. Aliases are rejected because S3 reports the resolved key identity, not the alias string.

func ValidateS3StoreEncryption

func ValidateS3StoreEncryption(encryption, kmsKeyID string) error

Types

type DeletePrecondition

type DeletePrecondition struct {
	ETag      string
	Size      int64
	UpdatedAt time.Time
}

DeletePrecondition is the state a caller observed for an object, used to make deletion conditional on the object not having changed since.

func PreconditionFor

func PreconditionFor(ref ObjectRef) DeletePrecondition

PreconditionFor returns the precondition describing ref.

type GC

type GC struct {
	// contains filtered or unexported fields
}

GC implements the §5 retention and payload-reclamation passes over one object-store prefix.

func NewGC

func NewGC(opts GCOptions) (*GC, error)

func (*GC) MarkedPayloads

func (g *GC) MarkedPayloads() []string

MarkedPayloads returns the payload keys currently held under a sweep mark. Exposed for operator tooling and tests; the set is per-process and advisory.

func (*GC) RunOnce

func (g *GC) RunOnce(ctx context.Context) (result GCResult, retErr error)

RunOnce verifies rollout and backend prerequisites, then executes both phases. A prerequisite or scan failure returns an error; a completed scan that declines to reclaim payloads returns a nil error and a GCResult with PayloadPhaseSkipped set.

type GCOptions

type GCOptions struct {
	Store  RetentionStore
	Prefix string
	Policy RetentionPolicy
	// VerifyPublishersUseClaims must confirm that every publisher sharing
	// this prefix uses object claims and all legacy in-flight publishes have
	// drained. It runs before each pass; fail it before allowing a downgrade
	// or a legacy publisher to resume. A missing verifier disables GC.
	VerifyPublishersUseClaims func(context.Context) error
	// Now defaults to time.Now. Injected so tests can drive the age
	// windows deterministically rather than by sleeping.
	Now    func() time.Time
	Logger *slog.Logger
}

GCOptions configures NewGC.

type GCResult

type GCResult struct {
	GroupsScanned       int
	ManifestsScanned    int
	ManifestsDeleted    []string
	PayloadsDeleted     []string
	MalformedManifests  []string
	PayloadPhaseSkipped bool
	// SkipReason explains PayloadPhaseSkipped in operator-facing
	// terms. Empty when the phase ran.
	SkipReason string
	// ManifestsClaimedConcurrently counts expired manifests whose
	// compare-and-delete lost to a concurrent publish rewriting the
	// same key (an idempotent republish of the same index/term).
	ManifestsClaimedConcurrently int
	// PayloadsAwaitingSweep counts payloads that are eligible for
	// reclamation but are still serving the §5 two-pass sweep delay.
	// A steady non-zero value on a busy prefix is normal.
	PayloadsAwaitingSweep int
	// PayloadsClaimedConcurrently counts payloads that were eligible
	// for reclamation but whose compare-and-delete lost to a
	// concurrent publish reusing them. A steady non-zero value is
	// healthy dedup traffic, not an error.
	PayloadsClaimedConcurrently int
}

GCResult reports what one RunOnce did and, just as importantly, what it refused to do. PayloadPhaseSkipped with a non-empty MalformedManifests is the expected shape when the bucket contains a manifest this build cannot parse.

type LocalStore

type LocalStore struct {
	// contains filtered or unexported fields
}

func NewLocalStore

func NewLocalStore(root string) (*LocalStore, error)

func (*LocalStore) AcquireObjectClaim

func (s *LocalStore) AcquireObjectClaim(ctx context.Context, key string) (ObjectClaim, error)

AcquireObjectClaim acquires a storage-visible claim for a local object key.

func (*LocalStore) Close

func (s *LocalStore) Close() error

Close releases the descriptor pinned by NewLocalStore. Callers must stop all store operations before closing it. Close is idempotent.

func (*LocalStore) DeleteObject

func (s *LocalStore) DeleteObject(ctx context.Context, key string) error

DeleteObject removes one object. A already-absent object is not an error: GC must be idempotent across retries and a concurrent reclamation of the same key is a benign race.

func (*LocalStore) DeleteObjectIfUnmodified

func (s *LocalStore) DeleteObjectIfUnmodified(ctx context.Context, key string, cond DeletePrecondition) error

DeleteObjectIfUnmodified removes key only when it still matches cond. See the RetentionStore contract for why the condition matters.

func (*LocalStore) GetObject

func (s *LocalStore) GetObject(ctx context.Context, key string) (io.ReadCloser, ObjectInfo, error)

func (*LocalStore) HeadObject

func (s *LocalStore) HeadObject(ctx context.Context, key string) (ObjectInfo, bool, error)

func (*LocalStore) ListObjects

func (s *LocalStore) ListObjects(ctx context.Context, prefix string) ([]ObjectRef, error)

ListObjects walks the pinned local root below prefix. Directories and irregular files are skipped, while symlinks fail the all-or-error listing contract closed. Returned keys are slash-separated and relative to the store root, matching the keys PutObject accepts.

func (*LocalStore) PutObject

func (s *LocalStore) PutObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)

func (*LocalStore) RefreshObject

func (s *LocalStore) RefreshObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)

func (*LocalStore) ValidateRetention

func (s *LocalStore) ValidateRetention(ctx context.Context) error

ValidateRetention checks the pass context; local stores have no bucket policy.

type Manifest

type Manifest struct {
	SchemaVersion  int               `json:"schema_version"`
	CreatedAt      time.Time         `json:"created_at"`
	SourceCluster  string            `json:"source_cluster,omitempty"`
	GroupID        uint64            `json:"group_id"`
	SnapshotIndex  uint64            `json:"snapshot_index"`
	SnapshotTerm   uint64            `json:"snapshot_term"`
	ConfState      ManifestConfState `json:"conf_state"`
	Payload        PayloadDescriptor `json:"payload"`
	BinaryVersion  string            `json:"binary_version,omitempty"`
	ManifestKey    string            `json:"manifest_key"`
	ManifestSHA256 string            `json:"manifest_sha256,omitempty"`
}

func DecodeManifest

func DecodeManifest(data []byte) (Manifest, error)

func LoadManifest

func LoadManifest(ctx context.Context, store ObjectStore, key string) (Manifest, error)

func PublishPersistedSnapshot

func PublishPersistedSnapshot(ctx context.Context, opts PublishOptions) (result *Manifest, retErr error)

func (Manifest) MarshalCanonical

func (m Manifest) MarshalCanonical() ([]byte, string, error)

type ManifestConfState

type ManifestConfState struct {
	Voters         []uint64 `json:"voters,omitempty"`
	Learners       []uint64 `json:"learners,omitempty"`
	VotersOutgoing []uint64 `json:"voters_outgoing,omitempty"`
	LearnersNext   []uint64 `json:"learners_next,omitempty"`
	AutoLeave      bool     `json:"auto_leave,omitempty"`
}

type ObjectClaim

type ObjectClaim interface {
	Release(ctx context.Context) error
}

ObjectClaim is an exclusive claim over one object key. Publishers hold a payload claim until its manifest is committed; retention holds the same claim while it validates and deletes that payload. The shared claim closes the final same-content race that an ETag precondition cannot detect.

type ObjectClaimStore

type ObjectClaimStore interface {
	AcquireObjectClaim(ctx context.Context, key string) (ObjectClaim, error)
}

ObjectClaimStore can acquire a cross-process claim for one object key. Implementations must use storage-visible conditional operations rather than a process-local mutex: publication and GC may run on different nodes.

type ObjectInfo

type ObjectInfo struct {
	Key       string
	Size      int64
	UpdatedAt time.Time
	// SHA256 is optional for metadata-only Head/Get paths; PutObject returns it
	// when the writer verified the committed content.
	SHA256               string
	ServerSideEncryption string
	SSEKMSKeyID          string
	// ETag is the store's opaque version token, when it supplies one.
	// Retention uses it as the compare-and-delete precondition.
	ETag string
}

type ObjectRef

type ObjectRef struct {
	Key       string
	Size      int64
	UpdatedAt time.Time
	// ETag is the store's opaque version token for the exact bytes
	// observed. It is the precondition DeleteObjectIfUnmodified uses
	// to make reclamation a compare-and-delete rather than a
	// check-then-delete. Empty when the store cannot supply one, in
	// which case the size + mtime pair is the fallback precondition.
	ETag string
}

ObjectRef is one object seen by ListObjects.

UpdatedAt is the object store's own last-modified time, not a value carried inside the object. Retention uses it only for the phase-2 payload grace period, where the question is "has this object been sitting unreferenced long enough", which is a property of the store rather than of the snapshot.

type ObjectRefresher

type ObjectRefresher interface {
	RefreshObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)
}

ObjectRefresher rewrites an already-verified object while preserving its canonical content. Publish uses this to make reuse of a small manifest visible to retention; content-addressed payloads are coordinated by claims and are not re-uploaded merely to advance metadata.

type ObjectStore

type ObjectStore interface {
	PutObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)
	GetObject(ctx context.Context, key string) (io.ReadCloser, ObjectInfo, error)
	HeadObject(ctx context.Context, key string) (ObjectInfo, bool, error)
}

type OffloadGroup

type OffloadGroup struct {
	GroupID uint64
	DataDir string
	// IsLeader is the cheap pre-check made before opening the snapshot.
	IsLeader func() bool
	// VerifyLeader is the authoritative check, re-run immediately before the
	// manifest is committed. See PublishOptions.VerifyLeader.
	VerifyLeader func(context.Context) error
}

OffloadGroup is one local Raft group the scheduler may publish for.

type PayloadDescriptor

type PayloadDescriptor struct {
	Key          string `json:"key"`
	Bytes        int64  `json:"bytes"`
	SHA256       string `json:"sha256"`
	SourceCRC32C uint32 `json:"source_crc32c"`
}

type PublishOptions

type PublishOptions struct {
	Store         PublishStore
	DataDir       string
	Prefix        string
	GroupID       uint64
	SourceCluster string
	BinaryVersion string
	CreatedAt     time.Time
	SpoolDir      string
	// VerifyLeader, when set, is re-checked immediately before the manifest is
	// committed. §4 requires leadership to hold at that instant, not merely when
	// the snapshot was opened: spooling a multi-gigabyte payload takes long
	// enough to lose an election. Failing here can leave an unreferenced
	// content-addressed payload, which GC reclaims, but never a committed
	// manifest naming a snapshot this node no longer had the right to publish.
	VerifyLeader func(context.Context) error
	// SkipIfNotNewerThan suppresses the publish when the persisted
	// snapshot's index is not greater than this value. Zero disables
	// the check.
	//
	// The comparison happens after the export is opened but BEFORE
	// the payload is spooled, which is the whole point: a scheduler
	// that ticks every 15 minutes over an unchanged snapshot would
	// otherwise re-read, re-hash and re-fsync a multi-gigabyte
	// payload every tick just to discover the object store already
	// has it.
	SkipIfNotNewerThan uint64
}

type PublishStore

type PublishStore interface {
	ObjectStore
	ObjectClaimStore
	ObjectRefresher
}

PublishStore is the capability set publication requires. Claims and refresh are part of the type instead of runtime assertions because every publish must coordinate with retention and remain idempotent when a manifest already exists.

type PutOptions

type PutOptions struct {
	Size        int64
	SHA256      string
	ContentType string
}

type RestoreOptions

type RestoreOptions struct {
	Store       ObjectStore
	ManifestKey string
	Manifest    *Manifest
	DataDir     string
	Peers       []etcdraftengine.Peer

	// ExpectGroupID is the Raft group the operator believes this data
	// directory belongs to. Required.
	//
	// Nothing downstream carries the group's identity: the restored
	// artifacts record index, term, peers and payload hash, but the group
	// comes only from the manifest, and startup derives the group from the
	// directory layout instead. So a group-2 manifest restored into a
	// group-1 data directory produces a perfectly valid-looking
	// directory that startup then loads as group 1 -- the wrong physical
	// FSM under another group's routing identity, with no error anywhere.
	// The only place that mistake can be caught is here, against what the
	// operator says they intended.
	//
	// A pointer because group 0 is a real group (the dedicated TSO group),
	// so zero cannot double as "unset".
	ExpectGroupID *uint64

	// ExpectSourceCluster is the cluster the operator believes this manifest
	// came from. Required.
	//
	// The group check alone is not enough when one bucket holds backups from
	// several clusters, even under different prefixes: another cluster's
	// manifest for the SAME group id passes it, and the restore produces a
	// structurally valid directory that startup then loads as this cluster's
	// FSM. Nothing downstream records the source cluster either, so this is
	// the only place the mistake is detectable.
	ExpectSourceCluster string

	// AllowLegacySourceClusterless permits restoring a legacy nonzero-group
	// manifest created before source-cluster became mandatory. The caller must
	// opt in explicitly because the manifest cannot prove its source cluster.
	AllowLegacySourceClusterless bool
}

type RetentionPolicy

type RetentionPolicy struct {
	MinGenerations int
	MaxAge         time.Duration
	PayloadGrace   time.Duration
	// MinMarkAge is the §5 two-pass sweep delay: a payload must have
	// been marked for at least this long, and must not have changed
	// since the mark, before it may be reclaimed.
	//
	// It exists because no conditional-delete primitive on a
	// general-purpose S3 bucket can detect a content-preserving
	// refresh: `If-Match` compares a content-derived ETag, which a
	// republish of identical bytes leaves untouched, and
	// `IfMatchLastModifiedTime` is directory-buckets only. Comparing
	// the object's observed state ACROSS passes is what detects that
	// refresh, so it must exceed the longest plausible publish.
	MinMarkAge time.Duration
}

RetentionPolicy is the per-prefix retention rule.

A manifest survives phase 1 if it is within MinGenerations newest for its group OR younger than MaxAge. The newest valid manifest for a group is always retained regardless of both, per §9's acceptance criterion that retention never deletes a group's newest manifest.

type RetentionStore

type RetentionStore interface {
	ObjectStore
	ObjectClaimStore
	// ValidateRetention checks backend-specific reclamation prerequisites
	// before each pass. Read-only restore must not require these permissions.
	ValidateRetention(ctx context.Context) error
	ListObjects(ctx context.Context, prefix string) ([]ObjectRef, error)
	DeleteObject(ctx context.Context, key string) error

	// DeleteObjectIfUnmodified deletes key only if it still matches
	// cond, and returns ErrObjectModified otherwise.
	//
	// This is what makes payload reclamation safe against a
	// concurrent publish. The grace window alone is not enough: a
	// publisher that reuses a content-addressed payload refreshes the
	// object to restart its grace, and an unconditional delete can
	// still land between GC observing the old state and the publisher
	// committing its manifest — leaving a committed manifest pointing
	// at bytes that no longer exist. Making the delete conditional on
	// the exact state GC validated turns that race into a skip.
	DeleteObjectIfUnmodified(ctx context.Context, key string, cond DeletePrecondition) error
}

RetentionStore is an ObjectStore that also supports the listing and deletion that retention/GC needs (design §5).

It is separate from ObjectStore and PublishStore so restore can use a read-only object client, publication requires claims at compile time, and GC additionally requires listing and deletion. A silently-no-op GC would be far worse: retention would appear configured while the bucket grew without bound.

ListObjects is all-or-error by contract: it MUST return every object under prefix or a non-nil error. §5 makes no-deletes-on-partial-scan a safety property, and a lister that silently truncated a page would make live payloads look unreferenced.

type S3Store

type S3Store struct {
	// contains filtered or unexported fields
}

func NewS3Store

func NewS3Store(ctx context.Context, cfg S3StoreConfig) (*S3Store, error)

func (*S3Store) AcquireObjectClaim

func (s *S3Store) AcquireObjectClaim(ctx context.Context, key string) (ObjectClaim, error)

AcquireObjectClaim acquires a storage-visible claim for an S3 object key.

func (*S3Store) DeleteObject

func (s *S3Store) DeleteObject(ctx context.Context, key string) error

DeleteObject removes one object. S3 delete is idempotent, so an already-absent key is not an error.

Versioned buckets: a delete without a VersionId only writes a delete marker, so the bytes survive as a noncurrent version that later ListObjectsV2 scans cannot see. Retention would then report successful reclamation while storage kept growing. Reclaiming those versions requires enumerating them (ListObjectVersions) and deleting each VersionId, which this store deliberately does not do — whether to enumerate versions, refuse versioned buckets outright, or require a noncurrent-version lifecycle policy is a deployment decision. Until that is settled, a versioned backup bucket MUST carry a noncurrent-version expiration lifecycle rule.

func (*S3Store) DeleteObjectIfUnmodified

func (s *S3Store) DeleteObjectIfUnmodified(ctx context.Context, key string, cond DeletePrecondition) error

DeleteObjectIfUnmodified deletes key only when it still matches cond, mapping S3's 412 precondition failure to ErrObjectModified.

When cond carries an ETag this is an exact compare-and-delete via If-Match. Without one it falls back to If-Match-Last-Modified-Time plus If-Match-Size, which is the same contract at coarser resolution. A store that honours neither would silently degrade to an unconditional delete, so an empty condition is refused instead.

func (*S3Store) GetObject

func (s *S3Store) GetObject(ctx context.Context, key string) (io.ReadCloser, ObjectInfo, error)

func (*S3Store) HeadObject

func (s *S3Store) HeadObject(ctx context.Context, key string) (ObjectInfo, bool, error)

func (*S3Store) ListObjects

func (s *S3Store) ListObjects(ctx context.Context, prefix string) ([]ObjectRef, error)

ListObjects pages through every object under prefix.

Per the RetentionStore contract this is all-or-error: any page failure returns an error and no partial slice, because §5 makes "an incomplete scan performs no deletes" a safety property. A truncated listing would make a live payload look unreferenced and let phase 2 delete data a committed manifest still points at.

func (*S3Store) PutObject

func (s *S3Store) PutObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)

func (*S3Store) RefreshObject

func (s *S3Store) RefreshObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)

func (*S3Store) ValidateRetention

func (s *S3Store) ValidateRetention(ctx context.Context) error

ValidateRetention checks versioning immediately before GC, without imposing bucket-policy permissions or lifecycle requirements on read-only restores.

type S3StoreConfig

type S3StoreConfig struct {
	Client                 S3ObjectClient
	Bucket                 string
	Region                 string
	Endpoint               string
	Profile                string
	ForcePathStyle         bool
	AccessKeyID            string
	SecretAccessKey        string
	SessionToken           string
	ServerSideEncryption   string
	SSEKMSKeyID            string
	DisableChecksumHeaders bool
	HTTPClientDecorator    func(aws.HTTPClient) (aws.HTTPClient, error)
	// AllowVersionedBucketWithLifecycle is an explicit operator assertion
	// that the bucket has a noncurrent-version expiration lifecycle. The
	// GC rejects Enabled/Suspended versioning by default because keyed
	// retention deletes otherwise create delete markers without reclaiming
	// bytes.
	AllowVersionedBucketWithLifecycle bool
}

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler is the §4 leader-only publisher: each process scans its own Raft groups on an interval and offloads a persisted snapshot when one exists that has not been published yet.

It never asks the state machine for a snapshot. Snapshot cadence stays owned by the Raft engine, so this milestone can only publish what the engine has already persisted -- an offload that forced its own snapshot would change compaction behaviour, which §10 lists as a non-goal.

func NewScheduler

func NewScheduler(store PublishStore, groups []OffloadGroup, prefix, sourceCluster, binaryVersion string, opts ...SchedulerOption) (*Scheduler, error)

NewScheduler builds the offload scheduler. It is opt-in: callers construct it only when object offload is configured. NewScheduler validates its configuration eagerly so an invalid scheduler cannot be constructed at all. In particular every group must supply both leadership callbacks: SyncOnce is exported and does not re-validate, so a nil callback that survived construction would be a follower publishing a manifest.

func (*Scheduler) LastPublishedIndex

func (s *Scheduler) LastPublishedIndex(groupID uint64) uint64

LastPublishedIndex reports the highest index this process has published for a group. Zero means "nothing published by this process", not "nothing published": another node or a previous run may hold newer manifests.

func (*Scheduler) Run

func (s *Scheduler) Run(ctx context.Context) error

Run scans on the configured interval until ctx is cancelled. Cancellation is the only stop condition; a failing group is retried on the next tick rather than tearing the loop down, because an object store outage must not stop the process.

func (*Scheduler) SyncOnce

func (s *Scheduler) SyncOnce(ctx context.Context)

SyncOnce runs one scan across every local group, bounded by the upload concurrency limit. Exported so tests and operators can force a pass.

type SchedulerObserver

type SchedulerObserver interface {
	ObserveSnapshotOffloadPublished(groupID, index uint64, payloadBytes int64, elapsed time.Duration)
	ObserveSnapshotOffloadSkipped(groupID uint64, reason string)
	ObserveSnapshotOffloadFailed(groupID uint64, err error)
}

SchedulerObserver receives per-attempt outcomes for metrics.

type SchedulerOption

type SchedulerOption func(*Scheduler)

func WithSchedulerClock

func WithSchedulerClock(now func() time.Time) SchedulerOption

func WithSchedulerConcurrency

func WithSchedulerConcurrency(n int) SchedulerOption

func WithSchedulerInterval

func WithSchedulerInterval(d time.Duration) SchedulerOption

func WithSchedulerJitter

func WithSchedulerJitter(d time.Duration) SchedulerOption

WithSchedulerJitter spreads multi-group work so every group in a process does not contend for the upload slot on the same tick.

func WithSchedulerLogger

func WithSchedulerLogger(l *slog.Logger) SchedulerOption

func WithSchedulerObserver

func WithSchedulerObserver(o SchedulerObserver) SchedulerOption

func WithSchedulerPublishTimeout

func WithSchedulerPublishTimeout(d time.Duration) SchedulerOption

WithSchedulerPublishTimeout bounds one publication, including store requests and spooling, so a stalled group cannot prevent later scans.

func WithSchedulerSpoolDir

func WithSchedulerSpoolDir(dir string) SchedulerOption

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址