Files
seaweedfs/weed/pb/s3_lifecycle.proto
T
Chris Lu 4e10669221 docs(s3/lifecycle): event-driven redesign (#9346)
* docs(s3/lifecycle): event-driven redesign

Replaces the synchronous PUT-handler walk with an event-driven worker
model: meta-log reader subscribed to one filer, client-side heap merge
with per-filer-shard MessagePosition cursors, bucket-level bootstrap
with inline delete, blocked-cursor handling for fatal events, durable
retry budget for sustained-transient promotion, retention mode gate
that downgrades reader-driven rules to scan_only when log retention
falls below the rule's event-log horizon.

* docs(s3/lifecycle): record Phase 0 verified assumptions

ReadPersistedLogBuffer payload carries Extended (event marshaled via
SubscribeMetadataResponse → ToProtoEntry). Meta-log files at
topics/.system/log/<date>/<HH-MM>.<filerId> are written without TtlSec
in filer_notify_append.go; retention is unbounded by default and only
shrinks if an operator sets a filer.conf rule with a TTL on the
SystemLogDir prefix. .versions/ filenames are v_<16h-ts><16h-rand>
with old/new (inverted) format distinguished by threshold
0x4000000000000000; getVersionTimestamp / compareVersionIds give
format-agnostic ordering for successor-version discovery.

* feat(s3/lifecycle): rule evaluator and dueAt helper

Evaluate(rule, info, now) returns the EvalResult by object shape:
IsMPUInit -> AbortMultipartUpload, IsDeleteMarker -> ExpireDeleteMarker
when sole survivor, IsLatest -> DeleteObject (Days or Date), non-current
-> DeleteVersion (Days fallback to ModTime when SuccessorModTime is
zero; NewerNoncurrent retention enforced when both are set).

ComputeDueAt mirrors Evaluate's shape and returns the earliest eligible
wall-clock time for the same (rule, info), used by the reader/bootstrap
to decide pending vs inline-delete.

Adds StatusEnabled/Disabled and SmallDelay consts and an IsMPUInit
flag on ObjectInfo so .uploads/<id>/ entries route to AbortMPU without
overloading IsLatest.

No callers; package compiles standalone.

* feat(s3/lifecycle): MinTriggerAge for safety-scan cadence

Returns the smallest non-zero day threshold across the rule's actions.
Used as max(MinTriggerAge, kindFloor) for the per-kind cadence; date,
count-only, and delete-marker-only rules return 0 so callers fall
through to their kind-specific floor.

* feat(s3/lifecycle): EventLogHorizon for retention mode gate

Returns the maximum event age the reader needs for a rule. Days-based
kinds return their day threshold; pure NewerNoncurrent (count) and
ExpiredObjectDeleteMarker return SmallDelay. Date rules return 0 (the
gate skips them). Multi-action rules take the max — strictest horizon
wins.

Drives the Phase 2 mode gate: metaLogRetention < EventLogHorizon(rule)
+ bootstrapLookbackMin -> scan_only with RETENTION_BELOW_HORIZON.

* feat(s3/lifecycle): RuleHash for per-rule state CAS

sha256 over canonicalized form, first 8 bytes. Stable across tag-key
reorder, prefix trailing-slash variation, ID renames, and Status flips
(state continuity is preserved when an operator toggles
Enabled/Disabled). Different action shapes — different days, filter,
or action type — hash differently.

Used by the per-rule state directory layout
/etc/s3/lifecycle/<bucket>/<rule_hash_hex>/ and by the bootstrap
detector's reconcile-on-PUT.

* feat(s3/lifecycle): add s3_lifecycle.proto storage schema

Defines the durable types backing the lifecycle worker:
LifecycleState (per-rule mode + bootstrap_complete + degraded_reason
incl. RETENTION_BELOW_HORIZON / LOST_LOG), PendingItem, EntryIdentity,
BootstrapState, ReaderState (per-filer-shard cursors plus
tail_drained_streams marker), BlockerRecord (rule_hash optional for
pre-evaluation failures), RetryBudgetEntry with the four-shape
StreamKey oneof, and RetryTarget (no action / no expected_identity —
retry replays handler against current state).

No callers; schema only. Wired into the pb Makefile.

* feat(s3/lifecycle): add S3LifecycleParams to TaskParams

Wires lifecycle subroutines into the existing worker dispatch.
Subtype is READ (cluster-singleton meta-log reader), BOOTSTRAP
(per-bucket walker), or DRAIN (per-rule pending). bucket / rule_hash
populated for the latter two; ContinuationHint is an advisory resume
point for kill-resumable BOOTSTRAP / READ.

oneof tag = 14, after the existing ec_balance_params at 13.

* fix(s3/lifecycle): noncurrent delete markers honor NoncurrentDays

A non-current delete marker is just another version per AWS S3 spec
and is eligible under NoncurrentVersionExpirationDays. The
IsDeleteMarker special case is meant only for the *current* delete
marker (sole-survivor ExpiredObjectDeleteMarker action), so guard
that switch arm with IsLatest. Without IsLatest, the bootstrap walker
silently skipped pre-existing non-current delete markers under a
NoncurrentDays rule because ComputeDueAt returned zero.

Mirrors the same fix in evaluate.go so the runtime decision matches.

* fix(s3/lifecycle): preserve prefix trailing slash in RuleHash

"logs" and "logs/" match different object sets under literal
strings.HasPrefix semantics: "logs" matches "logsmore/x", "logs/" does
not. Collapsing them in the hash would let an XML edit silently bind
the new rule to the previous rule's durable state directory, causing
stale bootstrap_complete and stale pending entries against a rule
that now matches a different set.

* fix(s3/lifecycle): add UNSPECIFIED sentinels to enum zero values

Proto3 best practice: enum 0 should be an _UNSPECIFIED sentinel, not
an active value. Persisted state schemas care most: a partially
populated payload (or one written by an older binary that didn't set
the field) would otherwise silently default to a semantically active
value.

- LifecycleState.RuleKind: shifts EXPIRATION_DAYS..EXPIRED_DELETE_MARKER
  from 0..5 to 1..6, with RULE_KIND_UNSPECIFIED at 0.
- LifecycleState.RuleMode: shifts EVENT_DRIVEN..PENDING_BOOTSTRAP from
  0..4 to 1..5.
- LifecycleState.DegradedReason: replaces NONE=0 with
  DEGRADED_REASON_UNSPECIFIED=0 (operators treat both as healthy).
- StreamKind: shifts ORIGINAL..PENDING from 0..3 to 1..4.
- S3LifecycleParams.Subtype: shifts READ..DRAIN from 0..2 to 1..3, so
  an unset subtype no longer routes into the cluster-singleton READ task.

No on-disk state has been written yet; renumbering is safe.

* docs(s3/lifecycle): per-action state, not per-rule

A single AWS lifecycle XML <Rule> can declare multiple actions in
parallel (e.g. ExpirationDays=90 + AbortMPU=7 + NoncurrentDays=30).
Each must drive its own delay/horizon/mode/pending stream
independently. Modeling the rule as one compiled entry with one kind
collapses these — picking the smallest delay (7d MPU) means the 90d
expiration cursor advances past objects that aren't yet due, and the
90d action never re-fires.

Restructure storage to per-action: every XML rule expands into N
compiled actions; state lives at <bucket>/<rule_hash>/<action_kind>/.
The intermediate rule_hash directory keeps a rule's actions grouped
for operator listing. Each action has its own state file with its own
mode + bootstrap_complete + degraded_reason; sibling actions of the
same rule can degrade independently.

* fix(s3/lifecycle): per-action proto schema + safety-scan counters

Realigns the durable schema with the per-action storage model and the
safety-scan contract in the design doc.

- Promote LifecycleState.RuleKind to a top-level ActionKind enum and
  rename rule_kind -> action_kind. The same enum now also keys
  BootstrapKey, PendingKey, and BlockerRecord so per-action streams
  under one rule never collapse.
- LifecycleState now keyed by (rule_hash, action_kind). Added
  last_safety_scan_ts_ns / next_safety_scan_ts_ns and the four
  observability counters the design specifies (evaluated_total,
  expired_total, metadata_only_total, error_total). Dropped
  last_evaluated_ns, deleted_total, skipped_object_lock_total, and
  pending_size — subsumed or out-of-band.
- BlockerRecord.action_kind is OPTIONAL (UNSPECIFIED for
  pre-evaluation failures, just like rule_hash).

No on-disk state has been written yet; the renumbering / rename is
free.

* fix(s3/lifecycle): per-action MinTriggerAge / EventLogHorizon helpers

The retention mode gate and safety-scan cadence run on each compiled
action independently. Taking a "min/max across all actions in the
rule" — as the previous helpers did — was wrong: a 90d ExpirationDays
sibling alongside a 7d AbortMPU action would cause the 90d cursor to
advance at 7d (because MinTriggerAge picked the smallest), and the
ExpirationDays action would never re-fire on objects that aged past
the 7d sweep window before reaching 90d.

Both helpers now take an ActionKind and return the threshold for that
specific action only. Returns 0 if the rule does not declare the
requested kind, which makes the gate a no-op and the cadence fall
through to the kind floor.

Also adds RuleActionKinds(rule), the canonical expansion that the
engine uses at compile time to turn one XML rule into N compiled
actions. NewerNoncurrentVersions paired with NoncurrentDays is
subsumed into a single NONCURRENT_DAYS action (AWS-paired
conditions); only when NewerNoncurrent stands alone does it become
NEWER_NONCURRENT.

* fix(s3/lifecycle): length-prefix RuleHash to remove delimiter ambiguity

The previous encoding used "tag=K=V\n" lines, which is ambiguous: a
tag (a=b, c) serializes identically to (a, b=c). A prefix containing
"\nexp_days=99" could likewise forge an action field. Either could
silently bind two semantically different rules to the same per-rule
state directory.

Switch to a length-prefixed canonical form: each scalar is written as
<field-tag-byte> <uvarint-length> <bytes>. Field-tag bytes namespace
each scalar so ("a=b" tag-key) and ("a" tag-key with "=b" leakage)
can't collide. Three regression tests pin the resistance.

* docs(s3/lifecycle): ActionKey is the engine identity, not rule_hash

Finishes the per-action restructure across the engine pseudocode,
bootstrap completion, drain locks, detector paths, policy CAS, and
Phase 2 plan. Every per-action data structure — engine indexes,
target modes, newly-completed sets, bootstrap completion bits, drain
keys, locks, metrics, status — is now keyed by
ActionKey{rule_hash, action_kind}, not by rule_hash alone.

Without this, sibling actions under one XML rule still shared
scheduling state in the engine: originalDelayGroups holding
[]ruleHash means a rule's 7d AbortMPU and 90d ExpirationDays
collapse into one entry, the smaller delay wins for cursor advance,
and the larger sibling never re-fires.

Helper API tightened: EvaluateAction(rule, kind, info, now) and
ComputeDueAt(rule, kind, info) replace the aggregate-rule signatures
so a caller asks one specific action's eligibility against one
entry, never "any action of this rule." Drain task lock includes
action_kind so siblings have independent re-arm timers. Policy CAS
moves from rule_hash to ActionKey membership.

* fix(s3/lifecycle): kind-aware EvaluateAction / ComputeDueAt

Replaces the aggregate-rule signatures with per-action ones:
EvaluateAction(rule, kind, info, now) and ComputeDueAt(rule, kind,
info). The old Evaluate(rule, info, now) could return the verdict of
ANY action declared on the rule, which sat wrong with the per-action
engine indexing (each ActionKey has its own delay group, mode, and
pending stream).

Each helper now decides exactly one (rule, kind) compiled action's
fate against one entry. Asking for a kind the rule doesn't declare,
or asking against the wrong object shape for that kind, returns
ActionNone / zero — never silently routes to a sibling.

Multi-action regression test: a rule with ExpirationDays=90 and
AbortMPU=7 evaluates each kind independently for the same entry; the
7d window has no influence on the 90d eligibility decision.
2026-05-07 10:02:32 -07:00

281 lines
11 KiB
Protocol Buffer

syntax = "proto3";
package s3_lifecycle_pb;
option go_package = "github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb";
option java_package = "seaweedfs.client";
option java_outer_classname = "S3LifecycleProto";
// Storage layout reference (see S3_LIFECYCLE_REDESIGN.md):
//
// /etc/s3/lifecycle/<bucket>/<rule_hash_hex>/<action_kind>/state -> LifecycleState
// /etc/s3/lifecycle/<bucket>/<rule_hash_hex>/<action_kind>/pending -> repeated PendingItem
// /etc/s3/lifecycle/<bucket>/_bootstrap -> BootstrapState
// /etc/s3/lifecycle/_reader/reader_state -> ReaderState
// /etc/s3/lifecycle/_reader/blockers -> repeated BlockerRecord
// /etc/s3/lifecycle/_reader/retry_budget -> repeated RetryBudgetEntry
//
// State is per-(rule_hash, action_kind), not per-rule: a single XML <Rule>
// with N action sub-elements expands into N compiled actions, each with
// its own state file and its own pending file. Sibling actions of the
// same rule can degrade or activate independently.
// ActionKind classifies the lifecycle action a single compiled entry
// represents. A single XML rule may produce multiple compiled entries —
// one per populated action.
enum ActionKind {
ACTION_KIND_UNSPECIFIED = 0;
EXPIRATION_DAYS = 1; // Expiration.Days
EXPIRATION_DATE = 2; // Expiration.Date
NONCURRENT_DAYS = 3; // NoncurrentVersionExpiration.NoncurrentDays (with optional NewerNoncurrent retention)
NEWER_NONCURRENT = 4; // NoncurrentVersionExpiration.NewerNoncurrentVersions, count-only (no NoncurrentDays)
ABORT_MPU = 5; // AbortIncompleteMultipartUpload.DaysAfterInitiation
EXPIRED_DELETE_MARKER = 6; // Expiration.ExpiredObjectDeleteMarker
}
// MessagePosition mirrors weed/util/log_buffer.MessagePosition for durable
// storage. ts_ns + offset uniquely identifies a position within a per-filer
// log buffer; per-shard cursors (see ReaderState) use this shape to skip past
// already-processed events when replaying.
message MessagePosition {
int64 ts_ns = 1;
int64 offset = 2;
}
// Cursor map keyed by filer_id. Each delay group's cursor (ReaderState) and
// the predicate cursor (ReaderState) are per-filer-shard maps because
// LogEntry.Offset is per-filer and not globally unique.
message FilerShardCursor {
string filer_id = 1;
MessagePosition position = 2;
}
// EntryIdentity is the CAS witness used by LifecycleDelete to detect that
// the live entry hasn't changed between worker decision and server execution.
// Mismatch -> STALE_IDENTITY -> NOOP_RESOLVED.
message EntryIdentity {
int64 mtime_ns = 1;
int64 size = 2;
string head_fid = 3;
bytes extended_hash = 4; // sha256 over sorted Extended map; cheap and stable
}
// LifecycleState captures the durable per-action scheduling state. Keyed by
// (rule_hash, action_kind); persisted at
// /etc/s3/lifecycle/<bucket>/<rule_hash_hex>/<action_kind>/state
//
// A single XML <Rule> with N populated action sub-elements expands into N
// LifecycleState records, one per sibling action.
message LifecycleState {
// Proto3 best practice: zero value is an UNSPECIFIED sentinel so that
// legacy / partially-populated payloads don't silently default to a
// semantically active value. Persisted state files always set explicit
// non-zero values; reading UNSPECIFIED indicates corruption or a missing
// field.
enum RuleMode {
RULE_MODE_UNSPECIFIED = 0;
EVENT_DRIVEN = 1;
SCAN_AT_DATE = 2;
SCAN_ONLY = 3;
DISABLED = 4;
PENDING_BOOTSTRAP = 5;
}
// DEGRADED_REASON_UNSPECIFIED replaces the old NONE sentinel: an unset
// / zero degraded_reason means "no active degradation" — operators
// treat UNSPECIFIED as healthy.
enum DegradedReason {
DEGRADED_REASON_UNSPECIFIED = 0;
LAG_HIGH = 1;
PENDING_FULL = 2;
DELETE_FAILURES = 3;
OPERATOR_PAUSED = 4;
RETENTION_BELOW_HORIZON = 5;
LOST_LOG = 6;
}
bytes rule_hash = 1; // 8 bytes; matches the parent rule dir
ActionKind action_kind = 2; // matches the leaf dir name
string rule_id = 3; // display only; identical across sibling actions
RuleMode mode = 4;
DegradedReason degraded_reason = 5;
int64 degraded_since_ns = 6;
bool bootstrap_complete = 7;
int64 bootstrap_started_at_ns = 8;
int64 bootstrap_completed_at_ns = 9;
// Safety-scan scheduling. Set by the safety-scan job when a SCAN_ONLY
// (or periodic safety-scan) bootstrap pass completes; consulted by the
// detector to decide whether to emit the next bootstrap task.
int64 last_safety_scan_ts_ns = 10;
int64 next_safety_scan_ts_ns = 11;
// Counters for observability; no behavior depends on these.
// - evaluated_total: live entries the worker considered for this action
// (filter-matched + due-checked); includes objects that ended up not
// yet eligible.
// - expired_total: successful DONE outcomes (object/version/marker/MPU
// removed) under this action.
// - metadata_only_total: subset of expired_total where the worker
// short-circuited via volume-TTL routing (chunks left to volume GC).
// - error_total: outcomes that did NOT advance the cursor under this
// action (RETRY_LATER, BLOCKED, FATAL_EVENT_ERROR).
int64 evaluated_total = 12;
int64 expired_total = 13;
int64 metadata_only_total = 14;
int64 error_total = 15;
}
// PendingItem records a not-yet-due eligibility for late-predicate exceptions
// (e.g. tag added at age 30d on a 60d rule). Drained by s3.lifecycle.drain.
// One use only — not the steady-state path.
message PendingItem {
string object_path = 1;
string version_id = 2;
int64 due_at_ns = 3;
EntryIdentity expected_identity = 4;
}
// BootstrapState tracks the bucket walker's resume point. Operator-resolution
// of a BOOTSTRAP-stream blocker does NOT mutate this field — the next
// scheduled bootstrap task picks up from last_scanned_path and re-walks.
message BootstrapState {
string last_scanned_path = 1;
int64 started_at_ns = 2;
int64 completed_at_ns = 3; // zero while in progress
}
// ReaderState is the cluster-singleton reader's durable cursors. One file at
// /etc/s3/lifecycle/_reader/reader_state, written by the singleton task.
message ReaderState {
string primary_filer_endpoint = 1;
// Per-delay-group, per-filer-shard cursors. Outer key is delay_seconds.
// Inner FilerShardCursor list maps filer_id -> MessagePosition.
map<int64, FilerShardCursorList> last_processed_original = 2;
// Predicate-change pass cursor, keyed by filer_id.
repeated FilerShardCursor last_processed_predicate = 3;
// Filer-shard keys whose cursor reached range.latest and were safely GC'd.
// Used by the lost-log gate at task entry to distinguish "F was tail-drained
// safely then pruned" from "F's logs vanished before catch-up". Encoded as
// stream-specific keys (same shape as RetryBudgetEntry.StreamKey).
repeated TailDrainedKey tail_drained_streams = 4;
int64 last_checkpoint_ns = 5;
}
message FilerShardCursorList {
repeated FilerShardCursor cursors = 1;
}
// TailDrainedKey identifies a (stream_kind, shard, [delay]) tuple whose
// cursor previously caught up to range.latest. Persisted in ReaderState so
// the lost-log gate can distinguish safe drain from data loss.
message TailDrainedKey {
StreamKind stream_kind = 1;
string filer_id = 2;
int64 delay_seconds = 3; // populated when stream_kind == ORIGINAL
}
// StreamKind classifies the four blockable streams. ORIGINAL/PREDICATE pause
// reader cursors; BOOTSTRAP pauses a bucket walker; PENDING pauses a rule's
// drain task. Zero is an UNSPECIFIED sentinel — a BlockerRecord whose
// stream_kind reads as UNSPECIFIED is a corruption signal, not a default.
enum StreamKind {
STREAM_KIND_UNSPECIFIED = 0;
ORIGINAL = 1;
PREDICATE = 2;
BOOTSTRAP = 3;
PENDING = 4;
}
// BlockerRecord is the durable record of a paused stream. The cursor stays
// at the failing position until the operator clears it via
// `s3.lifecycle.blockers retry|resume|quarantine`. There is no automatic
// dead-letter — silently routing a failed delete decision aside is unsafe
// for lifecycle.
message BlockerRecord {
StreamKind stream_kind = 1;
// Stream-specific scoping; populated per stream_kind.
string shard = 2; // filer_id; ORIGINAL/PREDICATE only
int64 delay_seconds = 3; // ORIGINAL only
MessagePosition position = 4; // ORIGINAL/PREDICATE only
// Common context. bucket/object_path/version_id always populated.
// rule_hash and action_kind are OPTIONAL: empty / UNSPECIFIED for
// pre-evaluation failures (e.g. handleEvent fetchLive FATAL where no
// action has been evaluated). When populated, both are set together —
// the failure is bound to a specific (rule, action) pair.
bytes rule_hash = 5;
string bucket = 6;
string object_path = 7;
string version_id = 8;
ActionKind action_kind = 14; // optional; UNSPECIFIED for pre-evaluation failures
string reason = 9; // "FATAL_EVENT_ERROR: malformed entry"
string last_error = 10; // raw error string from the last attempt
int64 first_seen_at_ns = 11;
int64 last_retry_at_ns = 12;
int32 retry_count = 13;
}
// StreamKey is the shape used by retry-budget tracking. One of four shapes
// per stream_kind, since the identifying tuple differs.
message StreamKey {
oneof key {
OriginalKey original = 1;
PredicateKey predicate = 2;
BootstrapKey bootstrap = 3;
PendingKey pending = 4;
}
}
message OriginalKey {
string shard = 1;
int64 delay_seconds = 2;
MessagePosition position = 3;
}
message PredicateKey {
string shard = 1;
MessagePosition position = 2;
}
message BootstrapKey {
string bucket = 1;
string object_path = 2;
string version_id = 3;
bytes rule_hash = 4;
ActionKind action_kind = 5; // distinguishes per-action streams under one rule
}
message PendingKey {
string bucket = 1;
bytes rule_hash = 2;
string object_path = 3;
string version_id = 4;
ActionKind action_kind = 5;
}
// RetryBudgetEntry tracks consecutive RETRY_LATER outcomes for a stream key.
// Promotes to BLOCKED when consecutive_retries >= retryBudgetMax (30) or age
// >= retryBudgetMaxAge (4h). Compacts on success (clearRetryBudget).
message RetryBudgetEntry {
StreamKey key = 1;
int32 consecutive_retries = 2;
int64 first_seen_at_ns = 3;
int64 last_retry_at_ns = 4;
}
// RetryTarget carries routing info needed to (a) write a BlockerRecord on
// promotion and (b) re-invoke the right primitive on operator retry. We
// deliberately do NOT carry expected_identity or action: the handler
// re-fetches live state and re-evaluates from scratch.
message RetryTarget {
StreamKey key = 1;
string bucket = 2;
string object_path = 3;
string version_id = 4;
bytes rule_hash = 5;
ActionKind action_kind = 6;
}