Commit Graph
2 Commits
Author SHA1 Message Date
Chris Lu 5ab3860005 feat(s3/lifecycle): LifecycleDelete RPC server (#9349)
* feat(s3/lifecycle): SeaweedS3LifecycleInternal.LifecycleDelete RPC

Adds the worker-to-S3 RPC the lifecycle worker calls to execute one
(rule, action) verdict against one entry. The S3 server is the only
component allowed to mutate filer state, so it gets the final word:
re-fetch, identity CAS, object-lock check, dispatch.

LifecycleDeleteRequest carries the routing tuple (bucket, object_path,
version_id, rule_hash, action_kind), the per-stream context echoed
into BlockerRecord on FATAL outcomes, and an EntryIdentity CAS witness
that lets the server detect mid-flight drift (mtime, size, head fid,
sorted-Extended hash).

LifecycleDeleteOutcome covers all five worker-actionable verdicts:
DONE / NOOP_RESOLVED / SKIPPED_OBJECT_LOCK / RETRY_LATER / BLOCKED.
Cursor-advance / pending-mutation rules per outcome are documented
inline.

Makefile updated to emit the gRPC stubs alongside the message types.

* feat(s3/lifecycle): LifecycleDelete server handler

Server-side handler for the worker-to-S3 RPC. Five-step flow:

1. Re-fetch live entry (NOT_FOUND -> NOOP_RESOLVED).
2. CAS on EntryIdentity (mtime / size / head fid / sorted-Extended
   sha256). Mismatch -> NOOP_RESOLVED with reason STALE_IDENTITY.
3. enforceObjectLockProtections with governanceBypassAllowed=false.
   Lifecycle never bypasses legal-hold or compliance retention; the
   safety scan re-attempts after the hold lifts. Lock refusal ->
   SKIPPED_OBJECT_LOCK (logged + counted, cursor advances).
4. Dispatch by action_kind:
   - EXPIRATION_DAYS / EXPIRATION_DATE: createDeleteMarker on
     versioned buckets, deleteUnversionedObjectWithClient otherwise.
   - NONCURRENT_DAYS / NEWER_NONCURRENT / EXPIRED_DELETE_MARKER:
     deleteSpecificObjectVersion (the marker is just a version).
   - ABORT_MPU: stub returning RETRY_LATER pending Phase 5 wiring.
5. Helper failures the server can't classify as transient -> BLOCKED
   with reason "FATAL_EVENT_ERROR: <detail>"; the worker writes a
   durable BlockerRecord and pauses the failing stream. Filer fetch
   transport errors -> RETRY_LATER (sustained transients eventually
   promote to BLOCKED via the worker's retry budget).

hashExtended uses length-prefixed encoding so a forged Extended
payload (e.g. one tag value containing the byte sequence of two real
tags) can't collide with a real two-tag map. Regression test pins
this.

Tests cover identity-comparison fields, hashExtended order/delimiter
stability, empty-request rejection. Full filer-integration tests come
in Layer 2.

* fix(s3/lifecycle): use stdlib bytes.Equal for ExtendedHash compare

Replaces a hand-rolled byte slice comparator with bytes.Equal —
idiomatic, slightly faster (the stdlib version is intrinsified on
amd64/arm64), and one fewer test surface to maintain.

* refactor(s3api): trim narration from lifecycle RPC + canonicalizer

Drop step-by-step doc-block narrations on LifecycleDelete and the
helpers that reproduced what the code already says. Keep WHY one-
liners at non-obvious spots: the http.Request=nil safety claim,
why hashExtended is length-prefixed, the safety-scan revisit
contract for SKIPPED_OBJECT_LOCK, the TODO marker for ABORT_MPU.

* refactor(s3/lifecycle): retryLater helper, drop inline literals

Adds retryLater() alongside done/noopResolved/blocked so the four
LifecycleDeleteOutcome constructions look the same. Replaces the two
inline RETRY_LATER literal returns (live-fetch transport error and
the ABORT_MPU stub) with the helper. No behavior change.

* fix(s3/lifecycle): default filer-error classification to RETRY_LATER

Versioning lookup, createDeleteMarker, deleteUnversionedObjectWithClient,
and deleteSpecificObjectVersion all do filer round-trips. Most failures
are transient (filer unavailable, slow, network blip), not deterministic
per-event errors. Returning BLOCKED for them was too aggressive: BLOCKED
halts the stream until an operator intervenes, which would surface
on every transient filer hiccup.

Default these paths to RETRY_LATER. The worker's retry budget already
promotes sustained transients to BLOCKED after a configured threshold,
which is the right place for that escalation. BLOCKED stays for the
truly deterministic error: the request-shape check (missing version_id
on noncurrent delete) and the unknown-action-kind dispatch fallback.

* fix(s3/lifecycle): honor versioning Suspended on current-version expiration

Treating Suspended-versioning buckets like never-versioned ones was
wrong: per AWS S3, current-version expiration on Suspended must
remove the existing null version and insert a new delete marker (the
same behavior a user DELETE produces). The previous code branched
only on isVersioningEnabled() — which returns false for Suspended —
and dropped through to the actual-delete path, losing the version
history that Suspended buckets are still expected to keep.

Switch to getVersioningState() and dispatch on three branches:

  Enabled    -> createDeleteMarker (current becomes non-current)
  Suspended  -> deleteSpecificObjectVersion("null"), createDeleteMarker
  Off / never configured -> deleteUnversionedObjectWithClient

The Suspended path's null-version deletion tolerates NotFound (the
null version may not exist when this is the first delete after a
versioning toggle); other errors classify as RETRY_LATER like the
rest of the dispatch.

* refactor(s3/lifecycle): trim narration on LifecycleDelete

Drop the 6-line versioning-state docblock and the inline
"transient -> RETRY_LATER" rationales; keep one-liners.

* fix(s3/lifecycle): bucket-not-found is NOOP; include ErrObjectNotFound

Three follow-ups from review (the rest were already addressed by
prior commits):

- Versioning lookup now distinguishes filer_pb.ErrNotFound
  (BUCKET_NOT_FOUND -> NOOP_RESOLVED) from genuinely transient
  failures (RETRY_LATER). A bucket deleted between live-fetch and
  versioning-lookup is benign, not transient.
- deleteUnversionedObjectWithClient and deleteSpecificObjectVersion
  now also recognise ErrObjectNotFound as a resolved-NOOP, matching
  the live-fetch step's classification.
2026-05-07 15:03:33 -07:00
Chris Lu 7f2b20d577 feat(s3/lifecycle): policy engine — XML conversion, Compile, decideMode, Match (#9348)
* feat(s3/lifecycle): XML lifecycle config to canonical Rule

LifecycleToCanonical takes a parsed *Lifecycle and returns
[]*s3lifecycle.Rule, the flat shape the engine compiles against.
Filter resolution mirrors AWS: <And> sub-elements (Prefix + Tags +
size filters) flatten into the canonical Rule's individual fields;
single <Tag> filter populates FilterTags with one entry; <Prefix>
filter takes precedence over the rule's top-level <Prefix>.

Multi-action rules (Expiration + NoncurrentVersion + AbortMPU on
the same XML <Rule>) populate every action field they declare.
RuleActionKinds expands the canonical rule into its compiled actions
downstream.

* feat(s3/lifecycle): engine snapshot skeleton + ActionKey type

Defines s3lifecycle.ActionKey{rule_hash, action_kind} as the engine's
primary identity, and adds the engine package's Snapshot type.
Snapshot is immutable after Compile (atomic-swapped on rebuild) and
holds the ActionKey-keyed routing indexes:

  - originalDelayGroups: map[time.Duration][]ActionKey
  - predicateActions:    []ActionKey
  - dateActions:         map[ActionKey]time.Time
  - actions:             map[ActionKey]*CompiledAction

CompiledAction.engineState is an atomic.Uint32 so MarkActive (called
after the durable bootstrap_complete + mode write commits) is visible
to in-flight reader passes without a recompile. The reader filters on
IsActive() before dispatching, so stale-snapshot dispatches are
prevented.

No callers yet; downstream commits add Compile, decideMode, and the
Match functions.

* feat(s3/lifecycle): decideMode + retention gate

decideMode picks the scheduling mode for one (rule, kind) compiled
action. Disabled rule -> DISABLED; EXPIRATION_DATE -> SCAN_AT_DATE;
reader-driven kind whose eventLogHorizon + bootstrapLookbackMin
exceeds metaLogRetention -> SCAN_ONLY; otherwise EVENT_DRIVEN. The
gate runs per (rule, kind), so a 90d ExpirationDays sibling can
degrade to scan_only while its 7d AbortMPU sibling stays active.

MetaLogRetention=0 is treated as "unbounded" — matches the SeaweedFS
default (Phase 0 verified that meta-log files are written without
TtlSec by default), so the gate doesn't trip until an operator opts
in to volume-TTL pruning of /topics/.system/log/.

RuleMode is a Go-level enum here, separate from the wire-form
LifecycleState.RuleMode in the proto package; the worker maps between
them when reading/writing the durable state file.

* feat(s3/lifecycle): Compile builds the engine snapshot per-action

Compile produces a fresh Snapshot from per-bucket canonical rules.
Each input rule expands into N CompiledActions via RuleActionKinds;
mode comes from decideMode; activation requires both
bootstrap_complete (from PriorStates) and mode==EVENT_DRIVEN.

Routing indexes are populated by mode:
- SCAN_AT_DATE: always indexed in dateActions (detector schedules at
  rule.date regardless of bootstrap status; the action runs once on
  the date and is then done).
- EVENT_DRIVEN + active: indexed in originalDelayGroups (and in
  predicateActions when the rule has tag/size filters).
- SCAN_ONLY / DISABLED / pending_bootstrap: not indexed; safety-scan
  tick or operator action handle these.

snapshot_id is monotonic per process; pending writes stamp it. The
new snapshot replaces the engine's atomic pointer; in-flight reader
passes continue against their loaded snapshot.

Tests cover: single-action rule, multi-action expansion (one rule ->
three CompiledActions with three distinct delay groups), pending
bootstrap exclusion from indexes, retention gate, sibling actions
degrading independently under partial retention, ExpirationDate path,
disabled rule, MarkActive flipping IsActive(), Compile producing
monotonic snapshot ids.

* feat(s3/lifecycle): MatchOriginalWrite / MatchPredicateChange / MatchPath

The reader feeds events through the engine's match functions to find
the active ActionKeys whose filter applies. The minimal Event shape
the engine takes (bucket, path, tags, size, IsLatest, IsDeleteMarker,
IsMPUInit) keeps engine free of filer_pb dependencies; the reader
extracts these fields from the persisted *filer_pb.LogEntry payload
in Phase 3.

- MatchOriginalWrite: per-delay-group sweep entry. Filters on shape =
  EventShapeOriginalWrite, prefix, tag, size, then per-kind shape
  gating (ABORT_MPU only on IsMPUInit; EXPIRED_DELETE_MARKER only on
  IsLatest+IsDeleteMarker).
- MatchPredicateChange: single near-now sweep. Returns only the
  predicate-sensitive subset of active ActionKeys.
- MatchPath: bucket-level walker entry. Returns every active action
  whose filter matches; bootstrap iterates these per object and calls
  EvaluateAction per kind.

All filter on a.IsActive() at routing time so MarkActive flips become
visible without recompile.

* fix(s3/lifecycle): scope ActionKey by bucket; defensive copies; tidy compile

Three findings on the engine PR addressed:

1. Critical (cross-bucket collision): ActionKey was {RuleHash, ActionKind}
   only. Two buckets with rules whose XML is identical produce the same
   RuleHash; the second bucket's Compile would overwrite the first
   bucket's CompiledAction in snap.actions. Add Bucket to ActionKey
   so the engine's identity matches the on-disk path layout
   /etc/s3/lifecycle/<bucket>/<rule_hash>/<action_kind>/. Regression
   test pins it.

2. Major (immutability leak): OriginalDelayGroups, PredicateActions,
   DateActions returned the snapshot's internal maps/slices by
   reference, letting an external caller mutate routing state and
   break the documented immutability contract. Return defensive
   copies.

3. Minor (redundant condition): mode==EVENT_DRIVEN already implies
   kind != EXPIRATION_DATE because decideMode routes the date kind
   to SCAN_AT_DATE. Drop the redundant check.

Tests updated to construct ActionKey with the new Bucket field.

* fix(s3/lifecycle): drop size filters from rulePredicateSensitive

An object's size is immutable once written: any content change is a
fresh write that flows through the original-write stream, not the
predicate-change one. Tagging rules really can flip post-PUT
(operator adds/removes a tag without rewriting), so they belong; size
filters do not.

Including size filters here was adding rules to predicateActions for
no purpose — every predicate-change sweep would waste cycles
re-evaluating size predicates that physically can't have changed.

* perf(s3/lifecycle): pre-sort AllActions at Compile time

Snapshot is immutable after Compile (engineState bit-flips don't
change membership), so the (bucket, rule_hash, action_kind) ordering
is stable for the snapshot's lifetime. Build the sorted slice once
and serve every AllActions() call from it; drop the per-call
sort.Slice. The bootstrap walker is the primary caller and may
iterate this on every task entry.

* docs(s3/lifecycle): note the FilterSizeGreaterThan=0 ambiguity

Per AWS S3 spec, <ObjectSizeGreaterThan>0</ObjectSizeGreaterThan>
explicitly excludes 0-byte objects, but with the int64 zero value as
the unset sentinel we can't distinguish that from omitted-and-default.
Document the limitation inline so a future deployment that needs the
distinction can switch to *int64 (or a paired set-bool) and update
the matchers / RuleHash accordingly. Not fixing now: the explicit-zero
configuration is unusual, the canonical Rule shape mirrors the same
zero-as-unset convention as s3api.Filter, and a structural fix
touches every filter-using site (evaluator, due_at, match, RuleHash).

* fix(s3/lifecycle): make ObjectInfo.NoncurrentIndex *int

The previous int field had a zero-value collision: 0 is both "newest
non-current version" (a valid index) and "uninitialised by ObjectInfo{}
literal." A caller who built &ObjectInfo{IsLatest: false} without
explicitly setting NoncurrentIndex would have it implicitly read as
"newest non-current," and the count-based NewerNoncurrent retention
would use that bogus 0 to decide eligibility.

Switch to *int so nil is explicitly "not a non-current version /
index not yet computed." The evaluator's NoncurrentDays and
NewerNoncurrent paths conservatively return ActionNone when the
index is nil — the safety scan will revisit once the index is
supplied. This removes a class of latent footguns in test setup and
in any future code path that constructs ObjectInfo without a
versioning-aware builder.

idx() helper added in tests to keep the call sites a one-liner.

* refactor(s3/lifecycle): trim narration from engine + helpers

Drop "what" comments where well-named identifiers already say it
(IsActive, MarkActive, AllActions, etc.); collapse multi-paragraph
"why" docs to one-liners where the design rationale is already in
the design doc. Keep WHY comments only at non-obvious load-bearing
spots: the routing-index activation predicate, the *int rationale on
NoncurrentIndex, the field-tag namespace in RuleHash, the SmallDelay
horizon rule.

Files: action_kind.go, rule.go, rule_hash.go, evaluate.go, due_at.go,
min_trigger_age.go, event_log_horizon.go, engine/engine.go,
engine/compile.go, engine/match.go, engine/mode.go.

No behavior change; tests untouched and pass.

* fix(s3/lifecycle): durable PriorState.Mode wins over decideMode

PriorState.Mode was declared but never read; Compile recomputed mode
via decideMode and stored that on every CompiledAction. Effect: an
action durably persisted as SCAN_ONLY (lag fallback or operator
pause) or DISABLED would silently re-promote to EVENT_DRIVEN on the
next engine rebuild as soon as decideMode's XML+retention predicate
said so. Defeats the durability of mode state.

Use prior.Mode when set; fall through to decideMode only for new
actions (no prior at all) and for legacy entries persisted before
Mode existed (zero value). Regression test pins both branches.

* fix(s3/lifecycle): MarkActive routability — index every EVENT_DRIVEN key

MarkActive's documented contract was "flip visible without a
recompile," but the routing indexes (originalDelayGroups,
predicateActions) were only populated when active && mode ==
EVENT_DRIVEN at compile time. So a key compiled with
BootstrapComplete=false would never enter the indexes; a later
MarkActive flipped engineState but MatchOriginalWrite /
MatchPredicateChange iterated the indexes and never saw the key.
Only MatchPath (which walks bi.actionKeys) and DateActions worked.

Index every EVENT_DRIVEN key regardless of `active`. The runtime
IsActive() filter inside filterMatching already gates dispatch, so
inactive entries are matched-but-not-fired; flipping MarkActive
makes them routable without recompile, matching the documented
contract.

Tests updated: TestCompile_BootstrapPendingIndexedButInactive
asserts the indexed-but-inactive shape; TestMatchOriginalWrite_MarkActiveBecomesRoutable
asserts a MarkActive flip routes the next match.

* test(s3/lifecycle): pin nil NoncurrentIndex no-op behavior

Two regression tests for the *int pointer migration: nil index
combined with NewerNoncurrent (either paired with NoncurrentDays or
standalone) must short-circuit to ActionNone rather than guess at
the version's position in the keep-N window.

* refactor(s3/lifecycle): trim follow-up narration on engine + helpers

Comments accumulated since the last sweep — the durable-Mode rationale,
the MarkActive routability note, the routing-index doc, the
NoncurrentIndex pointer rationale, and the EvaluateAction docblock.
Trimmed each to one or two terse lines; the underlying contracts live
in the design doc.

* docs(s3/lifecycle): note CompileInput one-per-bucket invariant
2026-05-07 15:00:49 -07:00