Files
seaweedfs/weed/pb/s3_lifecycle.proto
T
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

341 lines
14 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
}
// SeaweedS3LifecycleInternal is the worker-to-S3 service that performs the
// actual lifecycle deletion. The lifecycle worker computes the (rule, action)
// verdict locally; the S3 server is the only component allowed to mutate the
// filer state, so it gets the final word: it re-fetches the live entry,
// verifies the EntryIdentity CAS, runs object-lock protections, and dispatches
// to the appropriate internal helper.
service SeaweedS3LifecycleInternal {
rpc LifecycleDelete(LifecycleDeleteRequest) returns (LifecycleDeleteResponse);
}
// LifecycleDeleteOutcome captures every per-event verdict the worker needs.
// Cursor advance / pending mutation rules:
//
// DONE -> advance cursor / drop pending
// NOOP_RESOLVED -> advance cursor / drop pending (object already gone or stale identity)
// SKIPPED_OBJECT_LOCK -> log + counter; advance cursor (per design: object lock is operator concern)
// RETRY_LATER -> hold cursor; feed retry-budget; next batch retries from same position
// BLOCKED -> hold cursor; durable BlockerRecord written; operator must intervene
enum LifecycleDeleteOutcome {
LIFECYCLE_DELETE_OUTCOME_UNSPECIFIED = 0;
DONE = 1;
NOOP_RESOLVED = 2;
SKIPPED_OBJECT_LOCK = 3;
RETRY_LATER = 4;
BLOCKED = 5;
}
message LifecycleDeleteRequest {
// Routing.
string bucket = 1;
string object_path = 2; // bucket-relative; no leading slash
string version_id = 3; // empty for non-versioned
bytes rule_hash = 4; // 8 bytes; matches the rule's per-rule dir
ActionKind action_kind = 5; // chooses dispatch within the rule
// Stream context (echoed in BlockerRecord on FATAL outcomes so operators
// can resolve the right (shard, delay) tuple).
string stream_shard = 10;
int64 stream_delay_seconds = 11;
int64 stream_position_ts_ns = 12;
int64 stream_position_offset = 13;
// CAS witness. The server re-fetches the live entry and aborts as
// NOOP_RESOLVED (STALE_IDENTITY) if any field doesn't match.
EntryIdentity expected_identity = 20;
// Snapshot id at the time the worker computed this verdict; the server
// verifies the (rule_hash, action_kind) is still in the current policy
// snapshot and aborts as NOOP_RESOLVED (STALE_POLICY) if not.
uint64 engine_snapshot_id = 21;
}
message LifecycleDeleteResponse {
LifecycleDeleteOutcome outcome = 1;
// Human-readable cause, e.g. "STALE_IDENTITY: mtime drift",
// "FATAL_EVENT_ERROR: malformed entry", or "TRANSPORT_ERROR: filer
// unavailable". Echoed into BlockerRecord.last_error on FATAL.
string reason = 2;
}
// 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;
}