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;
}
