mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-05 19:47:47 +01:00
* docs(s3lifecycle): design for daily-replay worker Captures the algorithm and dev plan iterated on in PR #9431 and the discussion leading up to it: per-shard daily meta-log replay, walker as a per-day pass for ExpirationDate/ExpiredDeleteMarker/NewerNoncurrent plus a recovery branch over engine.RecoveryView(snap), explicit retention-window input to RulesForShard, two cursor hashes (ReplayContentHash + PromotedHash) that together detect every invalidation case. Implementation phases are sequenced so each can ship independently — Phase 1 (noncurrent_since stamp) just landed. * feat(s3/lifecycle): daily-replay worker behind algorithm flag (Phase 2) New weed/s3api/s3lifecycle/dailyrun package implementing the bounded daily meta-log scan from the design doc. One pass per Execute per shard: load cursor, scan events forward, route each through router.Route, dispatch any due Match, advance the cursor on success. Halt-on-failure keeps the cursor at the last fully-processed event so tomorrow resumes from the same point — head-of-line blocking is the deliberate failure signal. Replay-only in this phase. Phase 4 wires the walker for ExpirationDate, ExpiredDeleteMarker, NewerNoncurrent, and scan_only-promoted rules. Until then a typed UnsupportedRuleError refuses runs on those buckets: operators see the rejection in the activity log rather than silently losing rules. Behavior: - Per-shard cursor {TsNs, RuleSetHash, PromotedHash} JSON-persisted under /etc/s3/lifecycle/daily-cursors/. PromotedHash always-empty in Phase 2; Phase 4 turns it on. - Rule-change branch rewinds cursor to now - max_ttl when the replay-content hash mismatches. Cold start uses the same floor. - Transport errors retry 3x with exponential backoff capped at 5s; server outcomes (RETRY_LATER / BLOCKED) halt the run without retry. - Empty-replay sentinel: cursor TsNs=0 when no replay-eligible rules exist, only the hash gates a future addition. Worker shape: - New admin config field "algorithm" with enum streaming|daily_replay, default streaming. Existing deployments are unaffected. - handler.Execute branches on the flag: streaming routes through the current scheduler.Scheduler, daily_replay routes through dailyrun.Run. - dispatcher.NewFilerSiblingLister exported so both paths share the same .versions/ + null-bare lookup. Engine integration: - Local replayContentHash + maxEffectiveTTL helpers in dailyrun. Phase 4's engine surface (ReplayContentHash, MaxEffectiveTTL) will replace them with one-line redirects; the local versions hash the same fields so the cursor stays valid across the swap. Tests cover cursor persistence, unsupported-rule rejection, hash stability under rule reordering, hash sensitivity to TTL edits, max-TTL aggregation, dispatch retry budget, and request shape including the identity-CAS witness. Includes the design doc at weed/s3api/s3lifecycle/DESIGN.md so reviewers and future phases share the same spec. * feat(s3/lifecycle): default to daily_replay; streaming becomes the fallback knob The streaming dispatcher hasn't shipped to users yet, so there's no backward-compat surface to preserve. Flip the algorithm default from streaming to daily_replay so the new path is the standard from day one. Streaming stays as an explicit opt-in escape hatch during the Phase 4 walker rollout; Phase 5 deletes both the flag and the streaming code. Buckets whose lifecycle rules require walker-bound dispatch (ExpirationDate, ExpiredDeleteMarker, NewerNoncurrent, scan_only) will fail the daily_replay run with the existing UnsupportedRuleError until Phase 4 walker integration ships. Operators hitting that case can set algorithm=streaming until the follow-up lands. Updates the test for the default value and renames the unknown-value-fallback case to reflect the new default. * fix(s3/lifecycle/dailyrun): drop per-rule done flag — it suppressed due matches The done map was keyed by ActionKey = {Bucket, RuleHash, ActionKind}. That's only safe when each event produces at most one match per ActionKey with a single deterministic due-time formula — ExpirationDays and AbortMPU fit that shape because due_time = ev.TsNs + r.days is monotonic in event TsNs. But NoncurrentDays paired with NewerNoncurrentVersions > 0 (allowed in Phase 2 since it compiles to ActionKindNoncurrentDays) routes through routePointerTransitionExpand, which emits matches for every noncurrent sibling — each with its own SuccessorModTime taken from the demoting event for that specific sibling. A single event can therefore produce two matches for the same ActionKey on different objects with wildly different DueTimes. With the old code, a not-yet-due sibling encountered first would set done[ActionKey] = true and then the next sibling — even though its DueTime had already passed — would be skipped. Future events for the same rule would also be suppressed for the rest of the run. Objects that should have been deleted weren't. Fix: drop the early-stop optimization. Process every match independently. A future-DueTime match is now silently skipped without affecting any later match. The performance hit is small (Phase 2 is a single bounded daily pass, and the rate limiter is the real throughput governor); the correctness gain is non-negotiable. Also fixes the inverted comment in processMatches that described the old check as "due_time is past now" when it actually checked DueTime.After(now) (i.e., NOT yet due). Adds four targeted tests: - not-yet-due match first in slice does not suppress two later due matches for the same rule; - reversed slice ordering produces identical dispatch; - BLOCKED outcome halts the loop before later due matches are sent; - empty match slice is a no-op. Phase 4's walker-and-recovery integration can revisit a per-(rule, object) memoization if profiling argues for it. * fix(s3/lifecycle/dailyrun): address PR review — cursor advance, mode gate, ctx cancel, snapshot consistency Addresses PR #9446 review feedback. Eight distinct fixes: 1. CURSOR ADVANCEMENT (gemini, critical). The old code advanced the persisted cursor to lastOK = TsNs of the last event processed, including events whose matches were skipped as not-yet-due. Those skipped matches would never be re-scanned, so objects under long-TTL rules would never expire. Track a "stuck" flag in drainShardEvents: the first event with a skipped (future-DueTime) match stops cursorAdvanceTo from rising, but the loop keeps processing later events to dispatch any that ARE due. The persisted cursor sits at the last fully-processed event so tomorrow's run re-scans from the skipped event onward and the future-due matches get re-evaluated when they age in. processMatches now returns (skippedAny, halted, err) so the drain loop can tell apart "event fully drained" from "event had pending future-due matches." 2. MODE GATE (gemini). checkSnapshotForUnsupported only checked the ActionKind. A replay-eligible kind with Mode != ModeEventDriven (e.g. ModeScanOnly via retention promotion) passed the check but then got silently ignored by router.Route, which gates dispatch on Mode == ModeEventDriven. Reject loudly with the typed error so admin sees the rejection in the activity log. 3. WORKERS CONFIG (gemini). The handler hardcoded 16 concurrent shard goroutines regardless of cfg.Workers. Add a Workers field to dailyrun.Config and gate the goroutine fan-out on a semaphore of that size; the handler now passes cfg.Workers through. 4. SINGLE SNAPSHOT PER RUN (coderabbit). Run() validated against one snapshot but runShard() pulled a fresh cfg.Engine.Snapshot() per shard. Mid-run Compile would let shards process different rule sets. Capture snap at the top of Run, pass it down to every shard. 5. FROZEN runNow (coderabbit). drainShardEvents and processMatches accepted a `now func() time.Time` and called it multiple times. DueTime comparisons would slip as the run wore on. Capture runNow once at the top of Run and thread it through as a time.Time value. 6. CTX CANCELLATION (coderabbit). The drain loop's <-ctx.Done() case broke out of the loop and returned nil, marking interrupted runs as successful. Return ctx.Err() instead so the caller propagates the interrupt; cursorAdvanceTo carries whatever progress was made. 7. CURSOR LOAD VALIDATION (coderabbit + gemini). The persister silently accepted empty files, mismatched shard_ids, and hash slices shorter than 32 bytes (copy() would zero-pad). Each now returns a typed error so the run halts and an operator investigates rather than silently re-scanning from time zero or persisting a zero-padded hash that masks corruption forever. 8. DEAD BRANCH (coderabbit). The "lastOK < startTsNs → keep persisted" guard in runShard was unreachable because drainShardEvents initialized lastOK := startTsNs and only ever raised it. Removed along with the new cursor-advancement semantics that handle the "no events processed" case implicitly. Plus markdown lint: DESIGN.md fenced code blocks now carry a `text` language identifier to satisfy MD040. Skipped from the review: - gemini's "maxTTL == 0 incorrectly skips immediate expirations": actions with Days <= 0 don't compile to a CompiledAction (see weed/s3api/s3lifecycle/action_kind.go: `if rule.X > 0`). The new empty-replay sentinel uses `rsh == [32]byte{}` for clarity per gemini's suggested form, but the behavior is equivalent. Tests added/updated: - TestProcessMatches_AllDueNoSkippedFlag pins skippedAny=false when all matches are past their DueTime. - TestCheckSnapshotForUnsupported_NonEventDrivenModeRejected pins the new Mode check. - TestFilerCursorPersister_EmptyFileReturnsError, _ShardIDMismatchReturnsError, _HashLengthMismatchReturnsError pin the new validation rules. - Existing process-matches tests reshaped for the (skippedAny, halted, err) return tuple. Full build clean. Dailyrun + worker test packages green.
436 lines
16 KiB
Go
436 lines
16 KiB
Go
package dailyrun
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/reader"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/router"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// LifecycleClient is the same RPC contract the streaming dispatcher
|
|
// uses. Replicated here to avoid an import cycle with dispatcher; both
|
|
// shapes target s3_lifecycle_pb.SeaweedS3LifecycleInternalClient and
|
|
// neither owns the protobuf interface.
|
|
type LifecycleClient interface {
|
|
LifecycleDelete(ctx context.Context, req *s3_lifecycle_pb.LifecycleDeleteRequest) (*s3_lifecycle_pb.LifecycleDeleteResponse, error)
|
|
}
|
|
|
|
// Config bundles everything daily_run needs to make one shard-fan-out
|
|
// pass over the meta-log. Fields with zero values use documented
|
|
// defaults; required fields are checked at the top of Run.
|
|
type Config struct {
|
|
Shards []int
|
|
BucketsPath string
|
|
Engine *engine.Engine
|
|
FilerClient filer_pb.SeaweedFilerClient
|
|
Client LifecycleClient
|
|
Persister CursorPersister
|
|
Lister router.SiblingLister
|
|
|
|
// Workers caps how many shards run in parallel. Each shard owns its
|
|
// own meta-log subscription and rate limiter is shared across all
|
|
// of them, so the cap is about filer-side concurrency rather than
|
|
// throughput. Zero or negative → 1 (serial, the legacy default).
|
|
Workers int
|
|
|
|
// Limiter is the optional per-worker rate.Limiter shared across all
|
|
// shard goroutines on this worker. nil = no rate limit (the legacy
|
|
// "drain as fast as the filer can" behavior). Phase 3 wires the
|
|
// per-worker share from ClusterContext.Metadata into this field.
|
|
Limiter *rate.Limiter
|
|
|
|
// ClientName / ClientID identify this worker on the meta-log
|
|
// subscription. ClientID 0 randomizes per-run.
|
|
ClientName string
|
|
ClientID int32
|
|
|
|
// Now overrides time.Now for tests. nil = production wall clock.
|
|
Now func() time.Time
|
|
|
|
// EventBudget caps how many meta-log events each shard's reader
|
|
// consumes before returning. Zero = unbounded (the run-until-now
|
|
// behavior — see "stop condition" below). Tests set a small value
|
|
// to bound deterministic runs.
|
|
EventBudget int
|
|
}
|
|
|
|
// Run executes the daily replay for every shard in cfg.Shards
|
|
// concurrently. Returns the first non-nil error from any shard; other
|
|
// shards still run to completion so a transient filer error on one
|
|
// shard doesn't lose progress on the others.
|
|
//
|
|
// Phase 2 scope: replay only. A bucket whose compiled rules require
|
|
// walker-bound dispatch (ExpirationDate, ExpiredDeleteMarker,
|
|
// NewerNoncurrent) or any rule promoted to scan_only fails the whole
|
|
// run with an UnsupportedRuleError — the handler reports this so admin
|
|
// can either revert algorithm=streaming or wait for Phase 4.
|
|
func Run(ctx context.Context, cfg Config) error {
|
|
if err := validate(cfg); err != nil {
|
|
return err
|
|
}
|
|
now := cfg.Now
|
|
if now == nil {
|
|
now = func() time.Time { return time.Now().UTC() }
|
|
}
|
|
// Freeze "now" at the start of the run. Every shard, every match's
|
|
// DueTime comparison, and every cursor-floor calculation uses the
|
|
// same instant so a long-running shard doesn't see the boundary
|
|
// drift relative to a short-running peer.
|
|
runNow := now()
|
|
|
|
// Refuse runs whose snapshot includes a walker-bound action kind
|
|
// or any rule the router won't route. Done once against an
|
|
// immutable snapshot — every shard reuses this exact value so a
|
|
// mid-execution Compile can't make shards disagree about the rule
|
|
// set or the hash.
|
|
snap := cfg.Engine.Snapshot()
|
|
if unsupported := checkSnapshotForUnsupported(snap); unsupported != nil {
|
|
return unsupported
|
|
}
|
|
|
|
// Concurrency cap. cfg.Workers controls how many shards run in
|
|
// parallel; the rate limiter (Phase 3) governs throughput. With
|
|
// Workers=1 (the legacy default) the 16 shards process serially.
|
|
workers := cfg.Workers
|
|
if workers <= 0 {
|
|
workers = 1
|
|
}
|
|
if workers > len(cfg.Shards) {
|
|
workers = len(cfg.Shards)
|
|
}
|
|
sem := make(chan struct{}, workers)
|
|
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, len(cfg.Shards))
|
|
for _, sh := range cfg.Shards {
|
|
sh := sh
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func() {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
if err := runShard(ctx, cfg, snap, runNow, sh); err != nil {
|
|
errCh <- err
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
// Return the first error; the rest are logged at glog level so
|
|
// they're recoverable in the operational stream.
|
|
var first error
|
|
for err := range errCh {
|
|
if first == nil {
|
|
first = err
|
|
} else {
|
|
glog.V(1).Infof("daily_run: additional shard error: %v", err)
|
|
}
|
|
}
|
|
return first
|
|
}
|
|
|
|
func validate(cfg Config) error {
|
|
if cfg.Engine == nil {
|
|
return errors.New("daily_run: nil Engine")
|
|
}
|
|
if cfg.FilerClient == nil {
|
|
return errors.New("daily_run: nil FilerClient")
|
|
}
|
|
if cfg.Client == nil {
|
|
return errors.New("daily_run: nil Client (LifecycleDelete RPC)")
|
|
}
|
|
if cfg.Persister == nil {
|
|
return errors.New("daily_run: nil Persister")
|
|
}
|
|
if cfg.Lister == nil {
|
|
return errors.New("daily_run: nil Lister")
|
|
}
|
|
if cfg.BucketsPath == "" {
|
|
return errors.New("daily_run: empty BucketsPath")
|
|
}
|
|
for _, sh := range cfg.Shards {
|
|
if sh < 0 || sh >= s3lifecycle.ShardCount {
|
|
return fmt.Errorf("daily_run: shard %d out of [0, %d)", sh, s3lifecycle.ShardCount)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runShard executes the daily replay loop for a single shard. The
|
|
// algorithm is documented in DESIGN.md ("Algorithm" section).
|
|
//
|
|
// Phase 2 omissions vs. the full design:
|
|
// - No walker invocation on rule-change / cold-start / retention
|
|
// loss. The cursor is rewritten and the worker exits; Phase 4
|
|
// wires the walker over engine.RecoveryView(snap) on these
|
|
// branches.
|
|
// - PromotedHash is always the empty hash. The partition-flip
|
|
// trigger is dormant until Phase 4.
|
|
// - All walker-bound action kinds and scan_only-promoted rules are
|
|
// refused at validate-time (see checkSnapshotForUnsupported), so
|
|
// replay only ever sees ExpirationDays / NoncurrentDays / AbortMPU.
|
|
//
|
|
// snap is the engine snapshot captured at the top of Run; reusing the
|
|
// same value across shards guarantees they observe identical rules and
|
|
// hashes. runNow is the frozen instant for due-time comparisons.
|
|
func runShard(ctx context.Context, cfg Config, snap *engine.Snapshot, runNow time.Time, shardID int) error {
|
|
persisted, found, err := cfg.Persister.Load(ctx, shardID)
|
|
if err != nil {
|
|
return fmt.Errorf("shard=%d: load cursor: %w", shardID, err)
|
|
}
|
|
|
|
rsh := localReplayContentHash(snap)
|
|
maxTTL := localMaxEffectiveTTL(snap)
|
|
|
|
// Empty-replay sentinel: no replay-eligible active rules in the
|
|
// snapshot. We persist the hash so a future rule addition is
|
|
// detected as a content change. Use the hash rather than maxTTL
|
|
// here as the explicit "no replay state" signal — both fire on
|
|
// the same set in practice (action_kind.go only emits actions
|
|
// when their Days field is > 0), but the hash captures intent.
|
|
if rsh == [32]byte{} {
|
|
return cfg.Persister.Save(ctx, shardID, Cursor{
|
|
TsNs: 0,
|
|
RuleSetHash: rsh,
|
|
PromotedHash: [32]byte{}, // Phase 2: always empty
|
|
})
|
|
}
|
|
|
|
// Rule-change branch: hash mismatch (and a persisted cursor exists)
|
|
// rewinds to now - max_ttl. Phase 4 adds the walker call here.
|
|
if found && persisted.RuleSetHash != rsh {
|
|
next := Cursor{
|
|
TsNs: runNow.Add(-maxTTL).UnixNano(),
|
|
RuleSetHash: rsh,
|
|
// PromotedHash stays empty in Phase 2.
|
|
}
|
|
return cfg.Persister.Save(ctx, shardID, next)
|
|
}
|
|
|
|
// Cold start with no prior cursor: start scanning from now - max_ttl
|
|
// so a freshly-installed worker still expires already-due objects
|
|
// whose PUT events sit within meta-log retention. Phase 4 would
|
|
// invoke the walker here for the longer-than-retention case; Phase 2
|
|
// trusts that retention >= max_ttl in the deployments this code is
|
|
// enabled on.
|
|
startTsNs := persisted.TsNs
|
|
floor := runNow.Add(-maxTTL).UnixNano()
|
|
if startTsNs < floor {
|
|
startTsNs = floor
|
|
}
|
|
|
|
lastOK, _, drainErr := drainShardEvents(ctx, cfg, runNow, shardID, snap, startTsNs)
|
|
if drainErr != nil {
|
|
// Drain failed (transport, ctx cancel, or limiter shutdown):
|
|
// persist whatever progress we have so subsequent runs don't
|
|
// re-process the events we already dispatched, then propagate
|
|
// the error. Cursor save uses ctx — if ctx is already cancelled
|
|
// the save will fail too; that's fine, we still surface the
|
|
// underlying drain error.
|
|
_ = cfg.Persister.Save(ctx, shardID, Cursor{TsNs: lastOK, RuleSetHash: rsh})
|
|
return fmt.Errorf("shard=%d: drain: %w", shardID, drainErr)
|
|
}
|
|
|
|
// drainShardEvents stops advancing lastOK at the first event with a
|
|
// skipped (not-yet-due) match. Persisting that value means the next
|
|
// run resumes from before the skipped event and re-scans it —
|
|
// without that, future-DueTime matches would be lost. halted=true
|
|
// adds nothing here because lastOK already reflects the
|
|
// stuck-at-failure boundary.
|
|
return cfg.Persister.Save(ctx, shardID, Cursor{
|
|
TsNs: lastOK,
|
|
RuleSetHash: rsh,
|
|
// PromotedHash stays empty in Phase 2.
|
|
})
|
|
}
|
|
|
|
// drainShardEvents subscribes to the meta-log starting at startTsNs,
|
|
// routes every event through router.Route, and dispatches each Match
|
|
// whose due_time is past runNow. Returns (cursorAdvanceTo, halted, error):
|
|
// - cursorAdvanceTo: the highest TsNs the persisted cursor may safely
|
|
// advance to. Equals the TsNs of the last event whose matches were
|
|
// ALL dispatched (DONE/NOOP_RESOLVED/SKIPPED_OBJECT_LOCK) AND every
|
|
// prior event was likewise fully processed. Once an event with a
|
|
// not-yet-due match is encountered, cursorAdvanceTo stops growing —
|
|
// so subsequent runs re-scan that event and any after it.
|
|
// - halted: true when an unresolved dispatch outcome (RETRY_LATER /
|
|
// BLOCKED / transport error after in-run retries) stopped the loop.
|
|
// - error: stream/setup failure or context cancellation; caller
|
|
// persists cursorAdvanceTo and propagates the error so the run is
|
|
// marked as interrupted rather than successful.
|
|
func drainShardEvents(ctx context.Context, cfg Config, runNow time.Time, shardID int, snap *engine.Snapshot, startTsNs int64) (int64, bool, error) {
|
|
clientName := cfg.ClientName
|
|
if clientName == "" {
|
|
clientName = "worker-s3-lifecycle-daily"
|
|
}
|
|
clientID := cfg.ClientID
|
|
if clientID == 0 {
|
|
clientID = int32(util.RandomInt32())
|
|
}
|
|
runUpTo := runNow.UnixNano()
|
|
if startTsNs >= runUpTo {
|
|
// Nothing to do — cursor already at or past the run boundary.
|
|
return startTsNs, false, nil
|
|
}
|
|
|
|
events := make(chan *reader.Event, 64)
|
|
rd := &reader.Reader{
|
|
ShardID: shardID,
|
|
BucketsPath: cfg.BucketsPath,
|
|
StartTsNs: startTsNs,
|
|
Events: events,
|
|
EventBudget: cfg.EventBudget,
|
|
}
|
|
|
|
readerCtx, cancelReader := context.WithCancel(ctx)
|
|
defer cancelReader()
|
|
|
|
readerDone := make(chan error, 1)
|
|
go func() {
|
|
readerDone <- rd.Run(readerCtx, cfg.FilerClient, clientName, clientID)
|
|
}()
|
|
|
|
cursorAdvanceTo := startTsNs
|
|
// stuck flips to true at the first event with a skipped (not-yet-due)
|
|
// match. From that event onward, cursorAdvanceTo no longer rises —
|
|
// future runs must re-scan everything from cursorAdvanceTo + 1 onward
|
|
// so the future-due matches get re-evaluated when they age in.
|
|
stuck := false
|
|
halted := false
|
|
|
|
drain:
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
// Parent context cancellation (worker shutdown, MaxRuntime).
|
|
// Return whatever progress was made so far and propagate the
|
|
// error so the caller marks the run as interrupted.
|
|
cancelReader()
|
|
<-readerDone
|
|
return cursorAdvanceTo, true, ctx.Err()
|
|
case ev, ok := <-events:
|
|
if !ok {
|
|
break drain
|
|
}
|
|
if ev == nil {
|
|
continue
|
|
}
|
|
if ev.TsNs > runUpTo {
|
|
// Reached the run boundary: events past now belong to
|
|
// tomorrow's pass. Cancel the reader so it doesn't keep
|
|
// pulling live events, then exit.
|
|
cancelReader()
|
|
break drain
|
|
}
|
|
matches := router.Route(ctx, snap, ev, runNow, cfg.Lister)
|
|
eventSkipped, eventHalted, eventErr := processMatches(ctx, cfg, runNow, ev, matches)
|
|
if eventErr != nil {
|
|
// A non-dispatch error (e.g. limiter wait cancelled by
|
|
// shutdown). Cursor stays at the last fully-processed
|
|
// event; surface the error so the caller treats it as
|
|
// an interrupt.
|
|
cancelReader()
|
|
<-readerDone
|
|
return cursorAdvanceTo, true, eventErr
|
|
}
|
|
if eventHalted {
|
|
halted = true
|
|
break drain
|
|
}
|
|
if eventSkipped {
|
|
// First event with a not-yet-due match. Don't advance
|
|
// the cursor past it; keep processing later events to
|
|
// dispatch any due ones, but the persisted cursor stays
|
|
// at the previous cursorAdvanceTo so this event is
|
|
// re-scanned tomorrow.
|
|
stuck = true
|
|
continue
|
|
}
|
|
if !stuck {
|
|
cursorAdvanceTo = ev.TsNs
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for reader to drain so its goroutine doesn't outlive us.
|
|
cancelReader()
|
|
if rerr := <-readerDone; rerr != nil && !errors.Is(rerr, context.Canceled) {
|
|
glog.V(2).Infof("daily_run shard=%d: reader returned: %v", shardID, rerr)
|
|
}
|
|
return cursorAdvanceTo, halted, nil
|
|
}
|
|
|
|
// processMatches dispatches every match emitted from one event. Returns
|
|
// (skippedAny, halted, error):
|
|
// - skippedAny=true if at least one match had a DueTime past runNow.
|
|
// The caller must NOT advance the persisted cursor past this event
|
|
// so the skipped match gets re-scanned in a later run.
|
|
// - halted=true on an unresolved outcome (RETRY_LATER / BLOCKED /
|
|
// transport error after in-run retries). Caller exits the drain
|
|
// loop.
|
|
// - error: propagated from limiter.Wait when ctx is cancelled.
|
|
//
|
|
// A future-DueTime match is silently skipped — it does NOT terminate
|
|
// the loop or get cached as "done for this rule." A single event can
|
|
// produce multiple matches for the same ActionKey against different
|
|
// objects (routePointerTransitionExpand emits one match per noncurrent
|
|
// sibling, each with its own SuccessorModTime derived from a different
|
|
// demoting event), so a not-yet-due sibling must never gate a sibling
|
|
// that's already past its DueTime. Without per-object state the only
|
|
// safe behavior is per-match independence; the cursor-advance gate
|
|
// upstream handles re-scanning the skipped events tomorrow.
|
|
//
|
|
// runNow is the frozen run-start instant. Using it (rather than a
|
|
// fresh now() per call) keeps the boundary stable across all matches
|
|
// in this run.
|
|
func processMatches(ctx context.Context, cfg Config, runNow time.Time, ev *reader.Event, matches []router.Match) (skippedAny, halted bool, err error) {
|
|
for _, m := range matches {
|
|
if m.DueTime.After(runNow) {
|
|
skippedAny = true
|
|
continue
|
|
}
|
|
if cfg.Limiter != nil {
|
|
if waitErr := cfg.Limiter.Wait(ctx); waitErr != nil {
|
|
return skippedAny, true, waitErr
|
|
}
|
|
}
|
|
outcome, dispatchErr := dispatchWithRetry(ctx, cfg.Client, m)
|
|
if dispatchErr != nil {
|
|
// Exhausted in-run transport retries: halt; tomorrow retries
|
|
// from the same cursor.
|
|
glog.V(1).Infof("daily_run: transport error on %s/%s %s: %v",
|
|
m.Bucket, m.ObjectKey, m.Key.ActionKind, dispatchErr)
|
|
return skippedAny, true, nil
|
|
}
|
|
switch outcome {
|
|
case s3_lifecycle_pb.LifecycleDeleteOutcome_DONE,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_NOOP_RESOLVED,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_SKIPPED_OBJECT_LOCK:
|
|
// Cursor advances. The dispatch's own metric (in the RPC
|
|
// server) records the outcome; the daily-run side stays
|
|
// metric-quiet to avoid double-counting.
|
|
case s3_lifecycle_pb.LifecycleDeleteOutcome_RETRY_LATER,
|
|
s3_lifecycle_pb.LifecycleDeleteOutcome_BLOCKED:
|
|
glog.V(1).Infof("daily_run: %s on %s/%s %s",
|
|
outcome, m.Bucket, m.ObjectKey, m.Key.ActionKind)
|
|
return skippedAny, true, nil
|
|
default:
|
|
glog.V(1).Infof("daily_run: unknown outcome %v on %s/%s", outcome, m.Bucket, m.ObjectKey)
|
|
return skippedAny, true, nil
|
|
}
|
|
_ = ev // ev kept available for future per-event logging
|
|
}
|
|
return skippedAny, false, nil
|
|
}
|