* feat(s3/lifecycle): throttle steady-state walker by cfg.WalkerInterval
The steady-state and empty-replay walker fired on every dailyrun.Run
invocation, which is fine when Run is called at the bucket-walk cadence
the operator intends (e.g., once per hour or once per day), but
catastrophic when a fast driver like the s3tests CI workflow or the
admin worker scheduler invokes Run at multi-second cadence — each tick
ran a full subtree scan per shard, crushing the filer.
Decouple walker cadence from Run() invocation cadence: persist
LastWalkedNs in the per-shard cursor and fire the steady-state /
empty-replay walker only when (runNow - LastWalkedNs) >= cfg.WalkerInterval.
Cold-start and recovery walker fires (RecoveryView) stay unconditional
since those are bounded events that must run when their trigger
condition (no cursor, hash mismatch) is met. Recovery walker fires also
update LastWalkedNs so the subsequent steady-state pass doesn't
double-walk.
cfg.WalkerInterval=0 keeps the prior "fire every pass" behavior — the
in-repo integration tests and s3tests fast driver continue to work
unchanged. Production deployments should set this to the walk cost
budget (typically 1h-24h depending on cluster size).
Cursor file is back-compat: last_walked_ns is omitempty, so cursor
files written before this change decode as LastWalkedNs=0, which
walkerDue treats as "never walked steady-state" → walker fires next
pass to establish the anchor (same path a cold-start cursor takes).
No version bump.
Operator surface for WalkerInterval is the dailyrun.Config struct;
plumbing through worker.tasks.s3_lifecycle.Config and the admin
schema is a follow-up.
* fix(s3/lifecycle): suppress walker double-fire within a single pass
Two gemini-code-assist findings:
1. walkerDue with interval=0 returned true even when lastWalkedNs ==
runNow.UnixNano() — the cold-start / recovery branch already fired
the walker this pass, and the steady-state fall-through fired it
again. RecoveryView is a superset of every per-shard partition, so
the second walk added zero coverage and burned a full subtree scan.
Add a within-pass guard at the front of walkerDue: if the cursor's
LastWalkedNs equals runNow's UnixNano, the walker already ran this
pass — skip.
2. The empty-replay branch passed persisted.LastWalkedNs to walkerDue
instead of the local lastWalkedNs variable the rest of runShard
threads through. Trivially equal at this point in the function, but
the inconsistency would mask a future bug if any code above the
branch ever sets lastWalkedNs.
Test updates: TestWalkerDue gains the within-pass guard case plus a
companion "earlier same pass still fires" sanity check.
TestRunShard_ColdStartDoesNotDoubleWalk is new and pins the integration:
cold-start runShard with WalkerInterval=0 must call cfg.Walker exactly
once, not twice.
* fix(s3/lifecycle): reject negative WalkerInterval + lift within-pass guard
Two coderabbit findings:
1. validate() now rejects negative cfg.WalkerInterval. A typo like
-1h previously fell through walkerDue's `interval <= 0` branch and
silently re-enabled "walk every pass" — the exact behavior the
throttle was added to prevent. The admin-config parser already
clamps negative input to zero, but callers using dailyrun.Config
directly (tests, embedders) now get a loud error instead.
2. Within-pass double-fire suppression moves out of walkerDue and
into runShard's walkedThisPass local flag. walkerDue's equality
check (lastWalkedNs == runNow.UnixNano) was correct in production
(each pass freezes runNow at time.Now().UTC, no collisions) but
fragile in tests that inject the same runNow across distinct
passes — the test would see false suppression. Separating the
concerns also makes walkerDue answer one question (persisted-state
throttle) and runShard another (within-pass call-site dedup).
walker_interval_test.go: TestValidate_RejectsNegativeWalkerInterval
pins the new validation. TestWalkerDue's within-pass cases move out
(the function is pure throttle now); TestRunShard_ColdStartDoesNot
DoubleWalk still pins the integration behavior end-to-end.