* docs(s3/lifecycle): refresh DESIGN.md as-built + add wiki pages
DESIGN.md was written as a phased implementation plan ("Phase 2 will
ship X, Phase 4 will ship Y"). All phases are now merged, plus the
post-cutover changes from #9477/#9481/#9484/#9485/#9486 substantially
changed the worker model (single subscription, walker throttle,
observability gauges). Rewrite the doc in present tense describing
what's actually there.
Net changes vs the prior plan-style doc:
- Algorithm pseudo-code reflects the single-subscription fan-out plus
walkedThisPass within-pass guard.
- Walker invocation table replaces the implicit "two distinct calls"
prose with three call sites (recovery / steady-state / empty-replay)
and their throttle gates.
- New section on the subscription model (one Reader, ShardPredicate,
fan-out by ev.ShardID).
- New section on cursor.LastWalkedNs and the WalkerInterval throttle.
- Observability section: gauges, heartbeat tokens, what each means.
- "Implementation history" table maps phases to merged PRs.
- "Future work" lists the four optimizations we deferred (long-lived
subscription, bucket-coordinated walker, per-bucket lag metric,
filer meta-log retention).
Drop the "Phase N — ..." narrative from the bottom; the PR history
table is the durable artifact now.
Add wiki pages under docs/wiki/s3-lifecycle/ as source-of-truth for
the operator-facing docs. README explains the sync workflow with the
external seaweedfs.wiki.git repo. Five pages:
- Home.md — landing page, supported rule shapes, what the worker does
- Operator-Guide.md — config knobs, when to change each, walker
interval recommendations by cluster size
- Monitoring.md — Prometheus metric reference + heartbeat token table
+ suggested PromQL alerts
- Troubleshooting.md — stuck cursor, walker stuck, failure outcomes,
cursor schema for manual inspection
- Architecture.md — high-level overview for newcomers; sits between
Home.md (operator) and DESIGN.md (developer)
* docs(s3/lifecycle): address PR review feedback on docs
Coderabbit + gemini findings on #9491:
- Monitoring.md: clarify the "matches all dispatched" phrasing; note
that LIFECYCLE_DELETE_OUTCOME_UNSPECIFIED is the proto zero-value
(shouldn't appear in healthy systems); filter PromQL alerts to
ignore zero-valued gauges so fresh-install heartbeats don't trip.
- Operator-Guide.md, Troubleshooting.md: clarify weed shell -master
format as host:http_port.grpc_port (SeaweedFS ServerAddress).
- Troubleshooting.md: pause the s3_lifecycle job in the admin UI
before manually editing a cursor file, otherwise the worker's
save races with the operator's edit.
- Architecture.md, Home.md, Operator-Guide.md, Monitoring.md,
Troubleshooting.md, DESIGN.md: add language tags (`text`) to
fenced code blocks for markdownlint MD040 compliance.
- DESIGN.md: standardize on the S3 spec rule names
(`ExpiredObjectDeleteMarker`, `NewerNoncurrentVersions`,
`AbortIncompleteMultipartUpload`) and add a one-line note mapping
them to the engine's `ActionKind*` constants.
- README.md: prepend `cd "$(git rev-parse --show-toplevel)"` to the
sync workflow so the `cp` commands' repo-root-relative paths work
whether the operator's shell is at the repo root or at
docs/wiki/s3-lifecycle/.
- Home.md: was lagging the wiki-repo merged version (had the older
pre-merge content). Re-sync from the wiki repo so source matches.
* docs(s3/lifecycle): remove wiki pages from PR
The wiki pages belong in seaweedfs.wiki.git, not the main repo. The
source-of-truth concern that motivated adding them here is real but
the cost — every code-review touchpoint requires reviewers to load
operator-facing pages too — outweighs it. The wiki pages are already
pushed locally (~/dev/seaweedfs.wiki); they'll publish on the
operator-side workflow.
This PR remains scoped to DESIGN.md (the developer-facing reference
that does belong with the code).
* docs(s3/lifecycle): drop Implementation history section
git log is the durable record of what shipped when; the prose table
duplicates it and goes stale faster than commit metadata.
* docs(s3/lifecycle): soften 'exactly once per run' in Goal
The prior phrasing overstated the guarantee versus the failure model
documented later in the same file. Reword to: 'process due objects
each pass; retryable/blocked outcomes get retried from the cursor on
later runs.' Surfaces the head-of-line-blocking semantics up front so
the rest of the doc reads consistently.
Also: drop the stale 'see docs/wiki/s3-lifecycle/' pointer — those
pages live in the wiki repo, not the main repo.
19 KiB
S3 Lifecycle (Design)
The S3 lifecycle worker replaces the streaming + heap design with a daily meta-log replay. The worker runs as a scheduled job: "start, do today's work, stop" — no long-running per-shard goroutines, no future-buffered match heap.
This document is the as-built reference. For operator-facing guides, see the SeaweedFS wiki.
Goal
For each bucket lifecycle rule with TTL D days, the worker processes every object whose age exceeds D during each scheduled run, dispatching deletes via LifecycleDelete. Events whose dispatch returned a retryable / blocked outcome are reprocessed from the persisted cursor on later runs — head-of-line blocking is intentional rather than a per-key retry queue. No future-buffered Matches in memory. Cluster-wide delete rate cap allocated per worker. The worker exits when the pass completes.
Algorithm
One pass per scheduled invocation. The pass spawns one filer meta-log subscription, fans events out to per-shard processors by ShardID(bucket, key), and drains each shard's events through the router + dispatcher:
dailyrun.Run(ctx, cfg):
runNow = cfg.Now() // frozen for the whole pass
snap = cfg.Engine.Snapshot()
rsh = engine.ReplayContentHash(snap)
maxTTL = engine.MaxEffectiveTTL(snap)
if rsh != [32]byte{}: // replay-eligible rules present
globalStartTsNs = min over cfg.Shards of (persisted cursor or runNow - maxTTL)
reader = subscribeMeta(ShardPredicate ∈ cfg.Shards, StartTsNs = globalStartTsNs)
fanOut(reader.Events → shardEvents[shardID]) until ev.TsNs > runNow
spawn one goroutine per shard:
runShard(ctx, cfg, snap, runNow, shardID, shardEvents[shardID])
wait all
teardown reader + fan-out
runShard(ctx, cfg, snap, runNow, shardID, events):
persisted, found = cfg.Persister.Load(shardID)
retentionWindow = cfg.RetentionWindow or maxTTL // see "retention" below
promoted = engine.PromotedHash(snap, retentionWindow)
if rsh == [32]byte{}: // pure walker bucket
if walkerDue and Walker:
cfg.Walker(walkView) // RulesForShard.walk
lastWalkedNs = runNow.UnixNano()
save cursor (TsNs=0, rsh, promoted, lastWalkedNs)
return
mustWalkRecovery = found && (persisted.RuleSetHash != rsh || persisted.PromotedHash != promoted)
mustWalkColdStart = !found
if mustWalkRecovery or mustWalkColdStart:
cfg.Walker(engine.RecoveryView(snap)) // every rule, force-active
walkedThisPass = true
lastWalkedNs = runNow.UnixNano()
if mustWalkRecovery:
save cursor (TsNs = runNow - maxTTL, rsh, promoted, lastWalkedNs) // rewind
return
if Walker and !walkedThisPass and walkerDue:
cfg.Walker(walkView) // steady-state walker
lastWalkedNs = runNow.UnixNano()
startTsNs = !found ? runNow - maxTTL : persisted.TsNs // steady state honors cursor
lastOK, _, drainErr = drainShardEvents(ctx, cfg, runNow, shardID, snap, startTsNs, events)
save cursor (TsNs = lastOK, rsh, promoted, lastWalkedNs)
Walker invocations — three call sites, distinct conditions:
| Branch | View | Trigger | Throttle |
|---|---|---|---|
| Recovery | engine.RecoveryView(snap) |
mustWalkColdStart or mustWalkRecovery |
Unconditional |
| Steady state | RulesForShard.walk |
!walkedThisPass && walkerDue |
WalkerInterval |
| Empty replay | RulesForShard.walk |
rsh == [32]byte{} and walkerDue |
WalkerInterval |
walkerDue returns true when WalkerInterval == 0, or when no walk has happened yet (LastWalkedNs == 0), or when runNow - LastWalkedNs >= WalkerInterval. Within-pass double-fire suppression lives in runShard's walkedThisPass flag, not in walkerDue — the recovery branch fires the walker with RecoveryView (a superset of every per-shard partition), so the steady-state branch must not re-walk in the same pass.
Engine surface
// In engine:
func (e *Engine) Snapshot() *Snapshot
func (s *Snapshot) RulesForShard(shardID int, retentionWindow time.Duration) (replay, walk *Snapshot)
func RecoveryView(s *Snapshot) *Snapshot
func ReplayContentHash(s *Snapshot) [32]byte
func PromotedHash(s *Snapshot, retentionWindow time.Duration) [32]byte
func MaxEffectiveTTL(s *Snapshot) time.Duration
RulesForShard and RecoveryView return new *Snapshot instances with cloned *CompiledAction objects and shared (by pointer) rule definitions. Fields that differ from the base:
active— per clone, set per view.Mode— rewritten toModeEventDrivenonreplayclones; preserved onwalkandrecoveryclones. The rewrite is required becauserouter.Routegates onMode == ModeEventDriven, and today's compile preserves a persistentprior.Mode = ModeScanOnlythat would otherwise lock a rule out of replay even after retention rehabilitates it.- Action-map membership —
replaycontains only replay-eligible clones;walkcontains only walker-bound clones;recoverycontains every action.
Shared-by-pointer with the base: Rule definitions, predicate maps, RuleHash table.
router.Route integration
router.Route(ctx, snap, ev, now, lister) iterates every action with IsActive() == true in the snapshot. Two snapshots can't disagree on activation if they share the same *CompiledAction pointers, which is why RulesForShard clones.
| View | Clone settings | Why |
|---|---|---|
replay |
active = true, Mode = ModeEventDriven |
router.Route requires ModeEventDriven. Forced regardless of prior.Mode. |
walk |
active = true, Mode preserved |
Walker accepts any non-ModeDisabled Mode. |
recovery |
active = true, Mode preserved |
Walker iterates all action clones. |
Subscription model
One filer SubscribeMetadata stream per dailyrun.Run() call, covering every shard in cfg.Shards. The Reader carries a ShardPredicate func(int) bool that accepts the shard set; a fan-out goroutine routes events to per-shard channels by ev.ShardID. This replaces the earlier model (16 separate per-shard subscriptions per pass).
globalStartTsNs = min(per-shard cursor, runNow - maxTTL). Pre-loaded once at pass start so the subscription's StartTsNs covers every shard's needed range; per-shard drains then filter ev.TsNs <= shard.startTsNs locally.
Fan-out cancels the reader on the first ev.TsNs > runNow (meta-log events arrive in TsNs order; everything after is past the pass boundary). Per-shard channels are buffered to 256 events — large enough to absorb bursts without back-pressuring the fan-out.
Action kinds and dispatch paths
Table uses the S3-spec rule names (what operators type in lifecycle XML). The corresponding engine constants in weed/s3api/s3lifecycle/action_kind.go are ActionKindExpirationDays, ActionKindNoncurrentDays, ActionKindAbortMPU, ActionKindExpirationDate, ActionKindExpiredDeleteMarker, ActionKindNewerNoncurrent — same one-to-one mapping, shorter spelling.
| ActionKind | Trigger | Due time | Path | Early-stop in replay? |
|---|---|---|---|---|
ExpirationDays |
Latest-version PUT | ev.TsNs + r.ExpirationDays |
Replay | Yes |
NoncurrentDays |
Demotion (next PUT for same key) | entry.NoncurrentSince + r.NoncurrentDays |
Replay | Yes |
AbortIncompleteMultipartUpload |
MPU init | mpu_init.TsNs + r.AbortMPUDaysAfterInitiation |
Replay | Yes |
ExpirationDate |
Latest-version PUT, fires on now >= r.ExpirationDate |
r.ExpirationDate (constant) |
Walker | n/a |
ExpiredObjectDeleteMarker |
Delete marker with NumVersions == 1 |
"now if orphaned, else never" | Walker | n/a |
NewerNoncurrentVersions |
Version becomes noncurrent AND total noncurrents > r.NewerNoncurrentVersions |
"now if over the cap, else never" | Walker | n/a |
ExpiredObjectDeleteMarker and NewerNoncurrentVersions are walker-only because their due-time depends on current sibling state, not on any event's TsNs. The done early-stop in replay can't engage — there's nothing event-time-monotonic to early-stop on.
Cursor
Persisted per shard at /etc/s3/lifecycle/daily-cursors/shard-NN.json. Shape (weed/s3api/s3lifecycle/dailyrun/cursor.go):
type Cursor struct {
TsNs int64 // last meta-log event whose matches all dispatched
RuleSetHash [32]byte // ReplayContentHash of the rule set that wrote this
PromotedHash [32]byte // PromotedHash with retentionWindow at write time
LastWalkedNs int64 // wall-clock of the last successful walker fire
}
LastWalkedNs is JSON-omitempty, so cursor files written before that field existed decode cleanly as zero (treated as "never walked steady-state" → next pass seeds the anchor).
Cursor save uses a fresh context.Background() with a 5s timeout because the steady-state drain exits via passCtx cancellation (the only way an idle subscription ends). Saving with the canceled passCtx would silently drop the cursor and the next pass would re-replay from the same floor.
In steady state the start position honors the cursor verbatim — the floor runNow - maxTTL is applied only on cold start (!found). The drain freezes the cursor at the last pre-skip event so pending matches with DueTime == TsNs + maxTTL stay in scope across passes; bumping forward in steady state would orphan exactly those events.
Cursor hashes
The cursor stores two hashes that together detect every situation invalidating "everything before persisted.TsNs has been processed under the same rules":
RuleSetHash = engine.ReplayContentHash(snap) — content over the rule definitions (action kind, predicate, TTL value) of replay-eligible action kinds. Partition-independent.
PromotedHash = engine.PromotedHash(snap, retentionWindow) — hash of replay-eligible rules currently classified as walk due to scan_only promotion (their TTL exceeds retentionWindow).
Recovery triggers, complete list:
| Trigger | Detection | Why |
|---|---|---|
| Cold start | No persisted cursor | First run for this shard |
| Replay-rule edit | RuleSetHash mismatch |
Replay-eligible rule content changed |
| Partition flip | PromotedHash mismatch |
A replay-eligible rule moved between replay and walk |
Retention loss as a recovery trigger is a known gap: in stock SeaweedFS the filer's meta-log is effectively never GC'd (no on-disk retention policy for /topics/.system/log), so cfg.RetentionWindow defaults to maxTTL and PromotedHash stays empty. When operators add explicit meta-log retention, the cursor-vs-earliest-available check becomes load-bearing again.
Walker throttle
cfg.WalkerInterval decouples the walker's cadence from Run() invocation cadence. The s3tests CI workflow invokes the worker every 2s; a single daily admin schedule invokes it once per day. The walker fire rate should be set by walk cost, not by the invocation interval — so:
- Steady-state and empty-replay walker fires gate on
walkerDue(persisted.LastWalkedNs, runNow, WalkerInterval). 0keeps the prior "fire every pass" behavior (back-compat for tests).- Production: pick the walk cost budget per shard per cluster. Small cluster: 1h. Large cluster: 6h+.
- Recovery walker fires (cold-start, hash mismatch) are unconditional — these are bounded events that must run once.
Walker fires update Cursor.LastWalkedNs so the next pass's throttle has a fresh anchor. The recovery walker also updates it, so a steady-state branch in the same pass doesn't double-walk over the same superset.
Delete failure handling
Cursor advance is gated on success. The cursor only moves past events whose matches all returned DONE, NOOP_RESOLVED, or SKIPPED_OBJECT_LOCK. Any other outcome (RETRY_LATER, BLOCKED, transport error after in-run retries) halts the run and persists the cursor at the last fully-processed event.
- Head-of-line blocking is intentional. A transient filer error stalls today's pass; tomorrow's run resumes at the same cursor. Loud (operator sees stuck cursor in metrics) and idempotent (identity-CAS makes redundant deletes no-ops).
- In-run retry with backoff for transport errors only — default 3 attempts, exponential backoff capped at 5s. Server-side outcomes are not retried in-run.
- No retry queue. Removing the per-key freeze state was the whole point; adding it back would re-introduce the state machine the redesign replaces.
Rate limiting
Cluster-wide deletes-per-second cap, set in admin config. The admin allocator:
- Counts workers capable of
s3_lifecyclefrom the registry. - Divides
cluster_deletes_per_secondby the count. - Writes the per-worker share into
ExecuteJobRequest.ClusterContext.Metadata["s3_lifecycle.deletes_per_second"].
The worker reads the share and constructs one golang.org/x/time/rate.Limiter shared across all shard goroutines. dispatchWithRetry calls limiter.Wait(ctx) before each LifecycleDelete RPC.
Observability
Per-shard Prometheus gauges (weed/stats/metrics.go):
| Metric | What it tells you |
|---|---|
s3_lifecycle_cursor_min_ts_ns{shard} |
now - this is the per-shard replay lag |
s3_lifecycle_daily_run_last_walked_ns{shard} |
now - this is walker freshness; stuck = throttle misconfigured or walker failing |
s3_lifecycle_daily_run_shard_duration_seconds{shard} |
Wall-clock per shard pass |
s3_lifecycle_daily_run_events_scanned_total{shard} |
Counter of meta-log events drainShardEvents processed |
s3_lifecycle_dispatch_limiter_wait_seconds |
Per-dispatch wait time on the cluster rate limiter |
s3_lifecycle_dispatch_total{bucket,kind,outcome} |
Per-bucket dispatch counter |
Heartbeat log line, emitted once per Run():
daily_run: status=ok shards=16 errors=0 duration=7s cursor_lag_max=2h walked_max_age=3m
Tokens status, shards, errors, duration are stable for grep. cursor_lag_max=cold and walked_max_age=cold distinguish "not started yet" from "0s caught up".
Data model
noncurrent_since on version entries
A non-current version's TTL clock starts when the next version was written, not at its own mtime. The demoting PUT writes NoncurrentSinceNs on the demoted entry, set to the TsNs of the demoting meta-log event. Using the meta-log TsNs keeps noncurrent_since strictly monotonic in meta-log order across all replicas, immune to wall-clock skew.
The lifecycle evaluator uses ev.TsNs for current-version rules and entry.NoncurrentSinceNs for noncurrent rules — both monotonic in iteration order. Legacy entries with NoncurrentSinceNs == 0 fall back to entry mtime.
expected_mtime passed to LifecycleDelete (for identity CAS) is always the entry's own mtime. CAS identity and TTL clock are separate concerns.
Components
| Path | Role |
|---|---|
engine/ |
Rule compilation, snapshot, partition views |
evaluate.go, due_at.go, rule_hash.go, tags.go |
Engine-side rule evaluation |
reader/ |
Meta-log subscribe; one subscription per dailyrun.Run pass |
router/router.go |
Per-event rule evaluation |
bootstrap/walker.go |
Bucket walker with RunForShard(view, shardID) filter |
dispatcher/filer_persister.go |
Filer-backed cursor I/O |
dailyrun/run.go |
Main pass orchestrator: subscription, fan-out, per-shard runShard |
dailyrun/cursor.go |
Cursor type + filer JSON serializer |
dailyrun/walker_dispatcher.go |
Adapter from walker to LifecycleDelete RPC |
Configuration
Admin config (weed/worker/tasks/s3_lifecycle/):
| Key | Type | Default | What |
|---|---|---|---|
cluster_deletes_per_second |
int64 | 0 (unlimited) | Cluster-wide ceiling on lifecycle delete RPCs/s. Allocated per worker. |
cluster_deletes_burst |
int64 | 0 (= 2× rate) | Token-bucket burst across cluster. |
meta_log_retention_days |
int64 | 0 (unbounded) | How far back the filer's meta-log can reach. Rules with TTL > retention promote to walker. |
walker_interval_minutes |
int64 | 0 (fire every pass) | Minimum time between steady-state walker fires per shard. Set positive when worker runs at tighter cadence than the desired walk frequency. |
Worker config:
| Key | Default | What |
|---|---|---|
max_runtime_minutes |
60 | Wall-clock cap per dailyrun.Run call |
Failure & recovery
- Worker crashes mid-run. Cursor advances only past successfully-deleted events. On restart, the next pass resumes at the same cursor and re-attempts. Identity-CAS makes redundant deletes no-ops.
- Transient delete failure. Pass halts at the failing event, cursor stays. Tomorrow's pass retries from the same point. Stuck cursor is visible in
s3_lifecycle_cursor_min_ts_ns; operators see head-of-line blocking and address the root cause. - Identity drift (object overwritten between event and delete). Handled by
LifecycleDeleteRPC's identity-CAS, which returnsNOOP_RESOLVEDfor stale events. The algorithm dispatches optimistically and lets the server filter. - Cold start, rule edit, partition flip. All route into the recovery branch. The walker over
engine.RecoveryView(snap)catches already-due objects across the full rule set, then the cursor rewinds (rule edit) or stays at the cold-start floor.
Future work
Tracked as optimizations rather than blockers:
- Long-lived subscription across passes. Today the subscription is rebuilt per
Run(). Keeping it alive across passes would eliminate the 7s ctx-timeout per pass and the start/teardown overhead. Requires per-shard pending heap (events whoseDueTime > runNowwould be parked in-memory instead of replayed) and a hot-swappable snapshot for mid-pass config changes. Multi-day refactor; current model works. - Bucket-coordinated walker. Phase 4 has each shard walk the full bucket and filter by
ShardID(bucket, key)— simple but 16× the listing cost. A per-bucket coordinator (the worker owning shard 0 for that bucket lists once, routes matches to other shards) would cut listing cost. Worth doing if listing becomes the bottleneck for very large buckets. - Per-bucket dispatch lag metric. Currently only per-shard lag is exposed. Per-bucket would require a per-bucket cursor or a derived metric from
s3_lifecycle_dispatch_total{bucket,kind,outcome}. Punted on cardinality concerns; revisit when an operator asks for it. - Meta-log retention plumbing. If the filer adds GC for
/topics/.system/log, thePromotedHashpartition flip needs to consume the filer's actual retention horizon (currently dormant because retention is effectively infinite).