Documentation
¶
Index ¶
- Constants
- Variables
- func CleanStaleSpoolFiles(spoolDir string, olderThan time.Time) ([]string, error)
- func RestorePhysicalSnapshot(ctx context.Context, opts RestoreOptions) (*etcdraftengine.ExternalSnapshotRestoreResult, error)
- func SpoolDirFor(spoolDir, dataDir string) string
- func ValidateKMSKeyID(value string) error
- func ValidateS3StoreEncryption(encryption, kmsKeyID string) error
- type DeletePrecondition
- type GC
- type GCOptions
- type GCResult
- type LocalStore
- func (s *LocalStore) AcquireObjectClaim(ctx context.Context, key string) (ObjectClaim, error)
- func (s *LocalStore) Close() error
- func (s *LocalStore) DeleteObject(ctx context.Context, key string) error
- func (s *LocalStore) DeleteObjectIfUnmodified(ctx context.Context, key string, cond DeletePrecondition) error
- func (s *LocalStore) GetObject(ctx context.Context, key string) (io.ReadCloser, ObjectInfo, error)
- func (s *LocalStore) HeadObject(ctx context.Context, key string) (ObjectInfo, bool, error)
- func (s *LocalStore) ListObjects(ctx context.Context, prefix string) ([]ObjectRef, error)
- func (s *LocalStore) PutObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)
- func (s *LocalStore) RefreshObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)
- func (s *LocalStore) ValidateRetention(ctx context.Context) error
- type Manifest
- type ManifestConfState
- type ObjectClaim
- type ObjectClaimStore
- type ObjectInfo
- type ObjectRef
- type ObjectRefresher
- type ObjectStore
- type OffloadGroup
- type PayloadDescriptor
- type PublishOptions
- type PublishStore
- type PutOptions
- type RestoreOptions
- type RetentionPolicy
- type RetentionStore
- type S3ObjectClient
- type S3Store
- func (s *S3Store) AcquireObjectClaim(ctx context.Context, key string) (ObjectClaim, error)
- func (s *S3Store) DeleteObject(ctx context.Context, key string) error
- func (s *S3Store) DeleteObjectIfUnmodified(ctx context.Context, key string, cond DeletePrecondition) error
- func (s *S3Store) GetObject(ctx context.Context, key string) (io.ReadCloser, ObjectInfo, error)
- func (s *S3Store) HeadObject(ctx context.Context, key string) (ObjectInfo, bool, error)
- func (s *S3Store) ListObjects(ctx context.Context, prefix string) ([]ObjectRef, error)
- func (s *S3Store) PutObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)
- func (s *S3Store) RefreshObject(ctx context.Context, key string, body io.Reader, opts PutOptions) (ObjectInfo, error)
- func (s *S3Store) ValidateRetention(ctx context.Context) error
- type S3StoreConfig
- type Scheduler
- type SchedulerObserver
- type SchedulerOption
- func WithSchedulerClock(now func() time.Time) SchedulerOption
- func WithSchedulerConcurrency(n int) SchedulerOption
- func WithSchedulerInterval(d time.Duration) SchedulerOption
- func WithSchedulerJitter(d time.Duration) SchedulerOption
- func WithSchedulerLogger(l *slog.Logger) SchedulerOption
- func WithSchedulerObserver(o SchedulerObserver) SchedulerOption
- func WithSchedulerPublishTimeout(d time.Duration) SchedulerOption
- func WithSchedulerSpoolDir(dir string) SchedulerOption
Constants ¶
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.
const ( DefaultSchedulerInterval = 15 * time.Minute DefaultSchedulerConcurrency = 1 DefaultSchedulerPublishTimeout = 10 * time.Minute )
Default scheduling parameters from §4.
const (
ManifestSchemaVersion = 1
)
Variables ¶
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 ¶
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 RestorePhysicalSnapshot ¶
func RestorePhysicalSnapshot(ctx context.Context, opts RestoreOptions) (*etcdraftengine.ExternalSnapshotRestoreResult, error)
func SpoolDirFor ¶
SpoolDirFor reports the spool directory a publish with these options uses, so a caller can clean it without re-deriving the default.
func ValidateKMSKeyID ¶
ValidateKMSKeyID accepts canonical key ARNs and bare key IDs. Aliases are rejected because S3 reports the resolved key identity, not the alias string.
Types ¶
type DeletePrecondition ¶
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 (*GC) MarkedPayloads ¶
MarkedPayloads returns the payload keys currently held under a sweep mark. Exposed for operator tooling and tests; the set is per-process and advisory.
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 ¶
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 LoadManifest ¶
func PublishPersistedSnapshot ¶
func PublishPersistedSnapshot(ctx context.Context, opts PublishOptions) (result *Manifest, retErr error)
type ManifestConfState ¶
type ObjectClaim ¶
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 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 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 S3ObjectClient ¶
type S3ObjectClient interface {
PutObject(context.Context, *s3.PutObjectInput, ...func(*s3.Options)) (*s3.PutObjectOutput, error)
GetObject(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error)
HeadObject(context.Context, *s3.HeadObjectInput, ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
CreateMultipartUpload(context.Context, *s3.CreateMultipartUploadInput, ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error)
UploadPart(context.Context, *s3.UploadPartInput, ...func(*s3.Options)) (*s3.UploadPartOutput, error)
CompleteMultipartUpload(context.Context, *s3.CompleteMultipartUploadInput, ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error)
AbortMultipartUpload(context.Context, *s3.AbortMultipartUploadInput, ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error)
ListObjectsV2(context.Context, *s3.ListObjectsV2Input, ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
DeleteObject(context.Context, *s3.DeleteObjectInput, ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)
GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error)
}
type S3Store ¶
type S3Store struct {
// contains filtered or unexported fields
}
func NewS3Store ¶
func NewS3Store(ctx context.Context, cfg S3StoreConfig) (*S3Store, error)
func (*S3Store) AcquireObjectClaim ¶
AcquireObjectClaim acquires a storage-visible claim for an S3 object key.
func (*S3Store) DeleteObject ¶
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 (*S3Store) ListObjects ¶
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)
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 ¶
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.
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