mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 00:09:00 +01:00
* s3 lifecycle: add a LifecycleReady RPC that only authorizes the caller LifecycleReady runs the same checkAdminAuth as LifecycleDelete and reads no bucket or object metadata, so a lifecycle worker can confirm that an S3 front accepts its internal credentials before it starts a pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3 lifecycle: check S3 fronts with LifecycleReady before the pass Execute used to dial the first registered S3 front and start loading policies and walking buckets, so a missing or mismatched jwt.filer_signing.key surfaced only as repeated per-bucket delete failures. It now checks each front in order with LifecycleReady, using the credentials LifecycleDelete sends, under a 10s bound that also follows parent cancellation. The first front that passes serves the pass; fronts that fail are reported in the job's progress and the log. When none passes, the job fails before any listing with an error that names the front and says whether this worker has the key set, without key or token material. Fronts that predate LifecycleReady are checked with an empty LifecycleDelete, which they authorize and then reject before any lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
14 KiB
Protocol Buffer
348 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);
|
|
// LifecycleReady runs LifecycleDelete's authorization and nothing else,
|
|
// so a worker can check a front before it starts a pass.
|
|
rpc LifecycleReady(LifecycleReadyRequest) returns (LifecycleReadyResponse);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
message LifecycleReadyRequest {}
|
|
|
|
message LifecycleReadyResponse {}
|
|
|
|
// 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;
|
|
}
|