From 0a8656d3101e54278556040f0a8387e43d22ed80 Mon Sep 17 00:00:00 2001 From: upstream-mirror Date: Wed, 16 Sep 2026 03:28:49 +0500 Subject: [PATCH] top-up: 11 source files 128-600 KB --- S3_LIFECYCLE_REDESIGN.md | 2105 ++++ seaweed-volume/src/server/grpc_server.rs | 8428 ++++++++++++++++ seaweed-volume/src/server/handlers.rs | 4289 +++++++++ .../src/storage/erasure_coding/ec_volume.rs | 4398 +++++++++ seaweed-volume/src/storage/volume.rs | 8464 +++++++++++++++++ weed/admin/view/app/plugin_templ.go | 115 + weed/admin/view/app/s3_buckets_templ.go | 899 ++ weed/mq/kafka/protocol/handler.go | 4265 +++++++++ weed/query/engine/engine.go | 5599 +++++++++++ weed/s3api/s3api_object_handlers.go | 3557 +++++++ weed/s3api/s3api_object_handlers_copy.go | 3494 +++++++ 11 files changed, 45613 insertions(+) create mode 100644 S3_LIFECYCLE_REDESIGN.md create mode 100644 seaweed-volume/src/server/grpc_server.rs create mode 100644 seaweed-volume/src/server/handlers.rs create mode 100644 seaweed-volume/src/storage/erasure_coding/ec_volume.rs create mode 100644 seaweed-volume/src/storage/volume.rs create mode 100644 weed/admin/view/app/plugin_templ.go create mode 100644 weed/admin/view/app/s3_buckets_templ.go create mode 100644 weed/mq/kafka/protocol/handler.go create mode 100644 weed/query/engine/engine.go create mode 100644 weed/s3api/s3api_object_handlers.go create mode 100644 weed/s3api/s3api_object_handlers_copy.go diff --git a/S3_LIFECYCLE_REDESIGN.md b/S3_LIFECYCLE_REDESIGN.md new file mode 100644 index 0000000..357b978 --- /dev/null +++ b/S3_LIFECYCLE_REDESIGN.md @@ -0,0 +1,2105 @@ +# S3 Lifecycle Redesign — Event-Driven Expiration + +## Why + +`PutBucketLifecycleConfiguration` today walks every entry under the bucket inside the request handler (`weed/s3api/s3api_bucket_handlers.go:900-1068` → `weed/s3api/filer_util.go:191`), so it times out on buckets with tens of thousands of objects. Worse, only one rule shape actually expires anything: simple `Expiration.Days` + prefix-only filter on a non-versioned bucket. Tag/size filters, `Expiration.Date`, versioned buckets, `NoncurrentVersionExpiration`, `AbortIncompleteMultipartUpload`, and `ExpiredObjectDeleteMarker` are silent no-ops. There is no scan-time evaluator anywhere — `weed/s3api/s3lifecycle/rule.go` defines `Rule`, `ObjectInfo`, `EvalResult`, `Action` but no `Evaluate` and no callers. + +Three independent mechanisms exist today, only one of which is real enforcement: + +1. Read-triggered TTL check on the filer (`weed/filer/filer.go:396-408`, `:435-449`). Lazy: cold objects never expire. +2. Volume-level TTL: `filer.conf` + TTL volumes drop chunks wholesale, but only for writes that arrive after the rule exists. +3. The synchronous "back-stamp" inside the PUT handler that walks existing entries to bridge #1 and #2. This is what's slow. + +## Design + +### Core idea + +Drive lifecycle expiration off the persistent metadata change log at `/topics/.system/log//.` (`weed/filer/topics.go:5`, `weed/filer/filer_notify.go:166`). The same log powers `filer.sync`, `filer.backup`, and `filer.meta.tail`. Each event carries `OldEntry` and `NewEntry` with full attributes and `Extended`. The day/hour-minute layout makes time-windowed reads cheap. Cost scales with **change rate**, not bucket size. + +### Repo facts the design depends on + +Verified in the codebase: + +- Meta events are persisted under `SystemLogDir = TopicsDir + "/.system/log"` and accessed via `Filer.ReadPersistedLogBuffer` and `Filer.CollectLogFileRefs` (`weed/filer/filer_notify_read.go`). +- Each event's `EventNotification.OldEntry` and `NewEntry` are `*filer_pb.Entry` with full `Attributes` and `Extended` (`weed/pb/filer_pb/filer.pb.go:622-672`). +- Versioning constants (`weed/s3api/s3_constants/`): + - `VersionsFolder = ".versions"` — appended as a suffix, not a child. A versioned object at key `foo/bar.txt` has its versions under directory `foo/bar.txt.versions/` (`weed/s3api/filer_multipart.go:578`). + - `ExtDeleteMarkerKey = "Seaweed-X-Amz-Delete-Marker"` — a delete marker is a version file in the `.versions` directory whose `Extended[ExtDeleteMarkerKey] == "true"`. There is **no** `IsDeleteMarker` field on `filer_pb.Entry`. + - `ExtLatestVersionIdKey`, `ExtLatestVersionFileNameKey`, `ExtLatestVersionMtimeKey`, `ExtLatestVersionIsDeleteMarker` — the *current-version pointer* lives on the `.versions` directory entry's `Extended`. +- Multipart uploads live under `/.uploads//` (`s3a.genUploadsFolder` in `weed/s3api/s3api_object_handlers_multipart.go:475`). +- Existing internal delete helpers we will reuse: `s3a.createDeleteMarker(bucket, object)` (`weed/s3api/s3api_object_versioning.go:162`), `s3a.deleteSpecificObjectVersion(bucket, object, versionId)` (`:968`), `s3a.deleteUnversionedObjectWithClient(client, bucket, object)` (`weed/s3api/s3api_object_handlers_delete.go:169`). + +### Phase 0 — Verified assumptions + +Each open assumption resolved against the codebase at `sulfuric-podium`: + +**1. Persisted log payload includes `Extended`.** `Filer.logMetaEvent` (`weed/filer/filer_notify.go:110-119`) marshals `*filer_pb.SubscribeMetadataResponse{EventNotification:{OldEntry, NewEntry, …}}` into `LogEntry.data` via `entry.ToProtoEntry()`, and `Entry.extended` (`weed/pb/filer.proto:135`) is a `map` serialized as part of the proto. `ReadPersistedLogBuffer` (`weed/filer/filer_notify.go:203`) hands back the same `*filer_pb.LogEntry` whose `data` deserializes back to the full event with `Extended` intact. Lifecycle's predicate evaluation against tags/`Extended` keys is therefore directly available from the meta log without any side fetch. + +**2. Meta-log retention is unbounded by default.** Persisted log files at `/YYYY-MM-DD/HH-MM.` are written via `Filer.appendToFile` (`weed/filer/filer_notify_append.go:14-49`). The created `Entry` has no `TtlSec` field set, only `Crtime`/`Mtime`/`Mode`/`Uid`/`Gid`. There is no built-in cleaner: nothing under `weed/filer/` or `weed/server/` deletes `topics/.system/log/` files. The volume-level TTL only kicks in if an operator configures a `filer.conf` rule whose `LocationPrefix` matches `/topics/.system/log/` and whose `Ttl` is set, in which case `Filer.appendToFile` → `assignAndUpload` (`weed/filer/filer_notify_append.go:51-94`) routes through `MatchStorageRule` and the assigned volume carries the rule's `Ttl`. Without that explicit configuration, retention is operator-bounded only (manual deletion of old day directories). + +This means: +- For default deployments, `metaLogRetention = ∞` and the retention mode gate (`metaLogRetention < eventLogHorizon(rule) + bootstrapLookbackMin`) never trips — every reader-driven kind runs `event_driven`. +- For deployments that opt into volume-TTL pruning of the meta log, operators must configure `metaLogRetention` to match the configured TTL; the gate then promotes long-horizon rules to `scan_only` automatically. +- Phase 2 will read `metaLogRetention` from a cluster-config knob (default: a sentinel meaning "unbounded"). When the operator sets a TTL on `topics/.system/log/`, they must also set `metaLogRetention` to the same value; the design doc surfaces this as a required-coupled configuration in Phase 8 docs. + +**3. `.versions/` filename scheme.** Inside `.versions/`, version files are named `v_<32-hex>` where the 32-hex string is `<16-hex-timestamp><16-hex-random>` (`weed/s3api/s3api_version_id.go:30-55`, `:148-150`). Two formats coexist, distinguished by the timestamp portion's value relative to threshold `0x4000000000000000` (`weed/s3api/s3api_version_id.go:24`): +- **New (inverted) format**: timestamp portion = `MaxInt64 - now_ns`, value > threshold (~0x68… in 2025). Newer versions sort **earlier** lexicographically. Used for new `.versions/` directories. +- **Old (raw) format**: timestamp portion = `now_ns`, value < threshold. Older versions sort earlier lexicographically. Detected by reading `Extended[ExtLatestVersionIdKey]` on the `.versions/` directory entry and applying `isNewFormatVersionId` (`weed/s3api/s3api_version_id.go:58-70`). + +For the design's "successor" version computation (the version that replaced this one to make it non-current): +- New format: the lex-immediate-predecessor in `.versions/` (sorts earlier = newer). +- Old format: the lex-immediate-successor (sorts later = newer). +- Universal fallback: `getVersionTimestamp(versionId)` (`weed/s3api/s3api_version_id.go:74-92`) returns the actual ns timestamp regardless of format; `compareVersionIds(a, b)` (`:97-140`) gives a format-agnostic newest-first comparator. Phase 5 will use these helpers when discovering successor non-current time. + +The `.versions/` directory entry's `Extended` carries the current-version pointer (`ExtLatestVersionIdKey`, `ExtLatestVersionFileNameKey`, `ExtLatestVersionMtimeKey`, `ExtLatestVersionIsDeleteMarker`) — already documented above as repo facts. + +### Storage + +**Three storage areas, separated by ownership and write-concurrency boundaries.** + +The bucket directory's xattrs continue to hold the policy itself: + +- `Extended["s3-bucket-lifecycle-configuration-xml"]` — original XML (existing key, see `weed/s3api/s3api_bucket_lifecycle_config.go:11`). + +**Multi-action rules.** A single AWS lifecycle XML `` may declare multiple actions in parallel — for example `Expiration.Days=90` together with `AbortIncompleteMultipartUpload.DaysAfterInitiation=7` and `NoncurrentVersionExpiration.NoncurrentDays=30`. Each action has its **own** delay/horizon/mode and must drive its own pending stream and cursor independently. Modeling a rule as one compiled entry with one `kind` and one delay collapses these — e.g. 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 for them. + +The engine therefore **expands every XML rule into N compiled actions** at compile time, where N is the count of action sub-elements actually populated. Each compiled action has its own state, its own pending file, its own delay group, and its own mode. The shared filter (Prefix, Tags, Sizes, Status) is copied to each action — actions of the same rule are evaluated in parallel against the same filter. + +**Per-action state** lives outside the bucket under `/etc/s3/lifecycle////`. One subdirectory per action; one writer per directory (the per-action cluster lock). The intermediate `` keeps a rule's actions grouped so operators can list "rule R's status" by enumerating its action subdirectories: + +- `state` — protobuf, one record: + - `rule_hash` (8 bytes; matches the parent rule directory) + - `action_kind`: `EXPIRATION_DAYS` | `EXPIRATION_DATE` | `NONCURRENT_DAYS` | `NEWER_NONCURRENT` | `ABORT_MPU` | `EXPIRED_DELETE_MARKER` (matches the leaf directory name) + - `rule_id` (display only; can be empty/duplicate; identical across sibling actions of the same XML rule) + - `mode`: `event_driven` | `scan_at_date` | `scan_only` | `disabled` (scheduling intent — per action, not per rule) + - `degraded_reason`: `NONE` | `LAG_HIGH` | `PENDING_FULL` | `DELETE_FAILURES` | `OPERATOR_PAUSED` | `RETENTION_BELOW_HORIZON` | `LOST_LOG` (orthogonal health signal; a rule's individual action can be `event_driven AND LAG_HIGH`, with auto-promotion to `scan_only` deferred until the next threshold; `RETENTION_BELOW_HORIZON` and `LOST_LOG` are set together with `mode = scan_only` by the retention gate and lost-log GC respectively) + - `degraded_since_ns`: when the current `degraded_reason` was set; cleared together with reason + - `bootstrap_complete` + - `bootstrap_started_at_ns` + - `bootstrap_completed_at_ns` + - `last_safety_scan_ts_ns` + - `next_safety_scan_ts_ns` + - counters: `evaluated_total`, `expired_total`, `metadata_only_total`, `error_total` +- `pending` — protobuf, append-with-tombstones: `repeated PendingItem { path, version_id, due_at_ns, expected_identity }`. **One use only**: late predicate changes that create not-yet-due eligibility for *this* action (tag added at age 30d on a 60d rule's `EXPIRATION_DAYS` action). Dedupe key `(path, version_id)`. Sibling actions on the same rule have their own pending files. + +No per-rule `blocked` file (compliance ledger). Object-lock and retain-until are the operator's concern — see "Object lock and compliance" below. (Cluster-level `_reader/blockers` is unrelated — it tracks paused cursor positions for un-processable events, not retained objects.) + +Per-rule state does **not** include reader watermarks. Watermarks belong to the shared reader, not to individual rules — see below. + +**Cluster reader state** lives at `/etc/s3/lifecycle/_reader/`. **A single reader task** (`s3.lifecycle.read`, locked by one cluster-wide lock) is subscribed to **one** filer endpoint at a time. That endpoint is just a routing convenience: `Filer.CollectLogFileRefs(start_position, stop_ts)` returns chunk refs from per-filer log files in directory order, and the worker heap-merges them client-side. The single subscription is for connection management; cross-filer event ordering happens in the worker. + +Files: + +- `reader_state` — protobuf, single record (no reader groups): + - `primary_filer_endpoint` — current subscription target. Workers fail over to another filer if this one is unreachable. + - `last_processed_original: map>` — per delay group, per per-filer log shard. + - `last_processed_predicate: map` — per per-filer shard. + - `tail_drained_streams: set` — stream-specific markers for departed shards already observed at their retained tail. Keys are `(ORIGINAL, delay_seconds, filer_id)` or `(PREDICATE, filer_id)`, never just `filer_id`; one delay group being drained does not prove another delay group is safe. The marker is cleared when that stream is lazily seeded again after the shard reappears. + - `last_engine_compiled_at_ns: int64`. +- `blockers` — durable per-shard blocker records. When a deterministic per-event failure (or a sustained transient one) is observed, the worker pauses that shard at its current `MessagePosition` and records a `BlockerRecord` here; the cursor does **not** advance. Operators inspect via `weed shell s3.lifecycle.blockers list` and resolve manually (`retry`, `resume`, or `quarantine`). See "Blocked cursor handling" below. There is no automatic dead-letter — for lifecycle, silently routing a failed event aside would mean losing a delete decision; the design pauses instead. +- `retry_budget` — durable per-stream retry counter file. Tracks `consecutive_retries` for `(stream_kind, key)` tuples that produce repeated `RETRY_LATER`. Compacts on success; promotes to a `BlockerRecord` once the configured budget trips. See "Sustained-RETRY_LATER promotion" below. + +**Why per-filer cursors are unavoidable.** The persisted log is partitioned by filer (`.` files). The client-side heap merge produces a single ordered stream from N per-filer streams, but resuming after a partial-batch failure requires knowing each per-filer stream's position — `LogEntry.Offset` is per-buffer (per filerId), not globally unique. A single `(ts, offset)` cursor across the merged stream would conflate offsets from different filers. Therefore: + +- The cursor type per delay group is `map`. +- `MessagePosition = {ts_ns, offset}` (the existing log-buffer type, `weed/util/log_buffer/log_read.go:38`) is the per-shard tie-breaker. +- The worker maintains the heap merge over per-shard `MessagePosition`s; on resume, each filer's stream restarts at its own cursor position. +- Watermark exposed to operators / metrics is the low-water-mark: `min over filer_ids of last_processed.ts_ns` per delay group. Lag = `now - watermark`. + +This is more state than the previous "single subscription, single cursor" claim implied, but it's what correctness requires across a client-side multi-filer merge. The single subscription still gives connection-level simplicity (one filer endpoint, easy failover); the per-filer cursor map is internal bookkeeping. + +**Cursor-skip semantics.** `last_processed[filer_id]` points at the *last resolved event* for that shard. Resume must skip events with position `<= cursor[filer_id]` (using the `(ts, offset)` order per shard) — strict `<=`, not `<`, otherwise the last processed event is replayed on every resume. Equivalently: deliver events with `(event.ts, event.offset) > cursor[filer_id]`. Phase 3 introduces a new API variant that takes per-shard `MessagePosition`s and applies this comparator — see "API change" below. The existing `pb.ReadLogFileRefs` filter `logEntry.TsNs <= startTsNs` (`weed/pb/filer_pb_direct_read.go:333`) is the wrong shape because it can't disambiguate equal-ts events. + +> **Implementation note:** the API addition described below was not built. The shipped reader uses `SubscribeMetadata` with a single global cursor (the filer's server-side `MetaAggregator` already does the merge). The pseudocode is preserved as design context for a future per-shard architecture. + +**~~Required API addition~~ (Phase 3 dependency, not built)**: `pb.ReadLogFileRefsWithPosition` extending the current `ReadLogFileRefs`: + +- Take a per-shard `start_positions: map` instead of a single `startTsNs`. +- Per-event callback signature `eachLogEntry(event, filer_id, position) → CallbackResult`. +- Skip predicate uses `(ts, offset)` ordering per shard, not `TsNs <= startTsNs`. +- Return per-shard `last_positions: map`. + +`CallbackResult` (explicit enum): + +``` +enum CallbackResult { + Continue // proceed to next event + HaltAll // stop merge entirely; return last_positions for ALL shards + PauseShard(filer_id) // stop delivering events from THIS shard for the rest of this call + // (other shards continue); subsequent merge ticks within this call + // never invoke the callback again for filer_id +} +``` + +`last_positions` semantics on return: + +- For shards that returned `Continue` until exhaustion: `last_positions[filer_id]` = the last delivered event's position. +- For shards paused via `PauseShard(filer_id)`: `last_positions[filer_id]` = the position of the event whose callback returned `PauseShard` (the *paused-on* event, not the next un-delivered one). The lifecycle reader does **not** advance the cursor in this case — the BlockerRecord already records the failing position, and the cursor stays at the last *resolved* position recorded by the previous successful callback for that shard. +- For shards untouched (no events delivered before the call ended): `last_positions[filer_id]` = the value passed in via `start_positions[filer_id]` (unchanged). + +`HaltAll` exits the function immediately; `last_positions` reflects the state up to the point of halt, with the halting event's shard's `last_positions` entry equal to the position that returned `HaltAll`. + +No `afterChunk` hook. Under client-side heap merge, a chunk's events are interleaved with events from other shards, so "chunk read complete" does not mean "all events from this chunk delivered and resolved." A hook firing on chunk-read-completion could checkpoint past unresolved events. Checkpointing happens only inside the per-event callback (see "Periodic checkpointing" in the reader pseudocode below). + +The existing `pb.ReadLogFileRefs` stays unchanged for other consumers; the new variant is additive. + +Why one reader, one subscription: + +- **Watermark correctness is well-defined.** Each per-filer cursor is its own monotonic stream; the heap merge produces a single ordered delivery; cursors persist per shard so resume after partial failure is correct on each shard. Watermark surfaced externally is the low-water-mark across the per-filer cursors per delay group. +- **Filer load is metadata-only.** `CollectLogFileRefs` returns chunk file IDs and their time ranges — no event bodies are deserialized at the filer. Worker reads bodies from volume servers, which parallelizes naturally across the chunks of the merged stream. +- **No partitioning, no epochs, no migration.** Adding parallelism is not a primary scaling story; if a single worker can't keep up after raising in-worker chunk-read concurrency and `lifecycle.delete.concurrency`, the next-stage answer is segment-level intra-stream parallelism (see "Future scaling" below). Explicitly **non-v1**. + +**Flush-safety lag.** The filer's persisted log files appear on disk after `LogFlushInterval` (current constant: `time.Minute` — `weed/filer/filer.go:33`). The worker reads only events at `event.ts <= now - flushSafetyLag` (default `2 × LogFlushInterval`, so **≈2 minutes** with current defaults; reduces if `LogFlushInterval` is shortened) so a late-flushing filer cannot produce events older than the cursor after the cursor has advanced. All cutoffs are clamped: `effectiveCutoff = min(kindCutoff, now - flushSafetyLag)`. Phase 0 confirms the active value of `LogFlushInterval` and pins `flushSafetyLag` to it; the design treats `flushSafetyLag` as derived, not as a hardcoded number. + +**Primary filer failover.** Workers reach the primary filer via a standard filer client; on connection failure, fall back to the next healthy filer endpoint (discovered via the master). The cursor is portable — every filer reads the same persisted logs from the shared namespace, so the worker can resume against a different primary without state loss. + +Per-bucket bootstrap progress (used by bucket-level bootstrap tasks): + +- `/etc/s3/lifecycle//_bootstrap` — protobuf: `last_scanned_path`, `bootstrap_started_at_ns`, the engine snapshot ID it was launched against. One writer (the bucket's bootstrap task). + +Why `/etc/...` rather than inside the bucket: keeps system state out of bucket listings without needing list-time filters; matches existing convention (`/etc/seaweedfs/`, `/etc/...`). + +`rule_hash` is `sha256(canonicalize(rule))[:8]` over a length-prefixed canonical form (sorted tag map, prefix verbatim, every action's parameters — days/date/count/flags, filter fields). Length prefixing prevents delimiter forgery between adjacent fields. Stable across reorder and resilient to empty or duplicate `Rule.ID`. Prefix `"logs"` and `"logs/"` hash differently because they match different objects under literal `strings.HasPrefix` semantics. + +**Policy CAS is per-rule via the `rule_hash` directory layout.** Pending items live under `/pending`; the drain pass and the `LifecycleDelete` server check whether `rule_hash` is still in the current policy's hash set. Edits to other rules don't affect this rule's pending. See "Policy-version CAS" section below for full mechanics. + +### Tick (default 24h, configurable, jittered, per bucket) + +For each bucket with `lifecycle.xml`: + +1. Parse XML; compute `rule_hash` per rule. Expand each rule into its compiled actions (one per populated action element); compute `(rule_hash, action_kind)` keys. +2. Reconcile per-action `state` files under `/etc/s3/lifecycle////`: drop directories for `(rule_hash, action_kind)` pairs no longer present (after a grace period to absorb policy edit-and-revert); create directories for new pairs (`bootstrap_complete=false`). +3. Per **compiled action** (not per rule), decide mode in this order: + - `action_kind == EXPIRATION_DATE` → `mode = SCAN_AT_DATE`. Detector schedules a single bucket-level bootstrap at the action's `rule.date`. No per-object pending. No reader involvement. + - Else compute `eventLogHorizon(rule, action_kind)` — the age of an event the reader needs to be able to observe for *this specific action*: + - `EXPIRATION_DAYS` → `rule.Days` (age-origin events at `now - days`) + - `NONCURRENT_DAYS` → `rule.NoncurrentDays` (version-flip events at `now - days`) + - `ABORT_MPU` → `rule.DaysAfterInitiation` (MPU init events at `now - days`) + - `NEWER_NONCURRENT` → `smallDelay` (count-based; reader observes version-flip events near `now - smallDelay`) + - `EXPIRED_DELETE_MARKER` → `smallDelay` (immediate; reader observes the marker creation event near `now - smallDelay`) + If `metaLogRetention < eventLogHorizon(rule, action_kind) + bootstrapLookbackMin` → this action's `mode = scan_only` (with `degraded_reason = RETENTION_BELOW_HORIZON`). Event-driven would silently miss events that aged out of the log. Sibling actions on the same XML rule are evaluated independently — a 90d `EXPIRATION_DAYS` action may degrade to `scan_only` while its 7d `ABORT_MPU` sibling stays `event_driven`. + - Else if `bootstrap_complete == false` → run bootstrap (the same walker; on completion this action transitions to its target mode). + - Otherwise → `event_driven`. +4. Run `event_driven` actions: trigger pass, then drain `pending`. +5. Run `scan_only` actions: re-run bootstrap on the per-kind safety-scan cadence (see "Safety-scan cadence per rule kind" table). Date and count kinds don't have a TTL-style `MinTriggerAge`; their cadences are defined explicitly in that table. Bootstrap is idempotent; this is the same code path. + +`metaLogRetention` is read from cluster config (Phase 0 confirms the knob). If unconfigured, default to a conservative value (e.g. 30d) and require operators to extend it explicitly for longer-TTL rules. + +### Rule kinds and `dueAt` + +Each rule kind defines `dueAt(rule, live)` — the earliest wall-clock time the entry becomes eligible. The trigger pass uses a kind-specific cutoff to bound work; eligibility is always checked against `dueAt`. + +| Rule kind | Mode | `dueAt(rule, live)` | Original-write trigger cutoff | Predicate-change trigger cutoff | +|---|---|---|---|---| +| `EXPIRATION_DAYS` | `event_driven` | `live.mtime + days` | `now - days` | `now - smallDelay` | +| `EXPIRATION_DATE` | `scan_at_date` | n/a (single date-triggered sweep) | n/a | n/a | +| `NONCURRENT_DAYS` | `event_driven` | `successorMtime + days` | `now - days` (version-flip events) | `now - smallDelay` | +| `NEWER_NONCURRENT` | `event_driven` | `now` (count-based, immediate at flip) | `now - smallDelay` | `now - smallDelay` | +| `ABORT_MPU` | `event_driven` | `mpuInitMtime + days` | `now - days` (MPU init events) | n/a | +| `EXPIRED_DELETE_MARKER` | `event_driven` | `marker.mtime` (immediate when sole survivor) | `now - smallDelay` | n/a | + +`smallDelay` is a single global, default 1 minute, just to avoid racing in-flight writes. + +**Safety-scan cadence per rule kind.** A periodic re-bootstrap catches drift the reader cannot recover. Cadence depends on rule kind because `MinTriggerAge` is undefined for some: + +| Rule kind | Safety-scan cadence (default) | +|---|---| +| `EXPIRATION_DAYS` | `max(MinTriggerAge, 7d)` capped at 30d | +| `NONCURRENT_DAYS` | `max(MinTriggerAge, 7d)` capped at 30d | +| `ABORT_MPU` | `max(MinTriggerAge, 1d)` | +| `EXPIRATION_DATE` | `24h` (no `MinTriggerAge`; daily scan picks up new objects matching the prefix) | +| `NEWER_NONCURRENT` | `7d` (count-based; periodic catches missed version-flip events) | +| `EXPIRED_DELETE_MARKER` | `7d` | + +Operators can override per rule via shell command. `scan_only` mode (when retention < trigger window) uses the same cadence as the safety scan but it's the *only* enforcement path. + +The trigger pass uses two cutoffs **per rule** (not one): + +- **Age-origin events** (the "original-write" sweep covers all of these — the term "original-write" is a slight misnomer kept for continuity): cutoff = kind's "age-origin" column. These represent any event that establishes a new "object age" for AWS-semantic purposes (`LastModified` reset). Concretely: + - `OldEntry == nil && NewEntry != nil` — fresh PUT. + - `OldEntry != nil && NewEntry != nil` AND any of `Mtime`, `FileSize`, or chunk fileIds differ — overwrite or content rewrite. + - MPU init events (initialize a new upload, not yet completed). + An overwrite resets the lifecycle clock; the engine must re-evaluate. If we excluded overwrites from this stream, a 60d rule on a 30d-old object that was just overwritten would still see the original PUT event (already past the watermark) and never re-trigger; the rewritten content would be deleted at the original PUT's age + 60d, not the rewrite's age + 60d. +- **Predicate-change-only events** (`OldEntry != nil && NewEntry != nil` AND `Mtime/FileSize/chunks unchanged` AND `Extended` differs): cutoff = `now - smallDelay`. These represent tag/metadata edits that can change rule applicability *without* resetting the lifecycle clock. We must look at them quickly so a tag added at age 30d on a 60d rule doesn't wait until age 90d. `dueAt` is computed from `live.mtime + TTL`, so the object's eligibility instant is unchanged — but it gets on the radar at the right time. Events where both content and Extended changed are classified as age-origin, not predicate-change. + +Events whose `OldEntry.Extended` and `NewEntry.Extended` are byte-identical (and no other rule-relevant fields changed) are filtered out at ingestion — most internal `UpdateEntry` traffic is irrelevant. + +### Bootstrap — bucket-level, inline-delete + +One walk per bucket evaluates every applicable rule per object via the engine's bucket index. Currently-due objects are deleted **inline** (no per-object pending writes); not-yet-due entries are skipped (the reader picks them up via the original-write watermark sweep). The walker checkpoints `last_scanned_path` per page in `/etc/s3/lifecycle//_bootstrap`; on `TRANSPORT_ERROR` it stops and the next bootstrap task resumes from the cursor. + +``` +T_start := now() +snapshot := engine.Snapshot() + +// Refuse to start if a BOOTSTRAP blocker is active for this bucket — operator +// must clear it via blockers retry|resume|quarantine first. Without this guard +// the walker would re-attempt the same failing entry every tick and the cursor +// could advance past the blocked path if the underlying issue self-heals, +// without the blocker ever being explicitly resolved. +active_blockers := load("/etc/s3/lifecycle/_reader/blockers") +if any b in active_blockers where b.stream_kind == BOOTSTRAP and b.bucket == bucket: + return ContinuationHint{ next_run_after_ns: now + 60s, reason: "BOOTSTRAP blocker active" } + +for each entry under /buckets// starting from last_scanned_path: + if entry is internal (.uploads/, .versions/ for non-versioned, ...): continue + candidates := snapshot.MatchPath(bucket, entry.path) + if len(candidates) == 0: continue + info := buildObjectInfo(entry) + for each rule in candidates: + if rule.kind == EXPIRATION_DATE && rule.mode == SCAN_AT_DATE && now() < rule.date: + continue // wait for the scheduled date-triggered bootstrap + if !ruleAppliesToEntryShape(rule, entry): continue + if now() < computeDueAt(rule, info): + continue // not-yet-due: reader's original-write sweep will pick this up later + action := s3lifecycle.EvaluateAction(rule, action_kind, info, now()) + // Use the same deleteAndResolve contract as the reader/drain paths. + // Outcomes: DONE/NOOP_RESOLVED → walker advances; RETRY_LATER → walker + // halts at this entry, last_scanned_path stays at the previous successful + // entry, next bootstrap task resumes there; BLOCKED → blocker record was + // written and the walker halts (operator unblocks). + outcome := deleteAndResolve(DeleteContext{ + bucket: bucket, + object_path: entry.path, + version_id: entry.version_id, + rule_hash: rule.hash, + rule: rule, + stream_kind: BOOTSTRAP, + shard: "", // bootstrap is not shard-bound + delay_seconds: 0, // unused for BOOTSTRAP + position: MessagePosition{}, // unused for BOOTSTRAP + action: action, + expected_identity: identityFromLive(entry), + }) + stream_key := StreamKey{ stream_kind: BOOTSTRAP, bootstrap: BootstrapKey{ + bucket: bucket, object_path: entry.path, + version_id: entry.version_id, rule_hash: rule.hash } } + retry_target := RetryTarget{ + key: stream_key, + bucket: bucket, + object_path: entry.path, + version_id: entry.version_id, + rule_hash: rule.hash, + } + switch outcome { + case DONE, NOOP_RESOLVED: + clearRetryBudget(stream_key) + continue + case RETRY_LATER: + promoted := recordRetryLater(retry_target) + if promoted == BLOCKED: + return ErrBootstrapBlocked // promotion wrote BlockerRecord; halt and surface + return ErrBootstrapRetryLater // halt; resume from last_scanned_path next task + case BLOCKED: + clearRetryBudget(stream_key) + return ErrBootstrapBlocked // halt; operator must clear blocker + } + checkpoint last_scanned_path = entry.path // only after DONE/NOOP_RESOLVED for every rule + +// Walk completed for this snapshot. Commit cursor seed/rewind FIRST, then per-action +// completion. Order matters: engine.compile(action) keys activation off +// state[ActionKey].bootstrap_complete && state[ActionKey].mode == EVENT_DRIVEN; +// if either flips before the cursor seed commits, the engine could mark an action +// active while its cursor is still uninitialized. + +// Compute target modes UPFRONT, per ActionKey, so the seeder can filter on them. +// The seeder looks at target_mode to decide whether to rewind a delay group's +// cursor for this action (only EVENT_DRIVEN actions participate in reader sweeps +// and need the cursor floor). Computing modes after seeding would leave the +// seeder reading whatever default value the state field has — wrong. +target_modes := {} +for each ActionKey k in snapshot.action_keys_for_bucket(bucket): // expanded per-action + target_modes[k] = decideMode(snapshot.actions[k]) + +newly_completed := { k for k in snapshot.action_keys_for_bucket(bucket) + where state[k].bootstrap_complete == false } + +seedReaderCursorsForNewDelayGroups(snapshot, newly_completed, target_modes, T_start) + // (1) durable cluster write + +// (2) durable per-action writes — bootstrap_complete=true AND mode are committed +// atomically per ActionKey (single proto write per action), using the SAME +// target_modes computed upfront. Activation needs both: engine.compile activates +// an action iff bootstrap_complete && mode == EVENT_DRIVEN. +for each ActionKey k in snapshot.action_keys_for_bucket(bucket): + state[k].mode = target_modes[k] + state[k].bootstrap_started_at_ns = T_start + state[k].bootstrap_completed_at_ns = now() + state[k].bootstrap_complete = true // committed AFTER (1) + +engine.markActive(snapshot, action_keys_for_bucket) // (3) in-memory hint +remove _bootstrap (last_scanned_path) for this bucket +``` + +`decideMode(action)` is the same predicate used by the tick-time mode decision (date kind → `SCAN_AT_DATE`; reader-driven kind with `metaLogRetention < eventLogHorizon(rule, kind) + bootstrapLookbackMin` → `SCAN_ONLY`; rule explicitly disabled by operator → `DISABLED`; otherwise `EVENT_DRIVEN`). Computing it once at bootstrap completion and persisting it durably means subsequent engine refreshes don't have to recompute or guess. Computing it **before** seeding (rather than inside the per-action loop) lets the seeder see consistent target modes for every `ActionKey` it must consider. + +Note the `newly_completed` filter passed to `seedReaderCursorsForNewDelayGroups`: rewind only applies to actions transitioning `bootstrap_complete=false → true`. Routine safety-scan re-bootstraps for already-active actions do **not** rewind shared cursors. See "Cursor seeding/rewind" below for the rationale. + +**State transitions, explicit.** Bootstrap is bucket-level execution; completion is per-action: +- `_bootstrap` (per bucket): only the run cursor (`last_scanned_path`, `bootstrap_started_at_ns`, `snapshot_id`). Removed on completion. +- `state[ActionKey].bootstrap_complete` (per action): set to true for every `ActionKey` in the snapshot when the bucket walk finishes. Actions added after the walk started keep `bootstrap_complete=false` and trigger a new bucket walk on the next detector tick. +- `engine.markActive(ActionKey)` (in-memory): the action becomes a candidate in reader sweeps only after both (1) and (2) durable writes have committed for that key. + +Bootstrap **never writes to per-action pending** in the normal path. Pending exists only for late-predicate-change exceptions, which are observed by the reader, not by bootstrap. + +**Policy change during bootstrap.** Bootstrap binds to a `snapshot_id`. If lifecycle XML changes mid-walk: +- The current walk finishes (it's evaluating against the old snapshot, which is consistent with what the reader's cursor floors expect). +- The engine compiles a new snapshot. New `ActionKey`s start `pending_bootstrap`; unchanged `ActionKey`s carry their `bootstrap_complete=true`. (An XML edit that adds a new action sub-element to an existing rule introduces a new `ActionKey` for that kind only; the rule's other actions stay `active`.) +- Detector emits a new bucket bootstrap task on the next tick for the new snapshot. It walks again from the beginning (idempotent — inline deletes are CAS-protected). +- Removed `ActionKey`s' directories enter grace cleanup. Removing an action sub-element from an XML rule removes only that `ActionKey`; siblings under the same `rule_hash` survive. + +**Cursor seeding/rewind on bootstrap completion.** Bootstrap must touch the shared cursors for the **actions** that just transitioned `bootstrap_complete=false → true` — not every action in the snapshot, and not on every safety-scan re-bootstrap. Scoping by `newly_completed` `ActionKey`s is critical: an action joining an existing shared delay group needs the shared cursor pulled back to its safe floor (the cursor reflects work done for sibling actions and has advanced past the new action's floor), but routine safety scans for already-active actions must not rewind that same cursor — doing so would force every safety-scan tick to replay big shared windows. + +Rewind rule (Option B): + +- For each newly-completing `ActionKey` whose `targetOriginalDelayGroup(action)` is non-nil (computed from `action_kind`/delay, *not* from `engine.originalDelayGroups` membership — that collection only contains actions already activated, which excludes the action we're now completing): set `reader_state.last_processed_original[D.seconds][filer_id] = min(existing_cursor, MessagePosition{T_start - max(D, bootstrapLookbackMin), BEFORE_FIRST_OFFSET})` for every shard. The `targetOriginalDelayGroup` filter covers `EXPIRATION_DAYS`, `NONCURRENT_DAYS`, `ABORT_MPU`, `NEWER_NONCURRENT`, `EXPIRED_DELETE_MARKER`, and any future event-driven kind. `EXPIRATION_DATE` returns nil and is skipped. Comparison is the lex `(ts, offset)` order — same shape as the strict-`<=` skip predicate — not bare ts; otherwise an existing cursor at `(T, k)` with `k > BEFORE_FIRST_OFFSET` and `T == new_floor.ts_ns` would be left in place even though the new action's floor sits at `(T, BEFORE_FIRST_OFFSET)` and an event at `(T, 0)` should be delivered. +- For event-driven actions with effectively-zero delay (`NEWER_NONCURRENT`, `EXPIRED_DELETE_MARKER`): the delay group is `0` (or `smallDelay`); the cursor floor is `T_start - bootstrapLookbackMin`. Same min-comparison and rewind logic apply. +- For predicate-sensitive actions (those whose rule has tag/size filters): also `min(existing_cursor, MessagePosition{T_start - bootstrapLookbackMin, BEFORE_FIRST_OFFSET})` on `last_processed_predicate[filer_id]`. + +Sibling actions already in the same delay group will replay the rewind window. That replay is idempotent — pending upserts collapse on `(path, version_id)`, CAS on identity rejects redundant deletes — but operators should know it costs them work proportional to events in the rewind window. The reseed shell command and the bootstrap completion path both surface this in the status output. + +When a bucket bootstrap completes, it calls `seedReaderCursorsForNewDelayGroups(snapshot, newly_completed, target_modes, T_start, force_reseed_delays={}, force_reseed_predicate=false)`. The `newly_completed` argument is a set of `ActionKey`s, restricting rewind scope to actions transitioning `bootstrap_complete=false → true`. Routine safety-scan re-bootstraps for already-active actions pass empty `newly_completed`, empty `force_reseed_delays`, and `force_reseed_predicate=false`, so they don't rewind cursors. + +The optional **`force_reseed_delays`** and **`force_reseed_predicate`** parameters together form the **operator-driven scan_only re-enable path**. When `s3.lifecycle.reseed` flips an action from `scan_only` back to `event_driven`, the action already has `bootstrap_complete=true`, so it does not enter `newly_completed`. Without explicit force arguments, the seeder would skip it and the previously-deleted/stale cursors would never get re-seeded. The reseed shell command: + +1. Computes `force_reseed_delays = { D : D = targetOriginalDelayGroup(action), for action in affected_actions where target_mode == EVENT_DRIVEN }`. +2. Sets `force_reseed_predicate = any action in affected_actions where action.predicateSensitive`. The predicate cursor is **shared across the whole engine** (one `map`), so the seeder operates on it whenever any predicate-sensitive action is being re-enabled — independent of which delay group the action belongs to. While the action was in `scan_only`, predicate-change events for matching tags/sizes were not fed to the engine; without rewind, the action would silently miss any tag/metadata change that happened during the `scan_only` window. +3. Deletes `reader_state.last_processed_original[D.seconds]` for each `D` in `force_reseed_delays`. If `force_reseed_predicate` is true and the operator wants a hard reseed of the predicate cursor, also deletes `reader_state.last_processed_predicate` (otherwise the seeder will pull it back via `min(existing, new_floor)`, which is sufficient). +4. Runs a fresh bucket bootstrap that calls the seeder with the populated force arguments. The seeder treats them as needing seeding/rewind even when `newly_completed` is empty. +5. After the seeder commits, flips affected rules' `mode` from `scan_only` back to `event_driven` durably. + +Without this scoping, every safety scan would silently rewind every shared cursor and force unrelated rules to repeatedly replay big windows; without the force-delay path, scan_only re-enable would be silently broken because the deleted delay-cursor never gets re-seeded; without the force-predicate path, predicate-sensitive rules re-enabling from scan_only would silently miss tag/metadata changes that occurred during the scan_only window. + +``` +seedReaderCursorsForNewDelayGroups(snapshot, newly_completed, target_modes, T_start, + force_reseed_delays={}, force_reseed_predicate=false): + if newly_completed is empty AND force_reseed_delays is empty AND !force_reseed_predicate: + return // safety-scan re-bootstrap; no rewind, no force-seed + + acquire cluster lock /etc/s3/lifecycle/_reader/seeding.lock + rs := load("/etc/s3/lifecycle/_reader/reader_state") + + // Derive affected delay groups from each newly-completing rule's TARGET + // delay group (computed from rule.kind/delay), NOT from engine.originalDelayGroups + // membership: that collection only includes rules with bootstrap_complete && + // mode == EVENT_DRIVEN, so a rule that is *just now completing* is not yet a + // member when this seeding runs. + // + // target_modes is the precomputed map from the caller. + // + // targetOriginalDelayGroup(rule) returns the delay-group key the rule would + // join if event-driven: + // EXPIRATION_DAYS / NONCURRENT_DAYS / ABORT_MPU → rule.delay + // NEWER_NONCURRENT / EXPIRED_DELETE_MARKER → smallDelay (delay = 0) + // EXPIRATION_DATE → nil (not reader-driven) + affected_delays := {} + affects_predicate := false + + // Path 1: rules transitioning bootstrap_complete=false → true. + for rule in snapshot.rules where rule.hash in newly_completed: + if target_modes[rule.hash] != EVENT_DRIVEN: continue // SCAN_*/DISABLED skip rewind + if D := targetOriginalDelayGroup(rule); D != nil: + affected_delays.add(D) + if rule.predicateSensitive: + affects_predicate = true + + // Path 2: operator-driven scan_only re-enable. Force-seed each delay in + // force_reseed_delays even if no rule is in newly_completed. This is the + // only path that re-seeds an already-deleted cursor for an already-active rule. + for D in force_reseed_delays: + affected_delays.add(D) + + // Predicate cursor: rewound if any newly_completed predicate-sensitive rule + // OR if force_reseed_predicate is set. The latter handles scan_only re-enable + // for predicate-sensitive rules: while the rule was scan_only, predicate-change + // events weren't applied; without rewind we'd silently miss any tag/metadata + // change from that window. + if force_reseed_predicate: + affects_predicate = true + + known_filer_ids := list_filer_ids_in("/topics/.system/log/") + floor_ts_for(D) := T_start - max(D, bootstrapLookbackMin) + predicate_floor_ts := T_start - bootstrapLookbackMin + + for each delay D in affected_delays: + if rs.last_processed_original[D.seconds] is unset: + rs.last_processed_original[D.seconds] = {} + for filer_id in known_filer_ids: + new_floor := MessagePosition{ ts_ns: floor_ts_for(D), offset: BEFORE_FIRST_OFFSET } + existing := rs.last_processed_original[D.seconds][filer_id] + if existing is unset: + rs.last_processed_original[D.seconds][filer_id] = new_floor + else if compareMessagePosition(new_floor, existing) < 0: + // REWIND. compareMessagePosition is the lex (ts, offset) comparator — + // matching the strict <= skip semantics. Bare-ts comparison would + // miss boundary events when timestamps are equal but offsets differ. + rs.last_processed_original[D.seconds][filer_id] = new_floor + + if affects_predicate: + if rs.last_processed_predicate is unset: + rs.last_processed_predicate = {} + for filer_id in known_filer_ids: + new_floor := MessagePosition{ ts_ns: predicate_floor_ts, offset: BEFORE_FIRST_OFFSET } + existing := rs.last_processed_predicate[filer_id] + if existing is unset or compareMessagePosition(new_floor, existing) < 0: + rs.last_processed_predicate[filer_id] = new_floor + +save rs +release seeding.lock +``` + +Anchored to `T_start` (when the walk began), not `T_completed`. Replaying the past `D` window catches objects bootstrap missed under walk-cursor drift. Pre-existing delay-group / filer-id entries are pulled back if the new rule's safe floor is earlier than the current cursor; never advanced past their existing position. Sibling rules in the same delay group replay the rewind window — that's a known cost of cursor sharing; surfaced in the bootstrap-completion status. + +**New filer shards** discovered during steady-state operation (a filer joins the cluster, or a chunk ref names a `filer_id` not yet in `last_processed_*`) are seeded by the reader at task start via `seedNewlyDiscoveredShardsLazily` (see reader pseudocode). For each newly-discovered `filer_id`, the cursor floor is set to `min(earliest_retained_log_position[filer_id].ts_ns, safe_floor_ts)` with `offset = BEFORE_FIRST_OFFSET`, and any matching `tail_drained_streams` marker is cleared because this is a new retained-log epoch for that stream, where: + +- `earliest_retained_log_position[filer_id]` is read once via a filer probe (single RPC: `EarliestRetainedPositionPerShard`). +- `safe_floor_ts` is the kind-specific floor: `T_now - max(D, bootstrapLookbackMin)` for age-origin sweeps, `T_now - bootstrapLookbackMin` for predicate. + +Picking the *earlier* of the two ensures no still-retained event can be skipped: if the earliest retained log for that shard predates the safe floor, the cursor sits at that earlier position so the events are delivered. The `BEFORE_FIRST_OFFSET` sentinel ensures the earliest event itself isn't filtered out by the strict `<=` skip predicate. + +**Safety scan.** `SeaweedList` is not snapshot-isolated; an entry whose original-write event is older than `D` may be missed by the walk and the reader's replay alike. Defense: a periodic re-bootstrap on event-driven rules at the per-kind cadence (default `max(MinTriggerAge, 7d)` capped at 30d for age rules; 24h for date rules; 7d for count rules). Re-running is idempotent. + +**Date-based rules** (`EXPIRATION_DATE`): no per-object pending. `mode = SCAN_AT_DATE`. The detector schedules a single bucket-level bootstrap at `rule.date`; that bootstrap deletes all matches inline. No reader involvement for date rules. + +Before `rule.date`, no work happens. New objects PUT during the wait period don't need handling — the date-triggered bootstrap evaluates current state. Tag changes are likewise reflected. After `rule.date`, the bootstrap sweeps everything in one pass via inline `LifecycleDelete` calls (same as any other bootstrap); no per-object pending is written. + +Bootstrap is checkpointable: persist `last_scanned_path` after each page. On worker death, the next bootstrap task resumes from there. + +### Compiled policy engine + +A naive design — per-rule reader tasks, each scanning the meta log filtered to its bucket prefix — multiplies log I/O by `N_rules` and event-evaluation by `N_events × N_rules`. Wrong shape. + +The right shape: **one shared cluster-level reader, one compiled policy engine, one event read = one router lookup**. All rules across all buckets are evaluated by routing each event through the engine; rules with the same trigger delay share their cutoff sweep. + +**ActionKey is the engine's primary identity.** Every per-action data structure — engine indexes, target modes, newly-completed sets, bootstrap completion, drain/locks/metrics, status — is keyed by `ActionKey{rule_hash, action_kind}`, not by `rule_hash` alone. A single XML rule with two action sub-elements appears as **two** `ActionKey`s through the entire pipeline: separate delay group memberships, separate cursors, separate pending files, separate locks, separate completion bits. Sibling actions can complete bootstrap on different schedules and degrade independently. + +```go +type ActionKey struct { + RuleHash [8]byte + ActionKind ActionKind +} +``` + +**Engine shape** (rebuilt periodically and on lifecycle PUT/DELETE): + +``` +type Engine struct { + snapshot_id uint64 // monotonic; bumps on every rebuild + + // Per-bucket prefix trie -> candidate ActionKeys. + buckets map[bucket]*BucketIndex + + // Trigger-delay groups. Original-write events for these (rule, action) + // pairs become eligible at the same cutoff; the reader sweeps each group + // in one pass. Only ActionKeys with mode = EVENT_DRIVEN AND + // bootstrap_complete are included here — engine.compile filters before + // adding to these collections so the reader cannot route events to + // scan_only / scan_at_date / disabled / pending actions. + originalDelayGroups map[time.Duration][]ActionKey // e.g. 7d -> [{rA, ABORT_MPU}], 90d -> [{rA, EXPIRATION_DAYS}, {rB, NONCURRENT_DAYS}] + + // Predicate-change actions — single near-now sweep, no group needed. + // Same EVENT_DRIVEN + bootstrap_complete filter. + predicateActions []ActionKey + + // Date-driven actions — handled by SCAN_AT_DATE bootstrap, not the reader. + dateActions map[ActionKey]time.Time + + // Definitions (for evaluation). + actions map[ActionKey]*CompiledAction +} + +type BucketIndex struct { + prefixTrie *PrefixTrie // path prefix -> []ActionKey + tagIndex map[tagKey]map[tagValue][]ActionKey // optional: speeds up tag-filter actions + versioned bool +} + +type CompiledAction struct { + raw *s3lifecycle.Rule // shared with sibling actions of the same XML rule + key ActionKey // (rule_hash, action_kind) + bucket string + delay time.Duration // MinTriggerAge(rule, kind) for age kinds; zero for date/count + predicateSensitive bool // true if the rule's filter has tag/size predicates + mode RuleMode // EVENT_DRIVEN | SCAN_AT_DATE | SCAN_ONLY | DISABLED + // Mirrored from durable state[ActionKey].mode at compile time; + // engine.compile uses it to decide whether to register the + // action in originalDelayGroups / predicateActions. +} +``` + +The engine is built once per worker process from all bucket lifecycle xml; rebuilt on policy change events observed in the meta log itself. The `snapshot_id` stamps each evaluation; pending items recorded under one snapshot are still valid as long as their `ActionKey` survives in the next snapshot. + +**Two-phase action activation.** When the engine compiles a new `ActionKey` from a freshly-PUT lifecycle XML, the action starts in state `pending_bootstrap` and is **excluded from reader sweeps**. Only after the bucket's bootstrap task runs and seeds the cluster cursors for that action's delay group does the action transition to `active`. Activation is per `ActionKey`, not per rule — a rule's 7d `ABORT_MPU` action can become active before its 90d `EXPIRATION_DAYS` sibling finishes bootstrap. Concretely: + +``` +engine.compile(bucket, rule, kind): // called once per (rule, kind) pair + key := ActionKey{rule.hash, kind} + // Activation requires BOTH: + // (a) bootstrap_complete (durable; cursor seed already committed before this). + // (b) mode allows event-driven processing. + // The reader only routes events to actions with mode = EVENT_DRIVEN. SCAN_AT_DATE + // actions are handled by their date-triggered bootstrap; SCAN_ONLY actions are + // handled by periodic safety-scan bootstraps; DISABLED actions do nothing. + // Each CompiledAction carries its mode so reader matching can filter. + if state[key].bootstrap_complete && state[key].mode == EVENT_DRIVEN: + action.engine_state = active + else: + action.engine_state = inactive // pending_bootstrap, scan_only, scan_at_date, or disabled + +reader.MatchOriginalWrite/MatchPredicateChange/MatchPath: + return only ActionKeys with engine_state == active + +bootstrap.complete (REQUIRED ORDER — same as the main bootstrap pseudocode above): + target_modes := { key: decideMode(snapshot.actions[key]) + for key in snapshot.action_keys_for_bucket } + seedReaderCursorsForNewDelayGroups(snapshot, newly_completed, target_modes, T_start) + // (1) durable cluster + // (2) per-action durable writes — mode AND bootstrap_complete committed atomically per ActionKey. + for key in newly_completed: + state[key].mode = target_modes[key] + state[key].bootstrap_complete = true // AFTER (1) + engine.markActive(key) // (3) in-memory hint +``` + +The transition order matters: cursor seed must commit **before** the per-action durable write that sets both `mode` and `bootstrap_complete=true`. If the engine refreshes between (1) and (2) for some key, it sees `bootstrap_complete=false` and keeps that action `pending_bootstrap` — correct. If the engine refreshes between (2) and (3), it sees `bootstrap_complete=true && mode == EVENT_DRIVEN` and activates the action — also correct, because (1) already committed. The in-memory `markActive` step is only an optimization to avoid waiting for the next engine refresh; it does not gate correctness. Writing `mode` and `bootstrap_complete` in a single proto write per action (atomic at the file level) avoids any window where one is set and the other isn't. + +Policy changes during a bucket bootstrap: the bootstrap is bound to a specific `snapshot_id` (recorded in `_bootstrap`). If the policy changes mid-walk, the in-progress bootstrap finishes for its snapshot; the new policy compiles a fresh engine snapshot; new/changed `ActionKey`s start in `pending_bootstrap`; the next detector tick emits a fresh bucket bootstrap for the new snapshot. `ActionKey`s whose `(rule_hash, action_kind)` is unchanged carry over their `bootstrap_complete=true` and stay `active`. An XML edit that adds a new action sub-element to an existing rule produces a new `ActionKey` (same rule_hash, new action_kind) that goes through `pending_bootstrap` while the rule's other actions stay active. + +### Reader — single subscription, client-side merge + +> **Implementation note (post-Phase 3):** the production reader uses `client.SubscribeMetadata(...)` directly with a single global cursor. The filer's `SubscribeMetadata` server (`weed/server/filer_grpc_server_sub_meta.go`) already aggregates across peer filers — it reads disk-persisted logs from every filer via `sendLogFileRefs` then drains `MetaAggregator.MetaLogBuffer` for the in-memory tail — so the worker doesn't have to assemble its own multi-filer merge. The per-filer-shard cursor model below was designed before this was wired and was not built; the proto fields (`last_processed_original` map keyed by `filer_id`, `tail_drained_streams`) remain as forward-compat stubs. The pseudocode below describes the alternative the design considered, kept here for context. See "Multi-filer durability" and the obsoleted Phase 6 section. + +The reader is one cluster-singleton task subscribed to one filer at a time. `Filer.CollectLogFileRefs(start_position, stop_ts)` returns chunk refs from log files in directory order. The worker passes those refs to `pb.ReadLogFileRefsWithPosition` (the new API — see Task #19), which heap-merges per-filer chunks by event ts client-side and applies the per-shard `(ts, offset)` skip filter. Chunk *bodies* are read in parallel from volume servers via the returned fileIds. + +Cursors are per-filer-shard. `last_processed_original[D.seconds]` is `map`; `MessagePosition = {ts_ns, offset}` disambiguates equal-ts events within a per-filer chunk. Resume skips events with `(event.ts, event.offset) <= cursor[filer_id]` — strict `<=` so the last resolved event isn't replayed. + +``` +reader_state := load("/etc/s3/lifecycle/_reader/reader_state") +primary_filer := pickHealthyFiler(reader_state.primary_filer_endpoint) +flush_lag := 2 * LogFlushInterval // currently ~2 minutes; derived from filer config + +// One probe per task entry: which per-filer log shards exist, what's the earliest +// retained position on each, and what's the latest? `earliest` is used by +// lazy-seeding to safely floor new shards' cursors; `latest` is used for tail-drain +// GC immediately below. +retained_range := primary_filer.RetainedLogRangePerShard() // map +earliest_retained := { fid: r.earliest for fid, r in retained_range } + +// TAIL-DRAIN / LOST-LOG GC. Run BEFORE blocker load and seeding so the rest of +// the pass operates on the post-GC cursor map (no stale departed-filer entries +// pinning low_water_ts to ancient timestamps; correctly-degraded streams when +// logs were pruned ahead of the cursor). +// +// For each cursor entry (delay group D, filer_id F) — and analogously for the +// predicate cursor map: +// +// range := retained_range[F] +// +// case A — F has a retained range AND cursor[F] >= range.latest: +// The shard is tail-drained. Cursor caught up to (or past) the last +// retained event for F; no further events from F can arrive without +// reseeding via lazy-seeding when F reappears. Safe to delete cursor[F]. +// +// case B — F has a retained range AND cursor[F] < range.latest: +// Not tail-drained. Keep cursor[F]; the regular pass will continue draining. +// +// case C — F has NO retained range AND we have a durable +// `tail_drained_streams[stream_key]` marker (recorded the last time we observed +// case A and pruned this exact stream cursor): safe to skip — F's logs were pruned +// after this stream cursor caught up. No action; cursor was already removed in +// case A's pass. +// +// case D — F has NO retained range AND no `tail_drained_streams[stream_key]` +// marker AND cursor[F] is present: lost-log scenario. The shard's logs +// were pruned before the cursor reached `range.latest` — events were +// silently dropped. We must not GC the cursor as if it were safe; doing so +// would erase the only signal that those events ever existed. +// 1. Mark every reader-driven rule as `degraded_reason = LOST_LOG` and +// downgrade `mode = scan_only` until the next safety scan re-establishes +// correctness. The log shard is global metadata history; once it is pruned, +// we cannot prove which buckets were unaffected. +// 2. Surface `lifecycle_lost_log_total{filer_id}` and +// `lifecycle_degraded_streams{reason="LOST_LOG"}` metrics; emit a +// warning log including F and the cursor position. +// 3. Leave cursor[F] in place (don't GC) until an operator runs +// `s3.lifecycle.reseed -ack-lost-log --reason `, which clears +// lost-log cursor entries and the degraded flag together. Deleting silently is +// forbidden — the operator must explicitly acknowledge the data loss. +// +// `tail_drained_streams` is a small durable set in `reader_state` updated +// each time a stream cursor is removed under case A, so case C can distinguish +// "we already GC'd this exact stream safely" from "logs vanished without this +// stream catching up." The key is `(ORIGINAL, D, F)` or `(PREDICATE, F)`. +gcDepartedShards(reader_state, retained_range) +save reader_state // persist GC results before reads + +// LOAD ACTIVE BLOCKERS once at task entry. The reader uses this set to skip +// blocked (shard, stream, [delay]) tuples without re-reading their events +// each batch. Without this, a stuck shard would pull events repeatedly across +// every tick — wasted I/O and noisy "blocked again" log spam. +active_blockers := load("/etc/s3/lifecycle/_reader/blockers") +blocked_original := { (b.shard, b.delay_seconds) for b in active_blockers + where b.stream_kind == ORIGINAL } +blocked_predicate := { b.shard for b in active_blockers + where b.stream_kind == PREDICATE } + +// SEED FIRST, PERSIST, THEN READ. +for each delay group D in engine.originalDelayGroups: + seedNewlyDiscoveredShardsLazily( + reader_state.last_processed_original[D.seconds], + /*kind=*/ ageOrigin(D), earliest_retained, + reader_state.tail_drained_streams, now) +seedNewlyDiscoveredShardsLazily( + reader_state.last_processed_predicate, + /*kind=*/ predicateChange, earliest_retained, + reader_state.tail_drained_streams, now) +save reader_state // durable seed before any reads + +// Periodic checkpoint: persist reader_state every checkpointEveryNEvents (default 1000) +// or checkpointEveryT (default 5s), whichever first. + +// Pass A: age-origin events, one sweep per delay group. +for each delay group D in engine.originalDelayGroups: + effective_cutoff := min(now - D, now - flush_lag) + starts := reader_state.last_processed_original[D.seconds] // already seeded, persisted + + // Build the per-shard start positions to pass into the API. For shards with + // an active blocker, we pass MaxMessagePosition so ReadLogFileRefsWithPosition + // delivers nothing for them — the shard is paused at its previous cursor. + pass_starts := {} + for filer_id, pos in starts: + if (filer_id, D.seconds) in blocked_original: + pass_starts[filer_id] = MaxMessagePosition // skips all events for this shard + else: + pass_starts[filer_id] = pos + + // Listing covers shards we will actually process. We compute the low-water + // ONLY across non-blocked shards. + active_shards := { fid for fid in starts if (fid, D.seconds) not in blocked_original } + if active_shards is empty: // every shard for this delay is blocked + continue // skip to next delay group + low_water_ts := min over fid in active_shards: starts[fid].ts_ns + chunkRefs := primary_filer.CollectLogFileRefs(start_position: ts_only(low_water_ts), + stop_ts: effective_cutoff) + + halt_batch := false + pb.ReadLogFileRefsWithPosition( + chunkRefs, + readerFn, + start_positions: pass_starts, + stop_ts: effective_cutoff, + eachLogEntry(event, filer_id, position): + // First-statement guard: if this (shard, delay) was blocked earlier + // in THIS batch by a sibling event, the merge engine may still + // deliver later events from the same shard before our pauseShard() + // call propagates. Drop them without mutating the cursor — the + // cursor must stay at the position recorded in the BlockerRecord. + if (filer_id, D.seconds) in blocked_original: + return continueLoop // no cursor update + if !event.isOriginalWrite: + reader_state.last_processed_original[D.seconds][filer_id] = position + periodicCheckpoint() + return continueLoop + candidates := engine.MatchOriginalWrite(event, delayGroup=D) + if len(candidates) == 0: + reader_state.last_processed_original[D.seconds][filer_id] = position + periodicCheckpoint() + return continueLoop + // handleEvent owns the retry-budget interaction (recordRetryLater / + // clearRetryBudget) for the exact failing branch, so the reader + // callback only sees the final outcome. handleEvent already returned + // BLOCKED if its own recordRetryLater call promoted internally. + outcome := handleEvent(event, candidates, + /*stream_kind=*/ ORIGINAL, /*shard=*/ filer_id, + /*delay_seconds=*/ D.seconds, /*position=*/ position) + switch outcome: + case DONE, NOOP_RESOLVED: + reader_state.last_processed_original[D.seconds][filer_id] = position + periodicCheckpoint() + return continueLoop + case RETRY_LATER: + save reader_state + halt_batch = true + return halt + case BLOCKED: + blocked_original.add((filer_id, D.seconds)) + save reader_state + return pauseShard(filer_id) + ) + save reader_state + if halt_batch: return ContinuationHint{ next_run_after_ns: now + 60s, reason: "RETRY_LATER" } + +// Pass B: predicate-change events. +predicate_cutoff := now - max(smallDelay, flush_lag) +starts := reader_state.last_processed_predicate // already seeded + +pass_starts := {} +for filer_id, pos in starts: + if filer_id in blocked_predicate: + pass_starts[filer_id] = MaxMessagePosition // skip all events for this shard + else: + pass_starts[filer_id] = pos + +active_shards := { fid for fid in starts if fid not in blocked_predicate } +if active_shards is not empty: + low_water_ts := min over fid in active_shards: starts[fid].ts_ns + chunkRefs := primary_filer.CollectLogFileRefs(start_position: ts_only(low_water_ts), + stop_ts: predicate_cutoff) + + halt_batch := false + pb.ReadLogFileRefsWithPosition( + chunkRefs, readerFn, + start_positions: pass_starts, stop_ts: predicate_cutoff, + eachLogEntry(event, filer_id, position): + // First-statement guard: same-batch BLOCKED protection. + if filer_id in blocked_predicate: + return continueLoop // no cursor update + if !event.isPredicateChange: + reader_state.last_processed_predicate[filer_id] = position + periodicCheckpoint() + return continueLoop + candidates := engine.MatchPredicateChange(event) + if len(candidates) == 0: + reader_state.last_processed_predicate[filer_id] = position + periodicCheckpoint() + return continueLoop + // handleEvent owns retry-budget calls; reader callback only acts on outcome. + outcome := handleEvent(event, candidates, + /*stream_kind=*/ PREDICATE, /*shard=*/ filer_id, + /*delay_seconds=*/ 0, /*position=*/ position) + switch outcome: + case DONE, NOOP_RESOLVED: + reader_state.last_processed_predicate[filer_id] = position + periodicCheckpoint() + return continueLoop + case RETRY_LATER: + save reader_state + halt_batch = true + return halt + case BLOCKED: + blocked_predicate.add(filer_id) + save reader_state + return pauseShard(filer_id) + ) + save reader_state + if halt_batch: return ContinuationHint{ next_run_after_ns: now + 60s, reason: "RETRY_LATER" } +``` + +`periodicCheckpoint` is `if eventCount % checkpointEveryNEvents == 0 || timeSinceLastSave > checkpointEveryT { save reader_state }`. It batches per-event mutations into chunked durable writes; on crash, redo is bounded by the configured budget. Defaults: 1000 events or 5s. + +Notes on the listing call: `CollectLogFileRefs` accepts a single `MessagePosition` start. We pass the **low-water-mark ts across all per-filer cursors** so the listing returns chunks covering every shard's still-unread tail. `ReadLogFileRefsWithPosition` then applies the per-shard `start_positions` filter so each shard only delivers events strictly past its own cursor. This means we may stream a few already-processed events from shards that are ahead, but the per-shard filter drops them before the callback. + +`seedNewlyDiscoveredShardsLazily` handles the case where a chunk ref names a `filer_id` we've never seen before. The caller passes in the precomputed `earliest_per_shard` (probed once at task entry) so the helper does not re-issue the RPC: + +``` +seedNewlyDiscoveredShardsLazily(cursors, kind, earliest_per_shard, tail_drained_streams, now): + // earliest_per_shard: map, from + // primary_filer.EarliestRetainedPositionPerShard() probed once per task. + // kind: ageOrigin(D) or predicateChange. + // now: wall clock at task start (for the safe floor). + + for filer_id in earliest_per_shard: + if filer_id not in cursors: + // Floor at the earlier of (a) the earliest retained log for this shard, + // and (b) the kind-specific safe floor (now - max(kind.delay, bootstrapLookbackMin) + // for age-origin; now - bootstrapLookbackMin for predicate). The EARLIER + // of the two ensures retained events that predate the safe floor are + // delivered; BEFORE_FIRST_OFFSET ensures the earliest event itself is + // not filtered out by the strict <= skip predicate. + safe_floor_ts := safeFloorTsFor(kind, now) + shard_floor := min(earliest_per_shard[filer_id].ts_ns, safe_floor_ts) + cursors[filer_id] = MessagePosition{ ts_ns: shard_floor, offset: BEFORE_FIRST_OFFSET } + delete tail_drained_streams[tailDrainedStreamKey(kind, filer_id)] +``` + +The probe is a single filer RPC at task start, cheap. Passing the result in (rather than re-probing inside the helper) means a single task only issues one `EarliestRetainedPositionPerShard` call no matter how many delay groups call the seeder. + +Properties: + +- **Cursors are per-filer-shard**; cursor state per delay group is `map`. Resume after partial failure is correct on each shard independently. +- **Listing uses the low-water-mark ts**; per-shard filtering inside the new API. Single subscription endpoint; per-shard correctness. +- **Watermark exposed externally** is the low-water-mark across per-filer cursors per delay group. Lag = `now - watermark`. +- **Cost ≈ events × distinct_delay_groups** for age-origin passes; the per-delay sweeps each pull a time window from the merged stream. Real configs have a handful of distinct day values. +- For configs with many distinct delays (rare), the engine should optionally **merge nearby delay groups** into bins to bound the number of passes. +- **Filer is not the bottleneck** — it serves chunk metadata only. Volume reads parallelize per chunk. +- **Failover** moves the worker to a different `primary_filer_endpoint`; per-filer cursor map is portable because every filer reads the same persisted logs from the shared namespace. + +If a single worker cannot keep up after raising in-worker chunk-read concurrency and `lifecycle.delete.concurrency`, the next-stage answer is segment-level intra-stream parallelism (see "Future scaling" — explicitly non-v1). + +`handleEvent(event, candidates, stream_kind, shard, delay_seconds, position) → outcome`: + +Single contract. Fetch the live entry once, evaluate every candidate rule against it, return the aggregate outcome. The caller passes the stream context (`stream_kind`, `shard`, `delay_seconds`, `position`) that `handleEvent` forwards to `DeleteContext` and any `BlockerRecord` writes. Reader passes `ORIGINAL` or `PREDICATE` plus `(filer_id, D.seconds, position)` or `(filer_id, 0, position)` respectively; bootstrap and pending drain construct `DeleteContext` directly without going through `handleEvent`. + +The cursor (the per-shard `MessagePosition` for the event's `filer_id`) only advances past this event when **every** candidate returns `DONE`/`NOOP_RESOLVED`. If any candidate returns `RETRY_LATER` or `BLOCKED`, that outcome propagates up and the caller halts (or pauses the shard) per the reader pseudocode. + +``` +handleEvent(event, candidates, stream_kind, shard, delay_seconds, position): + // handleEvent owns its retry-budget interactions. Building a RetryTarget + // outside (in the reader callback) would have to guess "which candidate + // failed" — wrong if the failure is fetchLive ERR (no candidates evaluated + // yet) or an early candidate (not the last one). Doing it here means the + // RetryTarget reflects the actual failing branch. + stream_key := buildStreamKey(stream_kind, shard, delay_seconds, position) + base_target := RetryTarget{ + key: stream_key, + bucket: event.bucket, + object_path: event.path, + version_id: extractVersionId(event.path), // best effort; refined when live is available + // rule_hash filled in per branch below (nil before first candidate evaluation) + } + + // fetchLiveEntry is tri-state. Conflating not-found with errors would either + // advance the cursor over un-evaluated events (transport blip) or stall on a + // legitimately-deleted entry. Distinguish: + // FOUND → proceed. + // NOT_FOUND → object is gone (live state = absent). Resolved. + // ERR → transient transport/RPC failure → RETRY_LATER + retry budget. + // FATAL → deterministic decode/schema failure → BLOCKED. writeBlocker. + live, status := fetchLiveEntry(event.path) + switch status: + case NOT_FOUND: + clearRetryBudget(stream_key) + return NOOP_RESOLVED + case ERR: + return recordRetryLater(base_target) // returns RETRY_LATER or promoted BLOCKED + case FATAL: + // Persistent failure. Persist the blocker durably BEFORE returning + // BLOCKED — otherwise the cursor would pause without a record for + // the operator to inspect or clear, and the lag-fallback would + // eventually demote silently. If the durable write itself fails, + // return RETRY_LATER and let the next batch retry; the cursor stays. + ok := writeBlocker(BlockerRecord{ + stream_kind: stream_kind, // ORIGINAL or PREDICATE for reader callers + shard: shard, + delay_seconds: delay_seconds, // 0 for PREDICATE + position: position, + rule_hash: nil, // unset — failure is pre-evaluation, not bound to a rule + bucket: event.bucket, + object_path: event.path, + version_id: extractVersionId(event.path), // may be "" + reason: "fetch_live_FATAL", + last_error: reason, + first_seen_at_ns: now(), + last_retry_at_ns: now(), + retry_count: 1, + }) + if !ok: return RETRY_LATER // blocker write failed; treat like outage + // Clear any retry_budget entry that may have accumulated from prior + // transient ERRs at this same position. Without this, a stream that + // bounced through ERR → ERR → FATAL would leave a stale budget entry + // that confuses subsequent budget accounting. + clearRetryBudget(stream_key) + return BLOCKED + case FOUND: // continue + info := buildObjectInfo(live) + + aggregate := NOOP_RESOLVED // start from "nothing required" + for each rule in candidates: + if !ruleAppliesToEntryShape(rule, live): continue + action := s3lifecycle.EvaluateAction(rule, action_kind, info, now) + if action == ActionNone: continue + dueAt := computeDueAt(rule, info) + + var per_rule_outcome eventOutcome + ctx := DeleteContext{ + bucket: event.bucket, + object_path: event.path, + version_id: live.version_id, + rule_hash: rule.hash, + rule: rule, + stream_kind: stream_kind, // ORIGINAL or PREDICATE — passed in by the reader pass + shard: shard, // == event.filer_id when called from reader + delay_seconds: delay_seconds, // populated only when stream_kind == ORIGINAL + position: event.position, + action: action, + expected_identity: identityFromLive(live), + } + if event.isOriginalWrite(): + // event.ts crossed the original cutoff, but live.mtime may be newer + // (overwrite resets the clock). The overwrite produces its own + // age-origin event; this older event is stale. + if now < dueAt: + per_rule_outcome = NOOP_RESOLVED + else: + per_rule_outcome = deleteAndResolve(ctx) + else if event.isPredicateChange(): + if now >= dueAt: + per_rule_outcome = deleteAndResolve(ctx) + else: + // Late predicate change made the object newly match, but mtime+TTL + // is in the future. The original-write event is already past the + // watermark, so we'd miss this without an exception record. + err := upsertPending(rule, PendingItem{ + path: event.path, version_id: extractVersionId(...), + due_at_ns: dueAt.UnixNano(), + expected_identity: identityFromLive(live), + }) + if err == ErrPendingFull or err == ErrPendingWriteFailed: + // Demote the rule to scan_only durably. If the demote also + // fails (filer IO unhealthy), feed the retry budget for THIS + // rule's slot and return — sustained demote-failures will + // promote to BLOCKED. + if !durably_demote_rule_to_scan_only(rule.hash, reason: err): + rule_target := base_target + rule_target.rule_hash = rule.hash + return recordRetryLater(rule_target) + counter.pending_full_total{rule_hash}++; log_warn(...) + per_rule_outcome = NOOP_RESOLVED + else: + per_rule_outcome = NOOP_RESOLVED // exception recorded in pending + else: + per_rule_outcome = NOOP_RESOLVED // unknown event class + + if per_rule_outcome == RETRY_LATER: + // deleteAndResolve produced RETRY_LATER for THIS specific candidate + // rule. Feed the retry budget with a target that reflects the + // actual failing branch (this rule's hash + this rule's action). + rule_target := base_target + rule_target.rule_hash = rule.hash + return recordRetryLater(rule_target) + if per_rule_outcome == BLOCKED: + // deleteAndResolve already wrote a BlockerRecord with this rule's + // hash. Clear any retry budget that was tracking earlier transient + // failures for this stream key. + clearRetryBudget(stream_key) + return BLOCKED + aggregate = max(aggregate, per_rule_outcome) // DONE > NOOP_RESOLVED for telemetry + + // All candidates returned DONE/NOOP_RESOLVED. Clear any in-flight retry + // budget for this stream key — success at this position resets the budget. + clearRetryBudget(stream_key) + return aggregate // safe to advance cursor past event.ts +``` + +**Processing outcomes (single contract used by reader, bootstrap walker, and pending drain):** + +| Outcome | Cursor advance? | Meaning | +|---|---|---| +| `DONE` | yes | Action resolved (delete succeeded, marker created, abort completed). | +| `NOOP_RESOLVED` | yes | Object already deleted / changed / no longer applicable / object-lock skipped. No further work needed. | +| `RETRY_LATER` | **no** | Transient failure (S3 unreachable, network blip, filer IO error). The batch halts at this position; the next task retries from the same `MessagePosition`. | +| `BLOCKED` | **no** | Deterministic or persistent failure (`FATAL_EVENT_ERROR`, malformed entry, `TRANSPORT_ERROR` exceeded its retry budget). The shard is paused and a `BlockerRecord` is written. The cursor stays at the failing position until an operator intervenes. | + +**No automatic dead-lettering.** A dead letter would mean "this record is poison, skip it so the worker can keep moving." For lifecycle that's wrong: skipping a record means losing a delete decision (correctness). The worker pauses instead and surfaces the blocker. Operators decide whether to `retry`, `resume`, or explicitly `quarantine`. + +`deleteAndResolve`: + +``` +type StreamKind enum { ORIGINAL = 0; PREDICATE = 1; BOOTSTRAP = 2; PENDING = 3; } + +type DeleteContext struct { + // Identifying: + bucket string + object_path string + version_id string // optional; "" for non-versioned + rule_hash bytes + rule *CompiledRule + + // Stream / shard / position context (used by BlockerRecord scoping): + stream_kind StreamKind // ORIGINAL | PREDICATE for reader; BOOTSTRAP / PENDING otherwise + shard string // filer_id; "" for BOOTSTRAP / PENDING (not shard-bound) + delay_seconds int64 // populated when stream_kind == ORIGINAL; 0 otherwise + position MessagePosition // populated when stream_kind == ORIGINAL or PREDICATE + + // What to do: + action Action // CURRENT | VERSION | DELETE_MARKER | ABORT_MPU + expected_identity EntryIdentity // CAS +} + +deleteAndResolve(ctx DeleteContext): + // K in-memory retries against the same event for FATAL_EVENT_ERROR / worker-local + // deterministic errors. TRANSPORT_ERROR is NEVER counted here — it returns + // RETRY_LATER immediately. The caller feeds the durable retry_budget for this + // stream key; sustained TRANSPORT_ERROR promotes to BLOCKED, while watermark-lag + // fallback remains the retention-safety backstop. + for attempt in 1..K (default 5): + outcome := lifecycleDeleteRPC(ctx.bucket, ctx.object_path, ctx.version_id, + ctx.action, ctx.expected_identity) + switch outcome { + case DELETED: + return DONE + case NOT_FOUND, STALE_IDENTITY, STALE_POLICY, NO_LONGER_ELIGIBLE: + return NOOP_RESOLVED + case SKIPPED_OBJECT_LOCK: + counter.skipped_object_lock_total++; log_warn(...); return NOOP_RESOLVED + case TRANSPORT_ERROR: + return RETRY_LATER // batch halts; cursor stays + case FATAL_EVENT_ERROR: + // retry deterministically; if still FATAL after K attempts, fall through to BLOCKED + continue + } + + // K in-memory retries exhausted. The event is deterministically un-processable. + // Write a BlockerRecord scoped by stream_kind so the right cursor pauses; only + // return BLOCKED after the durable write commits. If the write itself fails + // (filer IO unhealthy), return RETRY_LATER instead — without a durable + // blocker, BLOCKED would silently halt the stream with nothing for the + // operator to inspect. + ok := writeBlocker(BlockerRecord{ + stream_kind: ctx.stream_kind, + shard: ctx.shard, + delay_seconds: ctx.delay_seconds, // populated only for ORIGINAL + position: ctx.position, // populated for ORIGINAL/PREDICATE + rule_hash: ctx.rule_hash, + bucket: ctx.bucket, + object_path: ctx.object_path, + version_id: ctx.version_id, + reason: "FATAL_EVENT_ERROR after K retries", + last_error: last_outcome.reason, + first_seen_at_ns: now(), + last_retry_at_ns: now(), + retry_count: K, + }) + if !ok: return RETRY_LATER // blocker write failed; treat like outage + // Clear any retry_budget entry that earlier RETRY_LATER attempts at this + // same key may have left; promotion to BLOCKED via FATAL is a different + // path from sustained-RETRY_LATER promotion, but they share the stream key. + clearRetryBudget(buildStreamKeyFromCtx(ctx)) + return BLOCKED +``` + +The function takes a single `DeleteContext` because four callers (reader-original, reader-predicate, bootstrap walker, pending drain) all populate the same shape. The `stream_kind` field tells `writeBlocker` which cursor to pause: ORIGINAL pauses `(shard, delay_seconds)`; PREDICATE pauses `shard` predicate stream; BOOTSTRAP pauses the bucket walker; PENDING pauses the rule's drain. + +**Watermark advance, per the outcome enum above.** The per-shard cursor `last_processed_*[filer_id]` advances past an event whose `handleEvent` returns `DONE` or `NOOP_RESOLVED`. It does **not** advance on `RETRY_LATER` (transient — next batch retries) or `BLOCKED` (persistent — operator intervention required). The meta log itself is the durable queue; the cursor's persisted position **is** the retry pointer. + +`drainPending` runs per `(bucket, rule_hash)`. At task entry it loads `_reader/blockers` and refuses to drain if a `PENDING` blocker is active for this rule: + +``` +// Task-start guard: if a PENDING blocker is active for this (bucket, rule_hash), +// don't drain. Operator must clear via blockers retry|resume|quarantine. +active_blockers := load("/etc/s3/lifecycle/_reader/blockers") +if any b in active_blockers where b.stream_kind == PENDING + and b.bucket == bucket + and b.rule_hash == rule.hash: + return ContinuationHint{ next_run_after_ns: now + 60s, reason: "PENDING blocker active" } + +// Per-item processing. +// Build the stream key + retry target BEFORE fetchLive, so that an ERR can +// participate in the retry budget. Without this, repeated transient fetchLive +// failures for the same pending item would never promote to BLOCKED. +stream_key := StreamKey{ stream_kind: PENDING, pending: PendingKey{ + bucket: bucket, rule_hash: rule.hash, + object_path: item.path, version_id: item.version_id } } +retry_target := RetryTarget{ + key: stream_key, + bucket: bucket, + object_path: item.path, + version_id: item.version_id, + rule_hash: rule.hash, +} +// RetryTarget intentionally carries no action / expected_identity — operator-driven +// retry replays drainPending against current live state, which re-fetches and +// re-evaluates from scratch. + +live, status := fetchLiveEntry(item.path) +switch status: + case NOT_FOUND: + clearRetryBudget(stream_key) // resolved (object gone) + pending.delete(item); return + case ERR: + // Transient failure — feed the retry budget. Sustained ERR promotes to BLOCKED. + promoted := recordRetryLater(retry_target) + if promoted == BLOCKED: + return // BlockerRecord written; drain pauses + return // leave pending intact; next drain retries + case FATAL: + // Deterministic per-event failure. Block this rule's drain at this item; + // skip the retry budget — FATAL is not a transient outage. + if !writeBlocker(BlockerRecord{ + stream_kind: PENDING, + shard: "", // PENDING is not shard-bound + delay_seconds: 0, + position: MessagePosition{}, // unused for PENDING + rule_hash: rule.hash, + bucket: bucket, + object_path: item.path, + version_id: item.version_id, + reason: "fetch_live_FATAL", + last_error: reason, + first_seen_at_ns: now(), + last_retry_at_ns: now(), + retry_count: 1, + }): return // blocker write failed; leave pending intact + clearRetryBudget(stream_key) // any prior ERR-driven budget is superseded + return // pending stays; drain pauses for this rule + case FOUND: // continue + clearRetryBudget(stream_key) // fetchLive succeeded; reset any pre-existing budget + +if !identityMatch(live, item.expected_identity): + // object moved since we deferred. The newer event already created its own pending + // or already deleted; drop this stale entry. + pending.delete(item); return + +info := buildObjectInfo(live) +dueAt := computeDueAt(rule, info) +if now < dueAt: + item.due_at_ns = dueAt.UnixNano() // mtime advanced (rare but possible via metadata update) + pending.upsert(item); return + +action := s3lifecycle.EvaluateAction(rule, action_kind, info, now) +if action == ActionNone: pending.delete(item); return + +// Same deleteAndResolve contract as reader and bootstrap. +outcome := deleteAndResolve(DeleteContext{ + bucket: bucket, + object_path: item.path, + version_id: item.version_id, + rule_hash: rule.hash, + rule: rule, + stream_kind: PENDING, + shard: "", // PENDING is not shard-bound + delay_seconds: 0, // unused for PENDING + position: MessagePosition{}, // unused for PENDING + action: action, + expected_identity: item.expected_identity, +}) +switch outcome { +case DONE, NOOP_RESOLVED: + clearRetryBudget(stream_key) + pending.delete(item) +case RETRY_LATER: + promoted := recordRetryLater(retry_target) + if promoted == BLOCKED: + // BlockerRecord already written by recordRetryLater on threshold trip. + // Leave pending intact; drain pauses on this rule until operator clears. + return + // Plain transient: leave pending intact; next drain retries. + return +case BLOCKED: + // BlockerRecord already written by deleteAndResolve. Leave pending intact; + // drain pauses on this rule until the operator clears the blocker. + clearRetryBudget(stream_key) // any in-flight retry budget is moot + return +} +``` + +### Why current state, not event payload + +Tags and other predicates can change after the original PUT. Binding the predicate to the event payload means a tag added at age 30d for a 60d rule isn't picked up until 60d after the tag-add — up to one TTL late. Live evaluation handles this in O(1) extra filer lookups. + +### CAS identity + +``` +type entryIdentity struct { + Mtime time.Time // entry.Attributes.Mtime + Size int64 // entry.Attributes.FileSize + HeadFid string // entry.Chunks[0].FileId, "" if zero-byte + ExtendedHash [8]byte // sha256(canonicalize(entry.Extended))[:8] +} +``` + +`ExtendedHash` covers tags and any other Extended-stored predicate input. Without it, a tag flipped between worker evaluation and server-side delete would let the server proceed against stale eligibility. + +Mtime alone is fooled by snapshot-restore. Head fid resolves it: a re-uploaded object lands on a fresh fid. + +### Policy-version CAS — per ActionKey, not per bucket + +A single bucket-wide etag would invalidate this action's pending whenever any *other* rule (or sibling action) on the same bucket is edited. Wrong: an unrelated edit shouldn't dump pending work that's still valid under this action. Worse, because pending wouldn't be re-enqueued unless the object's events fire again (and many objects' relevant events are already in the past), the work would simply be lost. + +Use the **`ActionKey{rule_hash, action_kind}`** as the CAS token. Pending items live under `/etc/s3/lifecycle////pending`, which already binds them to a specific (rule version, action kind) pair (the rule_hash changes when the rule's filter or any action's parameters change; the action_kind selects the sibling under that rule). On drain and on `LifecycleDelete`: + +- Server reads current `lifecycle.xml`, computes the `ActionKey` set (one per populated action sub-element), and checks whether `request.ActionKey` is a member. +- Member → proceed. +- Not a member → `STALE_POLICY`. Either the rule was removed / had its definition changed (so a new `rule_hash` now exists), or the specific action sub-element was removed from an otherwise-unchanged rule. The pending item is genuinely orphaned; drop it. The new `ActionKey` (if any) will go through bootstrap and pick up still-relevant work. + +Per-`ActionKey` CAS: +- An edit to rule A leaves rule B's actions untouched. +- An edit to rule A's filter or any action parameter creates a new `rule_hash`; the old `/` directory tree (with all its action sub-directories) is orphaned. The orphan is cleaned up after a grace period (so an edit-and-revert preserves progress). +- Removing one action sub-element from rule A while keeping others: only the corresponding `//` directory becomes orphaned; siblings stay live. (`rule_hash` is computed over the full action set, so removing or adding an action sub-element produces a new `rule_hash` for the rule. Both the new and old rule_hash directory trees coexist during the grace period; the new one is bootstrapped from scratch.) +- A removal of rule A entirely: the `/` tree becomes orphaned, cleaned up after grace. + +### Versioned buckets — corrected model + +Versioning facts (verified in repo): +- A versioned object at key `foo/bar.txt` lives under directory `foo/bar.txt.versions/` (suffix `.versions`, not a `.versions` subdirectory). +- Each version is its own file inside that directory; filename encodes timestamp + version id. +- A delete marker is a version file with `Extended[ExtDeleteMarkerKey] == "true"`. +- The current-version pointer is stored as `Extended[ExtLatestVersionFileNameKey]` (and friends) on the `.versions` *directory entry* itself. + +Worker handling: + +| Event path shape | Meaning | Worker action | +|---|---|---| +| `/.../.versions/` (NewEntry, no OldEntry) | New version added (PUT or DeleteMarker). | Trigger evaluation for the previously-latest version (now non-current). Use the parent dir's `ExtLatestVersionMtimeKey` *before* this event as the successor's becoming-current time → no, simpler: when handling, list the `.versions` dir and determine the just-became-non-current version; its `successorMtime = event.TsNs`. | +| `/.../.versions` (Old/NewEntry, attrs change on the directory) | Latest pointer flipped. | Same trigger as above, redundantly; idempotent. | +| `/.../.versions/` where Extended has `ExtDeleteMarkerKey=true` and that file is now the only entry in the dir | A delete marker is the sole survivor. | `ExpiredObjectDeleteMarker` evaluation. | + +For each versioned event, `ruleAppliesToEntryShape` filters out entries that don't match the rule's target (e.g. `Expiration.Days` rules target the current version on a non-versioned bucket; on a versioned bucket they create a new delete marker via `createDeleteMarker`). `NoncurrentVersionExpiration` targets non-current versions only. + +Action dispatch on versioned buckets: +- `ActionDeleteObject` (current version of versioned bucket) → `s3a.createDeleteMarker(bucket, key)`. +- `ActionDeleteVersion` (a specific non-current version) → `s3a.deleteSpecificObjectVersion(bucket, key, versionId)`. +- `ActionExpireDeleteMarker` → `s3a.deleteSpecificObjectVersion(bucket, key, versionId)` for the marker. + +### AbortIncompleteMultipartUpload + +Multipart uploads live under `/.uploads//`. Treat creation events for `` directories under `.uploads/` as triggers; eligibility is `now - mtime > rule.AbortMPUDays`. On expiry, call the existing abort-MPU path (which removes the upload directory). + +### Worker-to-S3 discovery and failover + +The worker process (`weed/worker/`) connects to the admin server today (`weed/worker/worker.go:81 SendHeartbeat`). The admin server is the natural discovery point for S3 endpoints. + +- **Registry.** S3 servers register their gRPC listen address with admin on startup, the same way they register HTTP today. New admin RPC `ListS3Endpoints() repeated S3Endpoint`. +- **Worker cache.** Each worker fetches the S3 endpoint list at startup and refreshes every 30s in the heartbeat path. Cache survives admin reachability blips. +- **Selection.** For each `LifecycleDelete`, pick a healthy endpoint round-robin. On RPC failure (DEADLINE_EXCEEDED, UNAVAILABLE), mark that endpoint unhealthy for 60s and retry on the next. +- **All endpoints unhealthy.** Reader and bootstrap halt at the next `LifecycleDelete` they need to make. The reader's per-shard cursor map stays at the last resolved `(filer_id → MessagePosition)` entries; the bootstrap walker's `last_scanned_path` stays at the last successful entry. The task returns `ContinuationHint{ next_run_after_ns: now + 60s }` and exits. No pending is appended for the halted batch — the meta log is the durable retry queue. No data loss; lifecycle pauses cleanly. When S3 returns, the next read/bootstrap task resumes from exactly the same per-shard cursors. +- **Co-located deployments.** When workers and S3 servers run on the same nodes, the worker can prefer its local S3 endpoint by IP affinity to keep traffic on-host. +- **S3 is not a hard dependency for the worker process to start.** The worker boots and registers; the reader and bootstrap tasks halt on first `LifecycleDelete` if no endpoint is healthy. Engine compilation and policy reconciliation continue (no S3 needed). Once S3 returns, the next scheduled read/bootstrap task picks up where the previous halted, replaying events from the stalled cursor. + +### Internal service-principal delete API — concrete shape + +The worker is in a separate process (`weed/worker/`) and must not depend on user IAM signatures. We add an internal gRPC method on the S3 server, callable only from authorized workers: + +``` +service S3InternalService { + rpc LifecycleDelete(LifecycleDeleteRequest) returns (LifecycleDeleteResponse); +} + +message EntryIdentity { + int64 mtime_ns = 1; + int64 size = 2; + string head_fid = 3; + bytes extended_hash = 4; // sha256(canonical Extended)[:8] +} + +message LifecycleDeleteRequest { + string bucket = 1; + string object_path = 2; // path under bucket dir + string version_id = 3; // optional, for ActionDeleteVersion + enum Action { CURRENT = 0; VERSION = 1; DELETE_MARKER = 2; ABORT_MPU = 3; } + Action action = 4; + EntryIdentity expected_identity = 5; + string trigger_rule_id = 6; + bytes trigger_rule_hash = 7; // server checks membership in current policy's rule-hash set + bool skip_chunk_delete = 8; +} + +message LifecycleDeleteResponse { + enum Outcome { + DELETED = 0; + NOT_FOUND = 1; + STALE_IDENTITY = 2; // CAS failed + STALE_POLICY = 3; // rule_hash no longer in current policy + NO_LONGER_ELIGIBLE = 4; // server-side re-evaluation says rule no longer applies + SKIPPED_OBJECT_LOCK = 5; // object under legal hold or retain-until — skip silently + TRANSPORT_ERROR = 6; // transient (network, RPC); retry by stalling watermark + FATAL_EVENT_ERROR = 7; // deterministic per-event failure (corrupted entry, internal panic); + // server's best attempt at distinguishing transient from permanent + } + Outcome outcome = 1; + string reason = 2; // human-readable detail; populated for FATAL_EVENT_ERROR and TRANSPORT_ERROR +} +``` + +**Worker-local error classifications.** Some failures occur entirely on the worker side and never reach the server: malformed event payload that fails to deserialize, identity construction failure, evaluator panic recovered via `defer recover()`. These are not RPC outcomes — they're worker errors. The worker treats them under the same K-retry-then-`BLOCKED` rule as `FATAL_EVENT_ERROR` (5 in-memory retries, then write a `BlockerRecord` and pause the shard). Server-side `FATAL_EVENT_ERROR` is for cases the server detects (e.g., dispatch helper returns an error not classifiable as transport or object-lock). + +Server handler (`weed/s3api/s3api_internal_lifecycle.go`, new file): + +1. Authn: shared HMAC token from cluster config or mTLS — same scheme used by other intra-cluster RPCs in `weed/security/`. +2. Re-fetch live entry and verify `expected_identity` matches in full (mtime, size, head fid, extended hash). Mismatch → `STALE_IDENTITY`. +3. Re-fetch bucket lifecycle xml; compute the rule-hash set; if `request.trigger_rule_hash` is not a member → `STALE_POLICY`. (Per-rule CAS, not per-bucket. Other rules' edits don't poison this rule's deletes.) +4. Re-evaluate the rule against the live entry. If `Evaluate(rule, info, now) == ActionNone` → `NO_LONGER_ELIGIBLE`. (Defence in depth: tags or filters could have just changed.) +5. **Object lock**: explicitly call `s3a.enforceObjectLockProtections(bucket, object, versionId, governanceBypassAllowed=false)` (defined in `weed/s3api/s3api_object_retention.go:570`). The existing low-level helpers (`deleteUnversionedObjectWithClient`, `deleteSpecificObjectVersion`, `createDeleteMarker`) do **not** check object lock — the check lives in the user-facing HTTP handler (`weed/s3api/s3api_object_handlers_delete.go:225,124,147,368`) and must be reproduced here. If `enforceObjectLockProtections` returns an error, return `SKIPPED_OBJECT_LOCK`. Lifecycle never bypasses governance retention. + +`enforceObjectLockProtections` currently takes `*http.Request` to extract the `x-amz-bypass-governance-retention` header; for lifecycle we hard-code `governanceBypassAllowed=false`. Phase 2 introduces a thin wrapper `enforceObjectLockForLifecycle(bucket, object, versionId)` that calls the same logic without needing a request, by either passing a synthetic request or refactoring the request-extraction step out of the core check. +6. Dispatch via the same internal helpers user deletes use: + - `CURRENT` on non-versioned → `deleteUnversionedObjectWithClient`. + - `CURRENT` on versioned → `createDeleteMarker`. + - `VERSION` → `deleteSpecificObjectVersion`. + - `DELETE_MARKER` → `deleteSpecificObjectVersion` (for the marker). + - `ABORT_MPU` → existing abort-upload code. +7. Emit access log + Prometheus counter with principal `lifecycle::`. + +**Worker handling per outcome:** + +| Outcome | Worker action | +|---|---| +| `DELETED` | Advance cursor / drop pending item. Increment `expired_total`. | +| `NOT_FOUND` | Advance cursor / drop pending item. | +| `STALE_IDENTITY` | Advance cursor / drop pending. The newer event (if any) re-triggers via the reader. | +| `STALE_POLICY` | Advance cursor / drop pending. New rule's directory bootstraps. | +| `NO_LONGER_ELIGIBLE` | Advance cursor / drop pending. | +| `SKIPPED_OBJECT_LOCK` | **Advance cursor / drop pending. Increment `skipped_object_lock_total` counter; log at warn.** No retry, no blocked file. The next safety-scan bootstrap evaluates again; if the lock has been released, the object deletes then. | +| `TRANSPORT_ERROR` | **Halt the batch.** Cursor stays at the last resolved event. Retry on next read task. The meta log is the durable retry queue. | + +This keeps replication, audit, object-lock, and metrics consistent without bypassing them and without piping the worker through the public IAM stack. + +### Metadata-only deletion when chunks are on TTL volumes + +When the entry's chunks were placed on TTL volumes, the volume server reclaims them wholesale on volume drop. Per-chunk `DeleteFile` RPCs are wasted work. The filer wire is already there: `Filer.DeleteChunks` honours `rule.DisableChunkDeletion` (`weed/filer/filer_deletion.go:588-594`). + +The worker computes `skip_chunk_delete` per object before calling `LifecycleDelete`: + +```go +ruleConf := f.FilerConf.MatchStorageRule(path) +skipChunkDelete := + live.Attributes.TtlSec > 0 && // chunks were placed on TTL volumes + ruleConf.Ttl != "" // path is still TTL-routed +``` + +The S3 server passes the flag to the filer delete path; per-chunk RPCs are skipped; the entry is removed; chunks are reclaimed on the volume's natural TTL drop. Storage reclamation lags by up to the volume's natural TTL boundary (bounded by the latest needle in that volume) — minutes-to-hours in practice, fine for lifecycle. + +Versioned buckets: `createDeleteMarker` writes a marker (no chunks deleted in either path; flag is moot). `deleteSpecificObjectVersion` honours `skip_chunk_delete` for non-current versions or expired markers. + +This composes with the operator-driven volume-TTL routing that replaces today's PUT back-stamp: an operator runs `weed shell s3.bucket.ttl.set -bucket X -ttl 60d` to add the `filer.conf` route. New writes flow to TTL volumes immediately. Existing entries can be back-stamped via an async maintenance task on `LaneLifecycle` (same code as today's `updateEntriesTTL`, parallelized and restartable). Once back-stamped, lifecycle deletes go through the metadata-only path. + +### Scheduling + +Existing maintenance scheduler in `weed/admin/maintenance/` and `weed/worker/`. The repo already anticipates this work: `weed/admin/plugin/scheduler_lane.go` defines `LaneLifecycle` (line 24), maps job type `s3_lifecycle` to it (line 83), exempts the lane from the default-lane admin lock (`laneRequiresLock[LaneLifecycle] = false`), and gives it a 5-minute idle sleep. Per-lane execution slots (`execRes` in `schedulerLaneState`) mean lanes don't starve each other for worker capacity. + +Concrete plumbing: + +- **Lane: `LaneLifecycle`, not `LaneDefault`.** Job-type string `s3_lifecycle` with **three subtypes** carried in params: `READ` (cluster singleton), `BOOTSTRAP` (per bucket), `DRAIN` (per `(bucket, ActionKey)`). The default lane is serialised under one admin lock and is reserved for vacuum/balance/EC/admin scripts; lifecycle must not enter it. +- Task type constants in `weed/worker/types/task_types.go`: `S3LifecycleReadTaskType`, `S3LifecycleBootstrapTaskType`, `S3LifecycleDrainTaskType`. All bound to lane `LaneLifecycle` via `jobTypeLaneMap`. +- Cluster-lock keys per subtype: + - `READ` → `s3.lifecycle.read` (cluster singleton). + - `BOOTSTRAP` → `s3.lifecycle.bootstrap:` (per bucket; bootstrap walks all `ActionKey`s for the bucket in one pass). + - `DRAIN` → `s3.lifecycle.drain:::` (per `ActionKey` — sibling actions of one rule have separate drain locks so their pending streams advance independently). +- Task params proto (extend `weed/pb/worker_pb/worker.proto`): + ``` + message S3LifecycleParams { + enum Subtype { SUBTYPE_UNSPECIFIED = 0; READ = 1; BOOTSTRAP = 2; DRAIN = 3; } + Subtype subtype = 1; + + // bucket is required for BOOTSTRAP and DRAIN. rule_hash is optional for + // BOOTSTRAP (omitting it means walk-all-actions-for-this-bucket) and + // required for DRAIN. action_kind is required for DRAIN; ignored by + // BOOTSTRAP (the walk evaluates every ActionKey per object). READ + // ignores all three. + string bucket = 2; + bytes rule_hash = 3; // 8 bytes when present + ActionKind action_kind = 7; // DRAIN only + + bool force = 4; + int64 batch_time_budget_ns = 5; + int32 batch_event_budget = 6; + } + ``` +- Continuation hint on task result (extend the result message returned to admin): + ``` + message ContinuationHint { + int64 next_run_after_ns = 1; // 0 = ASAP, >0 = earliest re-emit + int64 expected_work_size = 2; // pending+events_remaining + string prefer_worker_id = 3; // cache-locality hint + } + ``` + Lane scheduler honours `next_run_after_ns` as a per-`(bucket, ActionKey)` re-arm timer for DRAIN, per-`bucket` for BOOTSTRAP; biases routing by `prefer_worker_id`. Other lanes ignore the field. +- Handler registration in `weed/worker/tasks/s3lifecycle/`. `Register()` wired from `weed/worker/worker.go`. +- Detector in `weed/admin/maintenance/`: per `LaneLifecycle` tick, list buckets, walk each per-action `state` file at `/etc/s3/lifecycle////state`, emit `read` / `bootstrap` / `drain` tasks, respecting `next_run_after_ns` and the optional off-peak window (config knob `lifecycle.run_hours`, e.g. `"01-06"`). DRAIN scheduling is per `ActionKey`; sibling actions' next-run timers are independent. +- Per-`(bucket, ActionKey)` DRAIN singleton via cluster lock `s3.lifecycle.drain:::`. Detector skips emitting while a lock is held. +- Manual triggers via new shell commands: + - `weed/shell/command_s3_lifecycle_run.go` → enqueue with `force=true`. + - `weed/shell/command_s3_lifecycle_status.go` → print state. + +Why a separate lane matters here: +- Long-running batches don't take the default-lane admin lock and therefore never block vacuum/balance/EC. +- Per-lane execution reservation means lifecycle can saturate its own slot pool without starving volume management. +- Operators can dedicate workers to lifecycle by labelling capability `s3.lifecycle.scan` only on those workers. + +### Multi-filer durability + +> **Implementation note (post-Phase 3):** the worker did not end up building its own multi-filer merge. `SubscribeMetadata` on the filer server (`weed/server/filer_grpc_server_sub_meta.go:154`) routes through `MetaAggregator` when peer filers exist: it reads from every filer's disk-persisted logs first (via `sendLogFileRefs`/`Filer.CollectLogFileRefs`), then drains the aggregator's in-memory tail. Workers get an aggregated, durable stream from a single subscription. The per-filer-shard cursor architecture below describes what would be needed if the worker had to assemble the merge itself; we kept it here as design context and as a reference for the proto stubs (`LOST_LOG`, `tail_drained_streams`) that remain forward-compatible if the architecture ever changes. The genuine residual gap — auto-degrading the worker on `ResumeFromDiskError` — is tracked separately as a narrow follow-up rather than a Phase. + +`MetaAggregator` is **in-memory only** — `weed/filer/meta_aggregator.go:39`: *"MetaAggregator only aggregates data 'on the fly'. The logs are not re-persisted to disk. The old data comes from what each LocalMetadata persisted on disk."* + +So the aggregator can't be the worker's source of truth: a worker restart, a crash, or a peer outage all lose unpersisted aggregated events. The persistent source is per-filer logs at `/topics/.system/log//.` — written by each filer for its own events, durable, and queryable via `Filer.CollectLogFileRefs` and `Filer.ReadPersistedLogBuffer`. + +Worker design for multi-filer: + +- The persisted log lives in the **shared filer namespace**. Listing `/topics/.system/log/` returns chunk references for every filer's segments; reading is via `Filer.CollectLogFileRefs` and `Filer.ReadPersistedLogBuffer` against any filer endpoint. There is **no need to subscribe to each filer or run one task per filer.** +- Single cluster-singleton reader subscribed to one filer at a time. `Filer.CollectLogFileRefs` returns chunk refs in directory order; the worker heap-merges per-filer chunks client-side via `pb.ReadLogFileRefsWithPosition` (Task #19). Filer forwards metadata only — not a bottleneck. +- Cursors in `reader_state` (one writer): + - `last_processed_original: map>` — per delay group, per per-filer log shard. + - `last_processed_predicate: map` — per per-filer shard. + - `MessagePosition = {ts_ns, offset}` is the per-shard tie-breaker; `LogEntry.Offset` is per-buffer (per filerId), not globally unique, so cursor must track each shard. +- Cross-filer event ordering inside the heap is `(event.ts, filer_id, event.offset)`. `filer_id` is the secondary key because `event.offset` is per-filer-buffer and not globally unique; without `filer_id` in the order, two equal-ts events from different filers could swap relative positions across runs. The skip predicate stays per-shard: `(event.ts, event.offset) <= cursor[filer_id]` evaluated against that shard's own cursor. +- Filer-set discovery: list `/topics/.system/log//` and observe filerId suffixes that appear; new shards get lazy-seeded cursors via `seedNewlyDiscoveredShardsLazily`. +- **Departed-filer cursor GC and lost-log handling.** A filer is "tail-drained" for shard `F` at delay group `D` when there are **no retained log refs** for `F` with position `> reader_state.last_processed_original[D.seconds][F]` (and analogously for the predicate stream). `EarliestRetainedPositionPerShard()` is not sufficient on its own — knowing the earliest retained position doesn't prove there's nothing newer than the cursor. Phase 3 adds `RetainedLogRangePerShard()` returning `map`. Tail-drained cursor entries (`cursor >= range.latest`) are removed at task entry, right after the probe, and the stream key is recorded in `reader_state.tail_drained_streams` so that future probes can distinguish "this exact stream was safely drained then pruned" from "F's logs were pruned before this stream caught up." The marker must be stream-specific: `(ORIGINAL, delay_seconds, filer_id)` or `(PREDICATE, filer_id)`. A drained 30-day stream does not make the 60-day stream safe. Without this GC, an old departed filer would pin the low-water-mark forever, forcing every batch to issue a `CollectLogFileRefs` listing starting from an ancient ts and inflating both listing cost and reader watermark-lag. + + Critically, the **absence** of a retained range for `F` is not sufficient evidence that GC is safe. If `F` has no retained range AND this stream cursor was never observed to reach `range.latest` (no entry in `tail_drained_streams`), the shard's logs were pruned before catch-up — events between `cursor[F]` and the (now-gone) `range.latest` were silently dropped. The reader **must not** GC such cursor entries: they're the only durable evidence that those events existed. Instead, the reader downgrades all reader-driven rules to `mode = scan_only` with `degraded_reason = LOST_LOG`, surfaces `lifecycle_lost_log_total{filer_id}` / `lifecycle_degraded_streams{reason="LOST_LOG"}`, and emits a warning. The cursor stays put until an operator runs `s3.lifecycle.reseed -ack-lost-log --reason `, which clears the lost-log cursor entries and the degraded flag together after safety scans have covered the affected buckets — the operator must explicitly acknowledge the data loss before the system silently advances. + +`LogFlushInterval` (the period before in-memory events flush to disk) bounds how stale the persistent view can be. Current constant: `time.Minute` (`weed/filer/filer.go:33`), so the flush gap is on the order of one minute. Workers process events with a `2 × LogFlushInterval` lag from real time (default ~2 minutes), so flushes always complete before the cursor reaches them. + +The aggregated in-memory stream is still useful for the *bootstrap detector* (notifying the worker that a fresh policy was just PUT and a tick should run sooner than the next scheduled interval) — but it's a hint, never a substitute for the persisted log when computing watermarks or replaying history. + +### Object lock and compliance — out of scope as a special case + +Lifecycle does **not** model object lock state. Concretely: + +- No tracking of legal-hold or retain-until-date in lifecycle storage. +- No per-rule `blocked` file (compliance ledger). No retain-until rescheduling. No re-evaluation triggered by Extended-change events that release a hold. (The cluster-level `_reader/blockers` file is unrelated — it tracks unprocessable cursor positions, not retained objects.) +- The `LifecycleDelete` server explicitly invokes `s3a.enforceObjectLockProtections` (in `weed/s3api/s3api_object_retention.go`) with `governanceBypassAllowed=false` before dispatching the low-level delete helper. If that check refuses, the server returns `SKIPPED_OBJECT_LOCK`. The worker logs, increments a counter, and advances. + +What this means in practice: + +- A legal-hold-released object will not be re-attempted on the original-write watermark schedule (the cursor has passed). It is re-attempted on the next periodic safety-scan bootstrap (per-kind cadence). For age-based rules that's `max(MinTriggerAge, 7d)` capped at 30d, which is acceptable for non-compliance lifecycle and irrelevant for compliance flows that should not be expecting lifecycle-driven cleanup anyway. +- A persistently-locked object surfaces as a sustained `skipped_object_lock_total` counter for that bucket/rule. Operators with compliance workloads handle removal of locks separately from lifecycle. + +This is a deliberate scope cut. S3 object lock is a separate consistency surface; mixing its scheduling into lifecycle was producing complexity (legal-hold blocked file, retain-until rescheduling, re-eval on Extended change, K-retry guards, status surfacing) disproportionate to the value for typical workloads. + +What remains: + +- The S3 server's own object-lock enforcement is unchanged. Locked objects are not deleted. +- Lifecycle does not corrupt or interfere with object lock state. +- `skipped_object_lock_total` is exported as a Prometheus metric in Phase 7 so operators see when lifecycle and locks intersect. + +If a future need arises for compliance-aware lifecycle (e.g., automated retain-until-expiry deletion), it can be a separate feature with its own data model — not bolted into the event-driven path. + +### Blocked cursor handling — pause, don't dead-letter + +For lifecycle, automatic dead-lettering is the wrong default. A dead letter would mean "this record is poison, skip it so the worker can keep moving" — but skipping a record means losing a delete decision (correctness). Instead, when an event cannot be processed, the worker **pauses that shard at its current `MessagePosition`** and surfaces a blocker for the operator to resolve. + +Rules: + +- **`RETRY_LATER`** (transient — `TRANSPORT_ERROR`, network, filer IO): the batch halts at the failing event. Cursor stays at the last resolved position. Next batch retries from the same `MessagePosition`. No `BlockerRecord` is written; the durable `retry_budget` entry for this stream key may be created or incremented (see "Sustained-RETRY_LATER promotion" below). +- **`BLOCKED`** (deterministic — `FATAL_EVENT_ERROR` after K=5 in-memory retries, or `RETRY_LATER` against the same `(stream_kind, shard, delay_seconds, position)` past the durable retry budget — see "Sustained-RETRY_LATER promotion" below): the worker writes a `BlockerRecord`, marks the affected stream paused at `failing_position`, and stops processing that stream until an operator intervenes. Other shards and other streams continue. + +``` +/etc/s3/lifecycle/_reader/blockers (chunked, append-and-compact-by-stream) + enum StreamKind { ORIGINAL = 0; PREDICATE = 1; BOOTSTRAP = 2; PENDING = 3; } + + repeated BlockerRecord { + StreamKind stream_kind = 1; // distinguishes the four blockable streams + string shard = 2; // filer_id; populated when stream_kind == ORIGINAL or PREDICATE + int64 delay_seconds = 3; // populated when stream_kind == ORIGINAL only + MessagePosition position = 4; // populated when stream_kind == ORIGINAL or PREDICATE + + // Common context. bucket/object_path/version_id are populated for all + // streams. rule_hash is OPTIONAL: it is populated when the failure is + // bound to a specific rule (the typical case). For pre-evaluation + // failures — e.g. handleEvent's fetchLive FATAL where no rule has been + // evaluated yet — rule_hash is left empty (nil/zero-length bytes). + // Operators interpreting blockers should treat empty rule_hash as + // "applies to whatever rule the next replay selects" rather than as a + // missing field. See handleEvent fetchLive FATAL path for the one site + // that produces this. + bytes rule_hash = 5; // optional; empty for pre-evaluation failures + string bucket = 6; + string object_path = 7; + string version_id = 8; + + string reason = 9; // human-readable cause (e.g. "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; + } +``` + +**StreamKind semantics:** + +- `ORIGINAL`: the reader's per-shard cursor for `(shard, delay_seconds)` is paused at `position`. Subsequent ticks skip that `(shard, delay_seconds)` combination until the blocker is cleared. Other delay groups on the same shard, and the same delay group on other shards, continue. +- `PREDICATE`: the reader's per-shard predicate cursor for `shard` is paused at `position`. Other shards' predicate streams continue. Note that `delay_seconds` is not meaningful for the predicate stream — `BlockerRecord.delay_seconds` is only populated for `ORIGINAL`. +- `BOOTSTRAP`: the bucket bootstrap walker pauses; the bucket's `_bootstrap.last_scanned_path` is the resume point. The walker writes a blocker for the failing `(bucket, object_path)` and refuses to advance until cleared. Other buckets continue. +- `PENDING`: the rule's drain task pauses on the failing `(rule_hash, path, version_id)`. Other rules' drains continue. + +**Operator commands:** + +- `weed shell s3.lifecycle.blockers list [--stream ...] [--shard ...] [--bucket ...]` — show all blockers. +- `weed shell s3.lifecycle.blockers retry ` — re-attempt the failing event/path **right now**, in the shell command itself, bypassing the task-start guard. The shell synchronously re-runs the appropriate primitive (`handleEvent` for `ORIGINAL`/`PREDICATE`, the walker step for `BOOTSTRAP`, `drainPending` step for `PENDING`) against current live state. Per-stream retry-success effects: + - `ORIGINAL`: on `DONE`/`NOOP_RESOLVED`, remove blocker record AND advance `reader_state.last_processed_original[delay_seconds][shard]` past `position`. + - `PREDICATE`: on `DONE`/`NOOP_RESOLVED`, remove blocker record AND advance `reader_state.last_processed_predicate[shard]` past `position`. + - `BOOTSTRAP`: on `DONE`/`NOOP_RESOLVED` for the recorded `(bucket, object_path, version_id, rule_hash)`, remove blocker record. **Do not** advance `_bootstrap.last_scanned_path` directly — the next scheduled bootstrap task picks up from `last_scanned_path` (which already sits at the previous successful entry) and re-walks normally; if the entry is still due it will be re-attempted, if not it'll be skipped. This avoids the operator command racing with concurrent walker progress. + - `PENDING`: on `DONE`/`NOOP_RESOLVED`, remove blocker record AND tombstone the matching `PendingItem` in the rule's `pending` file (the live retry might have already deleted/marked the object). On `NOOP_RESOLVED` due to `STALE_IDENTITY`, also tombstone the pending entry — it referred to a stale identity. + - All streams, on failure (`RETRY_LATER` or `BLOCKED` again): update `retry_count` and `last_retry_at_ns` on the blocker record; leave it in place; do not advance any cursor or tombstone any pending. The next scheduled task remains gated by the task-start guard until the blocker clears. + + Idempotency: the live re-attempt uses the same `expected_identity` / `MessagePosition` CAS path as a normal worker invocation, so a concurrent successful delete by something else just produces `NOOP_RESOLVED` (`STALE_IDENTITY` / `NOT_FOUND`) and the blocker resolves cleanly. +- `weed shell s3.lifecycle.blockers resume ` — clear the blocker record AND its corresponding `retry_budget` entry, but **leave the cursor at the failing position**. The next reader/drain/bootstrap pass will encounter the event and re-process it normally with a fresh retry-budget counter. Use this when the operator has fixed the underlying cause (corrupted entry repaired, upstream bug patched, etc.) and wants the worker to attempt the same record on its own schedule rather than synchronously via `retry`. Resume does *not* advance past the event — that's `quarantine`. +- `weed shell s3.lifecycle.blockers quarantine --reason ` — manual decision to **skip the event and advance the cursor without processing it**. Clears the blocker AND its `retry_budget` entry. Logged with operator identity, reason, and a `lifecycle_quarantined_total` metric increment. This is the **only** path that intentionally drops a delete decision; only the operator can take it; the action is auditable. + +In all three resolution paths (`retry` success, `resume`, `quarantine`), the corresponding `retry_budget` entry is cleared along with the `BlockerRecord`. Stale retry-budget state is never left behind. + +The cursor advances **only** in two ways: a worker-initiated `DONE`/`NOOP_RESOLVED` outcome, or an operator-initiated `quarantine`. `resume` is "clear the diagnostic, let the worker retry on its own" — it never moves the cursor. + +**No automatic skip.** The worker does not advance past a `BLOCKED` event on its own. Watermark lag for that `(shard, stream)` accumulates and triggers the lag-correctness fallback (degraded → scan_only) at the usual thresholds. Persistent operator inattention falls back to safety scans, which is the correctness floor. + +**Sustained-RETRY_LATER promotion.** A `RETRY_LATER` outcome (`TRANSPORT_ERROR`, network blip, filer IO error) by itself doesn't write a `BlockerRecord` — the cursor stays and the next batch retries. But if the same logical retry target keeps producing `RETRY_LATER` across many batches, the underlying issue is no longer "transient." The stream would silently lag forever without operator attention. + +A small durable counter file at `/etc/s3/lifecycle/_reader/retry_budget` tracks consecutive retries per stream-specific key (the same key shape used by `BlockerRecord` for that stream): + +``` +// RetryTarget carries the routing/identification fields needed to (a) write a +// BlockerRecord on promotion and (b) point the operator's `blockers retry` +// at the right handler. +// +// We deliberately do NOT carry expected_identity or action. Retry semantics +// are: re-invoke the handler implied by stream_kind. The handler re-fetches +// live state and re-evaluates from scratch: +// ORIGINAL / PREDICATE → re-run handleEvent at (shard, position) — fetches +// live, evaluates each candidate rule against current +// state, attempts deletes with current-identity CAS. +// BOOTSTRAP → re-run walker step at (bucket, object_path) — +// fetches the entry, evaluates rule, attempts delete. +// PENDING → re-run drainPending step for the matching +// PendingItem (which itself carries expected_identity +// in the pending file). +// If the object changed between the original failure and the retry, CAS returns +// STALE_IDENTITY → NOOP_RESOLVED and the blocker resolves cleanly. Capturing +// the original action would just be a stale hint. +// +// This also means RetryTarget works uniformly for failure paths that AREN'T +// delete RPCs — fetchLive ERR, pending upsert/demotion failure — because all +// of them are handled by the same per-stream handler on retry. +message RetryTarget { + StreamKey key = 1; // oneof, matches BlockerRecord stream-specific compaction keys + + // Common context (populated for every stream). + string bucket = 2; + string object_path = 3; + string version_id = 4; + bytes rule_hash = 5; // for BOOTSTRAP/PENDING this is the rule the walker/drain + // was working under. For ORIGINAL/PREDICATE the failure + // can span multiple candidate rules; rule_hash is the + // one whose evaluation triggered the RETRY_LATER, but + // retry re-evaluates ALL current candidates against + // current state, so a stale rule_hash here doesn't + // mislead replay. +} + +message RetryBudget { + RetryTarget target = 1; + int32 consecutive_retries = 2; + int64 first_retry_at_ns = 3; + int64 last_retry_at_ns = 4; +} + +message StreamKey { + StreamKind stream_kind = 1; + oneof key { + OriginalKey original = 2; + PredicateKey predicate = 3; + BootstrapKey bootstrap = 4; + PendingKey pending = 5; + } +} + +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; } +message PendingKey { string bucket = 1; bytes rule_hash = 2; string object_path = 3; string version_id = 4; } +``` + +Note on `BootstrapKey`/`PendingKey`: these duplicate `bucket`/`object_path`/`version_id`/`rule_hash` from `RetryTarget`'s common context. The duplication is intentional — `StreamKey` is the **lookup/dedup key** for blockers and retry-budget entries (so two callers can find the same record); `RetryTarget`'s common-context fields are populated identically and used for the BlockerRecord/retry payload. For `ORIGINAL`/`PREDICATE`, the common context fields *are* the only place bucket/object info exists (the `StreamKey` carries shard/position only). + +Helper functions used by reader/bootstrap/drain: + +``` +recordRetryLater(target RetryTarget): + rb := load_or_create(target.key) + rb.target = target // refresh; the failing target should be stable + rb.consecutive_retries += 1 + if rb.first_retry_at_ns == 0: rb.first_retry_at_ns = now() + rb.last_retry_at_ns = now() + + if rb.consecutive_retries < retryBudgetMax and + now() - rb.first_retry_at_ns < retryBudgetMaxAge: + save retry_budget + return RETRY_LATER + + // Threshold tripped — promote to BLOCKED. The blocker must commit durably + // BEFORE we return BLOCKED, otherwise the caller would pause the stream + // with no operator-visible record. If the write fails, leave the + // retry_budget entry in place and return RETRY_LATER; the next batch will + // re-attempt the promotion. + ok := writeBlocker(BlockerRecord{ + stream_kind: target.key.stream_kind, + ...key fields from target.key..., + rule_hash: target.rule_hash, + bucket: target.bucket, + object_path: target.object_path, + version_id: target.version_id, + reason: "sustained RETRY_LATER", + last_error: "promoted from retry_budget", + first_seen_at_ns: rb.first_retry_at_ns, + last_retry_at_ns: rb.last_retry_at_ns, + retry_count: rb.consecutive_retries, + }) + if !ok: + save retry_budget // keep the budget; try promotion again next batch + return RETRY_LATER + delete retry_budget entry for target.key + return BLOCKED // caller treats as BLOCKED + +clearRetryBudget(streamKey): + delete retry_budget entry for streamKey if present // success → reset budget +``` + +Each caller passes a fully-populated `RetryTarget`. The reader path builds it **inside `handleEvent`** (per failing branch — fetch_live FATAL/ERR, deleteAndResolve outcome) so the retry target reflects which branch actually failed; the reader callback itself doesn't construct `RetryTarget`. Bootstrap builds it directly from the entry being walked; pending drain builds it directly from `item`. RetryTarget intentionally carries no `action` or `expected_identity`: operator-driven retry (and shell-side `blockers retry`) replays the appropriate primitive against current live state, which re-fetches and re-evaluates from scratch. + +Defaults: `retryBudgetMax = 30` (≈30 minutes at 1-batch-per-minute reader cadence), `retryBudgetMaxAge = 4h`, whichever comes first. The retry_budget file is bounded by the count of positions *actively retrying* (typically 0 or a small handful) and compacts on success. + +This gives sustained-transient → BLOCKED a concrete mechanism without a per-event durable record on every `RETRY_LATER`. + +The blocker file's compaction key is **stream-specific**, since the identifying tuple differs per stream: + +- `ORIGINAL`: `(stream_kind, shard, delay_seconds, position)` — `position` is required because the same shard can have multiple blockers at different `MessagePosition`s within the same delay group (sequential failures the operator hasn't cleared yet); without `position` they'd collapse. +- `PREDICATE`: `(stream_kind, shard, position)` — same reasoning; `delay_seconds` not used. +- `BOOTSTRAP`: `(stream_kind, bucket, object_path, version_id, rule_hash)` — without `version_id` and `rule_hash`, two different rules' walks failing on the same `(bucket, object_path)` would collapse into one record and the operator couldn't distinguish them. +- `PENDING`: `(stream_kind, bucket, rule_hash, object_path, version_id)` — different versions of the same key under the same rule are distinct pending items; without `version_id` they'd collapse. + +A re-blocking of the same key overwrites the previous record (preserves `first_seen_at_ns`, refreshes `last_retry_at_ns`, increments `retry_count`) rather than appending a new one. Resolution (via `retry` success, `resume`, or `quarantine`) removes the record by its full key. + +### Failure / restart + +- The meta log is the durable queue. Watermarks advance only past events that returned `DONE` or `NOOP_RESOLVED`. +- On `RETRY_LATER` (transient — S3 unreachable, network, filer IO): the batch stops at that event; the per-shard cursor stays at the last resolved `(filer_id → MessagePosition)`; the next batch retries the same event from the per-shard skip predicate. +- On `BLOCKED` (deterministic — `FATAL_EVENT_ERROR` after K=5 retries, or sustained `RETRY_LATER` past a per-shard threshold): the worker writes a `BlockerRecord` and pauses that `(shard, delay_seconds)` combination. Other shards and other delay groups continue. The cursor stays at `failing_position` until an operator runs `s3.lifecycle.blockers retry|resume|quarantine`. +- On bootstrap `RETRY_LATER`/`BLOCKED`: the walker stops; `last_scanned_path` stays at the previous successful entry; a `BlockerRecord` is written for the `BLOCKED` case. +- On pending drain `RETRY_LATER`/`BLOCKED`: the pending item stays in the rule's `pending` file; for `BLOCKED` a `BlockerRecord` is written and the rule's drain pauses. +- Mid-tick crash → next tick re-runs from last persisted state. +- Idempotent throughout: identity CAS in the server prevents double-delete; pending upsert keyed by `(path, version_id)` within each rule's directory. + +For permanently-failing events: see "Blocked cursor handling" above. The worker never auto-skips a failing event; it pauses and surfaces. Operator decides resume / retry / quarantine. + +### Scalability — pending is for exceptions, not normal scheduling + +The meta log + per-filer watermarks **are the scheduler** for age-based rules. Per-object pending is reserved for cases the watermark can't express: + +| Reason for pending | Bounded by | +|---|---| +| Late predicate change creates new eligibility on a not-yet-due object | rate of late-arriving tag/metadata changes that newly match a rule | + +What does **not** go into pending in normal operation: + +- **Future age-based deletes.** Discovered by the reader when their original-write event ages past the cutoff. The event log is the durable schedule. +- **Currently-due objects from bootstrap.** Bootstrap walks and deletes them inline; the walk cursor (`last_scanned_path`) is the resume point. +- **Date-rule matches.** `SCAN_AT_DATE` mode runs a single bootstrap at `rule.date`; no pending. + +**Steady-state pending size** in a normally-operating cluster: typically zero or low single-digit items per rule, populated only by late predicate-change exceptions (a tag added long after the original PUT that newly matches a rule, where `live.mtime + TTL` is still in the future). Independent of bucket size and write rate. Object-lock skips do *not* go to pending — they're logged-and-counted, with a periodic safety scan handling re-evaluation if the lock is later released. + +**S3 unavailable**: reader/bootstrap stop on `TRANSPORT_ERROR`, watermark stalls at the last resolved event, no pending growth. Next read task retries from the same watermark. Events are durable in the meta log for the configured retention. Operators see an alerting signal via watermark-lag metrics. + +**High write rate** (e.g. 10k/s log ingest with age=1d): the reader processes events as their `now - MinTriggerAge` cutoff sweeps past them. Each event is one synchronous `LifecycleDelete`. Throughput is bounded by `LifecycleDelete` parallelism (configurable via `lifecycle.delete.concurrency`, default 16 concurrent calls). Chunk reads from the merged log stream parallelize across volume servers (`lifecycle.reader.chunk_concurrency`, default 8). Pending stays empty in normal flow. + +**Delete throughput is coupled to reader progress.** Cursor advance is gated on per-event resolution; if `LifecycleDelete` throughput is slower than the rate at which events age into the original-write cutoff, the watermark lags behind real time. If the lag grows past `metaLogRetention`, events would be pruned from the log before they can be processed — without a fallback, silent under-deletion. + +Operators have three levers in order of cost: + +1. **Raise `lifecycle.delete.concurrency`** to fan out more in-flight `LifecycleDelete` RPCs. Effective until the S3 server pool is the bottleneck. +2. **Raise `lifecycle.reader.chunk_concurrency`** to fan out more in-flight chunk reads from volume servers. Effective until network or volume servers are the bottleneck. +3. **Scale S3 servers / network**. + +If those levers don't suffice, see "Future scaling" below. + +**Future scaling (non-v1, no committed phase).** If single-worker reader throughput is insufficient even after raising delete concurrency, chunk-read concurrency, and S3 server capacity, the design admits one extension: time-segment leases on the merged log stream. The merged stream's time range `[low_water, cutoff]` is partitioned into N time segments; each segment leased to a separate worker process; each worker processes its segment independently and reports `last_processed_position_per_shard` on completion; a coordinator gates **contiguous watermark commit** — a segment's results only commit (and its watermark advance only persists) once *every earlier segment has committed*. Per-bucket pending writes still use the same `(path, version_id)` dedup key, so cross-segment race on a path is idempotent. This stays worker-side (no per-filer fanout, no filer-side changes); the filer's role is unchanged. Concrete design and implementation are deferred — there is no committed phase for it. Operators hitting this scale should bring it up; until then v1 ships with one reader. + +**Correctness fallback when the lag approaches retention.** Alerting alone is not enough — by the time an operator scales capacity, events may already be pruned. The reader monitors **two parallel lag signals**: + +1. **Per-delay-group original-write lag** — `original_lag_seconds[delay_seconds] = now - low_water(reader_state.last_processed_original[delay_seconds])`. Affects every rule that uses that delay. +2. **Per-shard predicate lag** — `predicate_lag_seconds[filer_id] = now - reader_state.last_processed_predicate[filer_id].ts_ns`, with the engine-wide aggregate `predicate_lag_seconds = max over filer_id`. Affects every **predicate-sensitive** rule (any rule with tag/size filters), regardless of its delay group. Without this check, predicate-stream blockage would let tag/metadata changes age out of the meta log without any rule entering `scan_only`. + +Per-stream thresholds: + +| Lag (per stream) | Action | +|---|---| +| `< metaLogRetention × 0.5` | Healthy. Clear `degraded_reason = LAG_HIGH` if previously set. | +| `≥ × 0.5` and `< × 0.8` | Emit `LIFECYCLE_LAG_WARNING` metric + log; surface in `s3.lifecycle.status`. Set `degraded_reason = LAG_HIGH` if not already. | +| `≥ × 0.8` | Affected rules keep `mode = event_driven` AND `degraded_reason = LAG_HIGH`; force the next safety-scan bootstrap to run *immediately* without waiting for the per-kind cadence. Continue event-driven processing in parallel. | +| `≥ × 0.95` | Promote affected rules to `mode = scan_only` durably (set `degraded_reason = LAG_HIGH`, `degraded_since_ns = now`). The reader stops processing the affected stream — the affected delay group, or all predicate-sensitive rules' predicate ingestion, respectively. The safety scan is the only enforcement until the operator explicitly re-enables event-driven mode. | + +Affected-rules scope: + +- Original-write lag at threshold for delay `D` → rules whose `delay == D`. +- Predicate lag at threshold → all rules with `predicateSensitive == true` (scoped per bucket — promotion to `scan_only` is per-rule, but the trigger is engine-wide). + +**Re-enable** after a predicate-stream-induced `scan_only` uses the existing `force_reseed_predicate` path. The `s3.lifecycle.reseed` shell command takes `-predicate` to flag predicate-stream-driven re-enables: it sets `force_reseed_predicate=true` on the seed call, rewinding `last_processed_predicate` to `T_start - bootstrapLookbackMin` per filer, then flips affected rules back to `event_driven`. + +**Re-enabling event-driven mode after a `scan_only` fallback** is a delay-group operation, not a per-rule state flip. The cursor `last_processed_original[D.seconds]` is shared across **all rules with delay D** (e.g., two rules with 60-day expiration but different prefixes both consume the 60d cursor's stream). Reseeding for rule A's sake forces rules B, C, … on the same delay D to replay the reseed window too. That replay is idempotent (CAS on identity, pending upserts collapse) and the cost is bounded by the lookback window's events, but it must be acknowledged. + +Operator workflow: + +1. `weed shell s3.lifecycle.reseed -bucket X -delay ` — operates on the delay group, not a single rule. Lists all rules currently in `scan_only` mode for that delay and reports which buckets/rules will replay. +2. The command: + - Computes `force_reseed_delays = { D }` for the delay being re-enabled. (For multiple delays in one invocation, the set has multiple entries.) + - Deletes `reader_state.last_processed_original[D.seconds]` (the entire per-filer map for that delay group). + - Runs a fresh bucket-level bootstrap for **every bucket whose policy includes a rule with delay D** (idempotent, in parallel). Each bootstrap, on completion, calls **`seedReaderCursorsForNewDelayGroups(snapshot, newly_completed, target_modes, T_start, force_reseed_delays={D}, force_reseed_predicate=)`** — the explicit force arguments are required because the affected rules already have `bootstrap_complete=true` and therefore are NOT in `newly_completed`; without them the seeder's early-exit would leave both the deleted delay cursor un-seeded AND the predicate cursor un-rewound (silently missing tag/metadata changes that happened during the scan_only window for predicate-sensitive rules). The seeder seeds each `D` in `force_reseed_delays` at `T_start - max(D, bootstrapLookbackMin)` per filer with the before-first sentinel; if `force_reseed_predicate=true` it also rewinds the predicate cursor to `T_start - bootstrapLookbackMin` per filer. + - After the seeder commits, flips affected rules' `mode` from `scan_only` back to `event_driven` durably and clears `degraded_reason`. +3. The reader resumes processing the delay group; rules that were not in scan_only also re-receive the lookback window of events, idempotently. CAS on identity ensures no duplicate deletes; pending upserts collapse on `(path, version_id)`. + +Lost-log acknowledgement is a separate, explicit workflow because it admits that some retained metadata history was lost: + +1. `weed shell s3.lifecycle.reseed -ack-lost-log --reason ` — cluster-wide, only allowed while some rules have `degraded_reason = LOST_LOG`. +2. The command verifies an immediate safety-scan bootstrap has completed for every bucket with reader-driven lifecycle rules after `degraded_since_ns`; if not, it runs those bootstraps first and waits for completion. +3. It deletes the lost-log cursor entries for vanished shard streams, clears any matching `tail_drained_streams` markers, then force-reseeds all reader delay groups and the predicate cursor using the same `force_reseed_delays` / `force_reseed_predicate` path above. +4. After seeding commits, it clears `LOST_LOG`; rules return to `event_driven` only if the normal retention gate still passes. Rules whose `metaLogRetention < eventLogHorizon + bootstrapLookbackMin` remain `scan_only` with `RETENTION_BELOW_HORIZON`. + +If only one rule is to be reactivated and the operator wants to avoid replay for sibling rules, the alternative is to *split* the rule's delay into a unique value (e.g., `60d` → `61d`), which gives it its own delay-group cursor at the cost of changing AWS-visible semantics. Generally, delay-group-wide replay is acceptable. + +Without this protocol, naive per-rule re-enable would either replay too much (cursor at the old position before the lag spike) or skip newly-eligible events (cursor naively bumped to "now"). The reseed pins the cursor exactly at the safe replay floor. + +This guarantees no silent loss: by the time events would be pruned, the affected rules are already running on the bucket-walk fallback, with explicit signal in `status`. `degraded` and `scan_only` are surfaced via: + +- `lifecycle_rule_mode{rule_hash, mode}` — `event_driven | scan_at_date | scan_only | disabled`. +- `lifecycle_rule_degraded{rule_hash, reason}` — `NONE | LAG_HIGH | PENDING_FULL | DELETE_FAILURES | OPERATOR_PAUSED`. +- `watermark_lag_seconds{delay_group}` — primary indicator. +- `lifecycle_delete_concurrency_saturation` — fraction of the concurrent-call budget in use. +- `lifecycle_delete_p99_latency` — server-side delete latency. + +We deliberately do **not** introduce a durable delete-handoff queue. That would decouple cursor advance from delete completion at the cost of bringing back per-object pending growth and the scalability problems it causes. The simpler answer — auto-fallback to scan_only when lag threatens retention — is what this design commits to. + +**Bootstrap of a stale bucket** (lifecycle freshly enabled on a 60k-object bucket where all are currently due): bootstrap walks pages and deletes inline. No pending growth. The walk pauses on `TRANSPORT_ERROR` and resumes from `last_scanned_path`. Total wall time = `bucket_size / parallel_delete_throughput`. + +**Pending file format.** Append-only with tombstones; in-memory `(path, version_id) → offset` index for upsert lookups. Compaction triggered when tombstone ratio exceeds 50%. Because pending is bounded by exceptions, the file rarely grows beyond a few MB and compaction is cheap. + +**Backpressure cap on pending** (default 50k items per rule): triggered only by an unusual workload — typically a sudden burst of late predicate-change events on a long-TTL rule. The predicate-change stream is **shared across all rules** (one `last_processed_predicate: map` per shard, advanced by every predicate event the engine routes to *any* rule's candidates). We cannot selectively halt it for a single rule without halting predicate-change processing for all rules. The mechanism is therefore: + +1. On the first pending write for the affected rule that would exceed the cap (or any pending write that fails), the reader treats the rule as **degraded** and **atomically switches it to `scan_only` mode** (durable per-rule state write). The current event is then "resolved" for that rule — the next safety-scan bootstrap will re-evaluate any objects whose predicates changed late. +2. Other rules continue their predicate-change processing normally; the global predicate cursor keeps advancing. +3. The reader emits `PENDING_FULL{rule_hash}` (metric + log) so operators see why the rule degraded. +4. The same mechanism handles any unrecoverable `pending` write error (filer IO failure, etc.): attempt the durable demotion to `scan_only`. The cursor only advances after that demotion write *commits*. If the demotion write itself also fails (filer IO continues to be unhealthy), `handleEvent` returns `RETRY_LATER`; the batch halts with the per-shard cursor at the previous event, and the next read task retries. Advance is gated on durable degraded-state persistence, never on alerting alone. + +This preserves correctness without coupling one rule's exception load to the global predicate stream. Demoting a rule to `scan_only` is reversible — operators can manually return it to `event_driven` after the load subsides. + +Object-lock skips never enter pending; they're logged-and-counted only. Pending is exclusively the late-predicate-change exception path. + +**Health signals.** Watermark lag (per delay group AND per shard for predicate), pending size, S3 RPC error rate, `skipped_object_lock_total`, `blocked_streams{stream_kind}` (gauge of active blocker records). Sustained watermark lag is the primary indicator. Active blockers are the operator-attention indicator. + +### Observability + +CLI: +- `weed shell s3.lifecycle.status -bucket X` — mode, watermark, pending size, last tick, counts. + +Headers: +- `X-SeaweedFS-Lifecycle-LastTick` on `GetBucketLifecycleConfiguration`. + +Prometheus: +- `seaweedfs_s3_lifecycle_evaluated_total{bucket,rule_hash}` +- `seaweedfs_s3_lifecycle_expired_total{bucket,rule_hash,action}` +- `seaweedfs_s3_lifecycle_metadata_only_total{bucket,rule_hash}` — count of skip-chunk-delete deletes +- `seaweedfs_s3_lifecycle_skipped_object_lock_total{bucket,rule_hash}` — locked objects encountered +- `seaweedfs_s3_lifecycle_blocked_streams{stream_kind,bucket}` — gauge: number of active blocker records by stream_kind (ORIGINAL / PREDICATE / BOOTSTRAP / PENDING) +- `seaweedfs_s3_lifecycle_quarantined_total{stream_kind,bucket}` — counter: operator-quarantined records (auditable; only path that intentionally drops a delete decision) +- `seaweedfs_s3_lifecycle_predicate_lag_seconds{filer_id}` — primary indicator for predicate-stream health +- `seaweedfs_s3_lifecycle_errors_total{bucket,rule_hash,kind}` +- `seaweedfs_s3_lifecycle_pending_size{bucket,rule_hash}` +- `seaweedfs_s3_lifecycle_watermark_lag_seconds{bucket,rule_hash,delay_group}` + +Admin UI (the existing plugin lane filter at `weed/admin/view/app/plugin_lane.templ` + `weed/admin/dash/plugin_api.go:filterActivitiesByLane` already surfaces job type `s3_lifecycle` — confirmed by `dash/plugin_api_test.go:204-230`): + +1. **Per-bucket lifecycle panel on `s3_buckets.templ`.** Per rule: + - rule_id (or hash prefix if blank) + - mode: `event_driven` | `scan_only` + - bootstrap state: `complete` | `% scanned` + - watermark lag (`now - last_processed_ts`) + - pending size + - counters: evaluated / expired / errors / metadata-only + - actions: `Run now` (force batch), `Re-bootstrap`, `Pause` + +2. **Lifecycle lane summary on `plugin_lane.templ`** when `?lane=lifecycle`: + - active batches + - cluster-wide pending sum + - watermark lag p50/p99 + - off-peak window indicator if `lifecycle.run_hours` is set + +3. **JSON API endpoints under `weed/admin/dash/`:** + - `GET /api/s3/buckets/{name}/lifecycle/status` — decoded per-rule `state` files + - `POST /api/s3/buckets/{name}/lifecycle/run` — enqueue forced batch (optional `?rule_hash=`) + - `POST /api/s3/buckets/{name}/lifecycle/rebootstrap` — reset bootstrap for a rule + +4. **PUT XML editor (if/when it lands)**: warn on rules whose TTL exceeds meta-log retention with "Will run in scan_only mode at slower cadence." + +### PUT handler becomes (post-Phase 4 below) + +1. Validate XML. +2. Persist `lifecycle.xml` xattr. +3. Reconcile per-rule directories under `/etc/s3/lifecycle//`: create new dirs with `bootstrap_complete=false`; preserve `bootstrap_complete=true` and counters for unchanged rule hashes; mark removed rules' dirs for grace-period cleanup. (Reader watermarks live in cluster `reader_state`, not per-rule; unchanged.) +4. Wake the scan scheduler. +5. Return 200. + +Constant time, no bucket walk, no `filer.conf` mutation. + +### Migration + +- **Cannot strip the back-stamp until the worker can take over.** Phasing reflects this. +- Existing entries with `TtlSec` stamps from the old back-stamp keep working via the read-time filer check. Worker plus those stamps converge. +- First worker tick on each existing bucket runs the bootstrap scan once. +- `DeleteBucketLifecycle` clears xattrs; existing `TtlSec` stamps stay (don't aggressively unstamp — would resurrect objects). + +## Comparison + +| | Today | Proposed | +|---|---|---| +| `PUT` latency | O(N entries) | O(1) | +| Steady-state cost per tick | n/a (no scanner) | O(events × distinct_delay_groups) + pending drain (exceptions only) + amortized periodic safety-scan walk per bucket per safety cadence | +| Bootstrap cost | Hidden in `PUT` | Once per bucket per snapshot, in background; re-runs on safety cadence | +| Tag/size filters | Silent no-op | Live-evaluated; late-tag-add uses pending exception | +| Date filters | Silent no-op | `SCAN_AT_DATE` mode: scheduled bucket-level bootstrap at `rule.date` (no per-object pending) | +| Versioned buckets | Silent no-op | Driven by `.versions` events | +| `AbortIncompleteMultipartUpload` | Silent no-op | Triggered on `.uploads/` events | +| Cold-object handling | Never | Bounded by tick + due-time | +| Source of truth | `filer.conf` + xattr | xattr only | +| Failure recovery | None | Watermark + pending + CAS | + +--- + +# Dev Plan + +Phasing changed: **the back-stamp cannot be removed until the worker can take over.** The first behavior-changing PR is the worker, not the PUT slim-down. + +## Phase 0 — Confirm assumptions (research, no code) + +- Verify `Filer.ReadPersistedLogBuffer` returns events including `Extended` on `NewEntry`. +- Document retention policy of `/topics/.system/log/`. If pruned, find the knob and document it; this sets the threshold for `scan_only` mode. +- Confirm the exact filename scheme inside `.versions/` (sortable timestamp prefix? base32 of ts? affects how we determine "successor" version). +- Capture findings inline in this design doc. + +## Phase 1 — `s3lifecycle.Evaluate` (no callers) + +- Add `EvaluateAction(rule *Rule, kind ActionKind, info *ObjectInfo, now time.Time) EvalResult`, `ComputeDueAt(rule *Rule, kind ActionKind, info *ObjectInfo) time.Time`, `MinTriggerAge(rule *Rule, kind ActionKind) time.Duration`, `EventLogHorizon(rule *Rule, kind ActionKind) time.Duration`, and `RuleActionKinds(rule *Rule) []ActionKind` in `weed/s3api/s3lifecycle/`. All kind-aware: a single XML rule expands into N compiled actions and each helper operates on one (rule, kind) pair so sibling actions are scheduled, gated, and evaluated independently. `EventLogHorizon` returns `rule.Days` / `rule.NoncurrentDays` / `rule.DaysAfterInitiation` for age-based kinds, and `smallDelay` for `NEWER_NONCURRENT` and `EXPIRED_DELETE_MARKER`. `EXPIRATION_DATE` is not a reader-driven kind and the gate skips it. +- Cover: `ExpirationDays`, `ExpirationDate`, `ExpiredObjectDeleteMarker`, `NoncurrentVersionExpirationDays`, `NewerNoncurrentVersions`, `AbortMPUDaysAfterInitiation`, prefix + tag + size filters, And-combinations. +- Add `RuleHash(rule *Rule) [8]byte` over a length-prefixed canonical form (sorted tags, prefix verbatim, every action's parameters, days/date). Stable across XML reordering, ID renames, and Status flips; resistant to delimiter forgery. +- Unit tests in `evaluate_test.go` per rule shape, including AWS edge cases (date in future, zero days, empty prefix, And). +- No callers yet. **Independently mergeable.** + +## Phase 2 — LifecyclePolicyEngine + bucket-level bootstrap + LifecycleDelete RPC + scan_only gate (non-versioned) + +- Define protos in `weed/pb/s3_lifecycle.proto`: per-action `LifecycleState` keyed by `(rule_hash, action_kind)` (no watermarks), `PendingItem` (late-predicate exceptions only — no `BlockedItem`, no compliance ledger), `EntryIdentity`. Cluster-level `ReaderState` (watermark maps + `tail_drained_streams`). Top-level `ActionKind` enum referenced by `LifecycleState`, `BootstrapKey`, `PendingKey`, `BlockerRecord`, `RetryTarget`. +- Define `S3LifecycleParams` (subtype `READ` | `BOOTSTRAP` | `DRAIN`) and `ContinuationHint` in `weed/pb/worker_pb/worker.proto`. `S3LifecycleParams.action_kind` is required for DRAIN, ignored by BOOTSTRAP. +- Storage: + - Per-action: `/etc/s3/lifecycle////{state, pending}`. Each XML rule expands into N action subdirectories under one rule_hash dir. + - Per-bucket bootstrap progress: `/etc/s3/lifecycle//_bootstrap`. + - Cluster reader: `/etc/s3/lifecycle/_reader/{reader_state, blockers, retry_budget}`. Single reader task; `reader_state` holds per-filer-shard cursors (`map>` for originals and `map` for predicate) — required for resume correctness across the client-side merge. `blockers` is the durable record of paused stream entries (operator-resolvable). `retry_budget` tracks consecutive `RETRY_LATER` per stream key for promotion to BLOCKED at threshold. No reader-group partitioning, no assignment epochs. +- New package `weed/s3api/s3lifecycle/engine/` — `LifecyclePolicyEngine`. Compiles all bucket lifecycle xml into: + - `bucket → BucketIndex { prefixTrie, tagIndex, versioned }` keyed by `ActionKey`. + - `originalDelayGroups: map[duration][]ActionKey` for original-write sweeps. + - `predicateActions: []ActionKey`, `dateActions: map[ActionKey]time.Time`. + - Snapshot ID; rebuild on observed lifecycle xattr change events. +- Per-`ActionKey` policy CAS via `(rule_hash, action_kind)` membership — no per-bucket etag scheme. +- New package `weed/s3api/s3lifecycle/worker/`: + - **Bucket-level** bootstrap walker: walks each bucket once per run, evaluates every applicable `ActionKey` per object via the engine (`EvaluateAction(rule, kind, info, now)` for each compiled action). Inline delete for currently-due age actions; skip not-yet-due (log replay handles); skip date actions (SCAN_AT_DATE handles). `last_scanned_path` checkpointed in `_bootstrap`. Per-action completion writes commit *after* the walk. + - On `TRANSPORT_ERROR` the walker stops; resume from `last_scanned_path` next task. + - Skip versioned buckets with a logged warning. Implementation lands in Phase 5. +- **Mode gate (lands here):** at engine compile time, for each `ActionKey`, compute `eventLogHorizon(rule, action_kind)` per the table in §"Per-rule mode decision"; if `metaLogRetention < eventLogHorizon(rule, action_kind) + bootstrapLookbackMin`, mark *that action* `scan_only` with `degraded_reason = RETENTION_BELOW_HORIZON`. Applies to all reader-driven kinds (`EXPIRATION_DAYS`, `NONCURRENT_DAYS`, `ABORT_MPU`, `NEWER_NONCURRENT`, `EXPIRED_DELETE_MARKER`); date kind bypasses this gate. Sibling actions of the same rule are evaluated independently — a 90d `EXPIRATION_DAYS` may degrade while a 7d `ABORT_MPU` stays event-driven. Same bootstrap code path; detector emits at the per-kind cadence. +- `SCAN_AT_DATE` mode: detector schedules a single bootstrap at the action's `rule.date`. +- Worker-to-S3 discovery via admin's `ListS3Endpoints` RPC; cache 30s; rotate on RPC failure. +- New gRPC `LifecycleDelete` (`weed/s3api/s3api_internal_lifecycle.go`). Server handler steps 1–7 above; the request carries `(rule_hash, action_kind)` for ActionKey CAS. +- Object lock: `LifecycleDelete` server explicitly calls `s3a.enforceObjectLockProtections` (in `weed/s3api/s3api_object_retention.go`) with `governanceBypassAllowed=false` BEFORE dispatching low-level helpers. If the check refuses, returns `SKIPPED_OBJECT_LOCK`. Worker logs, increments counter, advances. No retain-until rescheduling, no blocked file. +- Admin server: register S3 endpoints; serve `ListS3Endpoints`. +- Wire task types in `weed/worker/tasks/s3lifecycle/`: + - `s3.lifecycle.bootstrap` per-bucket (not per-action). + - `s3.lifecycle.read` cluster-singleton (Phase 3 fills in). + - `s3.lifecycle.drain` per-`ActionKey` for exception drains. + All bound to `LaneLifecycle`. +- Cluster locks: `s3.lifecycle.read` (singleton), `s3.lifecycle.bootstrap:`, `s3.lifecycle.drain:::`, `s3.lifecycle._reader.seeding` (global, serializes seed writes across bucket bootstraps). +- Detector in `weed/admin/maintenance/`. +- Shell commands `s3.lifecycle.run`, `s3.lifecycle.status`. +- Worker runs alongside the back-stamp. +- Tests: bootstrap fixture bucket with multiple rules — verify one walk evaluates all; not-yet-due not enqueued; date rules deferred to SCAN_AT_DATE; transport error pauses then resumes; scan_only gate triggers on short retention. +- **Regression tests**: + - `target_modes` computed before seeding: inject a fixture where the seeder is called with rules whose `state.mode` field is at proto zero value; assert the seeder filters using the precomputed `target_modes` map (not the unwritten state field), so EVENT_DRIVEN rules trigger rewind and SCAN_AT_DATE/SCAN_ONLY/DISABLED rules do not. Without this, the seeder's filter would silently fail. + - `force_reseed_delays` for scan_only re-enable: start with a rule in `mode = scan_only` and `bootstrap_complete = true` (so it never enters `newly_completed`); delete `reader_state.last_processed_original[D.seconds]`; call the seeder with `newly_completed = {}` and `force_reseed_delays = {D}`; assert the cursor is re-seeded at `T_start - max(D, bootstrapLookbackMin)`. Then assert the same call WITHOUT `force_reseed_delays` does **not** re-seed (verifies the early-exit path is correct for routine safety scans). + - `force_reseed_predicate` for predicate-sensitive scan_only re-enable: start with a predicate-sensitive rule (e.g. `Filter.Tag` filter) in `mode = scan_only`; advance the predicate cursor in `reader_state.last_processed_predicate` past `T_start - bootstrapLookbackMin` to simulate normal reader progress while the rule was scanned-only; emit a tag-add meta event in that interval (would be predicate-relevant if the rule were active); flip the rule via the reseed workflow with `force_reseed_predicate = true`; assert the predicate cursor was rewound to `T_start - bootstrapLookbackMin` per shard; assert the previously-emitted tag-add event is now picked up by the next reader pass and routed through `handleEvent` for the re-enabled rule. Without `force_reseed_predicate` the test must show the cursor un-rewound and the tag-add event silently missed — this is the regression that motivates the parameter. + - Bootstrap fatal-error blocker: inject a rule with a corrupted entry that causes `LifecycleDelete` to return `FATAL_EVENT_ERROR` deterministically. Verify bootstrap walker calls `deleteAndResolve` (not `lifecycleDeleteRPC` directly), retries K=5 times, writes a durable `BlockerRecord` with `stream_kind = BOOTSTRAP`, returns `BLOCKED`, and `last_scanned_path` is left at the previous successful entry (NOT advanced past the failure). Verify the next bootstrap task refuses to advance past the blocker until the operator clears it. + - Reader fatal-error blocker: similar, with `stream_kind = ORIGINAL` and `(shard, delay_seconds, position)` populated for the original-write pass; with `stream_kind = PREDICATE` and `(shard, position)` for the predicate pass. The blocked `(shard, stream)` pauses; other shards/streams continue processing in the SAME batch — the BLOCKED outcome adds to in-memory blocked sets and the callback returns `PauseShard(filer_id)`, which removes that shard from the merge heap so subsequent buffered events from it are not delivered (verify via spy on the merge engine). The next reader task loads `_reader/blockers` and uses `MaxMessagePosition` for blocked shards' `start_positions` so no events are re-read until the blocker is cleared. + - Pending fatal-error blocker: similar, with `stream_kind = PENDING`; the rule's drain pauses; the pending item stays. + - `writeBlocker` failure path: if the durable blocker append fails, `deleteAndResolve` (and the inline FATAL fetchLive path in `handleEvent`) returns `RETRY_LATER` instead of `BLOCKED`. Verify the cursor stays at the failing position and the next batch retries. + - Operator commands: + - `blockers list` filters by `--stream`, `--shard`, `--bucket`. + - `blockers retry ` re-attempts synchronously, with stream-specific success effects: + - `ORIGINAL`: on `DONE`/`NOOP_RESOLVED` removes the record AND advances `reader_state.last_processed_original[delay_seconds][shard]` past the recorded `position`. + - `PREDICATE`: on `DONE`/`NOOP_RESOLVED` removes the record AND advances `reader_state.last_processed_predicate[shard]` past the recorded `position`. + - `BOOTSTRAP`: on `DONE`/`NOOP_RESOLVED` removes the record only; **does not** mutate `_bootstrap.last_scanned_path`. The next scheduled bootstrap task picks up from `last_scanned_path` and re-walks normally. + - `PENDING`: on `DONE`/`NOOP_RESOLVED` removes the record AND tombstones the matching `PendingItem` in the rule's `pending` file. `STALE_IDENTITY` outcomes also tombstone (the pending entry referred to a stale identity). + - All streams: on `RETRY_LATER`/`BLOCKED` again, update `retry_count` and `last_retry_at_ns`, leave the blocker, leave any cursors / pending items unchanged. + - `blockers resume ` clears the blocker AND its retry_budget entry but **leaves the cursor at the failing position**; the next reader/drain/bootstrap pass re-processes the event normally with a fresh retry-budget counter. Resume does **not** advance the cursor. + - `blockers quarantine --reason ` is the **only** path that advances the cursor without processing — auditable, increments `lifecycle_quarantined_total`. Clears the blocker AND its retry_budget entry. + - retry_budget cleanup: any direct-FATAL blocker write (fetchLive FATAL in handleEvent, K-exhausted FATAL_EVENT_ERROR in deleteAndResolve, fetchLive FATAL in drainPending) calls `clearRetryBudget(stream_key)` after the durable blocker write commits — verify no stale retry_budget entries remain after a sequence like ERR → ERR → FATAL. + - Operator resolution paths all clear retry_budget alongside the blocker record (verify via post-resolution inspection of `_reader/retry_budget`). + - Predicate-stream lag fallback: simulate predicate-cursor lag past `metaLogRetention × 0.95` for some shard while original-write streams remain healthy; verify all predicate-sensitive rules in affected buckets are durably promoted to `mode = scan_only` with `degraded_reason = LAG_HIGH`; verify rules without tag/size filters are unaffected. Verify `s3.lifecycle.reseed -predicate` re-enables them via the `force_reseed_predicate` path. + - Sustained `RETRY_LATER` (transient repeated past a threshold) eventually upgrades to `BLOCKED` so the shard doesn't silently lag forever; verify the upgrade fires and the lag-correctness fallback then promotes the rule to scan_only. + +## Phase 3 — Shared cluster reader + delay-group sweeps (non-versioned) + +> **Implementation note:** the shipped reader uses `client.SubscribeMetadata(...)` with a single global `(ts_ns, offset)` cursor (`weed/s3api/s3lifecycle/reader/reader.go`). The filer's `SubscribeMetadata` server already aggregates persisted + in-memory logs across peer filers, so the per-filer-shard cursor map and the `RetainedLogRangePerShard` / `EarliestRetainedPositionPerShard` filer RPCs (Task #19) were not built. The tail-drain matrix and lost-log tests below were written against that unbuilt architecture and are obsoleted; the only residual gap — auto-degrading on `ResumeFromDiskError` — is tracked as a separate narrow follow-up. The retention mode-gate, engine routing, predicate-change pass, SKIPPED_OBJECT_LOCK, TRANSPORT_ERROR, and cursor-trail tests remain valid and were exercised against the production reader/router/engine stack. + +- Implement `s3.lifecycle.read` task: cluster singleton, subscribed to one filer at a time. ~~`Filer.CollectLogFileRefs` returns chunk refs in directory order; the worker uses `pb.ReadLogFileRefsWithPosition` (Task #19) to heap-merge per-filer chunks by event ts client-side, with per-shard `MessagePosition` skip filtering.~~ The shipped task uses a direct `SubscribeMetadata` subscription; the filer server-side does the multi-filer merge. Cursor is one global `(ts_ns, offset)` position resumed via `SinceNs`. Failover to another filer endpoint on connection error; cursor is portable (one ts). Periodic checkpointing bounds crash redo. +- ~~**Tail-drain tests required in this phase**~~ — obsoleted by the single-cursor architecture; see Phase 6 for context. +- **Retention mode-gate tests** (per `eventLogHorizon` table, kinds beyond age-based): inject a rule of each reader-driven kind and toggle `metaLogRetention` above and below `eventLogHorizon(rule) + bootstrapLookbackMin`; verify the rule is `event_driven` above and `scan_only` with `degraded_reason = RETENTION_BELOW_HORIZON` below. Cover `EXPIRATION_DAYS`, `NONCURRENT_DAYS`, `ABORT_MPU`, `NEWER_NONCURRENT`, `EXPIRED_DELETE_MARKER`. Verify `EXPIRATION_DATE` bypasses the gate regardless of retention. +- Engine drives event routing: `engine.MatchOriginalWrite(event, delay=D)` and `engine.MatchPredicateChange(event)`. +- **One sweep per delay group** (e.g. `1d`, `30d`, `60d`, `90d`); each sweep advances `reader_state.last_processed_original[D.seconds]` (a single global `(ts_ns, offset)` `MessagePosition`). Single predicate-change sweep clamped to `min(now - smallDelay, now - flushSafetyLag)` advances `reader_state.last_processed_predicate` (also a single global position). +- One filer fetch per event (`fetchLiveEntryOnce`), reused across all candidate rules. +- Per-event resolution-or-stop: `Resolved` outcomes advance the cursor; `TRANSPORT_ERROR` halts the batch and the cursor stays. +- Engine refreshes when the reader observes a lifecycle-xattr `UpdateEntry` event. +- `s3.lifecycle.drain` task drains per-rule pending exceptions. +- Tests with fixture meta logs: + - 100 buckets × 5 rules — engine routes events with O(matched-rules) per-event candidate lookup. Verify log I/O scales with `events × distinct_delay_groups` (a small handful of passes), not `events × rule_count` (500x). + - Original-write events sweep per delay group; rules with same delay share a sweep. + - Predicate-change pass uses one near-now cutoff for all rules. + - Engine rebuild on lifecycle PUT observed in log. + - Late tag-add → predicate pass picks up promptly; rare exception path writes pending. + - SCAN_AT_DATE rule never adds to pending until rule.date. + - Rule edit creates new `` dir; unrelated rule's pending unaffected. + - `SKIPPED_OBJECT_LOCK` → counter increment, log, advance (no pending, no blocked). + - `TRANSPORT_ERROR` → batch stops; reader resumes from same cursor on next task. + - Original cursor trails `now - delay`; predicate cursor stays at `now - smallDelay`. + +## Phase 4 — Strip `updateEntriesTTL` from `PutBucketLifecycleConfiguration` + +- **Now safe**: worker has been the source of truth for at least one release. +- Remove `updateEntriesTTL` and `filer.conf` mutation from `PutBucketLifecycleConfigurationHandler`. +- Reconcile-on-PUT: preserve `bootstrap_complete=true` and counters for unchanged `rule_hash`; new hashes start with `bootstrap_complete=false` (engine_state=pending_bootstrap). Reader cursors live in cluster `reader_state` and are unaffected. +- Regression test: PUT on a 10k-entry bucket completes under 1s. +- Old `TtlSec`-stamped entries continue to work via the filer read-time check; worker takes over going forward. + +## Phase 5 — Versioning + MPU + +- Implement `ruleAppliesToEntryShape` for `.versions/` paths and the current-pointer directory entry. +- Discover successor's becoming-non-current time from `event.TsNs` (or by listing the `.versions` dir for the next-newer version's mtime if event came from the dir attr update). +- `ExpiredObjectDeleteMarker` triggered when a delete-marker is the sole survivor. +- `AbortIncompleteMultipartUpload` driven by events under `.uploads/`. +- Tests covering: new version PUT, delete marker creation, non-current expiration, expired-delete-marker, MPU abort. + +## Phase 6 — Multi-filer durable replay (obsoleted) + +**Status: not built. Architecture moved on; the production reader uses the filer's existing aggregation surface.** + +What the original Phase 6 set out to add — per-filer-shard cursor maps, `EarliestRetainedPositionPerShard` and `RetainedLogRangePerShard` filer RPCs, `tail_drained_streams` GC, `CollectLogFileRefs` heap-merge, lost-log degraded promotion — was designed under the assumption that the lifecycle worker would have to assemble its own multi-filer view because `MetaAggregator` is in-memory only. + +`SubscribeMetadata` already does that work server-side: when the filer has peer filers it reads disk-persisted logs from every filer first (via `sendLogFileRefs` → `Filer.CollectLogFileRefs`) and only then drains the in-memory aggregator. A single subscription returns a durable, multi-filer-merged stream. The Phase 3 shipped reader (`weed/s3api/s3lifecycle/reader/reader.go`) consumes that stream with one global `(ts_ns, offset)` cursor and never needed the per-shard cursor map. + +What the design's per-shard architecture would have bought us, and why each piece is no longer required: + +| Phase 6 mechanism | Why it's no longer required | +|---|---| +| Per-filer-shard cursor map | Single global cursor suffices because the filer presents one merged stream. Resume-replay overhead is bounded by `LogFlushInterval` (~1m) and the dispatcher's tick interval (~200ms), so the worst-case redundant work per batch is one minute of events that the cursor compare immediately skips. | +| `RetainedLogRangePerShard` / `EarliestRetainedPositionPerShard` filer RPCs | No per-shard cursor → no need for per-shard probe. `SubscribeMetadata` already returns `ResumeFromDiskError` when the cursor predates retained logs. | +| Tail-drain GC + `tail_drained_streams` | No per-shard cursor → no entries to GC. Departed filers' events are absorbed into the aggregated stream and the global cursor advances past them. | +| Lazy seeding `seedNewlyDiscoveredShardsLazily` | No per-shard cursor → no shards to seed. New filers' events appear automatically through the aggregator. | + +Forward-compat stubs that stay in the proto: `LifecycleState.DegradedReason.LOST_LOG`, `ReaderState.tail_drained_streams`, `last_processed_original` keyed by `filer_id`. They cost nothing and keep the on-disk state file readable if a future deployment ever needs a per-shard architecture. + +The single residual gap — and the only piece worth building — is auto-degrading the reader when `SubscribeMetadata` returns `ResumeFromDiskError`. Tracked as a separate narrow follow-up (catch the error in `reader.Run`, mark reader-driven actions `scan_only` with `degraded_reason = LOST_LOG`, increment the counter, advance the cursor to retained-earliest, expose `s3.lifecycle.reseed -ack-lost-log` to flip back to event_driven once safety scans cover the gap). It is a ~50-line change, not a Phase. + +## Phase 7 — Observability + +- Shell `s3.lifecycle.status` command. +- Admin UI tile. +- `X-SeaweedFS-Lifecycle-LastTick` header. +- Prometheus metrics. + +## Phase 8 — `scan_only` cadence tuning + operator hooks + +(The gate itself lands in Phase 2; this phase is observability and tuning.) + +- Surface `scan_only` mode in shell `s3.lifecycle.status` and admin UI. +- Configurable cadence per rule via shell command (operator override of the per-kind safety-scan cadence default). +- Document in bucket-lifecycle docs: when retention < TTL, the rule runs as periodic scan; trade-offs explained. +- Manual "scan now" via shell command for unscheduled runs. + +## Cut points + +- Phase 0–2 alone delivers, on **non-versioned** buckets only: + - Currently-due deletions for age-based rules via bucket-level bootstrap that calls `LifecycleDelete` inline (no pending/drain in this path). + - Date-rule deletions (`SCAN_AT_DATE` mode: a single scheduled bucket-level bootstrap at `rule.date` deletes all matches inline). + - Periodic safety scans (re-run bootstrap on the per-kind cadence) catch drift over time. + Not-yet-due age-based discovery via meta-log events arrives in Phase 3 (the reader). Versioned buckets remain unhandled until Phase 5; the worker skips them with a logged warning until then. The existing back-stamp keeps simple `Expiration.Days` working in parallel. +- Phase 3 brings steady-state event-driven expiration. The reader is a cluster singleton subscribed to one filer via `SubscribeMetadata`, which the filer's `MetaAggregator` already merges across peer filers. Single global `(ts_ns, offset)` cursor; multi-filer clusters work the same as single-filer clusters from this point forward. +- Phase 4 makes `PUT` constant-time. Required dependency: Phase 2 + Phase 3 shipped and stable. +- Phases 5–8 complete the surface. + +--- + +# Test Plan + +Four layers, mapped to existing repo patterns. Each layer has a different cost/scope. Test work lands in the same PR as the code it covers; CI gates are by layer. + +## Layer 1 — Unit (pure Go, milliseconds) + +Location: `weed/s3api/s3lifecycle/*_test.go` next to source. Standard table-driven Go tests. No fakes, no goroutines. + +| File | Coverage | +|---|---| +| `evaluate_test.go` | `EvaluateAction(rule, kind, info, now) → action`: every action kind × (eligible, not-eligible) × every filter shape (prefix, tag, size, And-combinations). AWS edge cases: zero days, future date, empty prefix, empty/duplicate Rule.ID. Multi-action rule: each kind independently. | +| `rule_hash_test.go` | Semantically-equivalent rules hash equal. Reordered tags hash equal. Different rules hash different. ID changes don't affect hash. | +| `due_at_test.go` | `computeDueAt(rule, info)` per kind. Boundary: mtime exactly at threshold, date in past, NewerNoncurrent at exactly N versions. | +| `min_trigger_age_test.go` | `MinTriggerAge` per kind. `EXPIRATION_DATE` returns sentinel; `NEWER_NONCURRENT` returns small. | +| `event_log_horizon_test.go` | `EventLogHorizon` per kind. Age kinds return their day count; `NEWER_NONCURRENT` and `EXPIRED_DELETE_MARKER` return `smallDelay`; `EXPIRATION_DATE` is not called and panics or returns sentinel. | +| `identity_test.go` | `EntryIdentity` builder produces stable hash for same Extended; differs when tags change. Snapshot-restore (different head fid, same mtime) detected. | + +Run: `go test ./weed/s3api/s3lifecycle/...`. Phase 1 lands these alongside the function. + +## Layer 2 — Component (in-process harness, seconds–minutes) + +Location: `test/plugin_workers/lifecycle/` — new package, mirrors `test/plugin_workers/vacuum/` and `test/plugin_workers/volume_balance/`. + +Reuses `test/plugin_workers/framework.go` (in-process plugin admin gRPC + worker). Extends it with: + +1. **Meta-log fixture loader** — takes a Go slice of `{filerId, tsNs, eventType, oldEntry, newEntry}`, writes into a test filer's `/topics/.system/log/` in the on-disk format. Used by all reader/watermark tests. +2. **Controllable clock** — `s3lifecycle.Now` wired to a test func; advance without sleeping. +3. **Fake S3 LifecycleDelete server** — records calls, returns scripted outcomes, so component tests assert exact RPC sequences. + +Files and the cases each owns: + +``` +test/plugin_workers/lifecycle/ + framework.go # harness extensions + detection_test.go # detector emits the right tasks given state + bootstrap_test.go # Phase 2 + reader_test.go # Phase 3 + drain_test.go # Phase 3 + versioning_test.go # Phase 5 + multi_filer_test.go # Phase 6 + metadata_only_test.go # Phase 2b + s3_discovery_test.go # Phase 2 +``` + +Cases: + +**Bootstrap** — inline-delete for age rules (currently-due deleted, not-yet-due skipped), `SCAN_AT_DATE` for date rules (date-triggered bucket walk), kill-resume via `last_scanned_path`, walk-cursor drift covered by past-`MinTriggerAge` cursor-seed, scan_only gate, per-rule `bootstrap_complete` set on completion before engine activation. + +**Reader / event-driven** — original-write threshold, late tag-add via predicate pass, two watermarks advance independently, cross-filer heap-merge, Extended-equal events filtered, lifecycle-XML xattr update reloads trie. + +**Drain / `LifecycleDelete`** — identity CAS reject, per-rule policy CAS (unrelated edit doesn't poison), `SKIPPED_OBJECT_LOCK` → log + counter + advance (no pending, no blocked). + +**Metadata-only delete** — `TtlSec > 0` + matching `filer.conf` rule → server invoked with `skip_chunk_delete=true`; intercept fake-filer chunk-deletion queue to assert no enqueue. + +**Worker–S3 discovery** — all endpoints unhealthy → continuation-hint backoff, no progress, no data loss; flapping endpoint rotates; S3 down at startup → worker boots, drains catch up on recovery. + +Run: `go test ./test/plugin_workers/lifecycle/...`. Phase mapping: each phase ships its half. + +## Layer 3 — Real-server integration (AWS SDK + live `weed server`, minutes per scenario) + +Location: `test/s3/lifecycle/` — new, copy of `test/s3/retention/` shape (Makefile-driven binary build, server start/stop, AWS SDK clients). + +``` +test/s3/lifecycle/ + Makefile # mirrors test/s3/retention/Makefile + test_config.json + s3_lifecycle_basic_test.go # PUT/GET/DELETE + s3_lifecycle_put_perf_test.go # 60k-object PUT < 1s — original ticket regression + s3_lifecycle_age_test.go # ExpirationDays end-to-end + s3_lifecycle_date_test.go # ExpirationDate end-to-end + s3_lifecycle_tag_filter_test.go # tag/size filters + s3_lifecycle_late_tag_test.go # tag added at age 30d on 60d rule expires at AWS-correct time + s3_lifecycle_versioning_test.go # versioned bucket flows + s3_lifecycle_mpu_test.go # AbortIncompleteMultipartUpload + s3_lifecycle_object_lock_test.go # retain-until + legal hold interactions + s3_lifecycle_metadata_only_test.go # volume-TTL fast path + s3_lifecycle_scan_only_test.go # short-retention forces scan_only +``` + +Time compression: existing build tag `lifecycle_10sec` (`weed/util/constants_lifecycle_interval_10sec.go`) treats 1 day as 10 seconds. Verify it covers `MinTriggerAge` math; add a `bootstrapLookbackMin` constant in the same files so it scales too. Makefile target: + +``` +test-fast: build-weed-10sec + go test -tags lifecycle_10sec -timeout 15m ./... +``` + +Run: `make -C test/s3/lifecycle test-with-server`. Phase mapping: end-to-end tests land in the same phase as the capability they cover. + +## Layer 4 — Multi-filer convergence (obsoleted) + +**Not built.** Phase 6 was obsoleted by `SubscribeMetadata`'s server-side aggregation (see the Phase 6 section). With a single global cursor on a server-merged stream, there are no per-filer-shard convergence properties for the worker to test — any merge correctness lives inside the filer's `MetaAggregator`, which has its own coverage in `weed/filer/`. + +If `ResumeFromDiskError` auto-degrade lands as a follow-up (task #21), its tests live with the reader package, not in a separate multi-filer harness. + +## CI gating + +| Layer | When | Time | +|---|---|---| +| `make test-unit` | every push | < 10s | +| `make test-component` | every push | < 2m | +| `make test-s3-lifecycle` (Layer 3, 10sec build tag) | PRs touching `weed/s3api/s3lifecycle/**`, `weed/worker/tasks/s3lifecycle/**`, or `weed/s3api/s3api_internal_lifecycle.go` | ~20m | +| `make test-multi-filer-lifecycle` | nightly | ~30m | + +CI must require `test-component` and `test-s3-lifecycle` green before merging Phase 4 (the PUT-strip). All layers green required for Phase 5+. diff --git a/seaweed-volume/src/server/grpc_server.rs b/seaweed-volume/src/server/grpc_server.rs new file mode 100644 index 0000000..6a0532f --- /dev/null +++ b/seaweed-volume/src/server/grpc_server.rs @@ -0,0 +1,8428 @@ +//! gRPC service implementation for the volume server. +//! +//! Implements the VolumeServer trait generated from volume_server.proto. +//! 48 RPCs: core volume operations are fully implemented, streaming and +//! EC operations are stubbed with appropriate error messages. + +use std::ops::ControlFlow; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use tokio_stream::Stream; +use tonic::{Request, Response, Status, Streaming}; + +use crate::pb::filer_pb; +use crate::pb::master_pb; +use crate::pb::master_pb::seaweed_client::SeaweedClient; +use crate::pb::volume_server_pb; +use crate::pb::volume_server_pb::volume_server_server::VolumeServer; +use crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT; +use crate::storage::needle::needle::{self, Needle}; +use crate::storage::types::*; +use crate::storage::volume::VolumeSpec; + +use super::grpc_client::{GRPC_MAX_MESSAGE_SIZE, build_grpc_endpoint}; +use super::volume_server::VolumeServerState; + +type BoxStream = Pin> + Send + 'static>>; + +fn volume_is_remote_only(dat_path: &str, has_remote_file: bool) -> bool { + has_remote_file && !std::path::Path::new(dat_path).exists() +} + +/// Map a numeric `VolumeScrubMode` to its proto enum name, matching Go's +/// `req.GetMode().String()` used for the Prometheus `mode` label. +fn scrub_mode_label(mode: i32) -> &'static str { + match mode { + 0 => "UNKNOWN", + 1 => "INDEX", + 2 => "FULL", + 3 => "LOCAL", + 4 => "CHECKSUM", + 5 => "READS", + _ => "UNKNOWN", + } +} + +fn unix_now_seconds() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as f64) + .unwrap_or(0.0) +} + +/// Record scrub metrics. `broken_shards` is `Some` only for EC scrubs so the +/// shard-failures family stays untouched on regular volume scrubs (matching Go). +/// How a scrub reacts to a volume that is not in the store when its turn comes. +/// +/// Nothing is held across the scan, so the volume set is free to change under a +/// node-wide scrub: the heartbeat drops a volume that reported an I/O error and +/// expires an EC volume whose destroy time passed, and a delete or an unmount +/// can land between any two volumes. Failing there would discard every result +/// gathered so far and leave every later volume unscrubbed. +/// +/// `Some(status)` means fail the request; `None` means skip this volume and +/// keep going. A volume the caller NAMED is always the former — that one is a +/// caller mistake, not a concurrent change, and callers match on the message. +fn scrub_vanished_volume(explicit: bool, kind: &str, vid: VolumeId) -> Option { + if explicit { + return Some(Status::not_found(format!( + "{} id {} not found", + kind, vid.0 + ))); + } + tracing::info!( + volume_id = vid.0, + "scrub: {} {} is no longer mounted, skipping", + kind, + vid.0 + ); + None +} + +/// How a scrub reacts to a `spawn_blocking` scan that failed to join. +/// +/// The scan runs on the blocking pool, so a panic in it reaches the loop as a +/// `JoinError` instead of unwinding here. Propagating it would discard every +/// volume already scanned AND skip `emit_scrub_metrics`: a panic scrubbing one +/// volume would hide real corruption found on the others and leave the +/// staleness alert firing with nothing recorded to explain it. Record it +/// against this volume and let the loop finish, the same way `scrub_volumes` +/// treats a per-volume scrub error. +/// +/// A panic is evidence about the volume, so it counts as broken. A join error +/// from runtime shutdown is not -- the volume was never scanned, and counting +/// it would put a false corruption into SCRUB_VOLUME_FAILURES. +fn record_scrub_join_failure( + e: &tokio::task::JoinError, + vid: VolumeId, + what: &str, + broken_volume_ids: &mut Vec, + details: &mut Vec, +) { + if e.is_panic() { + tracing::error!( + volume_id = vid.0, + "scrub: {} task for EC volume {} panicked: {}", + what, + vid.0, + e + ); + broken_volume_ids.push(vid.0); + details.push(format!("ecvol {}: {} task panicked: {}", vid.0, what, e)); + } else { + tracing::info!( + volume_id = vid.0, + "scrub: {} task for EC volume {} was cancelled, skipping", + what, + vid.0 + ); + details.push(format!("ecvol {}: {} task cancelled; skipped", vid.0, what)); + } +} + +fn emit_scrub_metrics(mode: i32, broken_volumes: usize, broken_shards: Option) { + let mode_label = scrub_mode_label(mode); + crate::metrics::SCRUB_LAST_TIME_SECONDS + .with_label_values(&[mode_label]) + .set(unix_now_seconds()); + crate::metrics::SCRUB_VOLUME_FAILURES + .with_label_values(&[mode_label]) + .inc_by(broken_volumes as u64); + if let Some(n) = broken_shards { + crate::metrics::SCRUB_SHARD_FAILURES + .with_label_values(&[mode_label]) + .inc_by(n as u64); + } +} + +/// Persist VolumeServerState to a state.pb file (matches Go's State.save). +fn save_state_file( + path: &str, + state: &volume_server_pb::VolumeServerState, +) -> Result<(), std::io::Error> { + if path.is_empty() { + return Ok(()); + } + use prost::Message; + let buf = state.encode_to_vec(); + std::fs::write(path, buf) +} + +/// Load VolumeServerState from a state.pb file (matches Go's State.Load). +pub fn load_state_file(path: &str) -> Option { + if path.is_empty() || !std::path::Path::new(path).exists() { + return None; + } + let data = std::fs::read(path).ok()?; + use prost::Message; + volume_server_pb::VolumeServerState::decode(data.as_slice()).ok() +} + +/// One disk location's stake in an EC volume, as seen by the rebuild handler. +struct LocInfo { + dir: String, + idx_dir: String, + shard_count: usize, + has_ecx: bool, +} + +/// Picks the location a rebuild should write into — the one holding an `.ecx` +/// and the most shards — and returns every other directory it may have to read +/// from. Shards are only half of what the rebuild needs: a split +/// `-dir`/`-dir.idx` layout keeps `.ecx`/`.ecj`/`.vif` with the INDEX, and on a +/// multi-disk server the chosen disk may hold nothing but shards while this +/// volume's `.vif` or generation-0 `.ecsum` sits on a sibling. Miss those and +/// the layout resolution falls back to 10+4 with the legacy striping and +/// reconstructs through the wrong matrix, so both directories of every other +/// location are listed. The rebuild's own two are passed separately by the +/// caller and dropped here, along with empties and duplicates. +/// +/// Returns `None` when no location holds an `.ecx`, i.e. there is nothing to +/// rebuild from. +fn select_rebuild_location(loc_infos: &[LocInfo]) -> Option<(usize, Vec)> { + let mut rebuild_loc_idx: Option = None; + let mut other_dirs: Vec = Vec::new(); + + for (i, info) in loc_infos.iter().enumerate() { + let better = info.has_ecx + && rebuild_loc_idx.is_none_or(|prev| info.shard_count > loc_infos[prev].shard_count); + if better { + if let Some(prev) = rebuild_loc_idx { + other_dirs.push(loc_infos[prev].dir.clone()); + other_dirs.push(loc_infos[prev].idx_dir.clone()); + } + rebuild_loc_idx = Some(i); + } else { + other_dirs.push(info.dir.clone()); + other_dirs.push(info.idx_dir.clone()); + } + } + + let rebuild_loc_idx = rebuild_loc_idx?; + let rebuild_dir = &loc_infos[rebuild_loc_idx].dir; + let rebuild_idx_dir = &loc_infos[rebuild_loc_idx].idx_dir; + other_dirs.retain(|d| !d.is_empty() && d != rebuild_dir && d != rebuild_idx_dir); + other_dirs.sort(); + other_dirs.dedup(); + + Some((rebuild_loc_idx, other_dirs)) +} + +struct WriteThrottler { + bytes_per_second: i64, + last_size_counter: i64, + last_size_check_time: std::time::Instant, +} + +impl WriteThrottler { + fn new(bytes_per_second: i64) -> Self { + Self { + bytes_per_second, + last_size_counter: 0, + last_size_check_time: std::time::Instant::now(), + } + } + + async fn maybe_slowdown(&mut self, delta: i64) { + if self.bytes_per_second <= 0 { + return; + } + + self.last_size_counter += delta; + let elapsed = self.last_size_check_time.elapsed(); + if elapsed <= std::time::Duration::from_millis(100) { + return; + } + + let over_limit_bytes = self.last_size_counter - self.bytes_per_second / 10; + if over_limit_bytes > 0 { + let over_ratio = over_limit_bytes as f64 / self.bytes_per_second as f64; + let sleep_time = std::time::Duration::from_millis((over_ratio * 1000.0) as u64); + if !sleep_time.is_zero() { + tokio::time::sleep(sleep_time).await; + } + } + + self.last_size_counter = 0; + self.last_size_check_time = std::time::Instant::now(); + } +} + +struct MasterVolumeInfo { + volume_id: VolumeId, + collection: String, + replica_placement: u8, + ttl: u32, + disk_type: String, + ip: String, + port: u16, +} + +pub struct VolumeGrpcService { + pub state: Arc, +} + +impl VolumeGrpcService { + /// Verifies the gRPC caller is allowed to invoke a destructive admin + /// operation. Mirrors the Go side's checkGrpcAdminAuth: an empty + /// whitelist accepts everyone (insecure-by-default for tests and + /// upgrades), a populated whitelist accepts only matching peer IPs. + /// + /// `remote_addr()` on a real gRPC connection always yields the peer's + /// SocketAddr; if it is somehow None we deny, matching "if we don't + /// know who the caller is, refuse." + fn check_grpc_admin_auth(&self, request: &Request) -> Result<(), Status> { + // Mirror Go's `if vs.guard == nil { return nil }`: with no whitelist and + // no signing key, write security is inactive and every caller is allowed. + // Real gRPC connections always carry peer info, so requiring it below only + // affects in-process callers (tests, upgrades) once a control is enabled. + if !self.state.guard.read().unwrap().is_write_active() { + return Ok(()); + } + let remote = match request.remote_addr() { + Some(addr) => addr, + None => { + tracing::warn!("gRPC admin auth failed: no peer info"); + return Err(Status::permission_denied("no peer info")); + } + }; + let host = remote.ip().to_string(); + let guard = self.state.guard.read().unwrap(); + if !guard.check_whitelist(&host) { + tracing::warn!( + "gRPC admin auth failed: {} is not whitelisted (remote: {})", + host, + remote, + ); + return Err(Status::permission_denied(format!("not authorized: {host}"))); + } + Ok(()) + } + + async fn notify_master_volume_readonly( + &self, + info: &MasterVolumeInfo, + is_readonly: bool, + ) -> Result<(), Status> { + // VolumeMarkReadonly mutates raft-replicated master topology, so it must + // reach the leader. Prefer the live leader the heartbeat is talking to + // (current_master_url), falling back to the static seed before the first + // heartbeat — mirrors store_ec.rs and Go's vs.GetMaster(). Sending it to + // the static seed fails "not current leader" after any master failover. + let master_url = { + let live = self.state.current_master_url.read().await.clone(); + if !live.is_empty() { + live + } else { + self.state.master_url.clone() + } + }; + if master_url.is_empty() { + return Ok(()); + } + let grpc_addr = parse_grpc_address(&master_url).map_err(|e| { + Status::internal(format!("invalid master address {}: {}", master_url, e)) + })?; + let endpoint = build_grpc_endpoint(&grpc_addr, self.state.outgoing_grpc_tls.as_ref()) + .map_err(|e| Status::internal(format!("master address {}: {}", master_url, e)))? + .connect_timeout(std::time::Duration::from_secs(5)) + .timeout(std::time::Duration::from_secs(30)); + let channel = endpoint + .connect() + .await + .map_err(|e| Status::internal(format!("connect to master {}: {}", master_url, e)))?; + let mut client = SeaweedClient::with_interceptor( + channel, + super::request_id::outgoing_request_id_interceptor, + ) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + client + .volume_mark_readonly(master_pb::VolumeMarkReadonlyRequest { + ip: info.ip.clone(), + port: info.port as u32, + volume_id: info.volume_id.0, + collection: info.collection.clone(), + replica_placement: info.replica_placement as u32, + ttl: info.ttl, + disk_type: info.disk_type.clone(), + is_readonly, + ..Default::default() + }) + .await + .map_err(|e| { + Status::internal(format!( + "set volume {} readonly={} on master {}: {}", + info.volume_id, is_readonly, master_url, e + )) + })?; + Ok(()) + } + + /// Shared helper matching Go's `makeVolumeReadonly(ctx, v, canDelete, persist)`. + /// Not gated on maintenance mode: marking a volume readonly only restricts a + /// server that is already meant to be read-only, and it is the first step of + /// moving a volume off a server under evacuation (issue #11066). + /// 1. Notify master (readonly=true) + /// 2. Mark local volume readonly + /// 3. Notify master again (cover heartbeat race) + async fn make_volume_readonly( + &self, + vid: VolumeId, + can_delete: bool, + persist: bool, + ) -> Result<(), Status> { + let info = { + let store = self.state.store.read().unwrap(); + let (loc_idx, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("volume {} not found", vid)))?; + MasterVolumeInfo { + volume_id: vid, + collection: vol.collection.clone(), + replica_placement: vol.super_block.replica_placement.to_byte(), + ttl: vol.super_block.ttl.to_u32(), + disk_type: store.locations[loc_idx].disk_type.to_string(), + ip: store.ip.clone(), + port: store.port, + } + }; + + // Step 1: stop master from redirecting traffic here + self.notify_master_volume_readonly(&info, true).await?; + + // Step 2: mark local volume readonly + { + let mut store = self.state.store.write().unwrap(); + if let Some((_, vol)) = store.find_volume_mut(vid) { + vol.set_read_only_persist(can_delete, persist) + .map_err(|e| Status::internal(e.to_string()))?; + } + self.state.volume_state_notify.notify_one(); + } + + // Step 3: notify master again to cover heartbeat race + self.notify_master_volume_readonly(&info, true).await?; + Ok(()) + } + + /// The scrub loop over an already-resolved volume list. + /// + /// Split out of `scrub_volume` so a test can hand it a list containing an + /// id that is not in the store: listing and lookup read the same map under + /// the same lock, so nothing outside can make an id vanish between them. + async fn scrub_volumes( + &self, + req: &volume_server_pb::ScrubVolumeRequest, + vids: Vec, + explicit: bool, + ) -> Result, Status> { + let mode = req.mode; + let mut total_volumes: u64 = 0; + let mut total_files: u64 = 0; + let mut broken_volume_ids: Vec = Vec::new(); + let mut details: Vec = Vec::new(); + let mut broken_vids: Vec = Vec::new(); + + // Scrub phase. The store read guard is taken PER VOLUME, never across the + // whole loop. + // + // Holding one guard for the entire loop meant a node-wide scrub pinned the + // store lock for the full scan of every volume it holds. The periodic + // heartbeat takes store.write(); once that writer is pending, a + // write-preferring std::sync::RwLock queues every later reader behind it, + // so every HTTP handler blocks and the node stops serving and + // heart-beating until the scrub finishes. Re-acquiring per volume lets the + // writer land between volumes. + // + // NOTE: v.scrub() still runs under the guard for the duration of ONE + // volume, which for a large volume is still a long hold. In + // scrub_ec_volume the INDEX, LOCAL and CHECKSUM arms snapshot a plan and + // scan with the guard released; FULL/READS releases it across the index + // walk but still re-takes it per needle, in + // store_ec::scrub_snapshot_under_lock. The same treatment here needs + // Volume to expose an equivalent plan and is left as a follow-up. + for vid in &vids { + // Re-resolve under a fresh guard each iteration; the volume set can + // legitimately change between volumes now that the lock is released. + let Some((scrub_result, file_count)) = ({ + let store = self.state.store.read().unwrap(); + store.find_volume(*vid).map(|(_, v)| { + // INDEX mode (1) calls scrub_index; FULL (2) and LOCAL (3) call scrub + let r = if mode == 1 { + v.scrub_index() + } else { + v.scrub() + }; + (r, v.file_count()) + }) + }) else { + if let Some(status) = scrub_vanished_volume(explicit, "volume", *vid) { + return Err(status); + } + continue; + }; + total_volumes += 1; + + match scrub_result { + Ok((files, broken)) => { + total_files += files; + if !broken.is_empty() { + broken_vids.push(*vid); + broken_volume_ids.push(vid.0); + for msg in broken { + details.push(format!("vol {}: {}", vid.0, msg)); + } + } + } + Err(e) => { + total_files += file_count.max(0) as u64; + broken_vids.push(*vid); + broken_volume_ids.push(vid.0); + details.push(format!("vol {}: scrub error: {}", vid.0, e)); + } + } + } + + // Match Go: if mark_broken_volumes_readonly, call makeVolumeReadonly on each broken volume. + // Collect errors via errors.Join semantics (return joined error if any fail). + let mut errs: Vec = Vec::new(); + if req.mark_broken_volumes_readonly { + for vid in &broken_vids { + match self.make_volume_readonly(*vid, false, true).await { + Ok(()) => { + details.push(format!("volume {} is now read-only", vid.0)); + } + // The volume was scrubbed, found broken, and then removed — + // the same concurrent teardown the scrub loop tolerates, one + // step later. There is nothing left to mark read-only, and + // failing here would throw away the whole scrub report. Go + // never reaches this at all: it passes makeVolumeReadonly the + // *storage.Volume it already holds, so a volume that left the + // store is still addressable there. + Err(e) if e.code() == tonic::Code::NotFound => { + tracing::info!( + volume_id = vid.0, + "scrub: volume {} vanished before mark-readonly, skipping", + vid.0 + ); + details.push(format!( + "volume {} vanished before mark-readonly; skipped", + vid.0 + )); + } + Err(e) => { + errs.push(e.message().to_string()); + details.push(e.message().to_string()); + } + } + } + } + + // Record metrics before the post-scrub error check so scrub failures are + // persisted even when a follow-up admin action (mark-readonly) fails. + emit_scrub_metrics(mode, broken_vids.len(), None); + + if !errs.is_empty() { + return Err(Status::internal(errs.join("\n"))); + } + + Ok(Response::new(volume_server_pb::ScrubVolumeResponse { + total_volumes, + total_files, + broken_volume_ids, + details, + })) + } + + /// The EC scrub loop over an already-resolved volume list. Same split, and + /// same vanished-volume rule, as `scrub_volumes` — see its comment. + async fn scrub_ec_volumes( + &self, + req: &volume_server_pb::ScrubEcVolumeRequest, + vids: Vec, + explicit: bool, + ) -> Result, Status> { + let mode = req.mode; + let force_deleted_needles_check = req.force_deleted_needles_check; + let mut total_volumes: u64 = 0; + let mut total_files: u64 = 0; + let mut broken_volume_ids: Vec = Vec::new(); + let mut broken_shard_infos: Vec = Vec::new(); + let mut details: Vec = Vec::new(); + + for vid in vids { + match mode { + 1 => { + // INDEX mode: check ecx index integrity only, no shard verification. + // Same shape as the CHECKSUM arm below: snapshot, release + // the store lock, then walk the index. + let Some(plan) = ({ + let store = self.state.store.read().unwrap(); + store.find_ec_volume(vid).map(|ecv| ecv.scrub_index_plan()) + }) else { + if let Some(status) = scrub_vanished_volume(explicit, "EC volume", vid) { + return Err(status); + } + continue; + }; + // Counted as attempted BEFORE the join, so a failed join + // cannot silently shrink total_volumes. + total_volumes += 1; + let (count, errs) = match tokio::task::spawn_blocking(move || plan.run()).await + { + Ok(v) => v, + Err(e) => { + record_scrub_join_failure( + &e, + vid, + "index scrub", + &mut broken_volume_ids, + &mut details, + ); + continue; + } + }; + total_files += count; + if !errs.is_empty() { + broken_volume_ids.push(vid.0); + for msg in errs { + details.push(format!("ecvol {}: {}", vid.0, msg)); + } + } + } + 2 | 5 => { + // FULL/READS: Go-parity per-needle local+remote walk, PLUS a TEMPORARY + // local Reed-Solomon parity check. The needle walk only reads + // DATA-shard intervals of LIVE needles, so on its own it can't + // catch silent bitrot in a PARITY shard or an unwalked cold + // region. Go closes that gap with a separate CHECKSUM mode over + // .ecsum, which Rust does not have yet; running both here is a + // deliberate divergence from Go FULL to preserve coverage. Drop + // verify_ec_shards from this arm once mode 4 (CHECKSUM) lands. + // + // The RS recompute needs every shard, so only run it when this + // node holds all data+parity shards (single-node EC); on a + // distributed layout it would report every non-local shard as + // missing. The shards need not share one disk: `verify_ec_shards` + // takes a directory per shard id, so a reconciled volume split + // across this node's disks still qualifies. + // Snapshot under a brief lock; release before await. + let Some(( + dirs, + collection, + data_shards, + parity_shards, + all_local, + skipped, + encode_ts_ns, + )) = ({ + let store = self.state.store.read().unwrap(); + let runtimes = store.find_all_ec_volumes(vid); + crate::storage::erasure_coding::ec_volume::merge_ec_runtimes(&runtimes).map( + |m| { + let total = + (m.anchor.data_shards + m.anchor.parity_shards) as usize; + let dirs: Vec> = (0..total) + .map(|id| { + m.slots + .get(id) + .copied() + .flatten() + .map(|(owner, _)| owner.dir.clone()) + }) + .collect(); + let all_local = dirs.iter().all(|d| d.is_some()); + ( + dirs, + m.anchor.collection.clone(), + m.anchor.data_shards as usize, + m.anchor.parity_shards as usize, + all_local, + m.skipped, + m.anchor.encode_ts_ns, + ) + }, + ) + }) + else { + if let Some(status) = scrub_vanished_volume(explicit, "EC volume", vid) { + return Err(status); + } + continue; + }; + total_volumes += 1; + + // (1) Per-needle local+remote walk (Go ScrubEcVolume parity). + let (files, mut shard_infos, mut errs) = + crate::server::store_ec::scrub_ec_volume_distributed( + &self.state, + vid, + encode_ts_ns, + force_deleted_needles_check, + mode == 5, + ) + .await; + total_files += files as u64; + + errs.extend(skipped); + + // (2) Local parity check, gated on all-shards-local. Blocking RS + // verify -> spawn_blocking; inputs are owned, no lock held. + if all_local { + let collection_pc = collection.clone(); + let dirs_pc = dirs; + let join = tokio::task::spawn_blocking(move || { + crate::storage::erasure_coding::ec_encoder::verify_ec_shards( + &dirs_pc, + &collection_pc, + vid, + data_shards, + parity_shards, + ) + }) + .await; + // Unlike the other arms this must NOT `continue`: the + // needle walk above already produced findings for this + // volume, and dropping them here would recreate the bug + // this handles, one scope down. So a panic becomes an + // error for the volume, and a cancellation -- which + // spawn_blocking only reports when the runtime is going + // down, so this response is unlikely to reach anyone -- + // records in details that the parity half did not run + // rather than inventing a corruption for it. + let (parity_broken, parity_details) = match join { + Ok(r) => r.unwrap_or_else(|e| { + (Vec::new(), vec![format!("verify_ec_shards: {}", e)]) + }), + Err(e) if e.is_panic() => ( + Vec::new(), + vec![format!("verify_ec_shards task panicked: {}", e)], + ), + Err(e) => { + details.push(format!( + "ecvol {}: verify_ec_shards task cancelled; parity check skipped ({})", + vid.0, e + )); + (Vec::new(), Vec::new()) + } + }; + + let mut seen: std::collections::HashSet = + shard_infos.iter().map(|s| s.shard_id).collect(); + for sid in parity_broken { + if seen.insert(sid) { + shard_infos.push(volume_server_pb::EcShardInfo { + shard_id: sid, + collection: collection.clone(), + volume_id: vid.0, + ..Default::default() + }); + } + } + shard_infos.sort_by_key(|s| s.shard_id); + errs.extend(parity_details); + } + + if !errs.is_empty() || !shard_infos.is_empty() { + broken_volume_ids.push(vid.0); + broken_shard_infos.extend(shard_infos); + for msg in errs { + details.push(format!("ecvol {}: {}", vid.0, msg)); + } + } + } + 3 => { + // LOCAL: verify each needle against the locally-held shards. + // Snapshot under a brief lock, then walk with the lock + // RELEASED: this reads every local needle's bytes, which + // under the guard stalls the node. See EcChecksumScrubPlan. + let Some(plan) = ({ + let store = self.state.store.read().unwrap(); + // Every disk holding this vid, not just the first: a + // reconciled volume's shards are split across runtimes. + crate::storage::erasure_coding::ec_volume::EcLocalScrubPlan::for_volumes( + &store.find_all_ec_volumes(vid), + ) + }) else { + if let Some(status) = scrub_vanished_volume(explicit, "EC volume", vid) { + return Err(status); + } + continue; + }; + total_volumes += 1; + // Synchronous CPU + file I/O: keep it off the async workers. + let (files, shard_infos, errs) = + match tokio::task::spawn_blocking(move || plan.run()).await { + Ok(v) => v, + Err(e) => { + record_scrub_join_failure( + &e, + vid, + "local scrub", + &mut broken_volume_ids, + &mut details, + ); + continue; + } + }; + total_files += files; + if !errs.is_empty() || !shard_infos.is_empty() { + broken_volume_ids.push(vid.0); + broken_shard_infos.extend(shard_infos); + for msg in errs { + details.push(format!("ecvol {}: {}", vid.0, msg)); + } + } + } + 4 => { + // CHECKSUM: verify each local shard's raw bytes against the + // bitrot checksum sidecar, exercising cold parity shards. + // Read-only. Mirrors Go's v.ChecksumScrub(). + // Snapshot under a brief lock, then verify with the lock + // RELEASED: this reads every byte of every local shard, which + // under the guard stalls the node. See EcChecksumScrubPlan. + let Some((plan, collection)) = ({ + let store = self.state.store.read().unwrap(); + let runtimes = store.find_all_ec_volumes(vid); + let collection = runtimes.first().map(|ecv| ecv.collection.clone()); + crate::storage::erasure_coding::ec_volume::EcChecksumScrubPlan::for_volumes( + &runtimes, + ) + .zip(collection) + }) else { + if let Some(status) = scrub_vanished_volume(explicit, "EC volume", vid) { + return Err(status); + } + continue; + }; + total_volumes += 1; + // Synchronous CPU + file I/O: keep it off the async workers. + // `plan.run()`'s first return is blocks scanned, not a file + // count — Go discards it (`_, shardInfos, serrs = v.ChecksumScrub()`) + // so TotalFiles stays a needle/file count and is not inflated + // by the block count. + let (_blocks_scanned, broken, errs) = + match tokio::task::spawn_blocking(move || plan.run()).await { + Ok(v) => v, + Err(e) => { + record_scrub_join_failure( + &e, + vid, + "checksum scrub", + &mut broken_volume_ids, + &mut details, + ); + continue; + } + }; + if !errs.is_empty() || !broken.is_empty() { + broken_volume_ids.push(vid.0); + for b in broken { + broken_shard_infos.push(volume_server_pb::EcShardInfo { + volume_id: vid.0, + collection: collection.clone(), + shard_id: b, + ..Default::default() + }); + } + for msg in errs { + details.push(format!("ecvol {}: {}", vid.0, msg)); + } + } + } + _ => unreachable!(), // validated above + } + } + + emit_scrub_metrics( + mode, + broken_volume_ids.len(), + Some(broken_shard_infos.len()), + ); + + Ok(Response::new(volume_server_pb::ScrubEcVolumeResponse { + total_volumes, + total_files, + broken_volume_ids, + broken_shard_infos, + details, + })) + } +} + +#[tonic::async_trait] +impl VolumeServer for VolumeGrpcService { + // ---- Core volume operations ---- + + async fn batch_delete( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let mut results = Vec::new(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + for fid_str in &req.file_ids { + let file_id = match needle::FileId::parse(fid_str) { + Ok(fid) => fid, + Err(e) => { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 400, // Bad Request + error: e, + size: 0, + version: 0, + }); + continue; + } + }; + + let mut n = Needle { + id: file_id.key, + cookie: file_id.cookie, + ..Needle::default() + }; + + // Check if this is an EC volume + let is_ec_volume = { + let store = self.state.store.read().unwrap(); + store.has_ec_volume(file_id.volume_id) + }; + + // Cookie validation (unless skip_cookie_check) + if !req.skip_cookie_check { + let original_cookie = n.cookie; + if !is_ec_volume { + let store = self.state.store.read().unwrap(); + match store.read_volume_needle(file_id.volume_id, &mut n) { + Ok(_) => {} + Err(e) => { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 404, + error: e.to_string(), + size: 0, + version: 0, + }); + continue; + } + } + } else { + // For EC volumes, verify needle exists in ecx index + let store = self.state.store.read().unwrap(); + if let Some(ec_vol) = store.find_ec_volume(file_id.volume_id) { + match ec_vol.find_needle_from_ecx(n.id) { + Ok(Some((_, size))) if !size.is_deleted() => { + // Needle exists and is not deleted — cookie check not possible + // for EC volumes without distributed read, so we accept it + n.data_size = size.0 as u32; + } + Ok(_) => { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 404, + error: format!("ec needle {} not found", fid_str), + size: 0, + version: 0, + }); + continue; + } + Err(e) => { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 404, + error: e.to_string(), + size: 0, + version: 0, + }); + continue; + } + } + } else { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 404, + error: format!("ec volume {} not found", file_id.volume_id), + size: 0, + version: 0, + }); + continue; + } + } + if n.cookie != original_cookie { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 400, + error: "File Random Cookie does not match.".to_string(), + size: 0, + version: 0, + }); + continue; + } + } + + // Reject chunk manifest needles + if n.is_chunk_manifest() { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 406, + error: "ChunkManifest: not allowed in batch delete mode.".to_string(), + size: 0, + version: 0, + }); + continue; + } + + n.last_modified = now; + + if !is_ec_volume { + let mut store = self.state.store.write().unwrap(); + // Recheck EC state under the write lock before mutating the .dat. The + // is_ec_volume snapshot was taken earlier under a separate read lock; + // ec.encode mounts EC shards (copied from the .dat) BEFORE deleting the + // originals, so the vid can be EC now while the .dat still exists. A + // delete_volume_needle here would tombstone that .dat, which the encode + // then removes — the delete is lost and the needle resurrected from the + // pre-tombstone shards. If the vid is now EC, return a retriable 503 so + // the filer requeues the delete onto the EC path. + if store.has_ec_volume(file_id.volume_id) { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 503, + error: format!( + "volume {} became ec during delete, try again", + file_id.volume_id + ), + size: 0, + version: 0, + }); + continue; + } + match store.delete_volume_needle(file_id.volume_id, &mut n) { + Ok(size) => { + if size.0 == 0 { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 304, + error: String::new(), + size: 0, + version: 0, + }); + } else { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 202, + error: String::new(), + size: size.0 as u32, + version: 0, + }); + } + } + Err(e) => { + // The volume vanished between the is_ec_volume snapshot and + // this mutation. If an EC volume now occupies the vid (ec.encode + // mounted EC then deleted the .dat under us), the delete belongs + // on the EC journal, not here — return a retriable 503 with the + // "try again" token so the filer requeues it and the retry hits + // the EC path. A bare exact "not found" would be dropped + // permanently by the filer chunk-GC. + if matches!(e, crate::storage::volume::VolumeError::NotFound) + && store.has_ec_volume(file_id.volume_id) + { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 503, + error: format!( + "volume {} became ec during delete, try again", + file_id.volume_id + ), + size: 0, + version: 0, + }); + } else { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 500, + error: e.to_string(), + size: 0, + version: 0, + }); + } + } + } + } else { + // EC volume deletion: journal the delete locally (with cookie validation, matching Go) + let mut store = self.state.store.write().unwrap(); + if let Some(ec_vol) = store.find_ec_volume_mut(file_id.volume_id) { + match ec_vol.journal_delete_with_cookie(n.id, n.cookie) { + Ok(()) => { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 202, + error: String::new(), + size: n.data_size, + version: 0, + }); + } + Err(e) => { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 500, + error: e.to_string(), + size: 0, + version: 0, + }); + } + } + } else { + results.push(volume_server_pb::DeleteResult { + file_id: fid_str.clone(), + status: 404, + error: format!("ec volume {} not found", file_id.volume_id), + size: 0, + version: 0, + }); + } + } + } + + Ok(Response::new(volume_server_pb::BatchDeleteResponse { + results, + })) + } + + async fn vacuum_volume_check( + &self, + request: Request, + ) -> Result, Status> { + let vid = VolumeId(request.into_inner().volume_id); + let store = self.state.store.read().unwrap(); + let garbage_ratio = match store.find_volume(vid) { + Some((_, vol)) => vol.garbage_level(), + None => return Err(Status::not_found(format!("not found volume id {}", vid))), + }; + Ok(Response::new(volume_server_pb::VacuumVolumeCheckResponse { + garbage_ratio, + })) + } + + type VacuumVolumeCompactStream = BoxStream; + async fn vacuum_volume_compact( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let preallocate = req.preallocate as u64; + let state = self.state.clone(); + + let (tx, rx) = tokio::sync::mpsc::channel(16); + + tokio::task::spawn_blocking(move || { + let compact_start = std::time::Instant::now(); + let report_interval: i64 = 128 * 1024 * 1024; + let next_report = std::sync::atomic::AtomicI64::new(report_interval); + + let tx_clone = tx.clone(); + let result = { + let mut store = state.store.write().unwrap(); + store.compact_volume(vid, preallocate, 0, |processed| { + let target = next_report.load(std::sync::atomic::Ordering::Relaxed); + if processed > target { + let resp = volume_server_pb::VacuumVolumeCompactResponse { + processed_bytes: processed, + load_avg_1m: 0.0, + }; + // If send fails (client disconnected), stop compaction + if tx_clone.blocking_send(Ok(resp)).is_err() { + return false; + } + next_report.store( + processed + report_interval, + std::sync::atomic::Ordering::Relaxed, + ); + } + true + }) + }; + + let success = result.is_ok(); + crate::metrics::VACUUMING_HISTOGRAM + .with_label_values(&["compact"]) + .observe(compact_start.elapsed().as_secs_f64()); + crate::metrics::VACUUMING_COMPACT_COUNTER + .with_label_values(&[if success { "true" } else { "false" }]) + .inc(); + + if let Err(e) = result { + let _ = tx.blocking_send(Err(Status::internal(e))); + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Response::new( + Box::pin(stream) as Self::VacuumVolumeCompactStream + )) + } + + async fn vacuum_volume_commit( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let vid = VolumeId(request.into_inner().volume_id); + + // Match Go's store_vacuum.go CommitCompactVolume: skip commit if stopping + if *self.state.is_stopping.read().unwrap() { + return Err(Status::internal(format!( + "volume id {} skips compact commit because volume server is stopping", + vid.0 + ))); + } + + let commit_start = std::time::Instant::now(); + let mut store = self.state.store.write().unwrap(); + let result = store.commit_compact_volume(vid); + crate::metrics::VACUUMING_HISTOGRAM + .with_label_values(&["commit"]) + .observe(commit_start.elapsed().as_secs_f64()); + crate::metrics::VACUUMING_COMMIT_COUNTER + .with_label_values(&[if result.is_ok() { "true" } else { "false" }]) + .inc(); + match result { + Ok((is_read_only, volume_size)) => Ok(Response::new( + volume_server_pb::VacuumVolumeCommitResponse { + is_read_only, + volume_size, + }, + )), + Err(e) => Err(Status::internal(e)), + } + } + + async fn vacuum_volume_cleanup( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let vid = VolumeId(request.into_inner().volume_id); + let mut store = self.state.store.write().unwrap(); + match store.cleanup_compact_volume(vid) { + Ok(()) => Ok(Response::new( + volume_server_pb::VacuumVolumeCleanupResponse {}, + )), + Err(e) => Err(Status::internal(e)), + } + } + + async fn delete_collection( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let collection = &request.into_inner().collection; + { + let mut store = self.state.store.write().unwrap(); + store + .delete_collection(collection) + .map_err(Status::internal)?; + } + // The delta the notify path derives is the only thing that tells the + // master these slots came free: a heartbeat carries the whole list only + // when the master asks, and a volume grown and destroyed between two of + // them was never in one at all. + self.state.volume_state_notify.notify_one(); + Ok(Response::new(volume_server_pb::DeleteCollectionResponse {})) + } + + async fn allocate_volume( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let rp = crate::storage::super_block::ReplicaPlacement::from_string(&req.replication) + .map_err(|e| Status::invalid_argument(e.to_string()))?; + let ttl = if req.ttl.is_empty() { + None + } else { + Some( + crate::storage::needle::ttl::TTL::read(&req.ttl) + .map_err(Status::invalid_argument)?, + ) + }; + let disk_type = DiskType::from_string(&req.disk_type); + + let version = if req.version > 0 { + crate::storage::types::Version(req.version as u8) + } else { + crate::storage::types::Version::current() + }; + + let mut store = self.state.store.write().unwrap(); + store + .add_volume( + vid, + disk_type, + &VolumeSpec { + collection: &req.collection, + replica_placement: Some(rp), + ttl, + preallocate: req.preallocate as u64, + version, + }, + ) + .map_err(|e| Status::internal(e.to_string()))?; + self.state.volume_state_notify.notify_one(); + + Ok(Response::new(volume_server_pb::AllocateVolumeResponse {})) + } + + async fn volume_sync_status( + &self, + request: Request, + ) -> Result, Status> { + let vid = VolumeId(request.into_inner().volume_id); + let store = self.state.store.read().unwrap(); + let (_, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + + Ok(Response::new(volume_server_pb::VolumeSyncStatusResponse { + volume_id: vid.0, + collection: vol.collection.clone(), + replication: vol.super_block.replica_placement.to_string(), + ttl: vol.super_block.ttl.to_string(), + tail_offset: vol.dat_file_size().unwrap_or(0), + compact_revision: vol.super_block.compaction_revision as u32, + idx_file_size: vol.idx_file_size(), + version: vol.version().0 as u32, + })) + } + + type VolumeIncrementalCopyStream = BoxStream; + async fn volume_incremental_copy( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Sync to disk first + { + let mut store = self.state.store.write().unwrap(); + if let Some((_, v)) = store.find_volume_mut(vid) { + let _ = v.sync_to_disk(); + } + } + + let store = self.state.store.read().unwrap(); + let (_, v) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + + let dat_size = v.dat_file_size().unwrap_or(0); + let super_block_size = v.super_block.block_size() as u64; + + // If since_ns is very large (after all data), return empty + if req.since_ns == u64::MAX || dat_size <= super_block_size { + drop(store); + let stream = tokio_stream::iter(Vec::new()); + return Ok(Response::new(Box::pin(stream))); + } + + // Use binary search to find the starting offset + let start_offset = if req.since_ns == 0 { + super_block_size + } else { + match v.binary_search_by_append_at_ns(req.since_ns) { + Ok((_offset, true)) => { + // All entries are before since_ns — nothing to send + drop(store); + let stream = tokio_stream::iter(Vec::new()); + return Ok(Response::new(Box::pin(stream))); + } + Ok((offset, false)) => { + let actual = offset.to_actual_offset(); + if actual <= 0 { + super_block_size + } else { + actual as u64 + } + } + Err(e) => { + return Err(Status::internal(format!( + "fail to locate by appendAtNs {}: {}", + req.since_ns, e + ))); + } + } + }; + // A corrupt index could place the located offset past the captured + // `.dat` size; nothing to send, and it would underflow `total` below. + if start_offset >= dat_size { + drop(store); + let stream = tokio_stream::iter(Vec::new()); + return Ok(Response::new(Box::pin(stream))); + } + // Capture a reader for the `.dat` while the volume lookup is still + // protected by the store lock, then drop the lock and stream the delta + // through a bounded channel fed by a blocking reader task (instead of + // buffering the whole delta into a Vec). Opening the local file -- or + // cloning the remote backend handle -- under the lock pins it to the + // volume that exists now, so a concurrent delete/recreate can't make us + // stream from the wrong file, and no store lock is held while a slow + // disk or S3 read runs. + enum DatReader { + Local(std::fs::File), + Remote(crate::storage::volume::RemoteDatFile), + } + let reader = if v.has_remote_file { + match v.remote_dat_file() { + Some(r) => DatReader::Remote(r), + None => { + return Err(Status::not_found(format!( + "remote dat not loaded for volume id {}", + vid + ))); + } + } + } else { + let path = v.file_name(".dat"); + let file = std::fs::File::open(&path) + .map_err(|e| Status::internal(format!("open {}: {}", path, e)))?; + DatReader::Local(file) + }; + drop(store); + + let total = dat_size - start_offset; + let (tx, rx) = tokio::sync::mpsc::channel::< + Result, + >(8); + + tokio::task::spawn_blocking(move || { + let buffer_size = 2 * 1024 * 1024u64; // 2MB chunks + let mut bytes_to_read = total; + let mut offset = start_offset; + + match reader { + DatReader::Local(file) => { + // Local volume: stream directly from the .dat, no lock held. + use std::io::{Read, Seek, SeekFrom}; + let mut reader = std::io::BufReader::new(file); + if let Err(e) = reader.seek(SeekFrom::Start(offset)) { + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); + return; + } + while bytes_to_read > 0 { + let chunk = std::cmp::min(bytes_to_read, buffer_size) as usize; + let mut buf = vec![0u8; chunk]; + match reader.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + buf.truncate(n); + let msg = volume_server_pb::VolumeIncrementalCopyResponse { + file_content: buf, + }; + if tx.blocking_send(Ok(msg)).is_err() { + return; // receiver dropped + } + bytes_to_read -= n as u64; + } + Err(e) => { + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); + return; + } + } + } + } + DatReader::Remote(remote) => { + // Remote/tiered volume: read through the cloned backend + // handle. No store lock is held while the (potentially slow) + // S3 fetch runs, so it never blocks store writers. + while bytes_to_read > 0 { + let chunk = std::cmp::min(bytes_to_read, buffer_size) as usize; + match remote.read_slice(offset, chunk) { + Ok(buf) if buf.is_empty() => break, + Ok(buf) => { + let read_len = buf.len() as u64; + let msg = volume_server_pb::VolumeIncrementalCopyResponse { + file_content: buf, + }; + if tx.blocking_send(Ok(msg)).is_err() { + return; // receiver dropped + } + bytes_to_read -= read_len; + offset += read_len; + } + Err(e) => { + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); + return; + } + } + } + } + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Response::new(Box::pin(stream))) + } + + async fn volume_mount( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + let mut store = self.state.store.write().unwrap(); + store + .mount_volume_by_id(vid, req.collection.as_deref()) + .map_err(|e| Status::internal(e.to_string()))?; + self.state.volume_state_notify.notify_one(); + + Ok(Response::new(volume_server_pb::VolumeMountResponse {})) + } + + async fn volume_unmount( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let vid = VolumeId(request.into_inner().volume_id); + let mut store = self.state.store.write().unwrap(); + // Go returns nil when volume is not found (idempotent unmount) + if store.unmount_volume(vid) { + self.state.volume_state_notify.notify_one(); + } + Ok(Response::new(volume_server_pb::VolumeUnmountResponse {})) + } + + async fn volume_consolidate_index( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let vid = VolumeId(request.into_inner().volume_id); + let mut store = self.state.store.write().unwrap(); + store + .consolidate_volume_index(vid) + .map_err(|e| Status::internal(e.to_string()))?; + Ok(Response::new( + volume_server_pb::VolumeConsolidateIndexResponse {}, + )) + } + + /// Allowed in maintenance mode: it removes data from the server rather than + /// adding any, and evacuating a server in maintenance mode ends each move by + /// deleting the source copy (issue #11066). + async fn volume_delete( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let mut store = self.state.store.write().unwrap(); + if req.only_empty { + let (_, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + if vol.file_count() > 0 { + return Err(Status::failed_precondition("volume not empty")); + } + } + store + .delete_volume(vid, req.only_empty, req.keep_remote_data) + .map_err(|e| match e { + crate::storage::volume::VolumeError::NotFound => { + Status::not_found(format!("not found volume id {}", vid)) + } + crate::storage::volume::VolumeError::NotEmpty => { + Status::failed_precondition("volume not empty") + } + other => Status::internal(other.to_string()), + })?; + self.state.volume_state_notify.notify_one(); + Ok(Response::new(volume_server_pb::VolumeDeleteResponse {})) + } + + async fn volume_mark_readonly( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + // Go: VolumeMarkReadonly looks the volume up before calling makeVolumeReadonly + { + let store = self.state.store.read().unwrap(); + store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("volume {} not found", vid)))?; + } + self.make_volume_readonly(vid, req.can_delete, req.persist) + .await?; + Ok(Response::new( + volume_server_pb::VolumeMarkReadonlyResponse {}, + )) + } + + async fn volume_mark_writable( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let info = { + let store = self.state.store.read().unwrap(); + let (loc_idx, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("volume {} not found", vid)))?; + MasterVolumeInfo { + volume_id: vid, + collection: vol.collection.clone(), + replica_placement: vol.super_block.replica_placement.to_byte(), + ttl: vol.super_block.ttl.to_u32(), + disk_type: store.locations[loc_idx].disk_type.to_string(), + ip: store.ip.clone(), + port: store.port, + } + }; + // Go: maintenance check (L194 in makeVolumeWritable) happens after volume lookup (L253-255) + self.state.check_maintenance()?; + + // Step 1: mark local volume as writable (save result; Go continues on error) + let mark_result = { + let mut store = self.state.store.write().unwrap(); + let res = store + .find_volume_mut(vid) + .ok_or_else(|| Status::not_found(format!("volume {} not found", vid))) + .and_then(|(_, vol)| { + vol.set_writable() + .map_err(|e| Status::internal(e.to_string())) + }); + if res.is_ok() { + self.state.volume_state_notify.notify_one(); + } + res + }; + + // Step 2: Go returns early if marking failed (L198-200), before notifying master. + mark_result?; + // Step 3: enable master to redirect traffic here + self.notify_master_volume_readonly(&info, false).await?; + Ok(Response::new( + volume_server_pb::VolumeMarkWritableResponse {}, + )) + } + + async fn volume_configure( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Validate replication string — return response error, not gRPC error + let rp = match crate::storage::super_block::ReplicaPlacement::from_string(&req.replication) + { + Ok(rp) => rp, + Err(e) => { + return Ok(Response::new(volume_server_pb::VolumeConfigureResponse { + error: format!("volume configure replication {}: {}", req.replication, e), + })); + } + }; + + let mut store = self.state.store.write().unwrap(); + + // Unmount the volume (Go propagates unmount errors via resp.Error; + // Rust unmount_volume returns bool, so not-found falls through to configure_volume) + store.unmount_volume(vid); + + // Modify the super block on disk (replica_placement byte) + if let Err(e) = store.configure_volume(vid, rp) { + let mut error = format!("volume configure {}: {}", vid, e); + // Error recovery: try to re-mount anyway + if let Err(mount_err) = store.mount_volume_by_id(vid, None) { + error += &format!(". Also failed to restore mount: {}", mount_err); + } + return Ok(Response::new(volume_server_pb::VolumeConfigureResponse { + error, + })); + } + + // Re-mount the volume + if let Err(e) = store.mount_volume_by_id(vid, None) { + return Ok(Response::new(volume_server_pb::VolumeConfigureResponse { + error: format!("volume configure mount {}: {}", vid, e), + })); + } + self.state.volume_state_notify.notify_one(); + + Ok(Response::new(volume_server_pb::VolumeConfigureResponse { + error: String::new(), + })) + } + + async fn volume_status( + &self, + request: Request, + ) -> Result, Status> { + let vid = VolumeId(request.into_inner().volume_id); + let store = self.state.store.read().unwrap(); + let (_, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + + // Go checks v.DataBackend != nil before building the response. + if !vol.has_data_backend() { + return Err(Status::internal(format!( + "volume {} data backend not found", + vid + ))); + } + + // Go uses v.DataBackend.GetStat() which returns the actual .dat file size + let volume_size = vol.dat_file_size().unwrap_or(0); + + Ok(Response::new(volume_server_pb::VolumeStatusResponse { + is_read_only: vol.is_read_only(), + volume_size, + file_count: vol.file_count() as u64, + file_deleted_count: vol.deleted_count() as u64, + })) + } + + async fn get_state( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(volume_server_pb::GetStateResponse { + state: Some(volume_server_pb::VolumeServerState { + maintenance: self.state.maintenance.load(Ordering::Relaxed), + version: self.state.state_version.load(Ordering::Relaxed), + }), + })) + } + + async fn set_state( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + + if let Some(new_state) = &req.state { + // Go's State.Update checks version: if incoming version != stored version → error. + let current_version = self.state.state_version.load(Ordering::Relaxed); + if new_state.version != current_version { + return Err(Status::failed_precondition(format!( + "version mismatch for VolumeServerState (got {}, want {})", + new_state.version, current_version + ))); + } + + // Save previous state for rollback on persistence failure (matches Go) + let prev_maintenance = self.state.maintenance.load(Ordering::Relaxed); + let prev_version = current_version; + + self.state + .maintenance + .store(new_state.maintenance, Ordering::Relaxed); + let new_version = self.state.state_version.fetch_add(1, Ordering::Relaxed) + 1; + + // Persist to disk (matches Go's State.save) + let pb = volume_server_pb::VolumeServerState { + maintenance: new_state.maintenance, + version: new_version, + }; + if let Err(e) = save_state_file(&self.state.state_file_path, &pb) { + // Rollback in-memory state on save failure (matches Go) + self.state + .maintenance + .store(prev_maintenance, Ordering::Relaxed); + self.state + .state_version + .store(prev_version, Ordering::Relaxed); + return Err(Status::internal(format!("failed to save state: {}", e))); + } + + Ok(Response::new(volume_server_pb::SetStateResponse { + state: Some(pb), + })) + } else { + // nil state = no-op, return current state + Ok(Response::new(volume_server_pb::SetStateResponse { + state: Some(volume_server_pb::VolumeServerState { + maintenance: self.state.maintenance.load(Ordering::Relaxed), + version: self.state.state_version.load(Ordering::Relaxed), + }), + })) + } + } + + type VolumeCopyStream = BoxStream; + async fn volume_copy( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // A pre-existing local replica is NOT deleted up front. Deleting before + // the source is confirmed reachable destroys a healthy copy on a + // transient source outage (and, on retry, can lose the volume + // entirely). The delete is deferred until read_volume_file_status below + // proves the source holds the volume; readability alone is the gate. + let had_existing_volume = { + let store = self.state.store.read().unwrap(); + store.find_volume(vid).is_some() + }; + + // Parse source_data_node address: "ip:port.grpcPort" or "ip:port" (grpc = port + 10000) + let source = &req.source_data_node; + let grpc_addr = parse_grpc_address(source).map_err(|e| { + Status::internal(format!( + "VolumeCopy volume {} invalid source_data_node {}: {}", + vid, source, e + )) + })?; + + let channel = build_grpc_endpoint(&grpc_addr, self.state.outgoing_grpc_tls.as_ref()) + .map_err(|e| { + Status::internal(format!("VolumeCopy volume {} parse source: {}", vid, e)) + })? + .connect() + .await + .map_err(|e| { + Status::internal(format!( + "VolumeCopy volume {} connect to {}: {}", + vid, grpc_addr, e + )) + })?; + + let mut client = + volume_server_pb::volume_server_client::VolumeServerClient::with_interceptor( + channel, + super::request_id::outgoing_request_id_interceptor, + ) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + + // Get file status from source + let vol_info = client + .read_volume_file_status(volume_server_pb::ReadVolumeFileStatusRequest { + volume_id: req.volume_id, + }) + .await + .map_err(|e| Status::internal(format!("read volume file status failed, {}", e)))? + .into_inner(); + + // Source is reachable and holds the volume: only now is it safe to drop + // an existing local replica before overwriting its files. + if had_existing_volume { + let mut store = self.state.store.write().unwrap(); + // keep remote data: the inbound copy carries a .vif that may point + // at the same cloud-tier object the existing volume references. + store.delete_volume(vid, false, true).map_err(|e| { + Status::internal(format!("failed to delete existing volume {}: {}", vid, e)) + })?; + drop(store); + self.state.volume_state_notify.notify_one(); + } + + let requested_disk_type = if !req.disk_type.is_empty() { + DiskType::from_string(&req.disk_type) + } else { + DiskType::from_string(&vol_info.disk_type) + }; + + let has_remote_dat = vol_info + .volume_info + .as_ref() + .map(|vi| !vi.files.is_empty()) + .unwrap_or(false); + // a remote-backed volume only lands its .idx/.vif locally; the .dat stays in the tier + let needed_space = if has_remote_dat { + vol_info.idx_file_size + } else { + vol_info.dat_file_size + }; + + // Find a free disk location using Go's Store.FindFreeLocation semantics. + let (data_base, idx_base, selected_disk_type) = { + let store = self.state.store.read().unwrap(); + let Some(loc_idx) = store.find_free_location_predicate(|loc| { + loc.disk_type == requested_disk_type + && loc.available_space.load(Ordering::Relaxed) > needed_space + }) else { + return Err(Status::internal(format!( + "no space left {}", + requested_disk_type.readable_string() + ))); + }; + let loc = &store.locations[loc_idx]; + ( + loc.directory.clone(), + loc.idx_directory.clone(), + loc.disk_type.clone(), + ) + }; + + let data_base_name = + crate::storage::volume::volume_file_name(&data_base, &vol_info.collection, vid); + let idx_base_name = + crate::storage::volume::volume_file_name(&idx_base, &vol_info.collection, vid); + + // Write a .note file to indicate copy in progress. A leftover note + // fails the volume load on restart, so a write failure must abort. + let note_path = format!("{}.note", data_base_name); + std::fs::write(¬e_path, format!("copying from {}", source)) + .map_err(|e| Status::internal(format!("write .note for volume {}: {}", vid, e)))?; + + let (tx, rx) = + tokio::sync::mpsc::channel::>(16); + let state = self.state.clone(); + + tokio::spawn(async move { + // Tracks whether mount_volume succeeded so the error branch can + // unmount (not just unlink) an orphaned replica. A mount that + // races a departing caller is the one case where the existing + // file-only cleanup leaves the volume loaded in memory. + let mut mounted = false; + let result = async { + // Nothing below is worth doing for a caller that has already + // gone: the transfer would spend the source's bandwidth and the + // destination's disk on a volume nobody will take delivery of. + if tx.is_closed() { + return Err(Status::cancelled(format!( + "volume {} copy cancelled by caller", + vid + ))); + } + + let report_interval: i64 = 128 * 1024 * 1024; + let mut next_report_target: i64 = report_interval; + let io_byte_per_second = if req.io_byte_per_second > 0 { + req.io_byte_per_second + } else { + state.maintenance_byte_per_second + }; + let mut throttler = WriteThrottler::new(io_byte_per_second); + + // Query master for preallocation settings (matching Go VolumeCopy behavior). + let mut preallocate_size: i64 = 0; + if !has_remote_dat { + let grpc_addr = super::heartbeat::to_grpc_address(&state.master_url); + // Race the master-configuration RPC against the caller's + // response channel: a stalled master (or a slow leader + // election) would otherwise hold the task and its .note + // past a departing caller, since the per-chunk checks in + // copy_file_from_source are never reached. + let config = tokio::select! { + res = super::heartbeat::try_get_master_configuration( + &grpc_addr, + state.outgoing_grpc_tls.as_ref(), + ) => res, + _ = tx.closed() => { + return Err(Status::cancelled(format!( + "volume {} copy cancelled by caller", + vid + ))); + } + }; + match config { + Ok(resp) => { + if resp.volume_preallocate { + preallocate_size = resp.volume_size_limit_m_b as i64 * 1024 * 1024; + } + } + Err(e) => { + tracing::warn!("get master {} configuration: {}", state.master_url, e); + } + } + + if preallocate_size > 0 { + let dat_path = format!("{}.dat", data_base_name); + let file = std::fs::File::create(&dat_path).map_err(|e| { + Status::internal(format!( + "create preallocated volume file {}: {}", + dat_path, e + )) + })?; + file.set_len(preallocate_size as u64).map_err(|e| { + Status::internal(format!("preallocate volume file {}: {}", dat_path, e)) + })?; + } + } + + // Copy .dat file + let mut progress = CopyProgress { + tx: &tx, + next_report_target: &mut next_report_target, + report_interval, + throttler: &mut throttler, + }; + if !has_remote_dat { + let dat_path = format!("{}.dat", data_base_name); + let dat_modified_ts_ns = copy_file_from_source( + &mut client, + &CopyFileSpec { + is_ec_volume: false, + collection: &req.collection, + volume_id: req.volume_id, + compaction_revision: vol_info.compaction_revision, + stop_offset: vol_info.dat_file_size, + dest_path: &dat_path, + ext: ".dat", + is_append: false, + ignore_source_not_found: true, + report_progress: true, + }, + &mut progress, + ) + .await?; + if dat_modified_ts_ns > 0 { + let _ = set_file_mtime(&dat_path, dat_modified_ts_ns); + } + } + + // Copy .idx file + let idx_path = format!("{}.idx", idx_base_name); + let idx_modified_ts_ns = copy_file_from_source( + &mut client, + &CopyFileSpec { + is_ec_volume: false, + collection: &req.collection, + volume_id: req.volume_id, + compaction_revision: vol_info.compaction_revision, + stop_offset: vol_info.idx_file_size, + dest_path: &idx_path, + ext: ".idx", + is_append: false, + ignore_source_not_found: false, + report_progress: false, + }, + &mut progress, + ) + .await?; + if idx_modified_ts_ns > 0 { + let _ = set_file_mtime(&idx_path, idx_modified_ts_ns); + } + + // Copy .vif file (ignore if not found on source) + let vif_path = format!("{}.vif", data_base_name); + let vif_modified_ts_ns = copy_file_from_source( + &mut client, + &CopyFileSpec { + is_ec_volume: false, + collection: &req.collection, + volume_id: req.volume_id, + compaction_revision: vol_info.compaction_revision, + stop_offset: 1024 * 1024, + dest_path: &vif_path, + ext: ".vif", + is_append: false, + ignore_source_not_found: true, + report_progress: false, + }, + &mut progress, + ) + .await?; + if vif_modified_ts_ns > 0 { + let _ = set_file_mtime(&vif_path, vif_modified_ts_ns); + } + + // Remove the .note file. A leftover note fails the load on the + // next restart, so a removal failure must fail the copy. + if let Err(e) = std::fs::remove_file(¬e_path) + && e.kind() != std::io::ErrorKind::NotFound + { + return Err(Status::internal(format!( + "remove .note for volume {}: {}", + vid, e + ))); + } + + // Verify file sizes + if !has_remote_dat { + let dat_path = format!("{}.dat", data_base_name); + check_copy_file_size(&dat_path, vol_info.dat_file_size)?; + } + if vol_info.idx_file_size > 0 { + check_copy_file_size(&idx_path, vol_info.idx_file_size)?; + } + + // Find last_append_at_ns from copied files + let last_append_at_ns = if !has_remote_dat { + find_last_append_at_ns( + &idx_path, + &format!("{}.dat", data_base_name), + vol_info.version, + ) + .unwrap_or(vol_info.dat_file_timestamp_seconds * 1_000_000_000) + } else { + vol_info.dat_file_timestamp_seconds * 1_000_000_000 + }; + + // An orphaned mount is how an abandoned copy does lasting + // damage: the destination carries that volume's index cache for + // the lifetime of the process, and under replication=000 the + // cluster is left holding one volume id on two servers, both + // writable, which concurrent writes can diverge. + if tx.is_closed() { + return Err(Status::cancelled(format!( + "volume {} copy cancelled by caller before mount", + vid + ))); + } + + // Mount the volume + { + let mut store = state.store.write().unwrap(); + store + .mount_volume(vid, &vol_info.collection, selected_disk_type) + .map_err(|e| { + Status::internal(format!("failed to mount volume {}: {}", vid, e)) + })?; + mounted = true; + } + state.volume_state_notify.notify_one(); + + // Send final response with last_append_at_ns. A failed send + // means the caller is gone: the mount above raced a departing + // receiver (the pre-mount is_closed() check cannot close that + // window), and leaving the volume mounted is exactly the orphan + // this PR prevents. Surface it as Cancelled so the error branch + // unmounts and deletes the replica it just created. + if tx + .send(Ok(volume_server_pb::VolumeCopyResponse { + last_append_at_ns, + processed_bytes: 0, + })) + .await + .is_err() + { + return Err(Status::cancelled(format!( + "volume {} copy cancelled by caller after mount", + vid + ))); + } + + Ok::<(), Status>(()) + } + .await; + + if let Err(e) = result { + // An abandoned copy is otherwise invisible here: the error goes + // to a channel nobody is reading. Logging it gives the operator + // the cause behind the balancer's "delete that copy, then re-run + // the move". + if e.code() == tonic::Code::Cancelled { + tracing::info!( + "volume {} copy from {} abandoned by its caller, discarding the partial copy", + vid, + req.source_data_node + ); + } else { + tracing::warn!( + "volume {} copy from {} failed: {}", + vid, + req.source_data_node, + e + ); + } + // Clean up on error. If the volume was mounted (the after-mount + // race), delete_volume unmounts it from the store AND removes + // the .dat/.idx/.vif in one step; the remove_file calls below + // cover the never-mounted partial-file case and are harmless + // no-ops when delete_volume already removed the files. + // + // keep_remote_data=true matches the pre-spawn delete_volume at + // the top of volume_copy: a remote-tier copy's .vif points at + // the same cloud object the source replica references, so + // destroying the abandoned destination with keep_remote_data= + // false would delete the source's remote data. + if mounted { + let mut store = state.store.write().unwrap(); + let _ = store.delete_volume(vid, false, true); + state.volume_state_notify.notify_one(); + } + let _ = std::fs::remove_file(format!("{}.dat", data_base_name)); + let _ = std::fs::remove_file(format!("{}.idx", idx_base_name)); + let _ = std::fs::remove_file(format!("{}.vif", data_base_name)); + let _ = std::fs::remove_file(¬e_path); + let _ = tx.send(Err(e)).await; + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Response::new(Box::pin(stream))) + } + + async fn read_volume_file_status( + &self, + request: Request, + ) -> Result, Status> { + let vid = VolumeId(request.into_inner().volume_id); + let store = self.state.store.read().unwrap(); + let (loc_idx, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + + let mod_time = vol.dat_file_mod_time(); + Ok(Response::new( + volume_server_pb::ReadVolumeFileStatusResponse { + volume_id: vid.0, + idx_file_timestamp_seconds: mod_time, + idx_file_size: vol.idx_file_size(), + dat_file_timestamp_seconds: mod_time, + dat_file_size: vol.dat_file_size().unwrap_or(0), + file_count: vol.file_count() as u64, + compaction_revision: vol.super_block.compaction_revision as u32, + collection: vol.collection.clone(), + disk_type: store.locations[loc_idx].disk_type.to_string(), + volume_info: Some(vol.volume_info.clone()), + version: vol.version().0 as u32, + }, + )) + } + + type CopyFileStream = BoxStream; + async fn copy_file( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + let file_name: String; + + if !req.is_ec_volume { + // Sync volume to disk before copying (matching Go's v.SyncToDisk()) + { + let mut store = self.state.store.write().unwrap(); + if let Some((_, v)) = store.find_volume_mut(vid) { + let _ = v.sync_to_disk(); + } + } + + let store = self.state.store.read().unwrap(); + let (_, v) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + + // Check compaction revision. Compare against the volume's live + // super_block.compaction_revision (matching Go's v.CompactionRevision, + // which is the embedded SuperBlock field). last_compact_revision() is + // a separate bookkeeping value recorded just before a compaction starts + // (for makeup-diff catch-up) and is intentionally left behind the live + // revision afterward — it is not interchangeable with the live value and + // must not be used here, or this check spuriously fails on every volume + // that has ever been compacted even once. + if req.compaction_revision != u32::MAX + && u32::from(v.super_block.compaction_revision) != req.compaction_revision + { + return Err(Status::failed_precondition(format!( + "volume {} is compacted", + vid.0 + ))); + } + + file_name = v.file_name(&req.ext); + drop(store); + } else { + // Sync EC volume journal to disk before copying (matching Go's ecv.SyncToDisk()) + { + let store = self.state.store.read().unwrap(); + if let Some(ecv) = store.find_ec_volume(vid) { + let _ = ecv.sync_to_disk(); + } + } + + // EC volume: search disk locations for the file + let store = self.state.store.read().unwrap(); + let mut found_path = None; + let ec_base = if req.collection.is_empty() { + format!("{}{}", vid.0, req.ext) + } else { + format!("{}_{}{}", req.collection, vid.0, req.ext) + }; + for loc in &store.locations { + let path = format!("{}/{}", loc.directory, ec_base); + if std::path::Path::new(&path).exists() { + found_path = Some(path); + } + let idx_path = format!("{}/{}", loc.idx_directory, ec_base); + if std::path::Path::new(&idx_path).exists() { + found_path = Some(idx_path); + } + } + drop(store); + + match found_path { + Some(p) => file_name = p, + None => { + if req.ignore_source_file_not_found { + let stream = tokio_stream::iter(Vec::new()); + return Ok(Response::new(Box::pin(stream))); + } + return Err(Status::not_found(format!( + "CopyFile not found ec volume id {}", + vid.0 + ))); + } + } + } + + // Open file and read content + let file = match std::fs::File::open(&file_name) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if req.ignore_source_file_not_found || req.stop_offset == 0 { + let stream = tokio_stream::iter(Vec::new()); + return Ok(Response::new(Box::pin(stream))); + } + return Err(Status::not_found(format!("{}", e))); + } + Err(e) => return Err(Status::internal(e.to_string())), + }; + + let metadata = file + .metadata() + .map_err(|e| Status::internal(e.to_string()))?; + let mod_ts_ns = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos() as i64) + .unwrap_or(0); + + // Stream the file in 2MB chunks from a blocking reader task instead of + // materializing the whole file into a Vec first. The previous code + // pushed every chunk into `results` and only then returned + // `tokio_stream::iter(results)`, so serving a large volume as a copy + // source (e.g. during `volume.fix.replication`) held the entire .dat + // (up to the volume size limit) resident at once and could OOM the + // process. A bounded channel caps memory at channel_capacity * 2MB and + // applies backpressure to the reader. + let stop_offset = req.stop_offset; + let (tx, rx) = + tokio::sync::mpsc::channel::>(8); + + tokio::task::spawn_blocking(move || { + use std::io::Read; + let buffer_size = 2 * 1024 * 1024u64; // 2MB chunks + let mut reader = std::io::BufReader::new(file); + let mut bytes_to_read = stop_offset; + let mut first = true; + + while bytes_to_read > 0 { + let chunk_size = std::cmp::min(bytes_to_read, buffer_size) as usize; + let mut buf = vec![0u8; chunk_size]; + match reader.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + let n = std::cmp::min(n, bytes_to_read as usize); + buf.truncate(n); + let msg = volume_server_pb::CopyFileResponse { + file_content: buf, + modified_ts_ns: if first { mod_ts_ns } else { 0 }, + }; + if tx.blocking_send(Ok(msg)).is_err() { + return; // receiver dropped, stop reading + } + first = false; + bytes_to_read -= n as u64; + } + Err(e) => { + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); + return; + } + } + } + + // If no data was sent, still send ModifiedTsNs + if first && mod_ts_ns != 0 { + let _ = tx.blocking_send(Ok(volume_server_pb::CopyFileResponse { + file_content: vec![], + modified_ts_ns: mod_ts_ns, + })); + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Response::new(Box::pin(stream))) + } + + async fn receive_file( + &self, + request: Request>, + ) -> Result, Status> { + self.state.check_maintenance()?; + + let mut stream = request.into_inner(); + let mut target_file: Option = None; + let mut file_path: Option = None; + let mut bytes_written: u64 = 0; + let mut resp_error: Option = None; + + let result: Result<(), Status> = async { + while let Some(req) = stream.message().await? { + match req.data { + Some(volume_server_pb::receive_file_request::Data::Info(info)) => { + // Determine file path + let path = if info.is_ec_volume { + let store = self.state.store.read().unwrap(); + // std::fs::File::create truncates in place; a mounted + // EcVolume holds fds on the same inodes, so overwriting + // corrupts live readers. + if store.has_ec_volume(VolumeId(info.volume_id)) { + let mounted_disks = + store.find_ec_volume_disk_ids(VolumeId(info.volume_id)); + resp_error = Some(format!( + "ec volume {} is mounted on disk_ids:{:?}; unmount before ReceiveFile", + info.volume_id, mounted_disks + )); + break; + } + // disk_id=0 means "unset" (protobuf default), so auto-select + // using the same primitive as volume_ec_shards_copy: prefer + // a disk that has the EC volume mounted, then a disk that + // owns the .ecx on disk (volume not yet mounted — relevant + // when shards stream in mid-rebuild before any + // VolumeEcShardsMount has happened; see #9212), then any + // HDD, then any disk. Pass the build's default data-shard + // count for free-slot maths; the helper takes it as a + // parameter so custom-ratio builds can swap it. + let vid = VolumeId(info.volume_id); + let dir = if info.disk_id > 0 { + let count = store.locations.len(); + if (info.disk_id as usize) >= count { + resp_error = Some(format!( + "invalid disk_id {}: only have {} disks", + info.disk_id, count + )); + break; + } + Some(store.locations[info.disk_id as usize].directory.clone()) + } else { + // The mounted-volume refusal above means no disk + // holds an in-memory claim here. + store + .find_ec_shard_target_location( + &info.collection, + vid, + DATA_SHARDS_COUNT as u32, + &[], + ) + .map(|i| store.locations[i].directory.clone()) + }; + drop(store); + let dir = match dir { + Some(d) => d, + None => { + resp_error = Some("no storage location available".to_string()); + break; + } + }; + let ec_base = if info.collection.is_empty() { + format!("{}", info.volume_id) + } else { + format!("{}_{}", info.collection, info.volume_id) + }; + format!("{}/{}{}", dir, ec_base, info.ext) + } else { + let store = self.state.store.read().unwrap(); + let existing = store + .find_volume(VolumeId(info.volume_id)) + .map(|(_, v)| v.file_name(&info.ext)); + if let Some(p) = existing { + drop(store); + p + } else if !info.disk_type.is_empty() { + // Staged-new-volume mode (EC decode onto a clean peer): + // the volume does not exist here yet. Pick a free-slot + // disk location of the requested medium and stage the + // file as .copying, to be renamed into place + // and mounted by VolumeEcShardsToVolume(from_staged). + // .idx.copying/.vif.copying are not valid volume names, + // so the scanner never half-loads a partial push. + let want = DiskType::from_string(&info.disk_type); + let staged_vid = VolumeId(info.volume_id); + // Don't stage the decoded .dat onto a disk that holds a + // shard of this vid. Check the mounted map and the on-disk + // files, so an unmounted or orphan shard (on disk, absent + // from the map) is caught too. + match store.find_free_location_predicate(|l| { + if l.disk_type != want { + return false; + } + if l.ec_volumes().any(|(v, _)| *v == staged_vid) { + return false; + } + let base = crate::storage::volume::volume_file_name( + &l.directory, + &info.collection, + staged_vid, + ); + !(0..crate::storage::erasure_coding::ec_shard::MAX_SHARD_COUNT) + .any(|i| { + std::fs::metadata(format!("{}.ec{:02}", base, i)) + .map(|m| m.len() > 0) + .unwrap_or(false) + }) + }) { + Some(i) => { + let dir = store.locations[i].directory.clone(); + drop(store); + let vfile = if info.collection.is_empty() { + format!("{}/{}", dir, info.volume_id) + } else { + format!("{}/{}_{}", dir, info.collection, info.volume_id) + }; + format!("{}{}.copying", vfile, info.ext) + } + None => { + drop(store); + return Err(Status::internal(format!( + "no {} disk location with a free slot for volume {}", + info.disk_type, info.volume_id + ))); + } + } + } else { + drop(store); + return Err(Status::not_found(format!( + "volume {} not found", + info.volume_id + ))); + } + }; + + target_file = Some(std::fs::File::create(&path).map_err(|e| { + Status::internal(format!("failed to create file: {}", e)) + })?); + file_path = Some(path); + } + Some(volume_server_pb::receive_file_request::Data::FileContent(content)) => { + if let Some(ref mut f) = target_file { + use std::io::Write; + match f.write(&content) { + Ok(n) => bytes_written += n as u64, + Err(e) => { + // Match Go: write failures are response-level errors, not gRPC errors + resp_error = Some(format!("failed to write file: {}", e)); + break; + } + } + } else { + // Go returns protocol violations as response-level errors + resp_error = Some("file info must be sent first".to_string()); + break; + } + } + None => { + resp_error = Some("unknown message type".to_string()); + break; + } + } + } + Ok(()) + } + .await; + + match result { + Ok(()) => { + // Check for protocol-level errors (returned in response body, not gRPC status) + if let Some(err_msg) = resp_error { + return Ok(Response::new(volume_server_pb::ReceiveFileResponse { + error: err_msg, + bytes_written: 0, + })); + } + if let Some(ref f) = target_file { + let _ = f.sync_all(); + } + Ok(Response::new(volume_server_pb::ReceiveFileResponse { + error: String::new(), + bytes_written, + })) + } + Err(e) => { + // Clean up partial file on stream error (Go parity: closes file, removes it) + if let Some(f) = target_file.take() { + drop(f); + } + if let Some(ref p) = file_path { + let _ = std::fs::remove_file(p); + } + Err(e) + } + } + } + + async fn read_needle_blob( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let offset = req.offset; + let size = Size(req.size); + + let store = self.state.store.read().unwrap(); + let (_, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + + let blob = vol.read_needle_blob(offset, size).map_err(|e| { + Status::internal(format!( + "read needle blob offset {} size {}: {}", + offset, size.0, e + )) + })?; + + Ok(Response::new(volume_server_pb::ReadNeedleBlobResponse { + needle_blob: blob, + })) + } + + async fn read_needle_meta( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let needle_id = NeedleId(req.needle_id); + + let store = self.state.store.read().unwrap(); + let (_, vol) = store.find_volume(vid).ok_or_else(|| { + Status::not_found(format!( + "not found volume id {} and read needle metadata at ec shards is not supported", + vid + )) + })?; + + let offset = req.offset; + let size = crate::storage::types::Size(req.size); + + let mut n = Needle { + id: needle_id, + flags: 0x08, + ..Needle::default() + }; + vol.read_needle_meta_at(&mut n, offset, size) + .map_err(|e| Status::internal(format!("read needle meta: {}", e)))?; + + let ttl_str = n.ttl.as_ref().map_or(String::new(), |t| t.to_string()); + Ok(Response::new(volume_server_pb::ReadNeedleMetaResponse { + cookie: n.cookie.0, + last_modified: n.last_modified, + crc: n.checksum.0, + ttl: ttl_str, + append_at_ns: n.append_at_ns, + })) + } + + async fn write_needle_blob( + &self, + request: Request, + ) -> Result, Status> { + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let needle_id = NeedleId(req.needle_id); + let size = Size(req.size); + + let mut store = self.state.store.write().unwrap(); + let (_, vol) = store + .find_volume_mut(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + + vol.write_needle_blob_and_index(needle_id, &req.needle_blob, size) + .map_err(|e| { + Status::internal(format!( + "write blob needle {} size {}: {}", + needle_id.0, size.0, e + )) + })?; + + Ok(Response::new(volume_server_pb::WriteNeedleBlobResponse {})) + } + + type ReadAllNeedlesStream = BoxStream; + async fn read_all_needles( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let state = self.state.clone(); + + let (tx, rx) = tokio::sync::mpsc::channel(32); + + // Stream needles lazily via a blocking task (matches Go's scanner pattern) + tokio::task::spawn_blocking(move || { + let store = state.store.read().unwrap(); + for &raw_vid in &req.volume_ids { + let vid = VolumeId(raw_vid); + let v = match store.find_volume(vid) { + Some((_, v)) => v, + None => { + let _ = tx.blocking_send(Err(Status::not_found(format!( + "not found volume id {}", + vid + )))); + return; + } + }; + + let needles = match v.read_all_needles() { + Ok(n) => n, + Err(e) => { + let _ = tx.blocking_send(Err(Status::internal(e.to_string()))); + return; + } + }; + + for n in needles { + let compressed = n.is_compressed(); + if tx + .blocking_send(Ok(volume_server_pb::ReadAllNeedlesResponse { + volume_id: raw_vid, + needle_id: n.id.into(), + cookie: n.cookie.0, + needle_blob: n.data, + needle_blob_compressed: compressed, + last_modified: n.last_modified, + crc: n.checksum.0, + name: n.name, + mime: n.mime, + })) + .is_err() + { + return; // receiver dropped + } + } + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Response::new(Box::pin(stream))) + } + + type VolumeTailSenderStream = BoxStream; + async fn volume_tail_sender( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + let (version, sb_size) = { + let store = self.state.store.read().unwrap(); + let (_, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + (vol.version().0 as u32, vol.super_block.block_size() as u64) + }; + + let state = self.state.clone(); + let (tx, rx) = tokio::sync::mpsc::channel(32); + + tokio::spawn(async move { + let since_ns = req.since_ns; + let idle_timeout = req.idle_timeout_seconds; + let mut last_timestamp_ns = since_ns; + let mut draining_seconds = idle_timeout as i64; + + loop { + // The search and the scan are blocking file (or S3) I/O, so + // each pass runs on a blocking thread and takes and drops the + // store guard itself (see tail_pass). The heartbeat, the sleep + // and the draining countdown stay here, so no thread is parked + // while the stream is idle. + let pass_state = state.clone(); + let pass_tx = tx.clone(); + let pass = tokio::task::spawn_blocking(move || { + tail_pass( + &pass_state, + vid, + sb_size, + version, + last_timestamp_ns, + &pass_tx, + ) + }) + .await; + + let (last_processed_ns, sent_any) = match pass { + Ok(TailPass::Scanned { + last_processed_ns, + sent_any, + }) => (last_processed_ns, sent_any), + // Caught up: heartbeat WITHOUT scanning, as Go does. + Ok(TailPass::CaughtUp) => (last_timestamp_ns, false), + Ok(TailPass::VolumeGone) => break, + Ok(TailPass::ClientGone) => return, + Ok(TailPass::Failed(msg)) => { + let _ = tx.send(Err(Status::internal(msg))).await; + return; + } + // A panic on the blocking thread must not look like a + // clean end of stream to the receiver. + Err(e) => { + let _ = tx + .send(Err(Status::internal(format!( + "streamFollow: tail pass for volume {} failed: {}", + vid, e + )))) + .await; + return; + } + }; + + if !sent_any { + // Send heartbeat + let msg = volume_server_pb::VolumeTailSenderResponse { + is_last_chunk: true, + version, + ..Default::default() + }; + if tx.send(Ok(msg)).await.is_err() { + return; + } + } + + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + if idle_timeout == 0 { + last_timestamp_ns = last_processed_ns; + continue; + } + if last_processed_ns == last_timestamp_ns { + draining_seconds -= 1; + if draining_seconds <= 0 { + return; // EOF + } + } else { + last_timestamp_ns = last_processed_ns; + draining_seconds = idle_timeout as i64; + } + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Response::new(Box::pin(stream))) + } + + async fn volume_tail_receiver( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Check volume exists + { + let store = self.state.store.read().unwrap(); + store.find_volume(vid).ok_or_else(|| { + Status::not_found(format!("receiver not found volume id {}", vid)) + })?; + } + + // Parse source address and connect + let source = &req.source_volume_server; + let grpc_addr = parse_grpc_address(source) + .map_err(|e| Status::internal(format!("invalid source address {}: {}", source, e)))?; + + let channel = build_grpc_endpoint(&grpc_addr, self.state.outgoing_grpc_tls.as_ref()) + .map_err(|e| Status::internal(format!("parse source: {}", e)))? + .connect() + .await + .map_err(|e| Status::internal(format!("connect to {}: {}", grpc_addr, e)))?; + + let mut client = + volume_server_pb::volume_server_client::VolumeServerClient::with_interceptor( + channel, + super::request_id::outgoing_request_id_interceptor, + ) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + + // Call VolumeTailSender on source + let mut stream = client + .volume_tail_sender(volume_server_pb::VolumeTailSenderRequest { + volume_id: req.volume_id, + since_ns: req.since_ns, + idle_timeout_seconds: req.idle_timeout_seconds, + }) + .await + .map_err(|e| Status::internal(format!("volume_tail_sender: {}", e)))? + .into_inner(); + + let state = self.state.clone(); + + // Receive needles from source and write locally + while let Some(resp) = stream + .message() + .await + .map_err(|e| Status::internal(format!("recv from tail sender: {}", e)))? + { + let needle_header = resp.needle_header; + let mut needle_body = resp.needle_body; + let resp_version = resp.version; + + if needle_header.is_empty() { + continue; + } + + // Collect all chunks if not last + if !resp.is_last_chunk { + // Need to receive remaining chunks + loop { + let chunk = stream + .message() + .await + .map_err(|e| Status::internal(format!("recv chunk: {}", e)))? + .ok_or_else(|| Status::internal("unexpected end of tail stream"))?; + needle_body.extend_from_slice(&chunk.needle_body); + if chunk.is_last_chunk { + break; + } + } + } + + // Parse needle from header + body + let mut n = Needle::default(); + n.read_header(&needle_header); + + if n.size.0 < 0 { + return Err(Status::invalid_argument(format!( + "unexpected negative needle size {} for needle {}", + n.size.0, n.id.0 + ))); + } else if n.size.0 > 0 { + // Normal needle: parse the body fields (DataSize, Data, flags, etc.) + n.read_body_v2(&needle_body) + .map_err(|e| Status::internal(format!("parse needle body: {}", e)))?; + } else { + // Delete tombstone (size == 0): body is checksum + timestamp + // (V3) or checksum only (V2) + padding. Validate minimum + // footer length for the protocol version. + use crate::storage::types::{ + NEEDLE_CHECKSUM_SIZE, TIMESTAMP_SIZE, VERSION_3, Version, + }; + let version = Version(resp_version as u8); + let min_footer = if version >= VERSION_3 { + NEEDLE_CHECKSUM_SIZE + TIMESTAMP_SIZE + } else { + NEEDLE_CHECKSUM_SIZE + }; + if needle_body.len() < min_footer { + return Err(Status::invalid_argument(format!( + "tombstone needle {} body too short: got {} bytes, need >= {} for version {}", + n.id.0, + needle_body.len(), + min_footer, + resp_version + ))); + } + } + + // Write needle to local volume + let mut store = state.store.write().unwrap(); + store + .write_volume_needle(vid, &mut n, false) + .map_err(|e| Status::internal(format!("write needle: {}", e)))?; + } + + Ok(Response::new( + volume_server_pb::VolumeTailReceiverResponse {}, + )) + } + + // ---- EC operations ---- + + async fn volume_ec_shards_generate( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let collection = &req.collection; + + // Find the volume's directory and validate collection + let (dir, idx_dir, vol_version, dat_file_size, expire_at_sec) = { + let store = self.state.store.read().unwrap(); + let (loc_idx, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("volume {} not found", vid)))?; + if vol.collection != req.collection { + return Err(Status::internal(format!( + "existing collection:{} unexpected input: {}", + vol.collection, req.collection + ))); + } + let version = vol.version().0 as u32; + let dat_size = vol.dat_file_size().unwrap_or(0) as i64; + let expire_at_sec = vol.expire_at_sec(); + ( + store.locations[loc_idx].directory.clone(), + store.locations[loc_idx].idx_directory.clone(), + version, + dat_size, + expire_at_sec, + ) + }; + + // Check existing .vif for EC shard config (matching Go's MaybeLoadVolumeInfo). + // The block size is recomputed by the encode for the current .dat, so + // only the ratio is carried over from a prior config. + let (data_shards, parity_shards, _) = + crate::storage::erasure_coding::ec_volume::read_ec_shard_config( + &dir, &idx_dir, collection, vid, + ) + .map_err(|e| { + tonic::Status::internal(format!("read ec shard config for volume {}: {}", vid.0, e)) + })?; + + let block_size = match crate::storage::erasure_coding::ec_encoder::write_ec_files( + &dir, + &idx_dir, + collection, + vid, + data_shards as usize, + parity_shards as usize, + ) { + Ok(block_size) => block_size, + Err(e) => { + // Cleanup partially-created .ecNN and .ecx files on failure (matching Go defer) + let base = crate::storage::volume::volume_file_name(&dir, collection, vid); + let total_shards = data_shards + parity_shards; + for i in 0..total_shards { + let shard_path = format!("{}.ec{:02}", base, i); + let _ = std::fs::remove_file(&shard_path); + } + let _ = std::fs::remove_file(format!("{}.ecx", base)); + return Err(Status::internal(e.to_string())); + } + }; + + // Write .vif file with EC shard metadata + { + let base = crate::storage::volume::volume_file_name(&dir, collection, vid); + let vif_path = format!("{}.vif", base); + let vif = crate::storage::volume::VifVolumeInfo { + version: vol_version, + dat_file_size, + expire_at_sec, + ec_shard_config: Some(crate::storage::volume::VifEcShardConfig { + data_shards, + parity_shards, + // This run's identity; the read path rejects a shard from a + // different encode run. + encode_ts_ns: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as i64, + block_size, + }), + ..Default::default() + }; + let content = serde_json::to_string_pretty(&vif) + .map_err(|e| Status::internal(format!("serialize vif: {}", e)))?; + std::fs::write(&vif_path, content) + .map_err(|e| Status::internal(format!("write vif: {}", e)))?; + } + + Ok(Response::new( + volume_server_pb::VolumeEcShardsGenerateResponse {}, + )) + } + + async fn volume_ec_shards_rebuild( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let collection = &req.collection; + + // Search ALL locations for shards, pick the best rebuild location + // (most shards + has .ecx), collect additional dirs. + // Matches Go's multi-location search in VolumeEcShardsRebuild. + let base_name = if collection.is_empty() { + format!("{}", vid.0) + } else { + format!("{}_{}", collection, vid.0) + }; + + let store = self.state.store.read().unwrap(); + let mut loc_infos: Vec = Vec::new(); + + for loc in &store.locations { + // Count shards in this location's directory + let mut shard_count = 0usize; + if let Ok(entries) = std::fs::read_dir(&loc.directory) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(&format!("{}.ec", base_name)) { + let suffix = &name[base_name.len() + 3..]; + if suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_digit()) { + shard_count += 1; + } + } + } + } + + // Check for .ecx in idx_directory first, then data directory + let idx_base = format!("{}/{}", loc.idx_directory, base_name); + let data_base = format!("{}/{}", loc.directory, base_name); + let has_ecx = std::path::Path::new(&format!("{}.ecx", idx_base)).exists() + || (loc.idx_directory != loc.directory + && std::path::Path::new(&format!("{}.ecx", data_base)).exists()); + + if shard_count == 0 && !has_ecx { + continue; + } + + loc_infos.push(LocInfo { + dir: loc.directory.clone(), + idx_dir: loc.idx_directory.clone(), + shard_count, + has_ecx, + }); + } + drop(store); + + if loc_infos.is_empty() { + return Ok(Response::new( + volume_server_pb::VolumeEcShardsRebuildResponse { + rebuilt_shard_ids: vec![], + }, + )); + } + + let (rebuild_loc_idx, other_dirs) = match select_rebuild_location(&loc_infos) { + Some(picked) => picked, + None => { + return Ok(Response::new( + volume_server_pb::VolumeEcShardsRebuildResponse { + rebuilt_shard_ids: vec![], + }, + )); + } + }; + + let rebuild_dir = loc_infos[rebuild_loc_idx].dir.clone(); + let rebuild_idx_dir = loc_infos[rebuild_loc_idx].idx_dir.clone(); + + // Determine data/parity shard config from rebuild dir + // The encode-time .dat size resolves the row count the ecx rebuild + // de-stripes with; 0 leaves it to infer from the padded shard extent. + // Both lookups search the sibling disks too: the rebuild writes into one + // location, but a multi-disk server may keep this volume's .vif or its + // generation-0 .ecsum on another, and defaulting to 10+4 with the + // legacy layout would reconstruct through the wrong matrix. + let dat_file_size = crate::storage::erasure_coding::ec_volume::load_vif_info_across_dirs( + &rebuild_dir, + &rebuild_idx_dir, + &other_dirs, + collection, + vid, + ) + .ok() + .flatten() + .map(|(v, _)| v.dat_file_size) + .unwrap_or(0); + let (data_shards, parity_shards, block_size) = + crate::storage::erasure_coding::ec_volume::read_ec_shard_config_across_dirs( + &rebuild_dir, + &rebuild_idx_dir, + &other_dirs, + collection, + vid, + ) + .map_err(|e| { + tonic::Status::internal(format!("read ec shard config for volume {}: {}", vid.0, e)) + })?; + let total_shards = data_shards + parity_shards; + + // Check which shards are missing (check rebuild dir and all other dirs) + let mut missing: Vec = Vec::new(); + for shard_id in 0..total_shards as u8 { + let shard = crate::storage::erasure_coding::ec_shard::EcVolumeShard::new( + &rebuild_dir, + collection, + vid, + shard_id, + ); + let mut found = std::path::Path::new(&shard.file_name()).exists(); + if !found { + for other_dir in &other_dirs { + let other_shard = crate::storage::erasure_coding::ec_shard::EcVolumeShard::new( + other_dir, collection, vid, shard_id, + ); + if std::path::Path::new(&other_shard.file_name()).exists() { + found = true; + break; + } + } + } + if !found { + missing.push(shard_id as u32); + } + } + + if missing.is_empty() { + return Ok(Response::new( + volume_server_pb::VolumeEcShardsRebuildResponse { + rebuilt_shard_ids: vec![], + }, + )); + } + + // Rebuild missing shards, searching all locations for input shards. + // Pass other_dirs so shards on sibling disks are found even when the + // primary rebuild dir doesn't hold them. This one takes a single flat + // list — the shape Go's RebuildEcFiles uses — so unlike the resolvers + // above it cannot be handed the rebuild's own index directory + // separately, and a split -dir/-dir.idx location keeps its .ecx and + // .vif there. Go's additionalDirs carries that directory for the same + // reason. + let mut rebuild_search_dirs: Vec = other_dirs.clone(); + if !rebuild_idx_dir.is_empty() && rebuild_idx_dir != rebuild_dir { + rebuild_search_dirs.push(rebuild_idx_dir.clone()); + } + let other_dir_refs: Vec<&str> = rebuild_search_dirs.iter().map(|s| s.as_str()).collect(); + crate::storage::erasure_coding::ec_encoder::rebuild_ec_files( + &rebuild_dir, + collection, + vid, + &missing, + data_shards as usize, + parity_shards as usize, + &other_dir_refs, + ) + .map_err(|e| Status::internal(format!("RebuildEcFiles: {}", e)))?; + + // Rebuild .ecx; use idx_directory with fallback to data directory + let ecx_base = format!("{}/{}", rebuild_idx_dir, base_name); + let ecx_rebuild_dir = if std::path::Path::new(&format!("{}.ecx", ecx_base)).exists() { + rebuild_idx_dir + } else if rebuild_idx_dir != rebuild_dir { + rebuild_dir.clone() + } else { + rebuild_idx_dir + }; + + // .ecx may live in a different directory than the data shards (split + // data/idx layout). Ensure rebuild_dir is searchable for data shards + // even when it isn't the chosen .ecx rebuild directory and isn't + // already covered by other_dir_refs. + let mut ecx_dir_refs: Vec<&str> = + Vec::with_capacity(other_dir_refs.len() + usize::from(ecx_rebuild_dir != rebuild_dir)); + if ecx_rebuild_dir != rebuild_dir { + ecx_dir_refs.push(rebuild_dir.as_str()); + } + ecx_dir_refs.extend(other_dir_refs.iter().copied()); + + crate::storage::erasure_coding::ec_encoder::rebuild_ecx_file( + &ecx_rebuild_dir, + collection, + vid, + data_shards as usize, + block_size, + dat_file_size, + &ecx_dir_refs, + ) + .map_err(|e| Status::internal(format!("RebuildEcxFile: {}", e)))?; + + Ok(Response::new( + volume_server_pb::VolumeEcShardsRebuildResponse { + rebuilt_shard_ids: missing, + }, + )) + } + + async fn volume_ec_shards_copy( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Select target location: + // When disk_id > 0: use that specific location. + // When disk_id == 0 (unset): auto-select via + // find_ec_shard_target_location, which prefers a disk that + // already owns one of the shards being copied (a retried move + // must overwrite in place, not leave two disks of this server + // claiming the same shard), then a disk that already has the EC + // volume mounted, then a disk that owns the .ecx on disk (volume + // not yet mounted — relevant for ec.rebuild, where only the + // first shard carries .ecx and subsequent shards must land on + // the same disk; see #9212), then any HDD, then any disk. Pass + // the build's default data-shard count; the helper takes it as a + // parameter so custom-ratio builds can swap it. + let (dest_dir, dest_idx_dir) = { + let store = self.state.store.read().unwrap(); + let count = store.locations.len(); + + if req.disk_id > 0 { + // Explicit disk selection + if (req.disk_id as usize) >= count { + return Err(Status::invalid_argument(format!( + "invalid disk_id {}: only have {} disks", + req.disk_id, count + ))); + } + let loc = &store.locations[req.disk_id as usize]; + (loc.directory.clone(), loc.idx_directory.clone()) + } else { + // A batch whose requested shards are already owned by + // different local disks has no single correct destination: + // writing them all to one disk would duplicate the other + // disks' claims. Refuse so the caller splits the batch per + // shard (or chooses explicitly via disk_id). + let owners = store.ec_shard_owner_disks(vid, &req.shard_ids); + if owners.len() > 1 { + let dirs: Vec<&str> = owners + .iter() + .map(|&i| store.locations[i].directory.as_str()) + .collect(); + return Err(Status::failed_precondition(format!( + "volume {} shards {:?} are already owned by multiple local disks {:?}: no single destination; copy per shard or pass disk_id", + req.volume_id, req.shard_ids, dirs + ))); + } + match store.find_ec_shard_target_location( + &req.collection, + vid, + DATA_SHARDS_COUNT as u32, + &req.shard_ids, + ) { + Some(i) => { + let loc = &store.locations[i]; + (loc.directory.clone(), loc.idx_directory.clone()) + } + None => { + return Err(Status::internal("no space left".to_string())); + } + } + } + }; + + // Connect to source and copy shard files via CopyFile + let source = &req.source_data_node; + let grpc_addr = parse_grpc_address(source).map_err(|e| { + Status::internal(format!( + "VolumeEcShardsCopy volume {} invalid source_data_node {}: {}", + vid, source, e + )) + })?; + + let channel = build_grpc_endpoint(&grpc_addr, self.state.outgoing_grpc_tls.as_ref()) + .map_err(|e| { + Status::internal(format!( + "VolumeEcShardsCopy volume {} parse source: {}", + vid, e + )) + })? + .connect() + .await + .map_err(|e| { + Status::internal(format!( + "VolumeEcShardsCopy volume {} connect to {}: {}", + vid, grpc_addr, e + )) + })?; + + let mut client = + volume_server_pb::volume_server_client::VolumeServerClient::with_interceptor( + channel, + super::request_id::outgoing_request_id_interceptor, + ) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + + // Copy each shard + for &shard_id in &req.shard_ids { + let ext = format!(".ec{:02}", shard_id); + let copy_req = volume_server_pb::CopyFileRequest { + volume_id: req.volume_id, + collection: req.collection.clone(), + is_ec_volume: true, + ext: ext.clone(), + compaction_revision: u32::MAX, + stop_offset: i64::MAX as u64, + ..Default::default() + }; + let mut stream = client + .copy_file(copy_req) + .await + .map_err(|e| { + Status::internal(format!( + "VolumeEcShardsCopy volume {} copy {}: {}", + vid, ext, e + )) + })? + .into_inner(); + + let file_path = { + let base = + crate::storage::volume::volume_file_name(&dest_dir, &req.collection, vid); + format!("{}{}", base, ext) + }; + let file = tokio::fs::File::create(&file_path) + .await + .map_err(|e| Status::internal(format!("create {}: {}", file_path, e)))?; + drain_copy_stream_to_file(&mut stream, file, &file_path, &ext).await?; + } + + // Copy .ecx file if requested + if req.copy_ecx_file { + let copy_req = volume_server_pb::CopyFileRequest { + volume_id: req.volume_id, + collection: req.collection.clone(), + is_ec_volume: true, + ext: ".ecx".to_string(), + compaction_revision: u32::MAX, + stop_offset: i64::MAX as u64, + ..Default::default() + }; + let mut stream = client + .copy_file(copy_req) + .await + .map_err(|e| { + Status::internal(format!( + "VolumeEcShardsCopy volume {} copy .ecx: {}", + vid, e + )) + })? + .into_inner(); + + let file_path = { + let base = + crate::storage::volume::volume_file_name(&dest_idx_dir, &req.collection, vid); + format!("{}.ecx", base) + }; + let file = tokio::fs::File::create(&file_path) + .await + .map_err(|e| Status::internal(format!("create {}: {}", file_path, e)))?; + drain_copy_stream_to_file(&mut stream, file, &file_path, ".ecx").await?; + } + + // Copy .ecj file if requested + if req.copy_ecj_file { + let copy_req = volume_server_pb::CopyFileRequest { + volume_id: req.volume_id, + collection: req.collection.clone(), + is_ec_volume: true, + ext: ".ecj".to_string(), + compaction_revision: u32::MAX, + stop_offset: i64::MAX as u64, + ignore_source_file_not_found: true, + ..Default::default() + }; + let mut stream = client + .copy_file(copy_req) + .await + .map_err(|e| { + Status::internal(format!( + "VolumeEcShardsCopy volume {} copy .ecj: {}", + vid, e + )) + })? + .into_inner(); + + let file_path = { + let base = + crate::storage::volume::volume_file_name(&dest_idx_dir, &req.collection, vid); + format!("{}.ecj", base) + }; + let file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&file_path) + .await + .map_err(|e| Status::internal(format!("create {}: {}", file_path, e)))?; + drain_copy_stream_to_file(&mut stream, file, &file_path, ".ecj").await?; + } + + // Copy .vif file if requested + if req.copy_vif_file { + let copy_req = volume_server_pb::CopyFileRequest { + volume_id: req.volume_id, + collection: req.collection.clone(), + is_ec_volume: true, + ext: ".vif".to_string(), + compaction_revision: u32::MAX, + stop_offset: i64::MAX as u64, + ignore_source_file_not_found: true, + ..Default::default() + }; + let mut stream = client + .copy_file(copy_req) + .await + .map_err(|e| { + Status::internal(format!( + "VolumeEcShardsCopy volume {} copy .vif: {}", + vid, e + )) + })? + .into_inner(); + + let file_path = { + let base = + crate::storage::volume::volume_file_name(&dest_dir, &req.collection, vid); + format!("{}.vif", base) + }; + let file = tokio::fs::File::create(&file_path) + .await + .map_err(|e| Status::internal(format!("create {}: {}", file_path, e)))?; + drain_copy_stream_to_file(&mut stream, file, &file_path, ".vif").await?; + } + + // Copy the generation-0 bitrot checksum sidecar (.ecsum) when requested, so + // protection travels with the shards. Tolerant of a missing source (no-op): + // this non-2PC path has no Prepare backstop, and an unprotected source + // simply leaves the copy unprotected. + if req.copy_ecsum_file { + let copy_req = volume_server_pb::CopyFileRequest { + volume_id: req.volume_id, + collection: req.collection.clone(), + is_ec_volume: true, + ext: ".ecsum".to_string(), + compaction_revision: u32::MAX, + stop_offset: i64::MAX as u64, + ignore_source_file_not_found: true, + ..Default::default() + }; + let mut stream = client + .copy_file(copy_req) + .await + .map_err(|e| { + Status::internal(format!( + "VolumeEcShardsCopy volume {} copy .ecsum: {}", + vid, e + )) + })? + .into_inner(); + + let file_path = { + let base = + crate::storage::volume::volume_file_name(&dest_dir, &req.collection, vid); + format!("{}.ecsum", base) + }; + let file = tokio::fs::File::create(&file_path) + .await + .map_err(|e| Status::internal(format!("create {}: {}", file_path, e)))?; + let written = + drain_copy_stream_to_file(&mut stream, file, &file_path, ".ecsum").await?; + // A missing source yields an empty stream; drop the 0-byte file so mount + // sees no sidecar (protection Off) rather than a truncated/invalid one. + // (drain_copy_stream_to_file has already closed the file on return.) + if written == 0 { + let _ = tokio::fs::remove_file(&file_path).await; + } + } + + Ok(Response::new( + volume_server_pb::VolumeEcShardsCopyResponse {}, + )) + } + + /// Allowed in maintenance mode: like `volume_delete` it only removes data, and + /// evacuating EC shards off a server in maintenance mode ends here (issue #11066). + async fn volume_ec_shards_delete( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + if req.full_teardown { + if req.encode_ts_ns == 0 { + // Blanket teardown (shell pre-encode cleanup / pre-upgrade caller): evict + // the volume and wipe every EC artifact for it on every disk, not just the + // listed shards, so a remote node retains no stale generation a fresh copy + // collides with. Echo the acknowledgement so the caller can tell a + // pre-upgrade server apart. + { + let mut store = self.state.store.write().unwrap(); + let _ = store.remove_ec_volume(vid); + for loc in &store.locations { + loc.remove_ec_volume_files_full_teardown(&req.collection, vid) + .map_err(|e| { + Status::internal(format!( + "full teardown of ec volume {}: {}", + req.volume_id, e + )) + })?; + } + } + } else { + // Generation-fenced teardown (stale-worker pre-distribute cleanup): wipe + // only a disk whose .vif generation is strictly OLDER than the request; + // preserve same-or-newer, generation 0 (recovered/pre-upgrade live volume), + // and an unreadable .vif, so a stale run never wipes a newer run's live + // shards. Unload and remove only the strictly-older disks, never node-wide. + let mut store = self.state.store.write().unwrap(); + for disk_id in 0..store.locations.len() { + let disk_gen = + store.locations[disk_id].ec_generation_ts_ns(&req.collection, vid); + let older = matches!(disk_gen, Some(g) if g > 0 && g < req.encode_ts_ns); + if !older { + tracing::info!( + volume_id = vid.0, + disk_id, + ?disk_gen, + req = req.encode_ts_ns, + "ec full teardown preserved: generation not older than request" + ); + continue; + } + store.locations[disk_id].remove_ec_volume(vid); + store.locations[disk_id] + .remove_ec_volume_files_full_teardown(&req.collection, vid) + .map_err(|e| { + Status::internal(format!( + "fenced teardown of ec volume {}: {}", + req.volume_id, e + )) + })?; + } + } + self.state.volume_state_notify.notify_one(); + return Ok(Response::new( + volume_server_pb::VolumeEcShardsDeleteResponse { + full_teardown_done: true, + }, + )); + } + + let mut store = self.state.store.write().unwrap(); + store.delete_ec_shards(vid, &req.collection, &req.shard_ids); + drop(store); + self.state.volume_state_notify.notify_one(); + Ok(Response::new( + volume_server_pb::VolumeEcShardsDeleteResponse { + full_teardown_done: false, + }, + )) + } + + async fn volume_ec_shards_mount( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Fetch a missing .ecx from a peer first so on-disk shards that never had + // a local index can be mounted (issue #10104). Driven on demand by + // ec.rebuild. volume_id 0 recovers every orphan on this server, including + // volumes the master never learned about. + if req.recover_missing_index { + crate::server::store_ec::recover_missing_ec_indexes(&self.state, req.volume_id).await; + } + + // Mount one shard at a time, returning error on first failure. + // Matches Go: for _, shardId := range req.ShardIds { err = vs.store.MountEcShards(...) } + let mut store = self.state.store.write().unwrap(); + for &shard_id in &req.shard_ids { + store + .mount_ec_shard(vid, &req.collection, shard_id, &req.source_disk_type) + .map_err(|e| { + Status::internal(format!("mount {}.{}: {}", req.volume_id, shard_id, e)) + })?; + } + // A delivery can bring the checksum manifest alongside the shards, but + // the receive path only writes the file. When this server already had + // the volume mounted, the EcVolume in memory keeps whatever protection + // state it resolved at mount — off, for a volume whose sidecar arrives + // now — until a remount. Re-resolve it here, where the shards it + // describes have just been added. + // + // Every per-disk runtime, not just the first: a vid mounts as one + // EcVolume per disk, the delivery lands the .ecsum on one of them, and + // the first-match lookup would leave the siblings reporting no + // protection. Each re-resolves against its own data and index + // directories, so a shared -dir.idx reaches all of them. + // Resolving across every EC metadata directory is what makes that + // reload mean something: startup mirroring gives each shard-bearing + // disk its own .ecx/.ecj/.vif but deliberately not the sidecar, so a + // runtime restricted to its own two directories would find nothing + // however often it reloaded. One delivered copy, reachable from all. + let ec_metadata_dirs = store.ec_metadata_dirs(); + for ec_vol in store.find_all_ec_volumes_mut(vid) { + ec_vol.reload_bitrot_sidecar(&ec_metadata_dirs); + } + drop(store); + self.state.volume_state_notify.notify_one(); + + Ok(Response::new( + volume_server_pb::VolumeEcShardsMountResponse {}, + )) + } + + async fn volume_ec_shards_unmount( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Unmount one shard at a time, returning error on first failure. + // Matches Go: for _, shardId := range req.ShardIds { err = vs.store.UnmountEcShards(...) } + let mut store = self.state.store.write().unwrap(); + for &shard_id in &req.shard_ids { + store + .unmount_ec_shard(vid, shard_id, req.encode_ts_ns) + .map_err(|e| { + Status::internal(format!("unmount {}.{}: {}", req.volume_id, shard_id, e)) + })?; + } + drop(store); + self.state.volume_state_notify.notify_one(); + Ok(Response::new( + volume_server_pb::VolumeEcShardsUnmountResponse {}, + )) + } + + type VolumeEcShardReadStream = BoxStream; + async fn volume_ec_shard_read( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + let store = self.state.store.read().unwrap(); + // Reconciled EC volumes can have their shards split across + // disks (e.g. shards 0/12 on disk 0, shard 1 on disk 1), so + // resolve the EcVolume from the *shard*'s home location + // rather than first-match `find_ec_volume(vid)` which would + // miss shards that live on a sibling. Mirrors Go's findEcShard. + let ec_vol = store + .find_ec_volume_with_shard(vid, req.shard_id) + .ok_or_else(|| { + Status::not_found(format!( + "ec volume {} shard {} not found", + req.volume_id, req.shard_id + )) + })?; + + // Reject a shard whose identity doesn't match the caller's index; the caller + // then recovers from parity. Lenient only when the caller has no identity + // (pre-upgrade reader): a known caller must not accept an unstamped holder, + // which would serve a stale shard from a different encode run. + if req.encode_ts_ns != 0 && req.encode_ts_ns != ec_vol.encode_ts_ns { + return Err(Status::failed_precondition(format!( + "ec shard {}.{} belongs to a different encode run", + req.volume_id, req.shard_id + ))); + } + + // Identity of the shard actually served, echoed on every response chunk so + // the client can reject a different encode run even from a pre-upgrade server. + let served_encode_ts_ns = ec_vol.encode_ts_ns; + + // Check if the requested needle is deleted (via .ecx index, matching Go) + if req.file_key > 0 { + let needle_id = NeedleId(req.file_key); + if let Some((_offset, size)) = ec_vol + .find_needle_from_ecx(needle_id) + .map_err(|e| Status::internal(e.to_string()))? + && size.is_deleted() + { + let results = vec![Ok(volume_server_pb::VolumeEcShardReadResponse { + is_deleted: true, + encode_ts_ns: served_encode_ts_ns, + ..Default::default() + })]; + return Ok(Response::new(Box::pin(tokio_stream::iter(results)))); + } + } + + // Read from the shard. Guaranteed to be present because + // find_ec_volume_with_shard already verified it. + let shard = ec_vol + .shards + .get(req.shard_id as usize) + .and_then(|s| s.as_ref()) + .ok_or_else(|| { + Status::not_found(format!( + "ec volume {} shard {} not mounted", + req.volume_id, req.shard_id + )) + })?; + + let total_size = if req.size > 0 { + req.size as usize + } else { + 1024 * 1024 + }; + + // Stream in 2MB chunks (matching Go's BufferSizeLimit) + const BUFFER_SIZE_LIMIT: usize = 2 * 1024 * 1024; + let mut results: Vec> = + Vec::new(); + let mut bytes_read: usize = 0; + let mut current_offset = req.offset as u64; + + while bytes_read < total_size { + let chunk_size = std::cmp::min(BUFFER_SIZE_LIMIT, total_size - bytes_read); + let mut buf = vec![0u8; chunk_size]; + let n = match shard.read_at(&mut buf, current_offset) { + Ok(n) => n, + Err(e) => { + ec_vol.check_read_write_error(Some(&e)); + return Err(Status::internal(e.to_string())); + } + }; + ec_vol.check_read_write_error(None); + if n == 0 { + break; + } + buf.truncate(n); + bytes_read += n; + current_offset += n as u64; + results.push(Ok(volume_server_pb::VolumeEcShardReadResponse { + data: buf, + is_deleted: false, + encode_ts_ns: served_encode_ts_ns, + })); + if n < chunk_size { + break; // short read means EOF + } + } + + Ok(Response::new(Box::pin(tokio_stream::iter(results)))) + } + + async fn volume_ec_blob_delete( + &self, + request: Request, + ) -> Result, Status> { + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let needle_id = NeedleId(req.file_key); + + // Go checks if needle is already deleted (via ecx) before journaling. + // Search all locations for the EC volume. + let mut store = self.state.store.write().unwrap(); + if let Some(ec_vol) = store.find_ec_volume_mut(vid) { + // Check if already deleted via ecx index + if let Ok(Some((_offset, size))) = ec_vol.find_needle_from_ecx(needle_id) + && size.is_deleted() + { + // Already deleted, no-op + return Ok(Response::new( + volume_server_pb::VolumeEcBlobDeleteResponse {}, + )); + } + ec_vol + .journal_delete(needle_id) + .map_err(|e| Status::internal(e.to_string()))?; + } + // If EC volume not mounted, it's a no-op (matching Go behavior) + Ok(Response::new( + volume_server_pb::VolumeEcBlobDeleteResponse {}, + )) + } + + async fn volume_ec_shards_to_volume( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Staged mode: the caller decoded off-box and streamed the normal volume + // here as .copying (ReceiveFile staged-new-volume mode). Adopt + // those files as a normal volume — rename .copying into place under a .note + // marker, then mount. This server holds no EC shards for the vid, so there + // is no in-place decode to run. + if req.from_staged { + let want = DiskType::from_string(&req.disk_type); + let base = { + let store = self.state.store.read().unwrap(); + if store.has_volume(vid) { + return Err(Status::internal(format!( + "staged volume {} already exists on this server", + req.volume_id + ))); + } + let mut base: Option = None; + for loc in store.locations.iter() { + if loc.disk_type != want { + continue; + } + let candidate = if req.collection.is_empty() { + format!("{}/{}", loc.directory, req.volume_id) + } else { + format!("{}/{}_{}", loc.directory, req.collection, req.volume_id) + }; + if std::path::Path::new(&format!("{}.dat.copying", candidate)).exists() { + base = Some(candidate); + break; + } + } + base + } + .ok_or_else(|| { + Status::not_found(format!( + "staged volume {}: no .dat.copying found on a {} disk", + req.volume_id, req.disk_type + )) + })?; + + for ext in [".dat", ".idx", ".vif"] { + if !std::path::Path::new(&format!("{}{}.copying", base, ext)).exists() { + return Err(Status::not_found(format!( + "staged volume {} missing {}.copying", + req.volume_id, ext + ))); + } + } + + // .note in-progress marker (VolumeCopy discipline): a crash mid-rename + // leaves a .note that fails the load and sweeps the partial volume. + let note = format!("{}.note", base); + std::fs::write(¬e, format!("adopting decoded volume {}", req.volume_id)) + .map_err(|e| Status::internal(format!("write .note: {}", e)))?; + + // Rename staged files into place, then drop the .note before mounting — + // a volume that still carries a .note is swept by the load scan. + for ext in [".vif", ".dat", ".idx"] { + let src = format!("{}{}.copying", base, ext); + let dst = format!("{}{}", base, ext); + if let Err(e) = std::fs::rename(&src, &dst) { + let _ = std::fs::remove_file(¬e); + return Err(Status::internal(format!("rename staged {}: {}", ext, e))); + } + } + let _ = std::fs::remove_file(¬e); + + { + let mut store = self.state.store.write().unwrap(); + store + .mount_volume_by_id(vid, Some(req.collection.as_str())) + .map_err(|e| { + Status::internal(format!("mount staged volume {}: {}", req.volume_id, e)) + })?; + } + self.state.volume_state_notify.notify_one(); + tracing::info!( + "volume_ec_shards_to_volume: adopted decoded volume {} from staging ({})", + req.volume_id, + base + ); + return Ok(Response::new( + volume_server_pb::VolumeEcShardsToVolumeResponse {}, + )); + } + + let store = self.state.store.read().unwrap(); + // Aggregate per-shard data dirs across all locations so the + // shard-presence check + decoder both see the union for + // cross-disk reconciled volumes (#9252). Mirrors Go's + // CollectEcShards. + let max_shard_count = crate::storage::erasure_coding::ec_shard::MAX_SHARD_COUNT; + let (ec_vol, shard_dirs) = store + .collect_ec_shard_dirs(vid, max_shard_count) + .ok_or_else(|| Status::not_found(format!("ec volume {} not found", req.volume_id)))?; + + if ec_vol.collection != req.collection { + return Err(Status::internal(format!( + "existing collection:{} unexpected input: {}", + ec_vol.collection, req.collection + ))); + } + + // Use EC context data shard count from the volume + let data_shards = ec_vol.data_shards as usize; + + // Validate data shard count range (matches Go's VolumeEcShardsToVolume) + if data_shards == 0 || data_shards > max_shard_count { + return Err(Status::invalid_argument(format!( + "invalid data shard count {} for volume {} (must be 1..{})", + data_shards, req.volume_id, max_shard_count + ))); + } + + // Check that all data shards are present somewhere on this server. + for (shard_id, dir) in shard_dirs[..data_shards].iter().enumerate() { + if dir.is_none() { + return Err(Status::internal(format!( + "ec volume {} missing shard {}", + req.volume_id, shard_id + ))); + } + } + + // Read the .ecx index to check for live entries + let ecx_path = ec_vol.ecx_file_name(); + let ecx_data = + std::fs::read(&ecx_path).map_err(|e| Status::internal(format!("read ecx: {}", e)))?; + let entry_count = ecx_data.len() / NEEDLE_MAP_ENTRY_SIZE; + + let mut has_live = false; + for i in 0..entry_count { + let start = i * NEEDLE_MAP_ENTRY_SIZE; + let (_, _, size) = + idx_entry_from_bytes(&ecx_data[start..start + NEEDLE_MAP_ENTRY_SIZE]); + if !size.is_deleted() { + has_live = true; + break; + } + } + + if !has_live { + return Err(Status::failed_precondition(format!( + "ec volume {} has no live entries", + req.volume_id + ))); + } + + // Reconstruct the volume from EC shards. Use the EcVolume's + // own dir for the produced .dat (matches the volume's home + // disk) and its `ecx_actual_dir` for the .ecx lookup, while + // reading each shard from its real on-disk location. + let dat_dir = ec_vol.dir.clone(); + let ecx_dir = ec_vol.ecx_actual_dir().to_string(); + let collection = ec_vol.collection.clone(); + let vif_dat_file_size = ec_vol.dat_file_size; + let (large_block_size, small_block_size) = + (ec_vol.large_block_size(), ec_vol.small_block_size()); + // shard_dirs[i] is guaranteed Some for i in 0..data_shards by + // the check above; collect concrete dirs for the decoder. + let per_shard_dirs: Vec = shard_dirs[..data_shards] + .iter() + .map(|d| d.clone().unwrap()) + .collect(); + drop(store); + + // Calculate .dat file size from .ecx entries (.ec00 lives on + // its own disk, .ecx on the index disk). + let dat_file_size = + crate::storage::erasure_coding::ec_decoder::find_dat_file_size_with_dirs( + &per_shard_dirs[0], + &ecx_dir, + &collection, + vid, + ) + .map_err(|e| Status::internal(format!("FindDatFileSize: {}", e)))?; + + // The shard block layout was fixed by the .dat size at encode time + // (recorded in .vif); deletions can shrink the live extent below a + // large-block row boundary, so the layout must not be derived from + // dat_file_size. The decoder infers the layout from the shard size + // when .vif does not record it. + // Write .dat file using block-interleaved reading from shards. + crate::storage::erasure_coding::ec_decoder::write_dat_file_from_shards( + &crate::storage::erasure_coding::ec_decoder::DatRebuild { + dat_dir: &dat_dir, + collection: &collection, + volume_id: vid, + dat_file_size, + encoded_dat_file_size: vif_dat_file_size, + data_shards, + shard_dirs: Some(&per_shard_dirs), + large_block_size: large_block_size as usize, + small_block_size: small_block_size as usize, + }, + ) + .map_err(|e| Status::internal(format!("WriteDatFile: {}", e)))?; + + // Write .idx file from .ecx and .ecj files (lives on idx dir). + crate::storage::erasure_coding::ec_decoder::write_idx_file_from_ec_index( + &dat_dir, + &collection, + vid, + ) + .map_err(|e| Status::internal(format!("WriteIdxFileFromEcIndex: {}", e)))?; + + // Go does NOT unmount EC shards or mount the volume here. + // The caller (ec.balance / ec.decode) handles mount/unmount separately. + + Ok(Response::new( + volume_server_pb::VolumeEcShardsToVolumeResponse {}, + )) + } + + async fn volume_ec_shards_info( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + let store = self.state.store.read().unwrap(); + let ec_vol = store + .find_ec_volume(vid) + .ok_or_else(|| Status::not_found(format!("ec volume {} not found", req.volume_id)))?; + + let mut shard_infos = Vec::new(); + for (i, shard) in ec_vol.shards.iter().enumerate() { + match shard { + Some(s) => { + shard_infos.push(volume_server_pb::EcShardInfo { + shard_id: i as u32, + size: s.file_size(), + collection: ec_vol.collection.clone(), + volume_id: req.volume_id, + }); + } + None => { + shard_infos.push(volume_server_pb::EcShardInfo { + shard_id: i as u32, + collection: ec_vol.collection.clone(), + volume_id: req.volume_id, + ..Default::default() + }); + } + } + } + + // Walk .ecx index to compute file counts and total size (matching Go's WalkIndex) + let (file_count, file_deleted_count, volume_size) = ec_vol + .walk_ecx_stats() + .map_err(|e| Status::internal(e.to_string()))?; + + // The layout this holder serves reads through, as Go reports it: a + // coordinator cannot otherwise tell a holder that understands the + // uniform block layout from one that dropped the unknown .vif field + // and mounted the volume as legacy. + let ec_shard_config = Some(volume_server_pb::EcShardConfig { + data_shards: ec_vol.data_shards, + parity_shards: ec_vol.parity_shards, + encode_ts_ns: 0, + block_size: ec_vol.block_size, + }); + + Ok(Response::new( + volume_server_pb::VolumeEcShardsInfoResponse { + ec_shard_infos: shard_infos, + volume_size, + file_count, + file_deleted_count, + ec_shard_config, + }, + )) + } + + // ---- Tiered storage ---- + + type VolumeTierMoveDatToRemoteStream = + BoxStream; + async fn volume_tier_move_dat_to_remote( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Validate volume exists and collection matches + let dat_path = { + let store = self.state.store.read().unwrap(); + let (_, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("volume {} not found", req.volume_id)))?; + + if vol.collection != req.collection { + return Err(Status::invalid_argument(format!( + "existing collection:{} unexpected input: {}", + vol.collection, req.collection + ))); + } + + let dat_path = vol.dat_path(); + + // Match Go's DiskFile check: if the .dat file is still local, we can + // keep tiering it even when remote file entries already exist. + if volume_is_remote_only(&dat_path, vol.has_remote_file) { + // Already on remote -- return empty stream (matches Go: returns nil) + let stream = tokio_stream::empty(); + return Ok(Response::new( + Box::pin(stream) as Self::VolumeTierMoveDatToRemoteStream + )); + } + + // Check if the destination backend already exists in volume info + let (backend_type, backend_id) = + crate::remote_storage::s3_tier::backend_name_to_type_id( + &req.destination_backend_name, + ); + for rf in &vol.volume_info.files { + if rf.backend_type == backend_type && rf.backend_id == backend_id { + return Err(Status::already_exists(format!( + "destination {} already exists", + req.destination_backend_name + ))); + } + } + + dat_path + }; + + // Store the source .dat mtime, not the upload time, so a reload computes + // TTL from real data age (matches Go VolumeTierMoveDatToRemote). + let dat_modified_secs = std::fs::metadata(&dat_path) + .and_then(|m| m.modified()) + .map_err(|e| Status::internal(format!("stat data file {}: {}", dat_path, e)))? + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + // Look up the S3 tier backend + let backend = { + let registry = self.state.s3_tier_registry.read().unwrap(); + registry.get(&req.destination_backend_name).ok_or_else(|| { + let keys = registry.names(); + Status::not_found(format!( + "destination {} not found, supported: {:?}", + req.destination_backend_name, keys + )) + })? + }; + + let (backend_type, backend_id) = + crate::remote_storage::s3_tier::backend_name_to_type_id(&req.destination_backend_name); + + let (tx, rx) = tokio::sync::mpsc::channel::< + Result, + >(16); + let state = self.state.clone(); + let keep_local = req.keep_local_dat_file; + let dest_backend_name = req.destination_backend_name.clone(); + + tokio::spawn(async move { + let result: Result<(), Status> = async { + // Nothing below is worth doing for a caller that has already + // gone: the upload would spend bandwidth and S3 storage on a + // transition nobody is waiting to hear the result of. + if tx.is_closed() { + return Err(Status::cancelled(format!( + "volume {} tier move to remote cancelled by caller", + vid + ))); + } + + // Upload the .dat file to S3 with progress + let tx_progress = tx.clone(); + let mut last_report = std::time::Instant::now(); + let (key, size) = backend + .upload_file(&dat_path, move |processed, percentage| { + // Checked on every part, not only where progress is + // reported: the report is rate-limited to one a second, + // so its result alone would miss a caller that left in + // between. Go aborts here too -- the progress callback + // returns stream.Send's error (s3_upload.go). + if tx_progress.is_closed() { + return Err(format!( + "volume {} tier move to remote cancelled by caller", + vid + )); + } + let now = std::time::Instant::now(); + if now.duration_since(last_report) >= std::time::Duration::from_secs(1) { + last_report = now; + if let Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) = + tx_progress.try_send(Ok( + volume_server_pb::VolumeTierMoveDatToRemoteResponse { + processed, + processed_percentage: percentage, + }, + )) + { + return Err(format!( + "volume {} tier move to remote cancelled by caller", + vid + )); + } + // A merely full channel is a slow reader, not a + // departed one; drop the report and keep going. + } + Ok(()) + }) + .await + .map_err(|e| { + Status::internal(format!( + "backend {} copy file {}: {}", + dest_backend_name, dat_path, e + )) + })?; + + // Deliberately no cancellation check here. Once the object is + // in S3 the cheap bookkeeping that follows is what makes the + // state consistent; stopping now would leave the object paid + // for and referenced by nothing. Go does not gate here either: + // its progress callback only runs during the transfer. + // Update volume info with remote file reference + { + let mut store = state.store.write().unwrap(); + if let Some((_, vol)) = store.find_volume_mut(vid) { + vol.volume_info.files.push(volume_server_pb::RemoteFile { + backend_type: backend_type.clone(), + backend_id: backend_id.clone(), + key, + offset: 0, + file_size: size, + modified_time: dat_modified_secs, + extension: ".dat".to_string(), + }); + vol.refresh_remote_write_mode().map_err(|e| { + Status::internal(format!( + "volume {} failed to refresh write mode: {}", + vid, e + )) + })?; + + if let Err(e) = vol.save_volume_info() { + return Err(Status::internal(format!( + "volume {} failed to save remote file info: {}", + vid, e + ))); + } + + // Switch the data backend from the local .dat to the remote + // object so reads keep working after upload (matches Go's + // LoadRemoteFile). + if let Err(e) = vol.load_remote_dat_file() { + return Err(Status::internal(format!( + "volume {} failed to load remote file: {}", + vid, e + ))); + } + + // Optionally remove local .dat file from disk + if !keep_local { + let dat = vol.dat_path(); + let _ = std::fs::remove_file(&dat); + } + } + } + + // Go does NOT send a final 100% progress message after upload completion + Ok(()) + } + .await; + + if let Err(e) = result { + // The error otherwise goes to a channel nobody is + // reading, leaving an abandoned tier move with no trace. + if e.code() == tonic::Code::Cancelled { + tracing::info!("volume {} tier move to remote abandoned by its caller", vid); + } else { + tracing::warn!("volume {} tier move to remote failed: {}", vid, e); + } + let _ = tx.send(Err(e)).await; + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Response::new( + Box::pin(stream) as Self::VolumeTierMoveDatToRemoteStream + )) + } + + type VolumeTierMoveDatFromRemoteStream = + BoxStream; + async fn volume_tier_move_dat_from_remote( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + // Note: Go does NOT check maintenance mode for TierMoveDatFromRemote + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Validate volume and get remote storage info + let (dat_path, storage_name, storage_key, remote_modified_secs) = { + let store = self.state.store.read().unwrap(); + let (_, vol) = store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("volume {} not found", req.volume_id)))?; + + if vol.collection != req.collection { + return Err(Status::invalid_argument(format!( + "existing collection:{} unexpected input: {}", + vol.collection, req.collection + ))); + } + + let (storage_name, storage_key) = vol.remote_storage_name_key(); + if storage_name.is_empty() || storage_key.is_empty() { + return Err(Status::failed_precondition(format!( + "volume {} is already on local disk", + vid + ))); + } + + // Check if the dat file already exists locally (matches Go's DataBackend DiskFile check) + let dat_path = vol.dat_path(); + if std::path::Path::new(&dat_path).exists() { + return Err(Status::failed_precondition(format!( + "volume {} is already on local disk", + vid + ))); + } + + let remote_modified_secs = vol + .volume_info + .files + .first() + .map(|f| f.modified_time) + .unwrap_or(0); + + (dat_path, storage_name, storage_key, remote_modified_secs) + }; + + // Look up the S3 tier backend + let backend = { + let registry = self.state.s3_tier_registry.read().unwrap(); + registry.get(&storage_name).ok_or_else(|| { + let keys = registry.names(); + Status::not_found(format!( + "remote storage {} not found from supported: {:?}", + storage_name, keys + )) + })? + }; + + let (tx, rx) = tokio::sync::mpsc::channel::< + Result, + >(16); + let state = self.state.clone(); + let keep_remote = req.keep_remote_dat_file; + + tokio::spawn(async move { + let result: Result<(), Status> = async { + // Nothing below is worth doing for a caller that has already + // gone, and the download would overwrite a .dat path the volume + // still reports as absent. + if tx.is_closed() { + return Err(Status::cancelled(format!( + "volume {} tier move from remote cancelled by caller", + vid + ))); + } + + // Download the .dat file from S3 with progress + let tx_progress = tx.clone(); + let mut last_report = std::time::Instant::now(); + let storage_name_clone = storage_name.clone(); + let _size = backend + .download_file(&dat_path, &storage_key, move |processed, percentage| { + // Checked on every part, not only where progress is + // reported: the report is rate-limited to one a second, + // so its result alone would miss a caller that left in + // between. Go aborts here too -- the progress callback + // returns stream.Send's error (s3_download.go). + if tx_progress.is_closed() { + return Err(format!( + "volume {} tier move from remote cancelled by caller", + vid + )); + } + let now = std::time::Instant::now(); + if now.duration_since(last_report) >= std::time::Duration::from_secs(1) { + last_report = now; + if let Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) = + tx_progress.try_send(Ok( + volume_server_pb::VolumeTierMoveDatFromRemoteResponse { + processed, + processed_percentage: percentage, + }, + )) + { + return Err(format!( + "volume {} tier move from remote cancelled by caller", + vid + )); + } + // A merely full channel is a slow reader, not a + // departed one; drop the report and keep going. + } + Ok(()) + }) + .await + .map_err(|e| { + // download_file pre-allocates the destination to the + // object's full size, so an aborted download leaves a + // .dat that is the right length and the wrong content. + // It has to go: this handler refuses to run at all when + // a local .dat exists, so leaving one wedges every + // retry on "already on local disk", and a restart would + // load the sparse file as the volume's data. + if let Err(rm) = std::fs::remove_file(&dat_path) + && rm.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + "volume {} could not remove the incomplete download {}: {}", + vid, + dat_path, + rm + ); + } + Status::internal(format!( + "backend {} copy file {}: {}", + storage_name_clone, dat_path, e + )) + })?; + + // Restore the .dat mtime so a reload computes TTL from real data age, + // not download time (matches Go VolumeTierMoveDatFromRemote). + if remote_modified_secs > 0 { + let modified_ts_ns = + (remote_modified_secs as i64).saturating_mul(1_000_000_000); + if let Err(e) = set_file_mtime(&dat_path, modified_ts_ns) { + tracing::warn!( + "volume {} restore data file {} modified time: {}", + vid, + dat_path, + e + ); + } else if let Ok(dat_file) = tokio::fs::File::open(&dat_path).await { + // Persist the restored mtime past the download's content fsync; + // best-effort, only TTL accuracy depends on it surviving a crash. + if let Err(e) = dat_file.sync_all().await { + tracing::warn!( + "volume {} fsync data file {} after mtime restore: {}", + vid, + dat_path, + e + ); + } + } + } + + // fsync the directory so the freshly downloaded .dat is durably linked + // before we rewrite the .vif and (later) delete the shared remote object. + if let Err(e) = crate::storage::volume::fsync_dir(&dat_path) { + return Err(Status::internal(format!( + "volume {} fsync dir for {}: {}", + vid, dat_path, e + ))); + } + + // Trim the remote reference, persist the .vif, and swap to the local + // .dat on BOTH paths BEFORE deleting the remote object. After this the + // volume serves from local disk (has_remote_file = false), so a crash + // before the delete only leaks the remote object; the .vif must never + // reference an object that has already been deleted. + { + let mut store = state.store.write().unwrap(); + let (_, vol) = store.find_volume_mut(vid).ok_or_else(|| { + Status::not_found(format!("volume {} disappeared during tier-down", vid)) + })?; + + // The volume could have been deleted/recreated or re-tiered while the + // download ran. Abort rather than trim the .vif or delete a remote + // object this volume no longer points at. + let (current_name, current_key) = vol.remote_storage_name_key(); + if current_name != storage_name || current_key != storage_key { + return Err(Status::failed_precondition(format!( + "volume {} remote reference changed during tier-down", + vid + ))); + } + + // Snapshot the remote reference before dropping it: the + // refresh below can fail, and a half-applied transition + // leaves the volume claiming local while the remote backend + // is still attached and the on-disk .vif still says remote + // — a state a retry reads as "already on local disk" and + // refuses to finish. + let removed_remote = if vol.volume_info.files.is_empty() { + None + } else { + Some(vol.volume_info.files.remove(0)) + }; + // Swaps the read-only sorted map out before the volume is + // published as writable; without it the first write would + // append to the local .dat and then fail to index. + if let Err(e) = vol.refresh_remote_write_mode() { + if let Some(remote) = removed_remote { + vol.volume_info.files.insert(0, remote); + } + // Put the derived flags and the needle map back where + // the restored reference says they belong. Best effort: + // if even this fails the volume stays pinned read-only, + // which is the safe end of the transition. + if let Err(restore_err) = vol.refresh_remote_write_mode() { + tracing::warn!( + volume_id = vid.0, + error = %restore_err, + "tier-down rollback could not restore the remote write mode", + ); + } + return Err(Status::internal(format!( + "volume {} failed to refresh write mode: {}", + vid, e + ))); + } + + if let Err(e) = vol.save_volume_info() { + return Err(Status::internal(format!( + "volume {} failed to save remote file info: {}", + vid, e + ))); + } + + if let Err(e) = vol.open_local_dat_backend() { + return Err(Status::internal(format!( + "volume {} failed to open local dat file {}: {}", + vid, dat_path, e + ))); + } + } + + // fsync the directory again so the rewritten .vif is durable before the + // remote object is removed. + if let Err(e) = crate::storage::volume::fsync_dir(&dat_path) { + return Err(Status::internal(format!( + "volume {} fsync dir for {} after saving volume info: {}", + vid, dat_path, e + ))); + } + + if keep_remote { + // Surviving replicas still reference this object; keep it intact. + return Ok(()); + } + + // Only the last replica to download deletes the shared remote object. + backend.delete_file(&storage_key).await.map_err(|e| { + Status::internal(format!( + "volume {} failed to delete remote file {}: {}", + vid, storage_key, e + )) + })?; + + // Go does NOT send a final 100% progress message after download completion + Ok(()) + } + .await; + + if let Err(e) = result { + // The error otherwise goes to a channel nobody is + // reading, leaving an abandoned tier move with no trace. + if e.code() == tonic::Code::Cancelled { + tracing::info!( + "volume {} tier move from remote abandoned by its caller", + vid + ); + } else { + tracing::warn!("volume {} tier move from remote failed: {}", vid, e); + } + let _ = tx.send(Err(e)).await; + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(rx); + Ok(Response::new( + Box::pin(stream) as Self::VolumeTierMoveDatFromRemoteStream + )) + } + + // ---- Server management ---- + + async fn volume_server_status( + &self, + _request: Request, + ) -> Result, Status> { + let store = self.state.store.read().unwrap(); + + let mut disk_statuses = Vec::new(); + for loc in &store.locations { + let (all, free) = get_disk_usage(&loc.directory); + let used = all.saturating_sub(free); + let percent_free = if all > 0 { + ((free as f64 / all as f64) * 100.0) as f32 + } else { + 0.0 + }; + let percent_used = if all > 0 { + ((used as f64 / all as f64) * 100.0) as f32 + } else { + 0.0 + }; + disk_statuses.push(volume_server_pb::DiskStatus { + dir: loc.directory.clone(), + all, + used, + free, + percent_free, + percent_used, + disk_type: loc.disk_type.to_string(), + }); + } + + Ok(Response::new( + volume_server_pb::VolumeServerStatusResponse { + disk_statuses, + memory_status: Some(super::memory_status::collect_mem_status()), + version: crate::version::full_version().to_string(), + data_center: self.state.data_center.clone(), + rack: self.state.rack.clone(), + state: Some(volume_server_pb::VolumeServerState { + maintenance: self.state.maintenance.load(Ordering::Relaxed), + version: self.state.state_version.load(Ordering::Relaxed), + }), + }, + )) + } + + async fn volume_server_leave( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + *self.state.is_stopping.write().unwrap() = true; + self.state.is_heartbeating.store(false, Ordering::Relaxed); + // Wake heartbeat loop to send deregistration. + self.state.volume_state_notify.notify_one(); + Ok(Response::new( + volume_server_pb::VolumeServerLeaveResponse {}, + )) + } + + async fn fetch_and_write_needle( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + self.state.check_maintenance()?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + + // Check volume exists + { + let store = self.state.store.read().unwrap(); + store + .find_volume(vid) + .ok_or_else(|| Status::not_found(format!("not found volume id {}", vid)))?; + } + + // Get remote storage configuration + let remote_conf = req + .remote_conf + .as_ref() + .ok_or_else(|| Status::invalid_argument("remote storage configuration is required"))?; + + // Reject SSRF-prone endpoints unless the operator opted out. Every + // S3-compatible backend (s3, wasabi, backblaze, ...) dials a + // caller-supplied endpoint URL directly via make_remote_storage_client, + // so validate whichever endpoint applies. An empty endpoint means the + // provider default (e.g. real AWS S3) and cannot target an internal + // host, so skip it. Extends the Go volume server's validateRemoteEndpoint + // gate, which only covered type "s3". + if !self.state.allow_untrusted_remote_endpoints + && let Some(endpoint) = crate::remote_storage::s3_compatible_endpoint(remote_conf) + && !endpoint.trim().is_empty() + { + crate::remote_storage::validate_remote_endpoint(endpoint) + .await + .map_err(|e| Status::invalid_argument(format!("reject remote endpoint: {}", e)))?; + } + + // Create remote storage client + let client = + crate::remote_storage::make_remote_storage_client(remote_conf).map_err(|e| { + Status::internal(format!( + "get remote client: make remote storage client {}: {}", + remote_conf.name, e, + )) + })?; + + let remote_location = req + .remote_location + .as_ref() + .ok_or_else(|| Status::invalid_argument("remote storage location is required"))?; + + // Read data from remote storage + let data = client + .read_file(remote_location, req.offset, req.size) + .await + .map_err(|e| { + Status::internal(format!("read from remote {:?}: {}", remote_location, e)) + })?; + + // Build needle and write locally + let mut n = Needle { + id: NeedleId(req.needle_id), + cookie: Cookie(req.cookie), + data_size: data.len() as u32, + data: data.clone(), + ..Needle::default() + }; + n.checksum = crate::storage::needle::crc::CRC::new(&n.data); + n.size = crate::storage::types::Size(4 + n.data_size as i32 + 1); + n.last_modified = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + n.set_has_last_modified_date(); + + // Validate every replica target before writing anything, so a malformed + // or internal target fails the request instead of leaving a local write + // behind. Mirrors the Go volume server. NOTE: like the Rust S3 endpoint + // guard, this validates the up-front DNS answer but does not yet re-check + // at connect time, so a rebinding hostname remains a follow-up. + if !self.state.allow_untrusted_remote_endpoints { + for replica in &req.replicas { + crate::remote_storage::validate_replica_target(&replica.url) + .await + .map_err(|e| { + Status::invalid_argument(format!("reject replica target: {}", e)) + })?; + } + } + + // Run local write and replica writes concurrently (matches Go's WaitGroup) + let mut handles: Vec>> = Vec::new(); + + // Spawn local write as a concurrent task + let state_clone = self.state.clone(); + let mut n_clone = n.clone(); + let needle_id = req.needle_id; + let size = req.size; + let local_handle = tokio::task::spawn_blocking(move || { + let mut store = state_clone.store.write().unwrap(); + store + .write_volume_needle(vid, &mut n_clone, false) + .map(|_| ()) + .map_err(|e| format!("local write needle {} size {}: {}", needle_id, size, e)) + }); + + // Spawn replica writes concurrently + if !req.replicas.is_empty() { + let file_id = format!("{},{:x}{:08x}", vid, req.needle_id, req.cookie); + let http_client = self.state.http_client.clone(); + let scheme = self.state.outgoing_http_scheme.clone(); + for replica in &req.replicas { + let raw_target = format!("{}/{}?type=replicate", replica.url, file_id); + let url = + crate::server::volume_server::normalize_outgoing_http_url(&scheme, &raw_target) + .map_err(Status::internal)?; + let data_clone = data.clone(); + let client_clone = http_client.clone(); + let needle_id = req.needle_id; + let size = req.size; + handles.push(tokio::spawn(async move { + let form = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(data_clone)); + client_clone + .post(&url) + .multipart(form) + .send() + .await + .map(|_| ()) + .map_err(|e| { + format!("remote write needle {} size {}: {}", needle_id, size, e) + }) + })); + } + } + + // Await ALL writes before checking errors (matches Go's wg.Wait()) + let local_result = local_handle.await; + let mut replica_results = Vec::new(); + for handle in handles { + replica_results.push(handle.await); + } + + // Check local write result + match local_result { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(Status::internal(e)), + Err(e) => return Err(Status::internal(format!("local write task failed: {}", e))), + } + + let e_tag = n.etag(); + + // Check replica write results + for result in replica_results { + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(Status::internal(e)), + Err(e) => return Err(Status::internal(format!("replication task failed: {}", e))), + } + } + + Ok(Response::new( + volume_server_pb::FetchAndWriteNeedleResponse { e_tag }, + )) + } + + async fn scrub_volume( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + + // Validate mode + let mode = req.mode; + match mode { + 1 | 2 | 3 | 5 => {} // INDEX=1, FULL=2, LOCAL=3, READS=5 (FULL for regular volumes) + _ => { + return Err(Status::invalid_argument(format!( + "unsupported volume scrub mode {}", + mode + ))); + } + } + + let explicit = !req.volume_ids.is_empty(); + let vids: Vec = if explicit { + req.volume_ids.iter().map(|&id| VolumeId(id)).collect() + } else { + self.state.store.read().unwrap().all_volume_ids() + }; + + self.scrub_volumes(&req, vids, explicit).await + } + + async fn scrub_ec_volume( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + + // Validate mode + let mode = req.mode; + match mode { + 1..=5 => {} // INDEX=1, FULL=2, LOCAL=3, CHECKSUM=4, READS=5 + _ => { + return Err(Status::invalid_argument(format!( + "unsupported EC volume scrub mode {}", + mode + ))); + } + } + + // Only the modes that walk needles can be strict about deleted ones. + let force_deleted_needles_check = req.force_deleted_needles_check; + if force_deleted_needles_check && mode != 2 && mode != 5 { + return Err(Status::invalid_argument( + "deleted needle checks are only supported for FULL and READS scrubs", + )); + } + + // Collect the volume ids under a brief lock, then release it: FULL (mode 2) + // reads remote shards and must not hold the !Send store guard across .await. + // Collect the volume ids under a brief lock, then release it: FULL (mode 2) + // reads remote shards and must not hold the !Send store guard across .await. + let explicit = !req.volume_ids.is_empty(); + let vids: Vec = if explicit { + req.volume_ids.iter().map(|&id| VolumeId(id)).collect() + } else { + let store = self.state.store.read().unwrap(); + // A vid mounted on N disks appears N times here. Left as-is, each + // duplicate rescans the same runtime and inflates total_volumes, + // while the sibling disks' shards are still never reached. + let mut seen = std::collections::HashSet::new(); + store + .locations + .iter() + .flat_map(|loc| loc.ec_volumes().map(|(vid, _)| *vid)) + .filter(|vid| seen.insert(*vid)) + .collect() + }; + + self.scrub_ec_volumes(&req, vids, explicit).await + } + + type QueryStream = BoxStream; + async fn query( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let mut stripes: Vec> = Vec::new(); + + for fid_str in &req.from_file_ids { + let file_id = needle::FileId::parse(fid_str).map_err(Status::internal)?; + + let mut n = Needle { + id: file_id.key, + cookie: file_id.cookie, + ..Needle::default() + }; + let original_cookie = n.cookie; + + let store = self.state.store.read().unwrap(); + store + .read_volume_needle(file_id.volume_id, &mut n) + .map_err(|e| Status::internal(e.to_string()))?; + drop(store); + + // Cookie mismatch: log and return empty stream (matching Go behavior where err is nil) + if n.cookie != original_cookie { + tracing::info!( + "volume query failed to read fid cookie {}: cookie mismatch", + fid_str + ); + let stream = tokio_stream::iter(stripes); + return Ok(Response::new(Box::pin(stream))); + } + + let input = req.input_serialization.as_ref(); + + // CSV input: no output (Go does nothing for CSV) + if input.is_some_and(|i| i.csv_input.is_some()) { + // No stripes emitted for CSV + continue; + } + + // JSON input: process lines + if input.is_some_and(|i| i.json_input.is_some()) { + let filter = req.filter.as_ref(); + let data_str = String::from_utf8_lossy(&n.data); + let mut records: Vec = Vec::new(); + + for line in data_str.lines() { + if line.trim().is_empty() { + continue; + } + let parsed: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + + // Apply filter + if let Some(f) = filter + && !f.field.is_empty() + && !f.operand.is_empty() + { + let field_val = &parsed[&f.field]; + let pass = match f.operand.as_str() { + ">" => { + if let (Some(fv), Ok(tv)) = + (field_val.as_f64(), f.value.parse::()) + { + fv > tv + } else { + false + } + } + ">=" => { + if let (Some(fv), Ok(tv)) = + (field_val.as_f64(), f.value.parse::()) + { + fv >= tv + } else { + false + } + } + "<" => { + if let (Some(fv), Ok(tv)) = + (field_val.as_f64(), f.value.parse::()) + { + fv < tv + } else { + false + } + } + "<=" => { + if let (Some(fv), Ok(tv)) = + (field_val.as_f64(), f.value.parse::()) + { + fv <= tv + } else { + false + } + } + "=" => { + if let (Some(fv), Ok(tv)) = + (field_val.as_f64(), f.value.parse::()) + { + fv == tv + } else { + field_val.as_str().is_some_and(|s| s == f.value) + } + } + "!=" => { + if let (Some(fv), Ok(tv)) = + (field_val.as_f64(), f.value.parse::()) + { + fv != tv + } else { + field_val.as_str().is_none_or(|s| s != f.value) + } + } + _ => true, + }; + if !pass { + continue; + } + } + + // Build output record: {selection:value,...} (Go's ToJson format — unquoted keys) + records.push(b'{'); + for (i, sel) in req.selections.iter().enumerate() { + if i > 0 { + records.push(b','); + } + records.extend_from_slice(sel.as_bytes()); + records.push(b':'); + let val = &parsed[sel]; + let raw = if val.is_null() { + "null".to_string() + } else { + // Use the raw JSON representation + val.to_string() + }; + records.extend_from_slice(raw.as_bytes()); + } + records.push(b'}'); + } + + stripes.push(Ok(volume_server_pb::QueriedStripe { records })); + } + } + + let stream = tokio_stream::iter(stripes); + Ok(Response::new(Box::pin(stream))) + } + + async fn volume_needle_status( + &self, + request: Request, + ) -> Result, Status> { + self.check_grpc_admin_auth(&request)?; + let req = request.into_inner(); + let vid = VolumeId(req.volume_id); + let needle_id = NeedleId(req.needle_id); + + let store = self.state.store.read().unwrap(); + + // Try normal volume first + if store.find_volume(vid).is_some() { + let mut n = Needle { + id: needle_id, + ..Needle::default() + }; + match store.read_volume_needle(vid, &mut n) { + Ok(_) => { + let ttl_str = n.ttl.as_ref().map_or(String::new(), |t| t.to_string()); + return Ok(Response::new( + volume_server_pb::VolumeNeedleStatusResponse { + needle_id: n.id.0, + cookie: n.cookie.0, + size: n.size.0 as u32, + last_modified: n.last_modified, + crc: n.checksum.0, + ttl: ttl_str, + }, + )); + } + Err(_) => return Err(Status::not_found(format!("needle not found {}", needle_id))), + } + } + + // Fall back to EC shards — read full needle from local shards + if let Some(ec_vol) = store.find_ec_volume(vid) { + match ec_vol.read_ec_shard_needle(needle_id) { + Ok(Some(n)) => { + let ttl_str = match &n.ttl { + Some(t) if n.has_ttl() => t.to_string(), + _ => String::new(), + }; + return Ok(Response::new( + volume_server_pb::VolumeNeedleStatusResponse { + needle_id: n.id.0, + cookie: n.cookie.0, + size: n.size.0 as u32, + last_modified: n.last_modified, + crc: n.checksum.0, + ttl: ttl_str, + }, + )); + } + Ok(None) => { + return Err(Status::not_found(format!("needle not found {}", needle_id))); + } + Err(e) => { + return Err(Status::internal(format!( + "read ec shard needle {} from volume {}: {}", + needle_id, vid, e + ))); + } + } + } + + Err(Status::not_found(format!("volume not found {}", vid))) + } + + async fn ping( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let now_ns = || { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as i64 + }; + + let start = now_ns(); + + // Empty target is a self-liveness probe and stays unauthenticated. + // Otherwise gate the dial on cluster membership: volume servers only + // know masters, so any other target type is refused. Mirrors Go's + // volume_grpc_admin.go Ping admission check. tonic forbids returning + // a body alongside an error, so we surface the InvalidArgument status + // alone — behaviour-identical to Go's status.Errorf return. + if !req.target.is_empty() + && !self + .state + .is_known_ping_target(&req.target, &req.target_type) + .await + { + return Err(Status::invalid_argument(format!( + "unknown ping target {} of type {}", + req.target, req.target_type + ))); + } + + // Route ping based on target type (matches Go's volume_grpc_admin.go Ping) + let remote_time_ns = if req.target_type == "volumeServer" { + match ping_volume_server_target(&req.target, self.state.outgoing_grpc_tls.as_ref()) + .await + { + Ok(t) => t, + Err(e) => { + return Err(Status::internal(format!( + "ping {} {}: {}", + req.target_type, req.target, e + ))); + } + } + } else if req.target_type == "master" { + // Connect to target master and call its Ping RPC + match ping_master_target(&req.target, self.state.outgoing_grpc_tls.as_ref()).await { + Ok(t) => t, + Err(e) => { + return Err(Status::internal(format!( + "ping {} {}: {}", + req.target_type, req.target, e + ))); + } + } + } else if req.target_type == "filer" { + match ping_filer_target(&req.target, self.state.outgoing_grpc_tls.as_ref()).await { + Ok(t) => t, + Err(e) => { + return Err(Status::internal(format!( + "ping {} {}: {}", + req.target_type, req.target, e + ))); + } + } + } else { + // Unknown target type → return 0 + 0 + }; + + let stop = now_ns(); + Ok(Response::new(volume_server_pb::PingResponse { + start_time_ns: start, + remote_time_ns, + stop_time_ns: stop, + })) + } +} + +/// Build a gRPC endpoint from a SeaweedFS server address. +fn to_grpc_endpoint( + target: &str, + tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>, +) -> Result { + let grpc_host_port = parse_grpc_address(target)?; + build_grpc_endpoint(&grpc_host_port, tls).map_err(|e| e.to_string()) +} + +/// Ping a remote volume server target by actually calling its Ping RPC (matches Go behavior). +async fn ping_volume_server_target( + target: &str, + tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>, +) -> Result { + let endpoint = to_grpc_endpoint(target, tls)?; + let channel = tokio::time::timeout(std::time::Duration::from_secs(5), endpoint.connect()) + .await + .map_err(|_| "connection timeout".to_string())? + .map_err(|e| e.to_string())?; + + let mut client = volume_server_pb::volume_server_client::VolumeServerClient::with_interceptor( + channel, + super::request_id::outgoing_request_id_interceptor, + ) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + let resp = client + .ping(volume_server_pb::PingRequest { + target: String::new(), + target_type: String::new(), + }) + .await + .map_err(|e| e.to_string())?; + Ok(resp.into_inner().start_time_ns) +} + +/// Ping a remote master target by actually calling its Ping RPC (matches Go behavior). +async fn ping_master_target( + target: &str, + tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>, +) -> Result { + let endpoint = to_grpc_endpoint(target, tls)?; + let channel = tokio::time::timeout(std::time::Duration::from_secs(5), endpoint.connect()) + .await + .map_err(|_| "connection timeout".to_string())? + .map_err(|e| e.to_string())?; + + let mut client = master_pb::seaweed_client::SeaweedClient::with_interceptor( + channel, + super::request_id::outgoing_request_id_interceptor, + ) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + let resp = client + .ping(master_pb::PingRequest { + target: String::new(), + target_type: String::new(), + }) + .await + .map_err(|e| e.to_string())?; + Ok(resp.into_inner().start_time_ns) +} + +/// Ping a remote filer target by calling its Ping RPC (matches Go behavior). +async fn ping_filer_target( + target: &str, + tls: Option<&super::grpc_client::OutgoingGrpcTlsConfig>, +) -> Result { + let endpoint = to_grpc_endpoint(target, tls)?; + let channel = tokio::time::timeout(std::time::Duration::from_secs(5), endpoint.connect()) + .await + .map_err(|_| "connection timeout".to_string())? + .map_err(|e| e.to_string())?; + + let mut client = filer_pb::seaweed_filer_client::SeaweedFilerClient::with_interceptor( + channel, + super::request_id::outgoing_request_id_interceptor, + ) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + let resp = client + .ping(filer_pb::PingRequest::default()) + .await + .map_err(|e| e.to_string())?; + Ok(resp.into_inner().start_time_ns) +} + +// parse_grpc_address moved to super::grpc_client::parse_grpc_address +// for sharing with the distributed-EC-read path in server/store_ec.rs. +// In-file callers below still write `parse_grpc_address(...)`; this +// `use` makes them resolve to the new home without churning every +// call site. +use super::grpc_client::parse_grpc_address; + +/// Set the modification time of a file from nanoseconds since Unix epoch. +fn set_file_mtime(path: &str, modified_ts_ns: i64) -> std::io::Result<()> { + use std::time::{Duration, SystemTime}; + let ts = if modified_ts_ns >= 0 { + SystemTime::UNIX_EPOCH + Duration::from_nanos(modified_ts_ns as u64) + } else { + SystemTime::UNIX_EPOCH + }; + let file = std::fs::File::open(path)?; + let ft = std::fs::FileTimes::new().set_accessed(ts).set_modified(ts); + file.set_times(ft) +} + +/// Drain a CopyFile stream into `file`, returning the number of bytes written. +/// +/// Uses async, buffered I/O (`tokio::fs` + `BufWriter`) so the receive-and-write +/// loop never blocks a Tokio worker thread on disk I/O — a synchronous +/// `std::fs::File::write_all` per chunk would stall the runtime for the duration +/// of each write (noticeable for a large `.ecx`/`.dat` on a slow or busy disk). +/// +/// On any recv/write/flush error the partially written destination is removed +/// (matching `receive_file` and the Go volume server), so a failed copy never +/// leaves a truncated shard or checksum file behind for a later reader to trip on. +async fn drain_copy_stream_to_file( + stream: &mut tonic::Streaming, + file: tokio::fs::File, + file_path: &str, + what: &str, +) -> Result { + use tokio::io::AsyncWriteExt; + let mut writer = tokio::io::BufWriter::new(file); + let write_all = async { + let mut written: u64 = 0; + while let Some(chunk) = stream + .message() + .await + .map_err(|e| Status::internal(format!("recv {}: {}", what, e)))? + { + writer + .write_all(&chunk.file_content) + .await + .map_err(|e| Status::internal(format!("write {}: {}", file_path, e)))?; + written += chunk.file_content.len() as u64; + } + writer + .flush() + .await + .map_err(|e| Status::internal(format!("flush {}: {}", file_path, e)))?; + Ok::(written) + }; + match write_all.await { + Ok(written) => Ok(written), + Err(e) => { + // Close the handle, then drop the truncated file so it isn't mistaken + // for a complete shard/sidecar. Best-effort: the original error wins. + drop(writer); + let _ = tokio::fs::remove_file(file_path).await; + Err(e) + } + } +} + +/// One file of a volume copy: what to ask the source for and where it lands. +#[derive(Clone, Copy)] +struct CopyFileSpec<'a> { + is_ec_volume: bool, + collection: &'a str, + volume_id: u32, + compaction_revision: u32, + stop_offset: u64, + dest_path: &'a str, + ext: &'a str, + is_append: bool, + ignore_source_not_found: bool, + /// Whether this file's bytes are reported back to the caller as progress. + report_progress: bool, +} + +/// Progress reporting and throttling shared by every file of one volume copy. +struct CopyProgress<'a> { + tx: &'a tokio::sync::mpsc::Sender>, + next_report_target: &'a mut i64, + report_interval: i64, + throttler: &'a mut WriteThrottler, +} + +/// Copy a file from a remote volume server via CopyFile streaming RPC. +/// Returns the modified_ts_ns received from the source. +async fn copy_file_from_source( + client: &mut volume_server_pb::volume_server_client::VolumeServerClient, + spec: &CopyFileSpec<'_>, + progress: &mut CopyProgress<'_>, +) -> Result +where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: http_body::Body + Send + 'static, + ::Error: Into + Send, +{ + let CopyFileSpec { + is_ec_volume, + collection, + volume_id, + compaction_revision, + stop_offset, + dest_path, + ext, + is_append, + ignore_source_not_found, + report_progress, + } = *spec; + let progress_tx = progress.tx; + let copy_req = volume_server_pb::CopyFileRequest { + volume_id, + ext: ext.to_string(), + compaction_revision, + stop_offset, + collection: collection.to_string(), + is_ec_volume, + ignore_source_file_not_found: ignore_source_not_found, + }; + + // Cancellation surfaces as Status::cancelled so the spawn's error branch + // can distinguish it from ordinary failures (logging + future handling). + // The other errors here stay Status::internal, matching the prior + // `.map_err(|e| Status::internal(e))` at the call sites. + let cancelled = || { + Status::cancelled(format!( + "volume {} {} copy cancelled by caller", + volume_id, ext + )) + }; + + // Race the initial RPC establishment against the caller's response channel: + // if the source stalls before sending response headers, the per-message + // select! below is never reached, and without this race the task, the + // source connection, and any preallocated files would outlive a caller + // that has already gone. + let mut stream = tokio::select! { + res = client.copy_file(copy_req) => { + res.map_err(|e| { + Status::internal(format!( + "failed to start copying volume {} {} file: {}", + volume_id, ext, e + )) + })? + .into_inner() + } + _ = progress_tx.closed() => return Err(cancelled()), + }; + + let mut file = if is_append { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dest_path) + .map_err(|e| Status::internal(format!("open file {}: {}", dest_path, e)))? + } else { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(dest_path) + .map_err(|e| Status::internal(format!("open file {}: {}", dest_path, e)))? + }; + + let mut progressed_bytes: i64 = 0; + let mut modified_ts_ns: i64 = 0; + + // The source stream's message() future is the one await in this loop that + // can hang indefinitely: a stalled source (slow disk, network partition, + // GC pause on the source host) never returns a chunk, and without racing + // it against the caller's response channel the detached task, the source + // connection, and the partial files (including the .note) all outlive the + // caller. select! lets a departing caller preempt the source read. + loop { + let resp = tokio::select! { + msg = stream.message() => { + msg.map_err(|e| Status::internal(format!("receiving {}: {}", dest_path, e)))? + } + _ = progress_tx.closed() => return Err(cancelled()), + }; + let resp = match resp { + Some(r) => r, + None => break, + }; + if resp.modified_ts_ns != 0 { + modified_ts_ns = resp.modified_ts_ns; + } + if !resp.file_content.is_empty() { + // The caller's response stream is the only thing that makes this + // copy worth finishing. Checked every chunk rather than only where + // progress is reported: the first report lands 128MB in, so a + // smaller volume would otherwise run to completion — and mount — + // long after its caller stopped listening. + if progress_tx.is_closed() { + return Err(cancelled()); + } + use std::io::Write; + file.write_all(&resp.file_content) + .map_err(|e| Status::internal(format!("write file {}: {}", dest_path, e)))?; + progressed_bytes += resp.file_content.len() as i64; + // A throttled copy sleeps seconds at a time; wake for a departing + // caller instead of finishing the nap first. + tokio::select! { + _ = progress.throttler.maybe_slowdown(resp.file_content.len() as i64) => {} + _ = progress_tx.closed() => return Err(cancelled()), + } + + if report_progress && progressed_bytes > *progress.next_report_target { + // Go aborts the transfer when this send fails + // (volume_grpc_copy.go: `return false`); so do we. + if progress_tx + .send(Ok(volume_server_pb::VolumeCopyResponse { + last_append_at_ns: 0, + processed_bytes: progressed_bytes, + })) + .await + .is_err() + { + return Err(cancelled()); + } + *progress.next_report_target = progressed_bytes + progress.report_interval; + } + } + } + + // If source file didn't exist (no modifiedTsNs received), remove empty file + // Go only removes when !isAppend + if modified_ts_ns == 0 && !is_append { + let _ = std::fs::remove_file(dest_path); + } + + Ok(modified_ts_ns) +} + +/// Verify that a copied file has the expected size. +fn check_copy_file_size(path: &str, expected: u64) -> Result<(), Status> { + match std::fs::metadata(path) { + Ok(meta) => { + if meta.len() != expected { + Err(Status::internal(format!( + "file {} size [{}] is not same as origin file size [{}]", + path, + meta.len(), + expected + ))) + } else { + Ok(()) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound && expected == 0 => Ok(()), + Err(e) => Err(Status::internal(format!( + "stat file {} failed: {}", + path, e + ))), + } +} + +/// Find the last append timestamp from copied .idx and .dat files. +/// Go returns (0, nil) for versions < Version3 since timestamps only exist in V3. +fn find_last_append_at_ns(idx_path: &str, dat_path: &str, version: u32) -> Option { + // Only Version3 has the append timestamp in the needle tail + if version < VERSION_3.0 as u32 { + return None; + } + use std::io::{Read, Seek, SeekFrom}; + + let mut idx_file = std::fs::File::open(idx_path).ok()?; + let idx_size = idx_file.metadata().ok()?.len(); + if idx_size == 0 || idx_size % (NEEDLE_MAP_ENTRY_SIZE as u64) != 0 { + return None; + } + + // Read the last index entry + let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + idx_file + .seek(SeekFrom::End(-(NEEDLE_MAP_ENTRY_SIZE as i64))) + .ok()?; + idx_file.read_exact(&mut buf).ok()?; + + let (_key, offset, _size) = idx_entry_from_bytes(&buf); + if offset.is_zero() { + return None; + } + + // Read needle header from .dat to get the append timestamp + let mut dat_file = std::fs::File::open(dat_path).ok()?; + let actual_offset = offset.to_actual_offset(); + + // Skip to the needle at the given offset, read header to get size + dat_file.seek(SeekFrom::Start(actual_offset as u64)).ok()?; + + // Read cookie (4) + id (8) + size (4) = 16 bytes header + let mut header = [0u8; 16]; + dat_file.read_exact(&mut header).ok()?; + let needle_size = i32::from_be_bytes([header[12], header[13], header[14], header[15]]); + if needle_size < 0 { + return None; + } + + // Seek to tail: offset + 16 (header) + size -> checksum (4) + timestamp (8) + // For delete needles (size == 0), the tail is right after the header. + let tail_offset = actual_offset as u64 + 16 + needle_size as u64; + dat_file.seek(SeekFrom::Start(tail_offset)).ok()?; + + let mut tail = [0u8; 12]; // 4 bytes checksum + 8 bytes timestamp + dat_file.read_exact(&mut tail).ok()?; + + // Timestamp is the last 8 bytes, big-endian + let ts = u64::from_be_bytes([ + tail[4], tail[5], tail[6], tail[7], tail[8], tail[9], tail[10], tail[11], + ]); + if ts > 0 { Some(ts) } else { None } +} + +/// What one `volume_tail_sender` pass did. +enum TailPass { + /// The volume is no longer mounted: the stream ends. + VolumeGone, + /// The receiver hung up: the stream ends without a status. + ClientGone, + /// The pass failed: the stream ends with this internal error. + Failed(String), + /// Nothing is newer than the caller's timestamp, and nothing was scanned. + CaughtUp, + /// A scan ran. `last_processed_ns` is the newest timestamp shipped, or the + /// caller's own when nothing was. + Scanned { + last_processed_ns: u64, + sent_any: bool, + }, +} + +/// One pass of `volume_tail_sender`, on a blocking thread: resolve where to +/// start under one store guard, then stream every needle newer than +/// `last_timestamp_ns` with the guard released. +fn tail_pass( + state: &VolumeServerState, + vid: VolumeId, + sb_size: u64, + version: u32, + last_timestamp_ns: u64, + tx: &tokio::sync::mpsc::Sender>, +) -> TailPass { + const BUFFER_SIZE_LIMIT: usize = 2 * 1024 * 1024; + + // `is_last` means the client is already caught up: nothing in the + // volume is newer than last_timestamp_ns. Go answers that with a + // heartbeat and does NOT scan (volume_grpc_tail.go, `if isLastOne`). + // Dropping that flag here is expensive, not just untidy: the scan + // below reads every needle from its start offset to the end bound, and + // when the search yields offset 0 the start offset falls back to + // sb_size -- the whole volume. A volume being moved is marked + // read-only first, so it is ALWAYS caught up, and the tail loop + // would re-read and discard the entire volume every 2s until the + // idle timeout expired. Measured at ~2.17 GB re-read six times in + // 35s for a 2.15 GB volume, which OOM-kills the source under a + // per-process memory cap. + // + // The start offset, the .dat handle and the end bound are captured under + // ONE store read guard, and the guard is dropped before any needle is + // read. The handle pins the inode the offset was resolved against: a + // vacuum commit replaces .dat by rename, and unmount or destroy unlink it, + // so the offset stays meaningful after either (see DatScanPlan). Reading + // needles under the guard stalled the node: needle writes and the + // heartbeat take store.write(), and the lock prefers writers, so every + // reader queued behind them until the scan finished. + let (plan, from) = { + let store = state.store.read().unwrap(); + let Some((_, vol)) = store.find_volume(vid) else { + return TailPass::VolumeGone; + }; + let from = if last_timestamp_ns > 0 { + match vol.binary_search_by_append_at_ns(last_timestamp_ns) { + Ok((_, true)) => return TailPass::CaughtUp, + Ok((offset, false)) => { + if offset.is_zero() { + sb_size + } else { + offset.to_actual_offset() as u64 + } + } + Err(e) => { + tracing::warn!("fail to locate by appendAtNs {}: {}", last_timestamp_ns, e); + return TailPass::Failed(format!( + "fail to locate by appendAtNs {}: {}", + last_timestamp_ns, e + )); + } + } + } else { + // No timestamp yet: the caller wants everything. + sb_size + }; + match vol.dat_scan_plan(from) { + Ok(plan) => (plan, from), + Err(e) => { + return TailPass::Failed(format!( + "streamFollow: scan volume {} from offset {}: {}", + vid, from, e + )); + } + } + }; + + let mut last_processed_ns = last_timestamp_ns; + let mut sent_any = false; + let mut client_gone = false; + let scanned = plan.scan(|needle| { + // Notice a receiver that hung up between sends too, so a pass over + // needles it already has does not read on for nobody. + if tx.is_closed() { + client_gone = true; + return ControlFlow::Break(()); + } + if needle.append_at_ns <= last_timestamp_ns && last_timestamp_ns > 0 { + return ControlFlow::Continue(()); + } + sent_any = true; + // Send body in chunks of BUFFER_SIZE_LIMIT + // Go sends needle_header on every chunk + let mut i = 0; + while i < needle.body.len() { + let end = std::cmp::min(i + BUFFER_SIZE_LIMIT, needle.body.len()); + let msg = volume_server_pb::VolumeTailSenderResponse { + needle_header: needle.header.to_vec(), + needle_body: needle.body[i..end].to_vec(), + is_last_chunk: end >= needle.body.len(), + version, + }; + if tx.blocking_send(Ok(msg)).is_err() { + client_gone = true; + return ControlFlow::Break(()); + } + i = end; + } + if needle.append_at_ns > last_processed_ns { + last_processed_ns = needle.append_at_ns; + } + ControlFlow::Continue(()) + }); + + if client_gone { + return TailPass::ClientGone; + } + match scanned { + Ok(()) => TailPass::Scanned { + last_processed_ns, + sent_any, + }, + // Streaming makes a failed pass partial: some needles may already be + // on the wire. Ending the stream cleanly would let the receiver + // (volume.move) treat a truncated tail as complete, so report it, as + // Go does (`streamFollow: %w`). + Err(e) => TailPass::Failed(format!( + "streamFollow: scan volume {} from offset {}: {}", + vid, from, e + )), + } +} + +/// Get disk usage (total, free) in bytes for the given path. +fn get_disk_usage(path: &str) -> (u64, u64) { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + let path = std::path::Path::new(path); + // Find the disk that contains this path (longest mount point prefix match) + let mut best: Option<&sysinfo::Disk> = None; + let mut best_len = 0; + for disk in disks.list() { + let mount = disk.mount_point(); + if path.starts_with(mount) && mount.as_os_str().len() > best_len { + best_len = mount.as_os_str().len(); + best = Some(disk); + } + } + match best { + Some(disk) => (disk.total_space(), disk.available_space()), + None => (0, 0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::MinFreeSpace; + use crate::remote_storage::s3_tier::{S3TierBackend, S3TierConfig, global_s3_tier_registry}; + use crate::security::{Guard, SigningKey}; + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::store::Store; + use std::sync::RwLock; + use tempfile::TempDir; + use tokio_stream::StreamExt; + + fn loc(dir: &str, idx_dir: &str, shard_count: usize, has_ecx: bool) -> LocInfo { + LocInfo { + dir: dir.to_string(), + idx_dir: idx_dir.to_string(), + shard_count, + has_ecx, + } + } + + // The rebuild reads its shards from one directory but resolves the volume's + // layout -- ratio and uniform block size -- from the .vif or the + // generation-0 .ecsum, which on a multi-disk server may sit anywhere. Every + // directory that could hold one has to be in the search list, or the + // resolution silently falls back to 10+4 with the legacy striping and + // reconstructs through the wrong matrix. + // The rebuild's own data and index directories are handed to the resolvers + // as their own arguments, so they are deliberately absent from this list -- + // unlike Go, whose resolver takes a single directory list and therefore + // carries the rebuild's index directory inside it. + #[test] + fn select_rebuild_location_excludes_the_rebuilds_own_dirs() { + for infos in [ + vec![loc("/data1", "/idx1", 3, true)], + vec![loc("/data1", "/data1", 3, true)], + ] { + let (idx, others) = select_rebuild_location(&infos).expect("a location with .ecx"); + assert_eq!(idx, 0); + assert!(others.is_empty(), "got {:?}", others); + } + } + + // The case two reviewers flagged: a sibling holding only shards while its + // index directory holds this volume's .vif. + #[test] + fn select_rebuild_location_searches_a_siblings_index_dir_not_just_its_data_dir() { + let infos = vec![ + loc("/data1", "/data1", 5, true), + loc("/data2", "/idx2", 2, false), + ]; + let (idx, others) = select_rebuild_location(&infos).expect("a location with .ecx"); + assert_eq!(idx, 0); + assert_eq!(others, vec!["/data2".to_string(), "/idx2".to_string()]); + } + + // Several disks pointed at one index directory is a normal -dir.idx + // deployment; the shared directory is worth searching but only once. + #[test] + fn select_rebuild_location_lists_a_shared_index_dir_once() { + let infos = vec![ + loc("/data1", "/data1", 5, true), + loc("/data2", "/shared-idx", 2, false), + loc("/data3", "/shared-idx", 1, false), + ]; + let (_, others) = select_rebuild_location(&infos).expect("a location with .ecx"); + assert_eq!( + others, + vec![ + "/data2".to_string(), + "/data3".to_string(), + "/shared-idx".to_string() + ] + ); + } + + // When the shared index directory is the rebuild's own it drops out, since + // the caller passes it separately. + #[test] + fn select_rebuild_location_omits_a_shared_index_dir_it_rebuilds_into() { + let infos = vec![ + loc("/data1", "/shared-idx", 5, true), + loc("/data2", "/shared-idx", 2, false), + ]; + let (_, others) = select_rebuild_location(&infos).expect("a location with .ecx"); + assert_eq!(others, vec!["/data2".to_string()]); + } + + // The winner moves as a fuller location turns up; the one it displaces + // still has to be searched, index directory included. + #[test] + fn select_rebuild_location_keeps_the_displaced_winners_dirs() { + let infos = vec![ + loc("/data1", "/idx1", 2, true), + loc("/data2", "/idx2", 9, true), + ]; + let (idx, others) = select_rebuild_location(&infos).expect("a location with .ecx"); + assert_eq!(idx, 1, "the fuller location wins"); + assert_eq!(others, vec!["/data1".to_string(), "/idx1".to_string()]); + } + + #[test] + fn select_rebuild_location_drops_empty_dirs() { + let infos = vec![loc("/data1", "", 3, true), loc("/data2", "", 1, false)]; + let (_, others) = select_rebuild_location(&infos).expect("a location with .ecx"); + assert_eq!(others, vec!["/data2".to_string()]); + } + + // Nothing carries an .ecx: there is no index to rebuild the shards against, + // so the caller answers with an empty rebuild rather than guessing. + #[test] + fn select_rebuild_location_is_none_without_an_ecx() { + let infos = vec![loc("/data1", "/idx1", 3, false)]; + assert!(select_rebuild_location(&infos).is_none()); + } + + #[test] + fn test_parse_grpc_address_with_explicit_grpc_port() { + // Format: "ip:port.grpcPort" — used by SeaweedFS for source_data_node + let result = parse_grpc_address("192.168.1.66:8080.18080").unwrap(); + assert_eq!(result, "192.168.1.66:18080"); + } + + #[test] + fn test_parse_grpc_address_with_implicit_grpc_port() { + // Format: "ip:port" — grpc port = port + 10000 + let result = parse_grpc_address("192.168.1.66:8080").unwrap(); + assert_eq!(result, "192.168.1.66:18080"); + } + + #[test] + fn test_parse_grpc_address_localhost() { + let result = parse_grpc_address("localhost:9333").unwrap(); + assert_eq!(result, "localhost:19333"); + } + + #[test] + fn test_parse_grpc_address_with_ipv4_dots() { + // Regression: naive split on '.' breaks on IP addresses + let result = parse_grpc_address("10.0.0.1:8080.18080").unwrap(); + assert_eq!(result, "10.0.0.1:18080"); + + let result = parse_grpc_address("10.0.0.1:8080").unwrap(); + assert_eq!(result, "10.0.0.1:18080"); + } + + #[test] + fn test_parse_grpc_address_invalid() { + assert!(parse_grpc_address("no-colon").is_err()); + } + + #[test] + fn test_volume_is_remote_only_requires_missing_local_dat_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let dat_path = temp_dir.path().join("1.dat"); + std::fs::write(&dat_path, b"dat").unwrap(); + + assert!(!volume_is_remote_only(dat_path.to_str().unwrap(), true)); + assert!(!volume_is_remote_only(dat_path.to_str().unwrap(), false)); + + std::fs::remove_file(&dat_path).unwrap(); + + assert!(volume_is_remote_only(dat_path.to_str().unwrap(), true)); + assert!(!volume_is_remote_only(dat_path.to_str().unwrap(), false)); + } + + fn spawn_fake_s3_server( + body: Vec, + ) -> ( + String, + tokio::sync::oneshot::Sender<()>, + std::sync::Arc, + ) { + use axum::Router; + use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; + use axum::routing::any; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let body = Arc::new(body); + let delete_count = Arc::new(AtomicUsize::new(0)); + let delete_count_handler = delete_count.clone(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async move { + let app = Router::new().fallback(any(move |method: Method, headers: HeaderMap| { + let body = body.clone(); + let delete_count = delete_count_handler.clone(); + async move { + if method == Method::DELETE { + delete_count.fetch_add(1, Ordering::SeqCst); + } + let bytes = body.as_ref(); + if let Some(range) = headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + && let Some(range_value) = range.strip_prefix("bytes=") + { + let mut parts = range_value.splitn(2, '-'); + let start = parts + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let end = parts + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| bytes.len().saturating_sub(1)); + let start = start.min(bytes.len()); + let end = end.min(bytes.len().saturating_sub(1)); + let payload = if start > end || start >= bytes.len() { + Vec::new() + } else { + bytes[start..=end].to_vec() + }; + let mut response_headers = HeaderMap::new(); + response_headers.insert( + header::CONTENT_RANGE, + HeaderValue::from_str(&format!( + "bytes {}-{}/{}", + start, + end, + bytes.len() + )) + .unwrap(), + ); + response_headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&payload.len().to_string()).unwrap(), + ); + return (StatusCode::PARTIAL_CONTENT, response_headers, payload); + } + + let mut response_headers = HeaderMap::new(); + response_headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&bytes.len().to_string()).unwrap(), + ); + (StatusCode::OK, response_headers, bytes.to_vec()) + } + })); + + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + }); + + // Wait for the server thread to be ready before returning. + ready_rx.recv().unwrap(); + (format!("http://{}", addr), shutdown_tx, delete_count) + } + + fn make_remote_only_service( + backend_id: &str, + ) -> ( + VolumeGrpcService, + TempDir, + tokio::sync::oneshot::Sender<()>, + Vec, + u64, + std::sync::Arc, + ) { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let (dat_bytes, super_block_size) = { + let mut volume = crate::storage::volume::Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &crate::storage::volume::VolumeSpec::default(), + ) + .unwrap(); + let mut needle = Needle { + id: NeedleId(7), + cookie: Cookie(0x7788), + data: b"remote-incremental-copy".to_vec(), + data_size: "remote-incremental-copy".len() as u32, + ..Needle::default() + }; + volume.write_needle(&mut needle, true, false).unwrap(); + volume.sync_to_disk().unwrap(); + ( + std::fs::read(volume.file_name(".dat")).unwrap(), + volume.super_block.block_size() as u64, + ) + }; + + let dat_path = format!("{}/1.dat", dir); + std::fs::remove_file(&dat_path).unwrap(); + + let (endpoint, shutdown_tx, delete_count) = spawn_fake_s3_server(dat_bytes.clone()); + // Use a test-specific backend_id to avoid racing with other tests + // that share the global registry. Never call clear() — only + // register/remove our own entries. + let tier_config = S3TierConfig { + access_key: "access".to_string(), + secret_key: "secret".to_string(), + region: "us-east-1".to_string(), + bucket: "bucket-a".to_string(), + endpoint, + storage_class: "STANDARD".to_string(), + force_path_style: true, + }; + { + let mut registry = global_s3_tier_registry().write().unwrap(); + registry.register( + format!("s3.{}", backend_id), + S3TierBackend::new(&tier_config), + ); + } + + let vif = crate::storage::volume::VifVolumeInfo { + files: vec![crate::storage::volume::VifRemoteFile { + backend_type: "s3".to_string(), + backend_id: backend_id.to_string(), + key: "remote-key".to_string(), + offset: 0, + file_size: dat_bytes.len() as u64, + modified_time: 123, + extension: ".dat".to_string(), + }], + version: Version::current().0 as u32, + bytes_offset: crate::storage::types::OFFSET_SIZE as u32, + dat_file_size: dat_bytes.len() as i64, + ..Default::default() + }; + std::fs::write( + format!("{}/1.vif", dir), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + let mut store = Store::new(NeedleMapKind::InMemory); + store + .add_location( + dir, + dir, + 10, + DiskType::HardDrive, + MinFreeSpace::Percent(1.0), + Vec::new(), + ) + .unwrap(); + + let state = Arc::new(VolumeServerState { + store: RwLock::new(store), + guard: RwLock::new(Guard::new( + &[], + SigningKey(vec![]), + 0, + SigningKey(vec![]), + 0, + )), + is_stopping: RwLock::new(false), + maintenance: std::sync::atomic::AtomicBool::new(false), + state_version: std::sync::atomic::AtomicU32::new(0), + concurrent_upload_limit: 0, + concurrent_download_limit: 0, + inflight_upload_data_timeout: std::time::Duration::from_secs(60), + inflight_download_data_timeout: std::time::Duration::from_secs(60), + inflight_upload_bytes: std::sync::atomic::AtomicI64::new(0), + inflight_download_bytes: std::sync::atomic::AtomicI64::new(0), + upload_notify: tokio::sync::Notify::new(), + download_notify: tokio::sync::Notify::new(), + data_center: String::new(), + rack: String::new(), + file_size_limit_bytes: 0, + maintenance_byte_per_second: 0, + is_heartbeating: std::sync::atomic::AtomicBool::new(true), + has_master: false, + pre_stop_seconds: 0, + volume_state_notify: tokio::sync::Notify::new(), + write_queue: std::sync::OnceLock::new(), + s3_tier_registry: std::sync::RwLock::new({ + // The tier-down handler resolves the backend from the per-server + // registry, so register it here too (reads use the global one). + let mut reg = crate::remote_storage::s3_tier::S3TierRegistry::new(); + reg.register( + format!("s3.{}", backend_id), + S3TierBackend::new(&tier_config), + ); + reg + }), + read_mode: crate::config::ReadMode::Local, + allow_untrusted_remote_endpoints: false, + master_url: String::new(), + master_urls: Vec::new(), + seed_master_set: std::collections::HashSet::new(), + current_master_url: tokio::sync::RwLock::new(String::new()), + self_url: String::new(), + http_client: reqwest::Client::new(), + outgoing_http_scheme: "http".to_string(), + outgoing_grpc_tls: None, + metrics_runtime: std::sync::RwLock::new( + crate::server::volume_server::RuntimeMetricsConfig::default(), + ), + metrics_notify: tokio::sync::Notify::new(), + fix_jpg_orientation: false, + has_slow_read: false, + read_buffer_size_bytes: 1024 * 1024, + security_file: String::new(), + cli_white_list: vec![], + state_file_path: String::new(), + }); + + ( + VolumeGrpcService { state }, + tmp, + shutdown_tx, + dat_bytes, + super_block_size, + delete_count, + ) + } + + fn make_local_service_with_volume( + collection: &str, + ttl: Option, + ) -> (VolumeGrpcService, TempDir) { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let mut store = Store::new(NeedleMapKind::InMemory); + store + .add_location( + dir, + dir, + 10, + DiskType::HardDrive, + MinFreeSpace::Percent(1.0), + Vec::new(), + ) + .unwrap(); + store + .add_volume( + VolumeId(1), + DiskType::HardDrive, + &VolumeSpec { + collection, + ttl, + ..Default::default() + }, + ) + .unwrap(); + { + let (_, volume) = store.find_volume_mut(VolumeId(1)).unwrap(); + let mut needle = Needle { + id: NeedleId(11), + cookie: Cookie(0x3344), + data: b"ec-generate".to_vec(), + data_size: b"ec-generate".len() as u32, + ..Needle::default() + }; + volume.write_needle(&mut needle, true, false).unwrap(); + volume.sync_to_disk().unwrap(); + } + + let state = Arc::new(VolumeServerState { + store: RwLock::new(store), + guard: RwLock::new(Guard::new( + &[], + SigningKey(vec![]), + 0, + SigningKey(vec![]), + 0, + )), + is_stopping: RwLock::new(false), + maintenance: std::sync::atomic::AtomicBool::new(false), + state_version: std::sync::atomic::AtomicU32::new(0), + concurrent_upload_limit: 0, + concurrent_download_limit: 0, + inflight_upload_data_timeout: std::time::Duration::from_secs(60), + inflight_download_data_timeout: std::time::Duration::from_secs(60), + inflight_upload_bytes: std::sync::atomic::AtomicI64::new(0), + inflight_download_bytes: std::sync::atomic::AtomicI64::new(0), + upload_notify: tokio::sync::Notify::new(), + download_notify: tokio::sync::Notify::new(), + data_center: String::new(), + rack: String::new(), + file_size_limit_bytes: 0, + maintenance_byte_per_second: 0, + is_heartbeating: std::sync::atomic::AtomicBool::new(true), + has_master: false, + pre_stop_seconds: 0, + volume_state_notify: tokio::sync::Notify::new(), + write_queue: std::sync::OnceLock::new(), + s3_tier_registry: std::sync::RwLock::new( + crate::remote_storage::s3_tier::S3TierRegistry::new(), + ), + read_mode: crate::config::ReadMode::Local, + allow_untrusted_remote_endpoints: false, + master_url: String::new(), + master_urls: Vec::new(), + seed_master_set: std::collections::HashSet::new(), + current_master_url: tokio::sync::RwLock::new(String::new()), + self_url: String::new(), + http_client: reqwest::Client::new(), + outgoing_http_scheme: "http".to_string(), + outgoing_grpc_tls: None, + metrics_runtime: std::sync::RwLock::new( + crate::server::volume_server::RuntimeMetricsConfig::default(), + ), + metrics_notify: tokio::sync::Notify::new(), + fix_jpg_orientation: false, + has_slow_read: false, + read_buffer_size_bytes: 1024 * 1024, + security_file: String::new(), + cli_white_list: vec![], + state_file_path: String::new(), + }); + + (VolumeGrpcService { state }, tmp) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_volume_consolidate_index_rpc() { + let (service, _tmp) = make_local_service_with_volume("consolidate_rpc", None); + + // Carry a peer address so the admin gate sees a caller; the test guard + // has an empty whitelist, so any peer is accepted. + let mut request = + Request::new(volume_server_pb::VolumeConsolidateIndexRequest { volume_id: 1 }); + request + .extensions_mut() + .insert(tonic::transport::server::TcpConnectInfo { + local_addr: None, + remote_addr: Some("127.0.0.1:65000".parse().unwrap()), + }); + + service.volume_consolidate_index(request).await.unwrap(); + + // Data and index share a directory here, so there is nothing to move; + // the volume stays mounted and keeps its needle. + let store = service.state.store.read().unwrap(); + let (_, v) = store.find_volume(VolumeId(1)).unwrap(); + assert_eq!(v.file_count(), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_volume_incremental_copy_streams_remote_only_volume_data() { + let (service, _tmp, shutdown_tx, dat_bytes, super_block_size, _delete_count) = + make_remote_only_service("incr_copy_test"); + + let response = service + .volume_incremental_copy(Request::new( + volume_server_pb::VolumeIncrementalCopyRequest { + volume_id: 1, + since_ns: 0, + }, + )) + .await + .unwrap(); + + let mut stream = response.into_inner(); + let mut copied = Vec::new(); + while let Some(message) = stream.next().await { + copied.extend_from_slice(&message.unwrap().file_content); + } + + assert_eq!(copied, dat_bytes[super_block_size as usize..]); + + let _ = shutdown_tx.send(()); + global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.incr_copy_test"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_tier_move_from_remote_swaps_to_local_and_deletes_object() { + let (service, tmp, shutdown_tx, dat_bytes, _super_block_size, delete_count) = + make_remote_only_service("tier_down_delete"); + let dat_path = format!("{}/1.dat", tmp.path().to_str().unwrap()); + assert!(!std::path::Path::new(&dat_path).exists()); + + let mut stream = service + .volume_tier_move_dat_from_remote(Request::new( + volume_server_pb::VolumeTierMoveDatFromRemoteRequest { + volume_id: 1, + collection: String::new(), + keep_remote_dat_file: false, + }, + )) + .await + .unwrap() + .into_inner(); + while let Some(message) = stream.next().await { + message.unwrap(); + } + + // The downloaded .dat is on local disk and matches the remote bytes. + assert!(std::path::Path::new(&dat_path).exists()); + assert_eq!(std::fs::read(&dat_path).unwrap(), dat_bytes); + + // The volume now serves from local disk with no remote reference. + { + let store = service.state.store.read().unwrap(); + let (_, vol) = store.find_volume(VolumeId(1)).unwrap(); + assert!(!vol.has_remote_file); + assert!(vol.volume_info.files.is_empty()); + assert!(vol.has_data_backend()); + } + + // The shared remote object was deleted exactly once. + assert_eq!(delete_count.load(std::sync::atomic::Ordering::SeqCst), 1); + + let _ = shutdown_tx.send(()); + global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.tier_down_delete"); + } + + // The progress callback is the only thing a tier transfer polls, so it is + // the only place a departing caller can be noticed. Go gives it an error + // return for exactly this (s3_upload.go:99, s3_download.go:84); this checks + // the Rust port now honours one. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_download_file_aborts_when_the_progress_callback_fails() { + let (service, tmp, shutdown_tx, _dat_bytes, _super_block_size, _delete_count) = + make_remote_only_service("tier_progress_abort"); + let backend = global_s3_tier_registry() + .read() + .unwrap() + .get("s3.tier_progress_abort") + .expect("backend registered"); + let dest = format!("{}/aborted.dat", tmp.path().to_str().unwrap()); + + let err = backend + .download_file(&dest, "remote-key", |_, _| Err("caller gone".to_string())) + .await + .expect_err("a failing progress callback must abort the download"); + assert!(err.contains("caller gone"), "unexpected error: {}", err); + + drop(service); + let _ = shutdown_tx.send(()); + global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.tier_progress_abort"); + } + + // An abandoned tier-down must leave the volume exactly as it found it. The + // partial .dat matters most: download_file pre-allocates the destination to + // the object's full size, this handler refuses to run while a local .dat + // exists, and a restart would load that file as the volume's data -- so a + // leftover wedges every retry on "already on local disk" and corrupts the + // volume on the way. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_tier_move_from_remote_cancelled_leaves_the_volume_untouched() { + let (service, tmp, shutdown_tx, _dat_bytes, _super_block_size, delete_count) = + make_remote_only_service("tier_down_cancel"); + let dat_path = format!("{}/1.dat", tmp.path().to_str().unwrap()); + assert!(!std::path::Path::new(&dat_path).exists()); + + let response = service + .volume_tier_move_dat_from_remote(Request::new( + volume_server_pb::VolumeTierMoveDatFromRemoteRequest { + volume_id: 1, + collection: String::new(), + keep_remote_dat_file: false, + }, + )) + .await + .unwrap(); + + // The caller hangs up: dropping the response drops the receiving half + // of the channel, which is all a cancelled RPC amounts to here. + drop(response); + + for _ in 0..60 { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!( + !std::path::Path::new(&dat_path).exists(), + "abandoned tier-down left a local .dat behind" + ); + } + + { + let store = service.state.store.read().unwrap(); + let (_, vol) = store.find_volume(VolumeId(1)).unwrap(); + assert!( + vol.has_remote_file, + "abandoned tier-down published the transition to local" + ); + assert!(!vol.volume_info.files.is_empty()); + } + assert_eq!( + delete_count.load(std::sync::atomic::Ordering::SeqCst), + 0, + "abandoned tier-down deleted the shared remote object" + ); + + let _ = shutdown_tx.send(()); + global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.tier_down_cancel"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_tier_move_from_remote_keep_remote_still_swaps_to_local() { + let (service, tmp, shutdown_tx, dat_bytes, _super_block_size, delete_count) = + make_remote_only_service("tier_down_keep"); + let dat_path = format!("{}/1.dat", tmp.path().to_str().unwrap()); + + let mut stream = service + .volume_tier_move_dat_from_remote(Request::new( + volume_server_pb::VolumeTierMoveDatFromRemoteRequest { + volume_id: 1, + collection: String::new(), + keep_remote_dat_file: true, + }, + )) + .await + .unwrap() + .into_inner(); + while let Some(message) = stream.next().await { + message.unwrap(); + } + + // Even when the shared remote object is kept for other replicas, this + // replica swaps to its freshly downloaded local .dat and drops its + // remote reference. + assert!(std::path::Path::new(&dat_path).exists()); + assert_eq!(std::fs::read(&dat_path).unwrap(), dat_bytes); + { + let store = service.state.store.read().unwrap(); + let (_, vol) = store.find_volume(VolumeId(1)).unwrap(); + assert!(!vol.has_remote_file); + assert!(vol.volume_info.files.is_empty()); + assert!(vol.has_data_backend()); + } + + // The shared remote object is kept for the surviving replicas. + assert_eq!(delete_count.load(std::sync::atomic::Ordering::SeqCst), 0); + + let _ = shutdown_tx.send(()); + global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.tier_down_keep"); + } + + /// Build a local service whose volume has a `.dat` large enough to span + /// several 2MB copy chunks, so the streaming copy paths are exercised + /// across multiple messages rather than a single buffer. + fn make_local_service_with_large_volume() -> (VolumeGrpcService, TempDir, Vec) { + let (service, tmp) = make_local_service_with_volume("", None); + { + let mut store = service.state.store.write().unwrap(); + let (_, v) = store.find_volume_mut(VolumeId(1)).unwrap(); + // Non-uniform payload so reassembly catches duplicated or reordered + // chunks that a repeated byte would hide. + let payload: Vec = (0..5 * 1024 * 1024usize) + .map(|i| (i as u8).wrapping_mul(31).wrapping_add(7)) + .collect(); + let mut needle = Needle { + id: NeedleId(99), + cookie: Cookie(0x1122), + data_size: payload.len() as u32, + data: payload, + ..Needle::default() + }; + v.write_needle(&mut needle, true, false).unwrap(); + v.sync_to_disk().unwrap(); + } + let dat_path = { + let store = service.state.store.read().unwrap(); + let (_, v) = store.find_volume(VolumeId(1)).unwrap(); + v.file_name(".dat") + }; + let dat_bytes = std::fs::read(&dat_path).unwrap(); + (service, tmp, dat_bytes) + } + + // Serve a VolumeGrpcService over a real gRPC endpoint. VolumeCopy dials its + // source_data_node rather than calling in process, so a cancellation test + // needs a source that is reachable over the wire. + async fn serve_source(service: VolumeGrpcService) -> (u16, tokio::sync::oneshot::Sender<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service( + crate::pb::volume_server_pb::volume_server_server::VolumeServerServer::new( + service, + ), + ) + .serve_with_incoming_shutdown( + tokio_stream::wrappers::TcpListenerStream::new(listener), + async { + let _ = shutdown_rx.await; + }, + ) + .await; + }); + (port, shutdown_tx) + } + + // The transfer itself must notice a caller that has gone, with no progress + // report to carry the news. The first report is 128MB in, so for anything + // smaller -- the ordinary case for a balance move -- the send result says + // nothing and only the closed channel does. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_copy_file_from_source_stops_when_the_caller_is_gone() { + let (source_service, _source_tmp, dat_bytes) = make_local_service_with_large_volume(); + assert!( + dat_bytes.len() > 2 * 1024 * 1024, + "fixture must span several 2MB chunks" + ); + let (port, _shutdown) = serve_source(source_service).await; + + let channel = tonic::transport::Endpoint::from_shared(format!("http://127.0.0.1:{}", port)) + .unwrap() + .connect() + .await + .unwrap(); + let mut client = volume_server_pb::volume_server_client::VolumeServerClient::new(channel) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + + let dest_tmp = TempDir::new().unwrap(); + let dest_path = format!("{}/copied.dat", dest_tmp.path().to_str().unwrap()); + + let (tx, rx) = + tokio::sync::mpsc::channel::>(16); + drop(rx); // exactly what a client hanging up does to the sender + + let mut next_report_target: i64 = 128 * 1024 * 1024; + let mut throttler = WriteThrottler::new(0); + let err = copy_file_from_source( + &mut client, + &CopyFileSpec { + is_ec_volume: false, + collection: "", + volume_id: 1, + compaction_revision: u32::MAX, + stop_offset: dat_bytes.len() as u64, + dest_path: &dest_path, + ext: ".dat", + is_append: false, + ignore_source_not_found: true, + report_progress: true, + }, + &mut CopyProgress { + tx: &tx, + next_report_target: &mut next_report_target, + report_interval: 128 * 1024 * 1024, + throttler: &mut throttler, + }, + ) + .await + .expect_err("a copy whose caller is gone must not run to completion"); + // Cancellation must surface as Status::cancelled, not Status::internal: + // the spawn's error branch classifies on the code, so an Internal here + // would be logged as a generic failure instead of "abandoned by caller". + assert_eq!( + err.code(), + tonic::Code::Cancelled, + "expected Cancelled, got {:?}: {}", + err.code(), + err + ); + assert!( + err.message().contains("cancelled by caller"), + "unexpected error: {}", + err + ); + + let copied = std::fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0); + assert!( + copied < dat_bytes.len() as u64, + "copy should have stopped short, wrote {} of {} bytes", + copied, + dat_bytes.len() + ); + } + + // An abandoned VolumeCopy must leave nothing behind: no mounted volume (its + // index cache would be held for the life of the process, and under + // replication=000 the cluster would carry one volume id on two writable + // servers), and none of the partial files or the .note, which fails the + // volume load on the next restart. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_volume_copy_cancelled_by_caller_mounts_nothing_and_cleans_up() { + let (source_service, _source_tmp, _dat_bytes) = make_local_service_with_large_volume(); + let (port, _shutdown) = serve_source(source_service).await; + + let (dest_service, dest_tmp) = make_local_service_with_volume("", None); + { + let mut store = dest_service.state.store.write().unwrap(); + store.delete_volume(VolumeId(1), false, false).unwrap(); + // available_space is filled in by the periodic disk check, which + // does not run in a unit test; without it VolumeCopy finds no + // location with room and never gets as far as copying. + for loc in &store.locations { + loc.check_disk_space(); + } + } + let dest_dir = dest_tmp.path().to_str().unwrap().to_string(); + let dest_file = |ext: &str| format!("{}/1{}", dest_dir, ext); + assert!( + !std::path::Path::new(&dest_file(".dat")).exists(), + "destination must start without the volume for this test to mean anything" + ); + + let response = dest_service + .volume_copy(Request::new(volume_server_pb::VolumeCopyRequest { + volume_id: 1, + collection: String::new(), + source_data_node: format!("127.0.0.1:1.{}", port), + disk_type: String::new(), + io_byte_per_second: 0, + replication: String::new(), + ttl: String::new(), + })) + .await + .unwrap(); + + // The caller hangs up. Dropping the response drops the receiving half of + // the channel, which is all a cancelled RPC amounts to on this side. + drop(response); + + // Give the copy longer than it would need to finish, and hold it to the + // assertion throughout rather than only at the end. + for _ in 0..100 { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let store = dest_service.state.store.read().unwrap(); + assert!( + store.find_volume(VolumeId(1)).is_none(), + "destination mounted a volume whose copy was abandoned" + ); + } + + for ext in [".dat", ".idx", ".vif", ".note"] { + assert!( + !std::path::Path::new(&dest_file(ext)).exists(), + "abandoned copy left {} behind", + dest_file(ext) + ); + } + } + + // If the caller departs in the narrow window between the pre-mount + // is_closed() check and the final tx.send, the volume is already mounted + // when the send fails. The task must roll back that mount — not just unlink + // the files — or the destination carries the volume's index cache for the + // life of the process. + // + // Reaching that window black-box needs a seam. The one the code already + // offers is the store write lock: the test takes it immediately after + // volume_copy returns, so the spawned task runs the copy to completion with + // the caller still attached, passes the pre-mount is_closed() check, then + // parks entering the mount block (state.store.write() blocks on the test's + // guard). The test then drops the response — the caller is gone — and + // releases the guard. The task acquires the lock, mounts, fails the final + // tx.send (receiver dropped), and must roll back via the error branch's + // delete_volume. Without the lock seam the task sees is_closed() at its + // very first check and returns before mount_volume, exercising the wrong + // path — the test would be green for the wrong reason. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[expect( + clippy::await_holding_lock, + clippy::readonly_write_lock, + reason = "the store write guard is a barrier that parks the copy task at the mount block" + )] + async fn test_volume_copy_after_mount_cancellation_rolls_back_mount() { + let (source_service, _source_tmp, _dat_bytes) = make_local_service_with_large_volume(); + let (port, _shutdown) = serve_source(source_service).await; + + let (dest_service, dest_tmp) = make_local_service_with_volume("", None); + { + let mut store = dest_service.state.store.write().unwrap(); + store.delete_volume(VolumeId(1), false, false).unwrap(); + for loc in &store.locations { + loc.check_disk_space(); + } + } + let dest_dir = dest_tmp.path().to_str().unwrap().to_string(); + let dest_file = |ext: &str| format!("{}/1{}", dest_dir, ext); + + let response = dest_service + .volume_copy(Request::new(volume_server_pb::VolumeCopyRequest { + volume_id: 1, + collection: String::new(), + source_data_node: format!("127.0.0.1:1.{}", port), + disk_type: String::new(), + io_byte_per_second: 0, + replication: String::new(), + ttl: String::new(), + })) + .await + .unwrap(); + + // Hold the store write lock so the task parks at the mount block after + // completing the copy and passing the pre-mount is_closed() check. + let store_guard = dest_service.state.store.write().unwrap(); + + // Give the copy time to complete and the task to reach the mount block. + // The 5 MB fixture copies in well under a second on a local loopback + // gRPC connection; 2 s is generous and keeps the test fast. + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // The caller hangs up while the task is parked at the mount block. + drop(response); + + // Release the store lock so the task can enter the mount block, mount, + // fail the final send, and roll back. + drop(store_guard); + + // Wait for the rollback to finish. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let store = dest_service.state.store.read().unwrap(); + assert!( + store.find_volume(VolumeId(1)).is_none(), + "destination still holds a mounted volume after after-mount cancellation" + ); + drop(store); + + for ext in [".dat", ".idx", ".vif", ".note"] { + assert!( + !std::path::Path::new(&dest_file(ext)).exists(), + "abandoned copy left {} behind", + dest_file(ext) + ); + } + } + + // copy_file must stream the whole .dat in 2MB chunks (not buffer it) and + // reassemble byte-for-byte, with the mtime carried only on the first message. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_copy_file_streams_full_dat_in_chunks() { + let (service, _tmp, dat_bytes) = make_local_service_with_large_volume(); + let stop_offset = dat_bytes.len() as u64; + assert!( + stop_offset > 2 * 1024 * 1024u64, + "fixture must exceed one 2MB chunk" + ); + + let response = service + .copy_file(Request::new(volume_server_pb::CopyFileRequest { + volume_id: 1, + ext: ".dat".to_string(), + compaction_revision: u32::MAX, + stop_offset, + collection: String::new(), + is_ec_volume: false, + ignore_source_file_not_found: false, + })) + .await + .unwrap(); + + let mut stream = response.into_inner(); + let mut messages = Vec::new(); + while let Some(m) = stream.next().await { + messages.push(m.unwrap()); + } + + assert!( + messages.len() >= 2, + "expected multiple streamed chunks, got {}", + messages.len() + ); + assert_ne!(messages[0].modified_ts_ns, 0); + assert!(messages[1..].iter().all(|m| m.modified_ts_ns == 0)); + + let copied: Vec = messages + .iter() + .flat_map(|m| m.file_content.clone()) + .collect(); + assert_eq!(copied, dat_bytes); + } + + // volume_tail_sender streams each needle in 2MB chunks with the header on + // every chunk, heartbeats once the caller is caught up, and ends when the + // idle timeout runs out. Reassembling the stream must reproduce the .dat + // after the superblock byte for byte, so a scan that skipped, reordered or + // re-sent a record fails here. The fixture holds a small needle and a 5MB + // one, covering the single- and multi-chunk paths. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_volume_tail_sender_streams_chunks_then_heartbeats() { + let (service, _tmp, dat_bytes) = make_local_service_with_large_volume(); + let sb_size = { + let store = service.state.store.read().unwrap(); + let (_, v) = store.find_volume(VolumeId(1)).unwrap(); + v.super_block.block_size() + }; + + let response = service + .volume_tail_sender(Request::new(volume_server_pb::VolumeTailSenderRequest { + volume_id: 1, + since_ns: 0, + idle_timeout_seconds: 1, + })) + .await + .unwrap(); + let mut stream = response.into_inner(); + let messages = tokio::time::timeout(std::time::Duration::from_secs(30), async { + let mut messages = Vec::new(); + while let Some(m) = stream.next().await { + messages.push(m.unwrap()); + } + messages + }) + .await + .expect("the tail must end once its idle timeout runs out"); + + let (heartbeats, chunks): (Vec<_>, Vec<_>) = + messages.iter().partition(|m| m.needle_header.is_empty()); + assert_eq!(heartbeats.len(), 1, "one caught-up pass before the timeout"); + assert!(heartbeats[0].is_last_chunk && heartbeats[0].needle_body.is_empty()); + assert!( + messages.last().unwrap().needle_header.is_empty(), + "the heartbeat follows the data" + ); + + let mut shipped = Vec::new(); + let mut ids = Vec::new(); + let mut chunks_per_needle = Vec::new(); + let mut current_header: Vec = Vec::new(); + let mut starting = true; + for m in &chunks { + if starting { + current_header = m.needle_header.clone(); + shipped.extend_from_slice(¤t_header); + ids.push(Needle::parse_header(¤t_header).1); + chunks_per_needle.push(0); + } else { + assert_eq!(m.needle_header, current_header, "header on every chunk"); + } + shipped.extend_from_slice(&m.needle_body); + *chunks_per_needle.last_mut().unwrap() += 1; + starting = m.is_last_chunk; + } + assert!(starting, "the last needle must end on is_last_chunk"); + assert_eq!(ids, vec![NeedleId(11), NeedleId(99)]); + assert_eq!(chunks_per_needle, vec![1, 3], "a 5MB body in 2MB chunks"); + assert!( + shipped == dat_bytes[sb_size..], + "the stream must reproduce the .dat after the superblock" + ); + } + + // copy_file must stop exactly at stop_offset, never streaming past it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_copy_file_respects_stop_offset() { + let (service, _tmp, dat_bytes) = make_local_service_with_large_volume(); + let stop_offset = dat_bytes.len() as u64 - 100; + + let response = service + .copy_file(Request::new(volume_server_pb::CopyFileRequest { + volume_id: 1, + ext: ".dat".to_string(), + compaction_revision: u32::MAX, + stop_offset, + collection: String::new(), + is_ec_volume: false, + ignore_source_file_not_found: false, + })) + .await + .unwrap(); + + let mut stream = response.into_inner(); + let mut copied = Vec::new(); + while let Some(m) = stream.next().await { + copied.extend_from_slice(&m.unwrap().file_content); + } + + assert_eq!(copied.len() as u64, stop_offset); + assert_eq!(copied, dat_bytes[..stop_offset as usize]); + } + + // volume_incremental_copy must stream a local volume from the super block + // to EOF in chunks, reassembling byte-for-byte. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_volume_incremental_copy_streams_local_volume_in_chunks() { + let (service, _tmp, dat_bytes) = make_local_service_with_large_volume(); + let super_block_size = { + let store = service.state.store.read().unwrap(); + let (_, v) = store.find_volume(VolumeId(1)).unwrap(); + v.super_block.block_size() as u64 + }; + + let response = service + .volume_incremental_copy(Request::new( + volume_server_pb::VolumeIncrementalCopyRequest { + volume_id: 1, + since_ns: 0, + }, + )) + .await + .unwrap(); + + let mut stream = response.into_inner(); + let mut messages = Vec::new(); + while let Some(m) = stream.next().await { + messages.push(m.unwrap()); + } + + assert!( + messages.len() >= 2, + "expected multiple streamed chunks, got {}", + messages.len() + ); + let copied: Vec = messages + .iter() + .flat_map(|m| m.file_content.clone()) + .collect(); + assert_eq!(copied, dat_bytes[super_block_size as usize..]); + } + + /// Build a bare-bones service with no on-disk store but a configurable + /// seed master set, for Ping admission tests. Matches the structure of + /// `make_local_service_with_volume` minus the volume bits. + fn make_service_with_seed_masters(seeds: &[&str]) -> (VolumeGrpcService, TempDir) { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let mut store = Store::new(NeedleMapKind::InMemory); + store + .add_location( + dir, + dir, + 10, + DiskType::HardDrive, + MinFreeSpace::Percent(1.0), + Vec::new(), + ) + .unwrap(); + + let master_urls: Vec = seeds.iter().map(|s| (*s).to_string()).collect(); + let seed_master_set = + crate::server::volume_server::VolumeServerState::build_seed_master_set(&master_urls); + + let state = Arc::new(VolumeServerState { + store: RwLock::new(store), + guard: RwLock::new(Guard::new( + &[], + SigningKey(vec![]), + 0, + SigningKey(vec![]), + 0, + )), + is_stopping: RwLock::new(false), + maintenance: std::sync::atomic::AtomicBool::new(false), + state_version: std::sync::atomic::AtomicU32::new(0), + concurrent_upload_limit: 0, + concurrent_download_limit: 0, + inflight_upload_data_timeout: std::time::Duration::from_secs(60), + inflight_download_data_timeout: std::time::Duration::from_secs(60), + inflight_upload_bytes: std::sync::atomic::AtomicI64::new(0), + inflight_download_bytes: std::sync::atomic::AtomicI64::new(0), + upload_notify: tokio::sync::Notify::new(), + download_notify: tokio::sync::Notify::new(), + data_center: String::new(), + rack: String::new(), + file_size_limit_bytes: 0, + maintenance_byte_per_second: 0, + is_heartbeating: std::sync::atomic::AtomicBool::new(true), + has_master: false, + pre_stop_seconds: 0, + volume_state_notify: tokio::sync::Notify::new(), + write_queue: std::sync::OnceLock::new(), + s3_tier_registry: std::sync::RwLock::new( + crate::remote_storage::s3_tier::S3TierRegistry::new(), + ), + read_mode: crate::config::ReadMode::Local, + allow_untrusted_remote_endpoints: false, + master_url: master_urls.first().cloned().unwrap_or_default(), + master_urls, + seed_master_set, + current_master_url: tokio::sync::RwLock::new(String::new()), + self_url: String::new(), + http_client: reqwest::Client::new(), + outgoing_http_scheme: "http".to_string(), + outgoing_grpc_tls: None, + metrics_runtime: std::sync::RwLock::new( + crate::server::volume_server::RuntimeMetricsConfig::default(), + ), + metrics_notify: tokio::sync::Notify::new(), + fix_jpg_orientation: false, + has_slow_read: false, + read_buffer_size_bytes: 1024 * 1024, + security_file: String::new(), + cli_white_list: vec![], + state_file_path: String::new(), + }); + + (VolumeGrpcService { state }, tmp) + } + + #[tokio::test] + async fn test_ping_empty_target_is_self_probe() { + // Empty target stays unauthenticated and returns Ok with timing fields + // populated — it is the local liveness probe path. + let (service, _tmp) = make_service_with_seed_masters(&[]); + let response = service + .ping(Request::new(volume_server_pb::PingRequest { + target: String::new(), + target_type: String::new(), + })) + .await + .expect("empty target ping must succeed"); + let inner = response.into_inner(); + assert!(inner.start_time_ns > 0); + assert!(inner.stop_time_ns >= inner.start_time_ns); + } + + #[tokio::test] + async fn test_ping_seed_master_target_passes_admission() { + // A target that matches a configured seed master clears admission. + // The dial itself may or may not succeed depending on what's listening + // on the loopback; either way, the response must not be the + // InvalidArgument the gate would surface. + let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333"]); + let result = service + .ping(Request::new(volume_server_pb::PingRequest { + target: "localhost:9333".to_string(), + target_type: "master".to_string(), + })) + .await; + if let Err(err) = result { + assert_ne!(err.code(), tonic::Code::InvalidArgument, "got {err:?}"); + assert!( + !err.message().contains("unknown ping target"), + "admission gate should have allowed this target: {}", + err.message() + ); + } + } + + #[tokio::test] + async fn test_ping_unknown_master_target_rejected() { + // A master-type target not in the seed list and not the current + // master is refused with InvalidArgument. + let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333"]); + let err = service + .ping(Request::new(volume_server_pb::PingRequest { + target: "localhost:9999".to_string(), + target_type: "master".to_string(), + })) + .await + .expect_err("unknown master target must be rejected"); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert_eq!( + err.message(), + "unknown ping target localhost:9999 of type master" + ); + } + + #[tokio::test] + async fn test_ping_volume_server_target_always_rejected() { + // Volume servers do not maintain a peer-volume list, so volumeServer + // pings are refused regardless of address. + let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333"]); + let err = service + .ping(Request::new(volume_server_pb::PingRequest { + target: "localhost:8080".to_string(), + target_type: "volumeServer".to_string(), + })) + .await + .expect_err("volumeServer target must be rejected"); + assert_eq!(err.code(), tonic::Code::InvalidArgument); + assert_eq!( + err.message(), + "unknown ping target localhost:8080 of type volumeServer" + ); + } + + #[tokio::test] + async fn test_ping_current_master_target_passes_admission() { + // A target that matches the current (post-leader-change) master also + // clears admission, even if it is not in the seed list. Pick a port + // that is extremely unlikely to be live so the test does not flake on + // a developer machine that happens to be running a real master. + let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333"]); + // Simulate the heartbeat goroutine having moved to a new leader. + *service.state.current_master_url.write().await = "127.0.0.1:1".to_string(); + + let result = service + .ping(Request::new(volume_server_pb::PingRequest { + target: "127.0.0.1:1".to_string(), + target_type: "master".to_string(), + })) + .await; + if let Err(err) = result { + assert_ne!(err.code(), tonic::Code::InvalidArgument, "got {err:?}"); + assert!( + !err.message().contains("unknown ping target"), + "leader-change master should be admitted: {}", + err.message() + ); + } + } + + #[tokio::test] + async fn test_ping_target_with_grpc_port_suffix_is_normalised() { + // Seed masters in pb.ServerAddress form (`host:port.grpcPort`) must + // match a Ping target sent in plain `host:port` form, since the gate + // normalises both sides through to_http_address. + let (service, _tmp) = make_service_with_seed_masters(&["localhost:9333.19333"]); + let result = service + .ping(Request::new(volume_server_pb::PingRequest { + target: "localhost:9333".to_string(), + target_type: "master".to_string(), + })) + .await; + if let Err(err) = result { + assert_ne!(err.code(), tonic::Code::InvalidArgument, "got {err:?}"); + } + } + + // Regression test for comparing the wrong compaction-revision field. + // last_compact_revision() is bookkeeping recorded just before a compaction + // starts (for makeup-diff catch-up) and is intentionally left behind + // super_block.compaction_revision once the compaction commits. copy_file's + // precondition check must compare against the live super_block value + // (matching Go's v.CompactionRevision), not the stale bookkeeping one, or + // it fails "volume N is compacted" on every volume that has ever been + // compacted even once. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_copy_file_accepts_live_revision_after_compaction() { + let (service, _tmp) = make_local_service_with_volume("", None); + + // Drive a real compaction to completion so the two revision fields + // actually diverge the way they do in production, rather than poking + // the struct fields directly. + let (revision_before, revision_after, last_compact_revision_after) = { + let mut store = service.state.store.write().unwrap(); + let (_, v) = store.find_volume_mut(VolumeId(1)).unwrap(); + let before = v.super_block.compaction_revision; + + v.compact_by_index(0, 0, |_| true).unwrap(); + v.commit_compact().unwrap(); + + ( + before, + v.super_block.compaction_revision, + v.last_compact_revision(), + ) + }; + + assert_eq!(revision_before, 0, "fresh volume starts at revision 0"); + assert_eq!( + revision_after, 1, + "live super_block.compaction_revision must advance after commit_compact" + ); + assert_eq!( + last_compact_revision_after, 0, + "last_compact_revision() is recorded pre-compaction and must stay \ + behind the live revision — this divergence is exactly what made \ + the old check fail permanently" + ); + + // The live revision (1) must be accepted: this is what + // read_volume_file_status would report to a caller right now. + let response = service + .copy_file(Request::new(volume_server_pb::CopyFileRequest { + volume_id: 1, + ext: ".dat".to_string(), + compaction_revision: revision_after as u32, + stop_offset: u64::MAX, + collection: String::new(), + is_ec_volume: false, + ignore_source_file_not_found: false, + })) + .await; + assert!( + response.is_ok(), + "copy_file must accept the live compaction_revision, got: {:?}", + response.err() + ); + + // A genuinely stale/wrong revision must still be rejected — the fix + // corrects which field is compared, it does not disable the check. + let stale_response = service + .copy_file(Request::new(volume_server_pb::CopyFileRequest { + volume_id: 1, + ext: ".dat".to_string(), + compaction_revision: 99, + stop_offset: u64::MAX, + collection: String::new(), + is_ec_volume: false, + ignore_source_file_not_found: false, + })) + .await; + let err = match stale_response { + Ok(_) => panic!("a genuinely stale revision must be rejected"), + Err(e) => e, + }; + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("is compacted")); + + // A request revision that only collides after truncating to u16 must be + // rejected: 65537 as u16 == 1 == the live revision, but as u32 they differ. + // Compare in u32 space (matching Go) so this cannot spuriously pass. + let truncating_response = service + .copy_file(Request::new(volume_server_pb::CopyFileRequest { + volume_id: 1, + ext: ".dat".to_string(), + compaction_revision: u32::from(revision_after) + (1 << 16), + stop_offset: u64::MAX, + collection: String::new(), + is_ec_volume: false, + ignore_source_file_not_found: false, + })) + .await; + let err = match truncating_response { + Ok(_) => panic!("a revision that only matches after u16 truncation must be rejected"), + Err(e) => e, + }; + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!(err.message().contains("is compacted")); + } + + #[tokio::test] + async fn test_volume_ec_shards_generate_persists_expire_at_sec() { + let ttl = crate::storage::needle::ttl::TTL::read("3m").unwrap(); + let (service, tmp) = make_local_service_with_volume("ttl", Some(ttl)); + let before = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + service + .volume_ec_shards_generate(Request::new( + volume_server_pb::VolumeEcShardsGenerateRequest { + volume_id: 1, + collection: "ttl".to_string(), + }, + )) + .await + .unwrap(); + + let vif_path = tmp.path().join("ttl_1.vif"); + let vif: crate::storage::volume::VifVolumeInfo = + serde_json::from_str(&std::fs::read_to_string(vif_path).unwrap()).unwrap(); + assert!(vif.expire_at_sec >= before + ttl.to_seconds()); + assert!(vif.expire_at_sec <= before + ttl.to_seconds() + 5); + } + + /// REGRESSION: a node-wide scrub must survive a volume that legitimately + /// disappears while it runs. + /// + /// Nothing is held across the scan, so the volume set changes under the + /// loop: the heartbeat drops a volume that reported an I/O error, and a + /// delete or an unmount can land between any two volumes. Aborting there + /// discards every result gathered so far and leaves every later volume + /// unscrubbed — on a large node, one expiry costs a whole scrub pass. + /// + /// The absent id goes FIRST, so the assertion fails if the abort ever comes + /// back: the volume behind it would never be reached. + #[tokio::test] + async fn test_scrub_skips_a_vanished_volume_and_scrubs_the_rest() { + let (service, _tmp) = make_local_service_with_volume("", None); + + let resp = service + .scrub_volumes( + &volume_server_pb::ScrubVolumeRequest { + mode: 1, + volume_ids: Vec::new(), + mark_broken_volumes_readonly: false, + }, + vec![VolumeId(17), VolumeId(1)], + false, + ) + .await + .expect("a volume missing from the node's own listing must not fail the scrub") + .into_inner(); + + assert_eq!( + resp.total_volumes, 1, + "the volume behind the vanished one must still be scrubbed" + ); + } + + /// A caller who NAMES a volume that is not here is making a mistake, not + /// racing a teardown, and still gets told — callers match on the message. + #[tokio::test] + async fn test_scrub_fails_on_an_explicitly_requested_missing_volume() { + let (service, _tmp) = make_local_service_with_volume("", None); + + let err = service + .scrub_volumes( + &volume_server_pb::ScrubVolumeRequest { + mode: 1, + volume_ids: vec![17], + mark_broken_volumes_readonly: false, + }, + vec![VolumeId(17)], + true, + ) + .await + .expect_err("an explicitly requested missing volume must still fail"); + + assert_eq!(err.code(), tonic::Code::NotFound); + assert!(err.message().contains("volume id 17 not found"), "{}", err); + } + + /// Same rule for EC volumes, which the heartbeat also expires under a store + /// write: delete_expired_ec_volumes destroys a volume whose destroy time has + /// passed, and volume_ec_shards_delete unmounts one on demand. + #[tokio::test] + async fn test_ec_scrub_skips_a_vanished_volume_and_scrubs_the_rest() { + let (service, _tmp) = make_local_service_with_volume("", None); + service + .volume_ec_shards_generate(Request::new( + volume_server_pb::VolumeEcShardsGenerateRequest { + volume_id: 1, + collection: String::new(), + }, + )) + .await + .unwrap(); + service + .volume_ec_shards_mount(Request::new(volume_server_pb::VolumeEcShardsMountRequest { + volume_id: 1, + collection: String::new(), + shard_ids: (0..14).collect(), + source_disk_type: String::new(), + recover_missing_index: false, + })) + .await + .unwrap(); + + let resp = service + .scrub_ec_volumes( + &volume_server_pb::ScrubEcVolumeRequest { + mode: 1, + volume_ids: Vec::new(), + force_deleted_needles_check: false, + }, + vec![VolumeId(17), VolumeId(1)], + false, + ) + .await + .expect("an EC volume missing from the node's own listing must not fail the scrub") + .into_inner(); + + assert_eq!( + resp.total_volumes, 1, + "the EC volume behind the vanished one must still be scrubbed" + ); + } + + #[tokio::test] + async fn test_ec_scrub_fails_on_an_explicitly_requested_missing_volume() { + let (service, _tmp) = make_local_service_with_volume("", None); + + let err = service + .scrub_ec_volumes( + &volume_server_pb::ScrubEcVolumeRequest { + mode: 1, + volume_ids: vec![17], + force_deleted_needles_check: false, + }, + vec![VolumeId(17)], + true, + ) + .await + .expect_err("an explicitly requested missing EC volume must still fail"); + + assert_eq!(err.code(), tonic::Code::NotFound); + assert!( + err.message().contains("EC volume id 17 not found"), + "{}", + err + ); + } + + async fn scrub_ec_volume_1( + service: &VolumeGrpcService, + mode: i32, + ) -> volume_server_pb::ScrubEcVolumeResponse { + service + .scrub_ec_volume(Request::new(volume_server_pb::ScrubEcVolumeRequest { + mode, + volume_ids: vec![1], + force_deleted_needles_check: false, + })) + .await + .unwrap() + .into_inner() + } + + #[tokio::test] + async fn test_scrub_ec_volume_reads_mode_reconstructs_missing_shard() { + let (service, _tmp) = make_local_service_with_volume("", None); + service + .volume_ec_shards_generate(Request::new( + volume_server_pb::VolumeEcShardsGenerateRequest { + volume_id: 1, + collection: String::new(), + }, + )) + .await + .unwrap(); + service + .volume_ec_shards_mount(Request::new(volume_server_pb::VolumeEcShardsMountRequest { + volume_id: 1, + collection: String::new(), + shard_ids: (0..14).collect(), + source_disk_type: String::new(), + recover_missing_index: false, + })) + .await + .unwrap(); + + // Seed the shard-location cache so the scrub skips the master lookup. + // The explicit-gRPC-port form targets port 1, so remote reads fail fast + // with connection-refused instead of resolving to a live local port. + { + let store = service.state.store.read().unwrap(); + let ecv = store.find_ec_volume(VolumeId(1)).unwrap(); + let mut locs = ecv.shard_locations.write().unwrap(); + for sid in 0u8..14 { + locs.insert(sid, vec!["127.0.0.1:255.1".to_string()]); + } + *ecv.shard_locations_refresh_time.lock().unwrap() = Some(std::time::Instant::now()); + } + + // All shards local: FULL is clean. + let resp = scrub_ec_volume_1(&service, 2).await; + assert!(resp.broken_volume_ids.is_empty(), "{:?}", resp.details); + assert_eq!(resp.total_files, 1); + + service + .volume_ec_shards_unmount(Request::new( + volume_server_pb::VolumeEcShardsUnmountRequest { + volume_id: 1, + shard_ids: vec![0], + encode_ts_ns: 0, + }, + )) + .await + .unwrap(); + + // With a shard unreadable, FULL flags it AND fails the needle... + let resp = scrub_ec_volume_1(&service, 2).await; + assert_eq!(resp.broken_volume_ids, vec![1]); + assert!(resp.broken_shard_infos.iter().any(|s| s.shard_id == 0)); + assert!(!resp.details.is_empty()); + + // ...while READS still reports the shard broken but reconstructs the + // interval from the local survivors, so no needle errors surface. + let resp = scrub_ec_volume_1(&service, 5).await; + assert_eq!(resp.broken_volume_ids, vec![1]); + assert!(resp.broken_shard_infos.iter().any(|s| s.shard_id == 0)); + assert!(resp.details.is_empty(), "{:?}", resp.details); + assert_eq!(resp.total_files, 1); + } + + /// Two locations, one vid: the split-disk / cross-disk-reconcile layout + /// `build_split_disk_store` exercises in store_ec_reconcile.rs. Neither + /// disk holds a `.dat`, so each disk's orphan shard survives + /// `check_orphaned_shards` and `Store::add_location`'s automatic + /// `reconcile_ec_shards_across_disks()` mounts it against its own disk, + /// pointing at the sibling's `.ecx` -- the same layout a real + /// seaweedfs/seaweedfs#9212 report produces. + fn make_local_service_with_split_disk_ec_volume(vid_raw: u32) -> (VolumeGrpcService, TempDir) { + SplitDiskEcFixture::new(vid_raw).build() + } + + /// Knobs on the two-location layout above, so one fixture can produce the + /// shapes the scrub tests need. `new()` reproduces the layout exactly as + /// `make_local_service_with_split_disk_ec_volume` has always built it; every + /// field is a deliberate departure from it. + struct SplitDiskEcFixture { + vid_raw: u32, + /// Which shard id each disk gets. Which disk holds the shard a needle + /// actually spans is the whole difference between reaching one disk and + /// reaching both, so the LOCAL/CHECKSUM tests move shard 0 to disk 1. + dir0_shard_id: u8, + dir1_shard_id: u8, + /// dir0's own `.vif` encode identity. `None` writes NO `.vif` in dir0 at + /// all -- the original layout, where `locate_vif_path` falls through to + /// dir1's copy and both runtimes therefore agree. `Some(ts)` writes one, + /// which is what lets the two disks disagree and drives the identity + /// fence in `merge_ec_runtimes`. + dir0_encode_ts_ns: Option, + dir1_encode_ts_ns: i64, + /// `false` writes the original 20 zero bytes: a structurally invalid + /// index, so every scrub that walks it reports something. `true` writes + /// one well-formed TOMBSTONE entry instead -- a deleted needle, which the + /// walk skips -- so a FULL/READS scrub of this volume is CLEAN and a + /// test can pin a clean-to-broken transition rather than a message. + clean_index: bool, + /// Where the generation-0 `.ecsum` is written. Its per-shard checksums + /// do not describe the placeholder bytes, so a CHECKSUM scrub reports + /// every shard it actually reads. With `Nowhere`, `bitrot_status` is + /// `Off` on both disks and `EcChecksumScrubPlan::run` returns clean + /// before touching a handle. + bitrot_sidecar: SidecarPlacement, + } + + /// Which disks get the `.ecsum`. The sidecar is deliberately NOT mirrored + /// in production (`Store::ec_metadata_dirs` exists so one copy stays + /// reachable rather than being duplicated), and at mount `EcVolume::new` + /// resolves it with no sibling directories -- so `Dir1Only` is not an + /// artificial shape, it is what a volume server looks like after any + /// restart when the one copy happens to live on the non-anchor disk. + #[derive(Clone, Copy, PartialEq, Eq)] + enum SidecarPlacement { + Nowhere, + BothDisks, + Dir1Only, + } + + impl SplitDiskEcFixture { + fn new(vid_raw: u32) -> Self { + SplitDiskEcFixture { + vid_raw, + dir0_shard_id: 0, + dir1_shard_id: 1, + dir0_encode_ts_ns: None, + dir1_encode_ts_ns: 0, + clean_index: false, + bitrot_sidecar: SidecarPlacement::Nowhere, + } + } + + fn build(self) -> (VolumeGrpcService, TempDir) { + make_local_service_with_split_disk_ec_volume_from(self) + } + } + + fn make_local_service_with_split_disk_ec_volume_from( + cfg: SplitDiskEcFixture, + ) -> (VolumeGrpcService, TempDir) { + let SplitDiskEcFixture { + vid_raw, + dir0_shard_id, + dir1_shard_id, + dir0_encode_ts_ns, + dir1_encode_ts_ns, + clean_index, + bitrot_sidecar, + } = cfg; + + let tmp = TempDir::new().unwrap(); + let dir0 = tmp.path().join("data0"); + let dir1 = tmp.path().join("data1"); + std::fs::create_dir_all(&dir0).unwrap(); + std::fs::create_dir_all(&dir1).unwrap(); + + let ec_vif = |encode_ts_ns: i64| crate::storage::volume::VifVolumeInfo { + version: 3, + ec_shard_config: Some(crate::storage::volume::VifEcShardConfig { + data_shards: 10, + parity_shards: 4, + encode_ts_ns, + ..Default::default() + }), + ..Default::default() + }; + + // dir0: one shard, no .ecx. + std::fs::write( + dir0.join(format!("{}.ec{:02}", vid_raw, dir0_shard_id)), + b"shard data nonempty", + ) + .unwrap(); + if let Some(ts) = dir0_encode_ts_ns { + std::fs::write( + dir0.join(format!("{}.vif", vid_raw)), + serde_json::to_string(&ec_vif(ts)).unwrap(), + ) + .unwrap(); + } + // dir1: one shard plus the index files. + std::fs::write( + dir1.join(format!("{}.ec{:02}", vid_raw, dir1_shard_id)), + b"shard data nonempty", + ) + .unwrap(); + let ecx = if clean_index { + let mut buf: Vec = Vec::new(); + crate::storage::idx::write_index_entry( + &mut buf, + crate::storage::types::NeedleId(1), + crate::storage::types::Offset::from_actual_offset(0), + crate::storage::types::TOMBSTONE_FILE_SIZE, + ) + .unwrap(); + buf + } else { + vec![0u8; 20] + }; + std::fs::write(dir1.join(format!("{}.ecx", vid_raw)), ecx).unwrap(); + std::fs::write(dir1.join(format!("{}.ecj", vid_raw)), b"").unwrap(); + std::fs::write( + dir1.join(format!("{}.vif", vid_raw)), + serde_json::to_string(&ec_vif(dir1_encode_ts_ns)).unwrap(), + ) + .unwrap(); + + if bitrot_sidecar != SidecarPlacement::Nowhere { + use crate::pb::volume_server_pb::{ + ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums, + }; + use crate::storage::erasure_coding::ec_bitrot; + let prot = EcBitrotProtection { + algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32, + block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32, + generation: 0, + ec_shard_config: Some(ec_bitrot::ec_shard_config(10, 4, 0)), + shards: (0..14u32) + .map(|shard_id| EcShardChecksums { + shard_id, + covered_size: 4, + block_crc32c: vec![0u8; 4], + }) + .collect(), + encode_uuid: vec![0u8; 16], + }; + // Each runtime resolves its sidecar from its own directory at mount + // (`load_bitrot_for_generation` gets no sibling dirs there), so a + // disk that gets no copy here mounts `BitrotStatus::Off`. + let sidecar_dirs: Vec<&std::path::Path> = match bitrot_sidecar { + SidecarPlacement::Nowhere => Vec::new(), + SidecarPlacement::BothDisks => vec![dir0.as_path(), dir1.as_path()], + SidecarPlacement::Dir1Only => vec![dir1.as_path()], + }; + for d in sidecar_dirs { + let base = crate::storage::volume::volume_file_name( + d.to_str().unwrap(), + "", + VolumeId(vid_raw), + ); + ec_bitrot::save_bitrot_sidecar(&ec_bitrot::bitrot_sidecar_path(&base, 0), &prot) + .unwrap(); + } + } + + let mut store = Store::new(NeedleMapKind::InMemory); + for d in [&dir0, &dir1] { + store + .add_location( + d.to_str().unwrap(), + d.to_str().unwrap(), + 100, + DiskType::HardDrive, + MinFreeSpace::Percent(0.0), + Vec::new(), + ) + .unwrap(); + } + + (split_disk_grpc_service(store), tmp) + } + + /// Wrap a hand-built two-location `Store` in the full + /// `VolumeServerState` the gRPC handlers need. Shared by the split-disk + /// fixtures so a second one costs a store, not another copy of this + /// 50-line literal. + fn split_disk_grpc_service(store: Store) -> VolumeGrpcService { + let state = Arc::new(VolumeServerState { + store: RwLock::new(store), + guard: RwLock::new(Guard::new( + &[], + SigningKey(vec![]), + 0, + SigningKey(vec![]), + 0, + )), + is_stopping: RwLock::new(false), + maintenance: std::sync::atomic::AtomicBool::new(false), + state_version: std::sync::atomic::AtomicU32::new(0), + concurrent_upload_limit: 0, + concurrent_download_limit: 0, + inflight_upload_data_timeout: std::time::Duration::from_secs(60), + inflight_download_data_timeout: std::time::Duration::from_secs(60), + inflight_upload_bytes: std::sync::atomic::AtomicI64::new(0), + inflight_download_bytes: std::sync::atomic::AtomicI64::new(0), + upload_notify: tokio::sync::Notify::new(), + download_notify: tokio::sync::Notify::new(), + data_center: String::new(), + rack: String::new(), + file_size_limit_bytes: 0, + maintenance_byte_per_second: 0, + is_heartbeating: std::sync::atomic::AtomicBool::new(true), + has_master: false, + pre_stop_seconds: 0, + volume_state_notify: tokio::sync::Notify::new(), + write_queue: std::sync::OnceLock::new(), + s3_tier_registry: std::sync::RwLock::new( + crate::remote_storage::s3_tier::S3TierRegistry::new(), + ), + read_mode: crate::config::ReadMode::Local, + allow_untrusted_remote_endpoints: false, + master_url: String::new(), + master_urls: Vec::new(), + seed_master_set: std::collections::HashSet::new(), + current_master_url: tokio::sync::RwLock::new(String::new()), + self_url: String::new(), + http_client: reqwest::Client::new(), + outgoing_http_scheme: "http".to_string(), + outgoing_grpc_tls: None, + metrics_runtime: std::sync::RwLock::new( + crate::server::volume_server::RuntimeMetricsConfig::default(), + ), + metrics_notify: tokio::sync::Notify::new(), + fix_jpg_orientation: false, + has_slow_read: false, + read_buffer_size_bytes: 1024 * 1024, + security_file: String::new(), + cli_white_list: vec![], + state_file_path: String::new(), + }); + + VolumeGrpcService { state } + } + + /// The dedupe lives on the implicit (empty `volume_ids`) path of + /// `scrub_ec_volume` itself -- `scrub_ec_volumes` is handed an + /// already-deduped list by every other caller in this test module, so it + /// cannot exercise this regression. Before the fix, flat-mapping every + /// location's `ec_volumes()` visited this split-disk vid twice and + /// `total_volumes` counted it twice. + #[tokio::test] + async fn test_scrub_ec_volume_node_wide_dedupes_a_split_disk_volume() { + let (service, _tmp) = make_local_service_with_split_disk_ec_volume(7040); + + let resp = service + .scrub_ec_volume(Request::new(volume_server_pb::ScrubEcVolumeRequest { + mode: 1, + volume_ids: vec![], + force_deleted_needles_check: false, + })) + .await + .unwrap() + .into_inner(); + + assert_eq!( + resp.total_volumes, 1, + "a vid split across two disks must be scrubbed once, not once per disk" + ); + } + + /// Issue one scrub mode against a single vid on a split-disk service. + async fn scrub_split_disk( + service: &VolumeGrpcService, + vid_raw: u32, + mode: i32, + ) -> volume_server_pb::ScrubEcVolumeResponse { + service + .scrub_ec_volume(Request::new(volume_server_pb::ScrubEcVolumeRequest { + mode, + volume_ids: vec![vid_raw], + force_deleted_needles_check: false, + })) + .await + .unwrap() + .into_inner() + } + + /// FULL/READS resolves shard locations up front, and with no master + /// configured that lookup fails and short-circuits the whole scrub with an + /// error of its own. Seed the cache so the needle walk actually runs and the + /// response reflects the volume rather than the missing master. + fn seed_all_shard_locations(service: &VolumeGrpcService, vid_raw: u32) { + let store = service.state.store.read().unwrap(); + for ecv in store.find_all_ec_volumes(VolumeId(vid_raw)) { + let mut locs = ecv.shard_locations.write().unwrap(); + for sid in 0u8..14 { + locs.insert(sid, vec!["127.0.0.1:255.1".to_string()]); + } + *ecv.shard_locations_refresh_time.lock().unwrap() = Some(std::time::Instant::now()); + } + } + + /// A disk the identity fence excluded is a disk the scrub did not read, + /// and the mode 2|5 arm must say so. `merge_ec_runtimes` hands the + /// exclusions back as `skipped`; the arm folds them into `errs`, which + /// flags the volume broken and prints the line. Before that, a volume + /// whose disks disagreed on encode identity scrubbed the anchor and + /// reported CLEAN -- the sibling disk went unmentioned. + /// + /// Both halves of the TRANSITION are asserted here: the same layout with + /// the disks AGREEING is clean, so the clean-to-broken change is pinned, + /// not just the message text. + /// + /// Across modes, `BitrotStatus::Off` is now the ONLY place an exclusion + /// goes unreported: modes 2|5 and 3 always report it, and mode 4 reports it + /// on `On` and (since `ec: report fenced-out disks on a malformed sidecar + /// too`) on `Invalid`. `Off` returns `(0, [], [])` before reporting + /// `skipped` because that is a hard Go-parity contract + /// (`case BitrotOff: return 0, nil, nil`), so on an unprotected generation + /// a CHECKSUM scrub of a fenced volume is clean and the excluded disk goes + /// unmentioned. That single exception is deliberate, not an oversight. + #[tokio::test] + async fn test_scrub_ec_volume_full_reports_a_fenced_out_disk() { + // Control: identical layout, both disks on encode run 200. + let (agreeing, _tmp_a) = SplitDiskEcFixture { + dir0_encode_ts_ns: None, // no dir0 .vif => falls through to dir1's + dir1_encode_ts_ns: 200, + clean_index: true, + ..SplitDiskEcFixture::new(7050) + } + .build(); + seed_all_shard_locations(&agreeing, 7050); + { + let store = agreeing.state.store.read().unwrap(); + let gens: Vec = store + .find_all_ec_volumes(VolumeId(7050)) + .iter() + .map(|v| v.encode_ts_ns) + .collect(); + assert_eq!(gens, vec![200, 200], "control fixture must NOT be fenced"); + } + + // The fenced volume: dir0 carries its own, older encode identity. + let (fenced, _tmp_f) = SplitDiskEcFixture { + dir0_encode_ts_ns: Some(100), + dir1_encode_ts_ns: 200, + clean_index: true, + ..SplitDiskEcFixture::new(7051) + } + .build(); + seed_all_shard_locations(&fenced, 7051); + { + let store = fenced.state.store.read().unwrap(); + let gens: Vec = store + .find_all_ec_volumes(VolumeId(7051)) + .iter() + .map(|v| v.encode_ts_ns) + .collect(); + assert_eq!( + gens, + vec![100, 200], + "fixture must mount both disks with DISAGREEING encode identities \ + for this to be meaningful" + ); + } + + // Both FULL (2) and READS (5) share the arm, so both must report it. + for mode in [2, 5] { + let clean = scrub_split_disk(&agreeing, 7050, mode).await; + assert!( + clean.broken_volume_ids.is_empty() && clean.details.is_empty(), + "mode {mode}: agreeing disks must scrub clean, else the fenced \ + case below proves nothing: {:?} {:?}", + clean.broken_volume_ids, + clean.details + ); + + let resp = scrub_split_disk(&fenced, 7051, mode).await; + assert_eq!( + resp.broken_volume_ids, + vec![7051], + "mode {mode}: a fenced-out disk must flag the volume broken, not \ + pass silently: {:?}", + resp.details + ); + assert_eq!( + resp.details.len(), + 1, + "mode {mode}: the exclusion is the only finding: {:?}", + resp.details + ); + assert!( + resp.details[0].starts_with("ecvol 7051: ") + && resp.details[0].contains("belong to encode run 100") + && resp.details[0].contains("they were not verified"), + "mode {mode}: {:?}", + resp.details + ); + } + } + + /// LOCAL and CHECKSUM must verify the shards on EVERY disk holding the vid, + /// not the ones on whichever disk `find_ec_volume` happened to return. The + /// layout makes that observable: disk 0 -- the disk the singular lookup + /// returns -- holds only shard 5, while shard 0 (the one the volume's single + /// needle spans, and the one the checksum sidecar is checked against) lives + /// on disk 1. Built from disk 0 alone, both scrubs simply never look at + /// shard 0. + #[tokio::test] + async fn test_scrub_ec_volume_local_and_checksum_reach_the_sibling_disk() { + // LOCAL reads only the shards the volume's one needle spans, so shard 5 + // is never touched; CHECKSUM reads every shard it holds, so both appear. + for (vid_raw, mode, bitrot_sidecar, want_broken) in [ + (7052u32, 3i32, SidecarPlacement::Nowhere, &[0u32][..]), + (7053, 4, SidecarPlacement::BothDisks, &[0, 5][..]), + ] { + let (service, _tmp) = SplitDiskEcFixture { + dir0_shard_id: 5, + dir1_shard_id: 0, + bitrot_sidecar, + ..SplitDiskEcFixture::new(vid_raw) + } + .build(); + + { + let store = service.state.store.read().unwrap(); + let first = store + .find_ec_volume(VolumeId(vid_raw)) + .expect("the singular lookup must still resolve the vid"); + assert!( + !first.has_shard(0) && first.has_shard(5), + "mode {mode}: the first-disk runtime must NOT hold shard 0, or a \ + first-disk-only scrub would reach it anyway and this proves nothing" + ); + assert!( + store + .find_all_ec_volumes(VolumeId(vid_raw)) + .iter() + .any(|v| v.has_shard(0)), + "mode {mode}: fixture must mount shard 0 on the sibling disk" + ); + } + + let resp = scrub_split_disk(&service, vid_raw, mode).await; + let mut broken: Vec = resp.broken_shard_infos.iter().map(|s| s.shard_id).collect(); + broken.sort_unstable(); + assert!( + broken.contains(&0), + "mode {mode}: shard 0 lives on the sibling disk and was never \ + verified: shards={broken:?} details={:?}", + resp.details + ); + assert_eq!( + broken, want_broken, + "mode {mode}: details={:?}", + resp.details + ); + assert_eq!(resp.broken_volume_ids, vec![vid_raw]); + } + } + + /// CHECKSUM must not source bitrot protection from the ANCHOR alone. + /// + /// The `.ecsum` sidecar is deliberately not mirrored across disks, and at + /// mount `EcVolume::new` resolves it with no sibling directories -- only + /// `VolumeEcShardsMount` ever passes `ec_metadata_dirs()`. So after any + /// volume-server restart the split-disk runtime that does not hold the + /// sidecar mounts `BitrotStatus::Off`. The anchor is the first + /// shard-bearing runtime at the maximum `encode_ts_ns`, picked with no + /// regard for which disk holds the sidecar -- so whenever the one copy + /// lands on the non-anchor disk (about half of all mirrored split-disk + /// layouts), taking `(prot, status)` from the anchor made `run()` return + /// `(0, [], [])` and the WHOLE volume scrubbed clean, silently. That is the + /// exact failure this branch exists to remove. + /// + /// The fixture is the same one the sibling-disk test uses, with the sidecar + /// written to dir1 ONLY. Both disks agree on encode identity, so nothing is + /// fenced and the plan's `unverifiable_sidecar` early return cannot fire -- + /// the volume must actually be SCANNED, and the placeholder shard bytes do + /// not match the sidecar, so every shard the merge reaches is reported. + #[tokio::test] + async fn test_checksum_scrub_takes_protection_from_the_disk_that_has_the_sidecar() { + let (service, _tmp) = SplitDiskEcFixture { + dir0_shard_id: 5, + dir1_shard_id: 0, + bitrot_sidecar: SidecarPlacement::Dir1Only, + ..SplitDiskEcFixture::new(7054) + } + .build(); + + // Without this the test could pass for the wrong reason: if the anchor + // ever resolved the sibling's sidecar itself, sourcing from the anchor + // would be fine and this would prove nothing. + { + use crate::storage::erasure_coding::ec_bitrot::BitrotStatus; + let store = service.state.store.read().unwrap(); + let runtimes = store.find_all_ec_volumes(VolumeId(7054)); + assert_eq!( + runtimes.len(), + 2, + "fixture must mount the vid on both disks" + ); + assert_eq!( + runtimes[0].bitrot_status, + BitrotStatus::Off, + "the ANCHOR disk must have NO sidecar, or this proves nothing" + ); + assert_eq!( + runtimes[1].bitrot_status, + BitrotStatus::On, + "the sibling disk must be the one carrying protection" + ); + assert_eq!( + runtimes[0].encode_ts_ns, runtimes[1].encode_ts_ns, + "the disks must AGREE on encode identity, so nothing is fenced \ + and the unverifiable-sidecar early return cannot mask the scan" + ); + } + + let resp = scrub_split_disk(&service, 7054, 4).await; + let mut broken: Vec = resp.broken_shard_infos.iter().map(|s| s.shard_id).collect(); + broken.sort_unstable(); + assert_eq!( + broken, + vec![0, 5], + "the volume must be SCANNED against the sibling's sidecar, not \ + returned clean-empty because the anchor mounted BitrotStatus::Off: \ + details={:?}", + resp.details + ); + assert_eq!( + resp.broken_volume_ids, + vec![7054], + "details={:?}", + resp.details + ); + } + + /// A two-location store holding a REAL 10+4 encoded volume with EVERY data + /// and parity shard present, scattered across both disks: shards 0..=6 on + /// disk 0, shards 7..=13 plus the `.ecx`/`.ecj`/`.vif` on disk 1. Disk 0 + /// gets no index files, so its shards mount only through the cross-disk + /// reconcile -- the real split-disk shape. + /// + /// This is the only layout that reaches mode 2's local Reed-Solomon parity + /// check over a genuinely MULTI-DIRECTORY `dirs`. The check is gated on + /// `all_local`, so every shard has to be present; the existing all-local + /// fixture keeps them in one directory, where every entry of `dirs` is the + /// same string and a permutation or off-by-one in the `slots` -> `dirs` + /// mapping is invisible. + /// + /// The `.dat`/`.idx` are left in a third directory that is NOT a store + /// location, so `prune_incomplete_ec_with_sibling_dat` finds no sibling + /// `.dat` and cannot delete the EC artefacts out from under the fixture. + fn make_service_with_two_disk_complete_ec_volume(vid_raw: u32) -> (VolumeGrpcService, TempDir) { + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src"); + let dir0 = tmp.path().join("data0"); + let dir1 = tmp.path().join("data1"); + for d in [&src, &dir0, &dir1] { + std::fs::create_dir_all(d).unwrap(); + } + let src_s = src.to_str().unwrap(); + let vid = VolumeId(vid_raw); + + // A real volume, really encoded: a parity check needs genuine + // Reed-Solomon parity to agree with, or "no corruption" means nothing. + { + let mut v = crate::storage::volume::Volume::new( + src_s, + src_s, + vid, + NeedleMapKind::InMemory, + &crate::storage::volume::VolumeSpec::default(), + ) + .unwrap(); + for i in 1..=8u64 { + let data = format!("test data for needle {} with a bit more length", i); + let mut n = crate::storage::needle::Needle { + id: crate::storage::types::NeedleId(i), + cookie: crate::storage::types::Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Default::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + v.close(); + } + crate::storage::erasure_coding::ec_encoder::write_ec_files(src_s, src_s, "", vid, 10, 4) + .unwrap(); + + for id in 0..14u8 { + let target = if id < 7 { &dir0 } else { &dir1 }; + std::fs::rename( + format!("{}/{}.ec{:02}", src_s, vid_raw, id), + format!("{}/{}.ec{:02}", target.to_str().unwrap(), vid_raw, id), + ) + .unwrap(); + } + let idx_dir = dir1.to_str().unwrap(); + std::fs::rename( + format!("{}/{}.ecx", src_s, vid_raw), + format!("{}/{}.ecx", idx_dir, vid_raw), + ) + .unwrap(); + std::fs::write(format!("{}/{}.ecj", idx_dir, vid_raw), b"").unwrap(); + std::fs::write( + format!("{}/{}.vif", idx_dir, vid_raw), + serde_json::to_string(&crate::storage::volume::VifVolumeInfo { + version: 3, + ec_shard_config: Some(crate::storage::volume::VifEcShardConfig { + data_shards: 10, + parity_shards: 4, + ..Default::default() + }), + ..Default::default() + }) + .unwrap(), + ) + .unwrap(); + + let mut store = Store::new(NeedleMapKind::InMemory); + for d in [&dir0, &dir1] { + store + .add_location( + d.to_str().unwrap(), + d.to_str().unwrap(), + 100, + DiskType::HardDrive, + MinFreeSpace::Percent(0.0), + Vec::new(), + ) + .unwrap(); + } + + (split_disk_grpc_service(store), tmp) + } + + /// The branch's newest production behavior, end to end: a mode 2 scrub of a + /// SPLIT-DISK volume whose shards are all local but spread over two + /// directories. + /// + /// `all_local` is now computed from the merged view, so this layout reaches + /// the local Reed-Solomon parity check for the first time -- and + /// `verify_ec_shards` is handed a `dirs` vector whose entries differ. + /// `test_scrub_ec_volume_reads_mode_reconstructs_missing_shard` also reaches + /// `all_local == true`, but every entry of its `dirs` is the same string, so + /// a permutation or off-by-one in the `slots` -> `dirs` mapping (the mode + /// 2|5 arm) cannot be observed there; and + /// `test_verify_ec_shards_reads_shards_from_multiple_dirs` builds its `dirs` + /// by hand and never goes through `merge_ec_runtimes` at all. + #[tokio::test] + async fn test_scrub_ec_volume_full_parity_checks_a_two_disk_all_local_volume() { + let (service, tmp) = make_service_with_two_disk_complete_ec_volume(7060); + seed_all_shard_locations(&service, 7060); + let dir0 = tmp.path().join("data0").to_str().unwrap().to_string(); + let dir1 = tmp.path().join("data1").to_str().unwrap().to_string(); + + // The fixture must actually produce the shape under test: every shard + // mounted, and the mapping the mode 2|5 arm builds `dirs` from spanning + // BOTH directories. Without this the test could pass over a one-disk + // layout and prove nothing about the mapping. + { + let store = service.state.store.read().unwrap(); + let runtimes = store.find_all_ec_volumes(VolumeId(7060)); + assert_eq!(runtimes.len(), 2, "the vid must mount on both disks"); + let merged = crate::storage::erasure_coding::ec_volume::merge_ec_runtimes(&runtimes) + .expect("two runtimes merge"); + assert!(merged.skipped.is_empty(), "{:?}", merged.skipped); + let dirs: Vec = (0..14) + .map(|id| { + merged.slots[id] + .unwrap_or_else(|| { + panic!( + "shard {id} is not mounted; all_local would be false \ + and the parity check would never run" + ) + }) + .0 + .dir + .clone() + }) + .collect(); + assert_eq!(dirs[0], dir0); + assert_eq!(dirs[6], dir0); + assert_eq!(dirs[7], dir1); + assert_eq!(dirs[13], dir1); + } + + // Real parity over a real encode, read through two directories: nothing + // may be fabricated as corrupt. + let resp = scrub_split_disk(&service, 7060, 2).await; + assert!( + resp.broken_volume_ids.is_empty() && resp.broken_shard_infos.is_empty(), + "an intact two-disk volume must not be reported broken: {:?}", + resp.details + ); + assert!(resp.details.is_empty(), "{:?}", resp.details); + assert_eq!(resp.total_files, 8, "the needle walk must have run"); + + // ...and the parity check really RAN, over the SECOND disk's shards. + // Shard 13 is a PARITY shard: the per-needle walk reads only live + // data-shard intervals, so nothing but `verify_ec_shards` can see this, + // and nothing but a correct `slots` -> `dirs` mapping can find the file. + // + // `assert_eq!` rather than `contains`, and the reason is specific: + // `dirs` is indexed BY SHARD ID and carries only directories, so an + // off-by-one or a permutation that lands the wrong DIRECTORY at some + // index makes `verify_ec_shards` look for a shard file that is not + // there -- the reported set is then not exactly `[13]`, which + // `contains(&13)` would still accept. A permutation that stays WITHIN + // one disk is not caught here: both entries name the same directory, so + // it is unobservable through `dirs` at all. That is a limit of this + // fixture, stated rather than papered over. + let shard13 = format!("{}/7060.ec13", dir1); + let mut bytes = std::fs::read(&shard13).unwrap(); + bytes[0] ^= 0xFF; + std::fs::write(&shard13, &bytes).unwrap(); + + let resp = scrub_split_disk(&service, 7060, 2).await; + let broken: Vec = resp.broken_shard_infos.iter().map(|s| s.shard_id).collect(); + assert_eq!( + broken, + vec![13], + "the parity check must reach disk 1 and blame exactly shard 13: {:?}", + resp.details + ); + assert_eq!(resp.broken_volume_ids, vec![7060]); + assert!( + resp.details + .iter() + .any(|d| d.contains("parity mismatch on shard 13")), + "the finding must come from the parity check, not the needle walk: {:?}", + resp.details + ); + } + + #[tokio::test] + async fn volume_delete_reports_absent_volume_as_not_found() { + let (service, _tmp) = make_service_with_seed_masters(&[]); + let err = service + .volume_delete(Request::new(volume_server_pb::VolumeDeleteRequest { + volume_id: 4242, + only_empty: false, + keep_remote_data: false, + })) + .await + .expect_err("deleting an absent volume must fail"); + assert_eq!(err.code(), tonic::Code::NotFound, "{err:?}"); + assert!(err.message().contains("not found"), "{err:?}"); + } + + #[tokio::test] + async fn volume_delete_only_empty_refuses_a_volume_with_data() { + let (service, _tmp) = make_local_service_with_volume("", None); + let err = service + .volume_delete(Request::new(volume_server_pb::VolumeDeleteRequest { + volume_id: 1, + only_empty: true, + keep_remote_data: false, + })) + .await + .expect_err("only_empty must refuse a volume holding a needle"); + assert_eq!(err.code(), tonic::Code::FailedPrecondition, "{err:?}"); + assert!(err.message().contains("volume not empty"), "{err:?}"); + assert!( + service + .state + .store + .read() + .unwrap() + .find_volume(VolumeId(1)) + .is_some(), + "refused delete must leave the volume mounted" + ); + } +} diff --git a/seaweed-volume/src/server/handlers.rs b/seaweed-volume/src/server/handlers.rs new file mode 100644 index 0000000..6419819 --- /dev/null +++ b/seaweed-volume/src/server/handlers.rs @@ -0,0 +1,4289 @@ +//! HTTP handlers for volume server operations. +//! +//! Implements GET/HEAD (read), POST/PUT (write), DELETE, /status, /healthz. +//! Matches Go's volume_server_handlers_read.go, volume_server_handlers_write.go, +//! volume_server_handlers_admin.go. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use axum::body::Body; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, Method, Request, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; + +use super::grpc_client::{GRPC_MAX_MESSAGE_SIZE, build_grpc_endpoint}; +use super::volume_server::{VolumeServerState, normalize_outgoing_http_url, to_http_address}; +use crate::config::ReadMode; +use crate::metrics; +use crate::pb::volume_server_pb; +use crate::storage::needle::needle::Needle; +use crate::storage::types::*; + +/// Slack added over the configured file-size limit when bounding the raw +/// request body, to allow for multipart/form-data framing overhead. The exact +/// per-file limit is still enforced on the parsed data after multipart parsing. +const UPLOAD_BODY_OVERHEAD: usize = 16 * 1024 * 1024; // 16 MiB + +/// Upper bound on bytes we will materialize in memory for a single request when +/// expanding stored content (gzip decompression or chunk-manifest assembly). +/// Guards against gzip bombs and crafted/oversized manifest sizes OOM-killing +/// the server; legitimate large objects are read via client-side chunk fetches. +const MAX_EXPANSION_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB + +/// Why `maybe_decompress_gzip` failed, so callers can distinguish a recoverable +/// "not valid gzip, use the raw bytes" from "the bomb cap was hit, reject it". +#[derive(Debug)] +enum GunzipError { + /// Input was not valid gzip / decode failed — callers may fall back to raw. + Decode, + /// Decompressed output would exceed `MAX_EXPANSION_BYTES` — must be rejected. + TooLarge, +} + +// ============================================================================ +// Inflight Throttle Guard +// ============================================================================ + +/// RAII guard that subtracts bytes from an atomic counter and notifies waiters on drop. +struct InflightGuard<'a> { + counter: &'a std::sync::atomic::AtomicI64, + bytes: i64, + notify: &'a tokio::sync::Notify, + metric: &'a prometheus::IntGauge, +} + +impl<'a> Drop for InflightGuard<'a> { + fn drop(&mut self) { + let new_val = self.counter.fetch_sub(self.bytes, Ordering::Relaxed) - self.bytes; + self.metric.set(new_val); + self.notify.notify_waiters(); + } +} + +/// Body wrapper that tracks download inflight bytes and releases them when dropped. +struct TrackedBody { + data: Vec, + state: Arc, + bytes: i64, +} + +impl http_body::Body for TrackedBody { + type Data = bytes::Bytes; + type Error = std::convert::Infallible; + + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + if self.data.is_empty() { + return std::task::Poll::Ready(None); + } + let data = std::mem::take(&mut self.data); + std::task::Poll::Ready(Some(Ok(http_body::Frame::data(bytes::Bytes::from(data))))) + } + + fn size_hint(&self) -> http_body::SizeHint { + http_body::SizeHint::with_exact(self.data.len() as u64) + } +} + +impl Drop for TrackedBody { + fn drop(&mut self) { + let new_val = self + .state + .inflight_download_bytes + .fetch_sub(self.bytes, Ordering::Relaxed) + - self.bytes; + metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val); + self.state.download_notify.notify_waiters(); + } +} + +fn finalize_bytes_response( + status: StatusCode, + headers: HeaderMap, + data: Vec, + state: Option>, +) -> Response { + if let Some(state) = state { + let data_len = data.len() as i64; + let new_val = state + .inflight_download_bytes + .fetch_add(data_len, Ordering::Relaxed) + + data_len; + metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val); + let tracked_body = TrackedBody { + data, + state, + bytes: data_len, + }; + let body = Body::new(tracked_body); + let mut resp = Response::new(body); + *resp.status_mut() = status; + *resp.headers_mut() = headers; + resp + } else { + (status, headers, data).into_response() + } +} + +// ============================================================================ +// Streaming Body for Large Files +// ============================================================================ + +/// Threshold in bytes above which we stream needle data instead of buffering. +const STREAMING_THRESHOLD: u32 = 1024 * 1024; // 1 MB + +/// Default chunk size for streaming reads from the dat file. +const DEFAULT_STREAMING_CHUNK_SIZE: usize = 64 * 1024; // 64 KB + +/// A body that streams needle data from the dat file in chunks using pread, +/// avoiding loading the entire payload into memory at once. +struct StreamingBody { + source: crate::storage::volume::NeedleStreamSource, + data_offset: u64, + data_size: u32, + pos: usize, + chunk_size: usize, + data_file_access_control: Arc, + hold_read_lock_for_stream: bool, + _held_read_lease: Option, + /// Pending result from spawn_blocking, polled to completion. + pending: Option>>, + /// For download throttling — released on drop. + state: Option>, + tracked_bytes: i64, + /// Server state used to re-lookup needle offset if compaction occurs during streaming. + server_state: Arc, + /// Volume ID for compaction-revision re-lookup. + volume_id: crate::storage::types::VolumeId, + /// Needle ID for compaction-revision re-lookup. + needle_id: crate::storage::types::NeedleId, + /// Compaction revision at the time of the initial read; if the volume's revision + /// changes between chunks, the needle may have moved and we must re-lookup its offset. + compaction_revision: u16, +} + +impl http_body::Body for StreamingBody { + type Data = bytes::Bytes; + type Error = std::io::Error; + + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + loop { + // If we have a pending read, poll it + if let Some(ref mut handle) = self.pending { + match std::pin::Pin::new(handle).poll(cx) { + std::task::Poll::Pending => return std::task::Poll::Pending, + std::task::Poll::Ready(result) => { + self.pending = None; + match result { + Ok(Ok(chunk)) => { + let len = chunk.len(); + self.pos += len; + return std::task::Poll::Ready(Some(Ok(http_body::Frame::data( + chunk, + )))); + } + Ok(Err(e)) => return std::task::Poll::Ready(Some(Err(e))), + Err(e) => { + return std::task::Poll::Ready(Some(Err(std::io::Error::other(e)))); + } + } + } + } + } + + let total = self.data_size as usize; + if self.pos >= total { + return std::task::Poll::Ready(None); + } + + // Check if compaction has changed the needle's disk location (Go parity: + // readNeedleDataInto re-reads the needle offset when CompactionRevision changes). + let relookup_result = { + let store = self.server_state.store.read().unwrap(); + if let Some((_, vol)) = store.find_volume(self.volume_id) { + if vol.super_block.compaction_revision != self.compaction_revision { + // Compaction occurred — re-lookup the needle's data offset + Some(vol.re_lookup_needle_data_offset(self.needle_id)) + } else { + None + } + } else { + None + } + }; + if let Some(result) = relookup_result { + match result { + Ok((new_offset, new_rev)) => { + self.data_offset = new_offset; + self.compaction_revision = new_rev; + } + Err(_) => { + return std::task::Poll::Ready(Some(Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "needle not found after compaction", + )))); + } + } + } + + let chunk_len = std::cmp::min(self.chunk_size, total - self.pos); + let file_offset = self.data_offset + self.pos as u64; + + let source_clone = match self.source.clone_for_read() { + Ok(source) => source, + Err(e) => return std::task::Poll::Ready(Some(Err(e))), + }; + let data_file_access_control = self.data_file_access_control.clone(); + let hold_read_lock_for_stream = self.hold_read_lock_for_stream; + + let handle = tokio::task::spawn_blocking(move || { + let _lease = if hold_read_lock_for_stream { + None + } else { + Some(data_file_access_control.read_lock()) + }; + let mut buf = vec![0u8; chunk_len]; + source_clone.read_exact_at(&mut buf, file_offset)?; + Ok::(bytes::Bytes::from(buf)) + }); + + self.pending = Some(handle); + // Loop back to poll the newly created future + } + } +} + +impl Drop for StreamingBody { + fn drop(&mut self) { + if let Some(ref st) = self.state { + let new_val = st + .inflight_download_bytes + .fetch_sub(self.tracked_bytes, Ordering::Relaxed) + - self.tracked_bytes; + metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val); + st.download_notify.notify_waiters(); + } + } +} + +// ============================================================================ +// URL Parsing +// ============================================================================ + +/// Parse volume ID and file ID from URL path. +/// Supports: "vid,fid", "vid/fid", "vid,fid.ext", "vid/fid/filename.ext" +/// Extract the file_id string (e.g., "3,01637037d6") from a URL path for JWT validation. +fn extract_file_id(path: &str) -> String { + let path = path.trim_start_matches('/'); + // Strip extension and filename after second slash + if let Some(comma) = path.find(',') { + let after_comma = &path[comma + 1..]; + let fid_part = if let Some(slash) = after_comma.find('/') { + &after_comma[..slash] + } else if let Some(dot) = after_comma.rfind('.') { + &after_comma[..dot] + } else { + after_comma + }; + // Strip "_suffix" from fid (Go does this for filenames appended with underscore) + let fid_part = if let Some(underscore) = fid_part.rfind('_') { + &fid_part[..underscore] + } else { + fid_part + }; + format!("{},{}", &path[..comma], fid_part) + } else { + path.to_string() + } +} + +fn streaming_chunk_size(read_buffer_size_bytes: usize, data_size: usize) -> usize { + std::cmp::min( + read_buffer_size_bytes.max(DEFAULT_STREAMING_CHUNK_SIZE), + data_size.max(1), + ) +} + +fn parse_url_path(path: &str) -> Option<(VolumeId, NeedleId, Cookie)> { + let path = path.trim_start_matches('/'); + + // Try "vid,fid" or "vid/fid" or "vid/fid/filename" formats + let (vid_str, fid_part) = if let Some(pos) = path.find(',') { + (&path[..pos], &path[pos + 1..]) + } else { + let pos = path.find('/')?; + (&path[..pos], &path[pos + 1..]) + }; + + // For fid part, strip extension from the fid (not from filename) + // "vid,fid.ext" -> fid is before dot + // "vid/fid/filename.ext" -> fid is the part before the second slash + let fid_str = if let Some(slash_pos) = fid_part.find('/') { + // "fid/filename.ext" - fid is before the slash + &fid_part[..slash_pos] + } else if let Some(dot) = fid_part.rfind('.') { + // "fid.ext" - strip extension + &fid_part[..dot] + } else { + fid_part + }; + + let vid = VolumeId::parse(vid_str).ok()?; + let (needle_id, cookie) = + crate::storage::needle::needle::parse_needle_id_cookie(fid_str).ok()?; + + Some((vid, needle_id, cookie)) +} + +// ============================================================================ +// Volume Lookup + Proxy/Redirect +// ============================================================================ + +/// A volume location returned by master lookup. +#[derive(Clone, Debug, Deserialize)] +struct VolumeLocation { + url: String, + #[serde(rename = "readOnly", default)] + read_only: bool, + #[serde(rename = "readOnlyCanDelete", default)] + read_only_can_delete: bool, + // Master often omits publicUrl when it matches url (Go json omitempty). + #[serde(rename = "publicUrl", default)] + public_url: String, + #[serde(rename = "grpcPort", default)] + grpc_port: u32, +} + +impl VolumeLocation { + fn public_or_url(&self) -> &str { + if self.public_url.is_empty() { + &self.url + } else { + &self.public_url + } + } +} + +/// Master /dir/lookup response. +#[derive(Debug, Deserialize)] +struct LookupResult { + #[serde(default)] + locations: Option>, + #[serde(default)] + error: Option, +} + +/// Look up volume locations from the master via HTTP /dir/lookup. +async fn lookup_volume( + client: &reqwest::Client, + scheme: &str, + master_url: &str, + volume_id: u32, +) -> Result, String> { + // master_url may be in SeaweedFS's "host:httpPort.grpcPort" form (mirrors + // Go's pb.ServerAddress); strip the gRPC port before building the HTTP URL. + let master_http = to_http_address(master_url); + let url = normalize_outgoing_http_url( + scheme, + &format!("{}/dir/lookup?volumeId={}", master_http, volume_id), + )?; + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("lookup request failed: {}", e))?; + let result: LookupResult = resp + .json() + .await + .map_err(|e| format!("lookup parse failed: {}", e))?; + if let Some(err) = result.error + && !err.is_empty() + { + return Err(err); + } + Ok(result.locations.unwrap_or_default()) +} + +fn grpc_address_for_location(location: &VolumeLocation) -> Result { + let raw = location + .url + .trim_start_matches("http://") + .trim_start_matches("https://"); + + if location.grpc_port > 0 { + let (host, _) = raw + .rsplit_once(':') + .ok_or_else(|| format!("cannot parse address: {}", location.url))?; + return Ok(format!("{}:{}", host, location.grpc_port)); + } + + if let Some(colon_idx) = raw.rfind(':') { + let port_part = &raw[colon_idx + 1..]; + if let Some(dot_idx) = port_part.rfind('.') { + let host = &raw[..colon_idx]; + let grpc_port = &port_part[dot_idx + 1..]; + grpc_port + .parse::() + .map_err(|e| format!("invalid grpc port: {}", e))?; + return Ok(format!("{}:{}", host, grpc_port)); + } + + let port: u16 = port_part + .parse() + .map_err(|e| format!("invalid port: {}", e))?; + let host = &raw[..colon_idx]; + return Ok(format!("{}:{}", host, port as u32 + 10000)); + } + + Err(format!("cannot parse address: {}", location.url)) +} + +async fn batch_delete_file_ids( + state: &VolumeServerState, + file_ids: &[String], +) -> Result<(), String> { + let mut lookup_cache: HashMap> = HashMap::new(); + let mut server_to_file_ids: HashMap> = HashMap::new(); + + for file_id in file_ids { + let parsed = crate::storage::needle::needle::FileId::parse(file_id) + .map_err(|e| format!("chunk delete {}: {}", file_id, e))?; + let volume_id = parsed.volume_id.0; + + let locations = if let Some(locations) = lookup_cache.get(&volume_id) { + locations.clone() + } else { + let locations = lookup_volume( + &state.http_client, + &state.outgoing_http_scheme, + &state.master_url, + volume_id, + ) + .await + .map_err(|e| format!("chunk delete {}: {}", file_id, e))?; + if locations.is_empty() { + return Err(format!("chunk delete {}: file not found", file_id)); + } + lookup_cache.insert(volume_id, locations.clone()); + locations + }; + + for location in locations { + let grpc_addr = grpc_address_for_location(&location) + .map_err(|e| format!("chunk delete {}: {}", file_id, e))?; + server_to_file_ids + .entry(grpc_addr) + .or_default() + .push(file_id.clone()); + } + } + + for (grpc_addr, batch) in server_to_file_ids { + let endpoint = build_grpc_endpoint(&grpc_addr, state.outgoing_grpc_tls.as_ref()) + .map_err(|e| format!("batch delete {}: {}", grpc_addr, e))?; + let channel = endpoint + .connect() + .await + .map_err(|e| format!("batch delete {}: {}", grpc_addr, e))?; + let mut client = + volume_server_pb::volume_server_client::VolumeServerClient::with_interceptor( + channel, + super::request_id::outgoing_request_id_interceptor, + ) + .max_decoding_message_size(GRPC_MAX_MESSAGE_SIZE) + .max_encoding_message_size(GRPC_MAX_MESSAGE_SIZE); + + let response = client + .batch_delete(volume_server_pb::BatchDeleteRequest { + file_ids: batch.clone(), + skip_cookie_check: true, + }) + .await + .map_err(|e| format!("batch delete {}: {}", grpc_addr, e))? + .into_inner(); + + for result in response.results { + if !result.error.is_empty() { + return Err(format!("chunk delete {}: {}", result.file_id, result.error)); + } + if result.status >= 400 { + return Err(format!( + "chunk delete {}: status {}", + result.file_id, result.status + )); + } + } + } + + Ok(()) +} + +/// Helper to synchronously replicate a request to peer volume servers. +async fn do_replicated_request( + state: &VolumeServerState, + vid: u32, + method: axum::http::Method, + path: &str, + query: &str, + headers: &axum::http::HeaderMap, + body: Option, +) -> Result<(), String> { + let locations = lookup_volume( + &state.http_client, + &state.outgoing_http_scheme, + &state.master_url, + vid, + ) + .await + .map_err(|e| format!("lookup volume failed: {}", e))?; + + let copy_count = { + let store = state.store.read().unwrap(); + store + .find_volume(VolumeId(vid)) + .map_or(1, |(_, v)| v.super_block.replica_placement.get_copy_count()) + }; + let allow_delete = method == axum::http::Method::DELETE; + let eligible_locations: Vec<_> = locations + .into_iter() + .filter(|loc| { + (!loc.read_only && allow_delete) + || (!loc.read_only && !allow_delete) + || (allow_delete && loc.read_only_can_delete) + }) + .collect(); + if eligible_locations.len() < copy_count as usize { + return Err(format!( + "replicating operations [{}] is less than volume {} replication copy count [{}]", + eligible_locations.len(), + vid, + copy_count + )); + } + + let self_http = to_http_address(&state.self_url); + let remote_locations: Vec<_> = eligible_locations + .into_iter() + .filter(|loc| { + if (!allow_delete && loc.read_only) + || (allow_delete && loc.read_only && !loc.read_only_can_delete) + { + return false; + } + to_http_address(&loc.url) != self_http + && to_http_address(loc.public_or_url()) != self_http + }) + .collect(); + + if remote_locations.is_empty() { + return Ok(()); + } + + let new_query = if query.is_empty() { + String::from("type=replicate") + } else { + format!("{}&type=replicate", query) + }; + + let mut futures = Vec::new(); + for loc in remote_locations { + // Defensively strip a `.grpcPort` suffix in case loc.url carries it + // (e.g. from an older master); see lookup_volume for the same fix. + let peer_http = to_http_address(&loc.url); + let url = normalize_outgoing_http_url( + &state.outgoing_http_scheme, + &format!("{}{}?{}", peer_http, path, new_query), + )?; + let client = state.http_client.clone(); + + let mut req_builder = client.request(method.clone(), &url); + + // Forward relevant headers + if let Some(ct) = headers.get(axum::http::header::CONTENT_TYPE) { + req_builder = req_builder.header(axum::http::header::CONTENT_TYPE, ct); + } + if let Some(ce) = headers.get(axum::http::header::CONTENT_ENCODING) { + req_builder = req_builder.header(axum::http::header::CONTENT_ENCODING, ce); + } + if let Some(md5) = headers.get("Content-MD5") { + req_builder = req_builder.header("Content-MD5", md5); + } + if let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) { + req_builder = req_builder.header(axum::http::header::AUTHORIZATION, auth); + } + + if let Some(ref b) = body { + req_builder = req_builder.body(b.clone()); + } + + futures.push(async move { + match req_builder.send().await { + Ok(r) if r.status().is_success() => Ok(()), + Ok(r) => { + crate::metrics::UPLOAD_ERROR_COUNTER + .with_label_values(&[&r.status().as_u16().to_string()]) + .inc(); + Err(format!("{} returned status {}", url, r.status())) + } + Err(e) => { + crate::metrics::UPLOAD_ERROR_COUNTER + .with_label_values(&["0"]) + .inc(); + Err(format!("{} failed: {}", url, e)) + } + } + }); + } + + let results = futures::future::join_all(futures).await; + let mut errors = Vec::new(); + for res in results { + if let Err(e) = res { + errors.push(e); + } + } + + if !errors.is_empty() { + return Err(errors.join(", ")); + } + + Ok(()) +} + +/// Extracted request info needed for proxy/redirect (avoids borrowing Request across await). +struct ProxyRequestInfo { + original_headers: HeaderMap, + original_query: String, + path: String, + vid_str: String, + fid_str: String, +} + +fn build_proxy_request_info( + path: &str, + headers: &HeaderMap, + query_string: &str, +) -> Option { + let trimmed = path.trim_start_matches('/'); + let (vid_str, fid_str) = if let Some(pos) = trimmed.find(',') { + let raw_fid = &trimmed[pos + 1..]; + let fid = if let Some(slash) = raw_fid.find('/') { + &raw_fid[..slash] + } else if let Some(dot) = raw_fid.rfind('.') { + &raw_fid[..dot] + } else { + raw_fid + }; + (trimmed[..pos].to_string(), fid.to_string()) + } else { + let pos = trimmed.find('/')?; + let after = &trimmed[pos + 1..]; + let fid_part = if let Some(slash) = after.find('/') { + &after[..slash] + } else { + after + }; + (trimmed[..pos].to_string(), fid_part.to_string()) + }; + + Some(ProxyRequestInfo { + original_headers: headers.clone(), + original_query: query_string.to_string(), + path: path.to_string(), + vid_str, + fid_str, + }) +} + +/// Handle proxy or redirect for a non-local volume read. +async fn proxy_or_redirect_to_target( + state: &VolumeServerState, + info: ProxyRequestInfo, + vid: VolumeId, + allow_local_redirect: bool, +) -> Response { + // Look up volume locations from master + let locations = match lookup_volume( + &state.http_client, + &state.outgoing_http_scheme, + &state.master_url, + vid.0, + ) + .await + { + Ok(locs) => locs, + Err(e) => { + tracing::warn!("volume lookup failed for {}: {}", vid.0, e); + return StatusCode::NOT_FOUND.into_response(); + } + }; + + if locations.is_empty() { + return StatusCode::NOT_FOUND.into_response(); + } + + // Filter out self, then shuffle remaining + let mut candidates: Vec<&VolumeLocation> = locations + .iter() + .filter(|loc| !loc.url.contains(&state.self_url)) + .collect(); + + if candidates.is_empty() { + return StatusCode::NOT_FOUND.into_response(); + } + + // Shuffle for load balancing + if candidates.len() >= 2 { + use rand::seq::SliceRandom; + let mut rng = rand::rng(); + candidates.shuffle(&mut rng); + } + + let target = candidates[0]; + + match state.read_mode { + ReadMode::Proxy => proxy_request(state, &info, target).await, + ReadMode::Redirect => redirect_request(&info, target, &state.outgoing_http_scheme), + ReadMode::Local if allow_local_redirect => { + redirect_request(&info, target, &state.outgoing_http_scheme) + } + ReadMode::Local => unreachable!(), + } +} + +/// Proxy the request to the target volume server. +async fn proxy_request( + state: &VolumeServerState, + info: &ProxyRequestInfo, + target: &VolumeLocation, +) -> Response { + // Build target URL, adding proxied=true query param + let path = info.path.trim_start_matches('/'); + // Defensively strip a `.grpcPort` suffix in case target.url carries it. + let target_http = to_http_address(&target.url); + + let raw_target = if info.original_query.is_empty() { + format!("{}/{}?proxied=true", target_http, path) + } else { + format!( + "{}/{}?{}&proxied=true", + target_http, path, info.original_query + ) + }; + let target_url = match normalize_outgoing_http_url(&state.outgoing_http_scheme, &raw_target) { + Ok(url) => url, + Err(e) => { + tracing::warn!("proxy target url {} invalid: {}", raw_target, e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + // Build the proxy request + let mut req_builder = state.http_client.get(&target_url); + + // Forward all original headers + for (name, value) in &info.original_headers { + if let Ok(v) = value.to_str() { + req_builder = req_builder.header(name.as_str(), v); + } + } + + let resp = match req_builder.send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!("proxy request to {} failed: {}", target_url, e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + // Build response, copying headers and body from remote + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut response_headers = HeaderMap::new(); + for (name, value) in resp.headers() { + if name.as_str().eq_ignore_ascii_case("server") { + continue; + } + response_headers.insert(name.clone(), value.clone()); + } + + // Stream the proxy response body instead of buffering it entirely + let byte_stream = resp.bytes_stream(); + let body = Body::from_stream(byte_stream); + + let mut response = Response::new(body); + *response.status_mut() = status; + *response.headers_mut() = response_headers; + response +} + +/// Return a redirect response to the target volume server. +fn redirect_request(info: &ProxyRequestInfo, target: &VolumeLocation, scheme: &str) -> Response { + // Build query string: preserve collection, add proxied=true, drop readDeleted (Go parity) + let mut query_params = Vec::new(); + if !info.original_query.is_empty() { + for param in info.original_query.split('&') { + if let Some((key, value)) = param.split_once('=') + && key == "collection" + { + query_params.push(format!("collection={}", value)); + } + // Intentionally drop readDeleted and other params (Go parity) + } + } + query_params.push("proxied=true".to_string()); + let query = query_params.join("&"); + + // Defensively strip a `.grpcPort` suffix in case target.url carries it. + let target_http = to_http_address(&target.url); + let raw_target = format!( + "{}/{},{}?{}", + target_http, info.vid_str, info.fid_str, query + ); + let location = match normalize_outgoing_http_url(scheme, &raw_target) { + Ok(url) => url, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + Response::builder() + .status(StatusCode::MOVED_PERMANENTLY) + .header("Location", &location) + .header("Content-Type", "text/html; charset=utf-8") + .body(Body::from(format!( + "Moved Permanently.\n\n", + location + ))) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +// ============================================================================ +// Query parameters +// ============================================================================ + +#[derive(Deserialize, Default)] +pub struct ReadQueryParams { + #[serde(rename = "response-content-type")] + pub response_content_type: Option, + #[serde(rename = "response-cache-control")] + pub response_cache_control: Option, + pub dl: Option, + #[serde(rename = "readDeleted")] + pub read_deleted: Option, + /// cm=false disables chunk manifest expansion (returns raw manifest JSON). + pub cm: Option, + /// Image resize width + pub width: Option, + /// Image resize height + pub height: Option, + /// Image resize mode: "fit" or "fill" + pub mode: Option, + /// Image crop parameters + pub crop_x1: Option, + pub crop_y1: Option, + pub crop_x2: Option, + pub crop_y2: Option, + /// S3 response passthrough headers + #[serde(rename = "response-content-encoding")] + pub response_content_encoding: Option, + #[serde(rename = "response-expires")] + pub response_expires: Option, + #[serde(rename = "response-content-language")] + pub response_content_language: Option, + #[serde(rename = "response-content-disposition")] + pub response_content_disposition: Option, + /// Pretty print JSON response + pub pretty: Option, +} + +// ============================================================================ +// Read Handler (GET/HEAD) +// ============================================================================ + +/// Called from the method-dispatching store handler with a full Request. +pub async fn get_or_head_handler_from_request( + State(state): State>, + request: Request, +) -> Response { + let uri = request.uri().clone(); + let headers = request.headers().clone(); + + // Parse query params manually from URI + let query_params: ReadQueryParams = uri + .query() + .and_then(|q| serde_urlencoded::from_str(q).ok()) + .unwrap_or_default(); + + get_or_head_handler_inner(state, headers, query_params, request).await +} + +pub async fn get_or_head_handler( + State(state): State>, + headers: HeaderMap, + query: Query, + request: Request, +) -> Response { + get_or_head_handler_inner(state, headers, query.0, request).await +} + +async fn get_or_head_handler_inner( + state: Arc, + headers: HeaderMap, + query: ReadQueryParams, + request: Request, +) -> Response { + let path = request.uri().path().to_string(); + let raw_query = request.uri().query().map(|q| q.to_string()); + let method = request.method().clone(); + + // JWT check for reads — must happen BEFORE path parsing to match Go behavior. + // Go's GetOrHeadHandler calls maybeCheckJwtAuthorization before NewVolumeId, + // so invalid paths with JWT enabled return 401, not 400. + let file_id = extract_file_id(&path); + let token = extract_jwt(&headers, request.uri()); + if state + .guard + .read() + .unwrap() + .check_jwt_for_file(token.as_deref(), &file_id, false) + .is_err() + { + let body = serde_json::json!({"error": "wrong jwt"}); + return Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + } + + let (vid, needle_id, cookie) = match parse_url_path(&path) { + Some(parsed) => parsed, + None => return StatusCode::BAD_REQUEST.into_response(), + }; + + // Check if volume exists locally; if not, proxy/redirect based on read_mode. + // This mirrors Go's hasVolume + hasEcVolume check in GetOrHeadHandler. + // NOTE: The RwLockReadGuard must be dropped before any .await to keep the future Send. + let has_volume = state.store.read().unwrap().has_volume(vid); + let has_ec_volume = state.store.read().unwrap().has_ec_volume(vid); + + if !has_volume && !has_ec_volume { + // Check if already proxied (loop prevention) + let query_string = request.uri().query().unwrap_or("").to_string(); + let is_proxied = query_string.contains("proxied=true"); + + if is_proxied || state.read_mode == ReadMode::Local || state.master_url.is_empty() { + return StatusCode::NOT_FOUND.into_response(); + } + + // For redirect, fid must be stripped of extension (Go parity: parseURLPath returns raw fid). + let info = match build_proxy_request_info(&path, request.headers(), &query_string) { + Some(info) => info, + None => return StatusCode::NOT_FOUND.into_response(), + }; + + return proxy_or_redirect_to_target(&state, info, vid, false).await; + } + + // Download throttling — matches Go's checkDownloadLimit + waitForDownloadSlot + let download_guard = if state.concurrent_download_limit > 0 { + let timeout = state.inflight_download_data_timeout; + let deadline = tokio::time::Instant::now() + timeout; + let query_string = request.uri().query().unwrap_or("").to_string(); + + let current = state.inflight_download_bytes.load(Ordering::Relaxed); + if current > state.concurrent_download_limit { + metrics::HANDLER_COUNTER + .with_label_values(&[metrics::DOWNLOAD_LIMIT_COND]) + .inc(); + + // Go tries proxy to replica ONCE before entering the blocking wait + // loop (checkDownloadLimit L65). It does NOT retry on each wakeup. + let should_try_replica = + !query_string.contains("proxied=true") && !state.master_url.is_empty() && { + let store = state.store.read().unwrap(); + store.find_volume(vid).is_some_and(|(_, vol)| { + vol.super_block.replica_placement.get_copy_count() > 1 + }) + }; + if should_try_replica + && let Some(info) = + build_proxy_request_info(&path, request.headers(), &query_string) + { + return proxy_or_redirect_to_target(&state, info, vid, true).await; + } + + // Blocking wait loop (Go's waitForDownloadSlot) + loop { + if tokio::time::timeout_at(deadline, state.download_notify.notified()) + .await + .is_err() + { + return json_error_with_query( + StatusCode::TOO_MANY_REQUESTS, + "download limit exceeded", + raw_query.as_deref(), + ); + } + let current = state.inflight_download_bytes.load(Ordering::Relaxed); + if current <= state.concurrent_download_limit { + break; + } + } + } + // We'll set the actual bytes after reading the needle (once we know the size) + Some(state.clone()) + } else { + None + }; + + // Read needle — branching between regular volume and EC volume paths. + // EC volumes always do a full read (no streaming/meta-only). + let mut n = Needle { + id: needle_id, + cookie, + ..Needle::default() + }; + + let read_deleted = query.read_deleted.as_deref() == Some("true"); + let has_range = headers.contains_key(header::RANGE); + let ext = extract_extension_from_path(&path); + // Go checks resize and crop extensions separately: resize supports .webp, crop does not. + let has_resize_ops = is_image_resize_ext(&ext) + && (query.width.unwrap_or(0) > 0 || query.height.unwrap_or(0) > 0); + // Go's shouldCropImages (L410) requires x2 > x1 && y2 > y1 (x1/y1 default 0). + // Only disable streaming when a real crop will actually happen. + let has_crop_ops = is_image_crop_ext(&ext) && { + let x1 = query.crop_x1.unwrap_or(0); + let y1 = query.crop_y1.unwrap_or(0); + let x2 = query.crop_x2.unwrap_or(0); + let y2 = query.crop_y2.unwrap_or(0); + x2 > x1 && y2 > y1 + }; + let has_image_ops = has_resize_ops || has_crop_ops; + + // Stream info is only available for regular volumes, not EC volumes. + let stream_info; + let bypass_cm; + let track_download; + let can_stream; + let can_handle_head_from_meta; + let can_handle_range_from_source; + + if has_ec_volume && !has_volume { + // ---- EC volume read path (always full read, no streaming) ---- + // + // The distributed read path already does a local-first pass + // in its Snapshot phase under the same store read lock the + // legacy code would have taken — so calling it directly + // serves both the "all shards local" fast case and the + // "some intervals need peer fetch + reconstruct" general + // case without paying for the local interval reads twice. + match crate::server::store_ec::read_ec_shard_needle_distributed(&state, vid, needle_id) + .await + { + Ok(Some(ec_needle)) => { + n = ec_needle; + } + Ok(None) => { + metrics::HANDLER_COUNTER + .with_label_values(&[metrics::ERROR_GET_NOT_FOUND]) + .inc(); + return StatusCode::NOT_FOUND.into_response(); + } + Err(e) => { + let kind = if e.kind() == std::io::ErrorKind::NotFound { + metrics::ERROR_GET_NOT_FOUND + } else { + metrics::ERROR_GET_INTERNAL + }; + metrics::HANDLER_COUNTER.with_label_values(&[kind]).inc(); + if e.kind() == std::io::ErrorKind::NotFound { + return StatusCode::NOT_FOUND.into_response(); + } + return (StatusCode::INTERNAL_SERVER_ERROR, format!("ec read: {}", e)) + .into_response(); + } + } + + // Validate cookie (matches Go behavior after ReadEcShardNeedle) + if n.cookie != cookie { + return StatusCode::NOT_FOUND.into_response(); + } + + // EC volumes: no streaming support + stream_info = None; + bypass_cm = query.cm.as_deref() == Some("false"); + track_download = download_guard.is_some(); + can_stream = false; + can_handle_head_from_meta = false; + can_handle_range_from_source = false; + } else { + // ---- Regular volume read path (with streaming support) ---- + + // Try meta-only read first for potential streaming + let store = state.store.read().unwrap(); + let si_result = store.read_volume_needle_stream_info(vid, &mut n, read_deleted); + stream_info = match si_result { + Ok(info) => Some(info), + Err(crate::storage::volume::VolumeError::StreamingUnsupported) => None, + Err(crate::storage::volume::VolumeError::NotFound) => { + metrics::HANDLER_COUNTER + .with_label_values(&[metrics::ERROR_GET_NOT_FOUND]) + .inc(); + return StatusCode::NOT_FOUND.into_response(); + } + Err(crate::storage::volume::VolumeError::Deleted) => { + metrics::HANDLER_COUNTER + .with_label_values(&[metrics::ERROR_GET_NOT_FOUND]) + .inc(); + return StatusCode::NOT_FOUND.into_response(); + } + Err(e) => { + metrics::HANDLER_COUNTER + .with_label_values(&[metrics::ERROR_GET_INTERNAL]) + .inc(); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("read error: {}", e), + ) + .into_response(); + } + }; + drop(store); + + // Validate cookie + if n.cookie != cookie { + return StatusCode::NOT_FOUND.into_response(); + } + + bypass_cm = query.cm.as_deref() == Some("false"); + track_download = download_guard.is_some(); + let can_direct_source_read = stream_info.is_some() + && !n.is_compressed() + && !(n.is_chunk_manifest() && !bypass_cm) + && !has_image_ops; + + // Determine if we can stream (large, direct-source eligible, no range) + can_stream = can_direct_source_read + && n.data_size > STREAMING_THRESHOLD + && !has_range + && method != Method::HEAD; + + // Go uses meta-only reads for all HEAD requests, regardless of compression/chunked files. + can_handle_head_from_meta = stream_info.is_some() && method == Method::HEAD; + can_handle_range_from_source = can_direct_source_read && has_range; + + // For chunk manifest or any non-streaming path, we need the full data. + // If we can't stream, do a full read now. + if !can_stream && !can_handle_head_from_meta && !can_handle_range_from_source { + // Re-read with full data + let mut n_full = Needle { + id: needle_id, + cookie, + ..Needle::default() + }; + let store = state.store.read().unwrap(); + match store.read_volume_needle_opt(vid, &mut n_full, read_deleted) { + Ok(count) => { + if count < 0 { + return StatusCode::NOT_FOUND.into_response(); + } + } + Err(crate::storage::volume::VolumeError::NotFound) => { + return StatusCode::NOT_FOUND.into_response(); + } + Err(crate::storage::volume::VolumeError::Deleted) => { + return StatusCode::NOT_FOUND.into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("read error: {}", e), + ) + .into_response(); + } + } + drop(store); + // Use the full needle from here (it has the same metadata + data) + n = n_full; + } + } + + // Build ETag and Last-Modified BEFORE conditional checks and chunk manifest expansion + // (matches Go order: conditional checks first, then chunk manifest) + let etag = format!("\"{}\"", n.etag()); + + // Build Last-Modified header (RFC 1123 format) — must be done before conditional checks + let last_modified_str = if n.last_modified > 0 { + use chrono::{TimeZone, Utc}; + Utc.timestamp_opt(n.last_modified as i64, 0) + .single() + .map(|dt| dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()) + } else { + None + }; + + // Check If-Modified-Since FIRST (Go checks this before If-None-Match) + if n.last_modified > 0 + && let Some(ims_header) = headers.get(header::IF_MODIFIED_SINCE) + && let Ok(ims_str) = ims_header.to_str() + { + // Parse HTTP date format: "Mon, 02 Jan 2006 15:04:05 GMT" + if let Ok(ims_time) = + chrono::NaiveDateTime::parse_from_str(ims_str, "%a, %d %b %Y %H:%M:%S GMT") + && (n.last_modified as i64) <= ims_time.and_utc().timestamp() + { + let mut resp = StatusCode::NOT_MODIFIED.into_response(); + if let Some(ref lm) = last_modified_str { + resp.headers_mut() + .insert(header::LAST_MODIFIED, lm.parse().unwrap()); + } + // Go sets ETag AFTER the 304 return paths (L235), so 304 does NOT include ETag + return resp; + } + } + + // Check If-None-Match SECOND + if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) + && let Ok(inm) = if_none_match.to_str() + && inm == etag + { + let mut resp = StatusCode::NOT_MODIFIED.into_response(); + if let Some(ref lm) = last_modified_str { + resp.headers_mut() + .insert(header::LAST_MODIFIED, lm.parse().unwrap()); + } + // Go sets ETag AFTER the 304 return paths (L235), so 304 does NOT include ETag + return resp; + } + + // Chunk manifest expansion (needs full data) — after conditional checks, before response + // Pass ETag so chunk manifest responses include it (matches Go: ETag is set on the + // response writer before tryHandleChunkedFile runs). + if n.is_chunk_manifest() + && !bypass_cm + && let Some(resp) = try_expand_chunk_manifest( + &state, + &n, + &method, + &path, + &query, + &etag, + &last_modified_str, + ) + .await + { + return resp; + } + // If manifest expansion fails (invalid JSON etc.), fall through to raw data + + let mut response_headers = HeaderMap::new(); + response_headers.insert(header::ETAG, etag.parse().unwrap()); + + // H1: Emit pairs as response headers + if n.has_pairs() + && !n.pairs.is_empty() + && let Ok(pair_map) = + serde_json::from_slice::>(&n.pairs) + { + for (k, v) in &pair_map { + if let (Ok(hname), Ok(hval)) = ( + axum::http::HeaderName::from_bytes(k.as_bytes()), + axum::http::HeaderValue::from_str(v), + ) { + response_headers.insert(hname, hval); + } + } + } + + // H8: Use needle stored name when URL path has no filename (only vid,fid) + let mut filename = extract_filename_from_path(&path); + let mut ext = ext; + if n.name_size > 0 && filename.is_empty() { + filename = String::from_utf8_lossy(&n.name).to_string(); + if ext.is_empty() + && let Some(dot_pos) = filename.rfind('.') + { + ext = filename[dot_pos..].to_lowercase(); + } + } + + // H6: Determine Content-Type: filter application/octet-stream, use mime_guess + // For chunk manifests, skip extension-based MIME override — use stored MIME as-is (Go parity) + let content_type = if let Some(ref ct) = query.response_content_type { + Some(ct.clone()) + } else if n.is_chunk_manifest() { + // Chunk manifests: use stored MIME but filter application/octet-stream (Go L334) + if !n.mime.is_empty() { + let mt = String::from_utf8_lossy(&n.mime).to_string(); + if mt.starts_with("application/octet-stream") { + None + } else { + Some(mt) + } + } else { + None + } + } else { + // Get MIME from needle, but filter out application/octet-stream + let needle_mime = if !n.mime.is_empty() { + let mt = String::from_utf8_lossy(&n.mime).to_string(); + if mt.starts_with("application/octet-stream") { + String::new() + } else { + mt + } + } else { + String::new() + }; + + if !needle_mime.is_empty() { + Some(needle_mime) + } else { + // Fall through to extension-based detection + let detect_ext = if !ext.is_empty() { + ext.clone() + } else if !filename.is_empty() { + if let Some(dot_pos) = filename.rfind('.') { + filename[dot_pos..].to_lowercase() + } else { + String::new() + } + } else { + String::new() + }; + if !detect_ext.is_empty() { + mime_guess::from_ext(detect_ext.trim_start_matches('.')) + .first() + .map(|m| m.to_string()) + } else { + None // Omit Content-Type entirely + } + } + }; + if let Some(ref ct) = content_type { + response_headers.insert(header::CONTENT_TYPE, ct.parse().unwrap()); + } + + // Cache-Control override from query param + if let Some(ref cc) = query.response_cache_control { + response_headers.insert(header::CACHE_CONTROL, cc.parse().unwrap()); + } + + // S3 response passthrough headers + if let Some(ref ce) = query.response_content_encoding { + response_headers.insert(header::CONTENT_ENCODING, ce.parse().unwrap()); + } + if let Some(ref exp) = query.response_expires { + response_headers.insert(header::EXPIRES, exp.parse().unwrap()); + } + if let Some(ref cl) = query.response_content_language { + response_headers.insert("Content-Language", cl.parse().unwrap()); + } + if let Some(ref cd) = query.response_content_disposition { + response_headers.insert(header::CONTENT_DISPOSITION, cd.parse().unwrap()); + } + + // Last-Modified + if let Some(ref lm) = last_modified_str { + response_headers.insert(header::LAST_MODIFIED, lm.parse().unwrap()); + } + + // H7: Content-Disposition — inline by default, attachment only when dl is truthy + // Only set if not already set by response-content-disposition query param + if !response_headers.contains_key(header::CONTENT_DISPOSITION) && !filename.is_empty() { + let disposition_type = if let Some(ref dl_val) = query.dl { + if parse_go_bool(dl_val).unwrap_or(false) { + "attachment" + } else { + "inline" + } + } else { + "inline" + }; + let disposition = format_content_disposition(disposition_type, &filename); + if let Ok(hval) = disposition.parse() { + response_headers.insert(header::CONTENT_DISPOSITION, hval); + } + } + + // ---- Streaming path: large uncompressed files ---- + if can_stream && let Some(info) = stream_info { + response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap()); + response_headers.insert( + header::CONTENT_LENGTH, + info.data_size.to_string().parse().unwrap(), + ); + + let tracked_bytes = info.data_size as i64; + let tracking_state = if download_guard.is_some() { + let new_val = state + .inflight_download_bytes + .fetch_add(tracked_bytes, Ordering::Relaxed) + + tracked_bytes; + metrics::INFLIGHT_DOWNLOAD_SIZE.set(new_val); + Some(state.clone()) + } else { + None + }; + + let streaming = StreamingBody { + source: info.source, + data_offset: info.data_file_offset, + data_size: info.data_size, + pos: 0, + chunk_size: streaming_chunk_size(state.read_buffer_size_bytes, info.data_size as usize), + _held_read_lease: if state.has_slow_read { + None + } else { + Some(info.data_file_access_control.read_lock()) + }, + data_file_access_control: info.data_file_access_control, + hold_read_lock_for_stream: !state.has_slow_read, + pending: None, + state: tracking_state, + tracked_bytes, + server_state: state.clone(), + volume_id: info.volume_id, + needle_id: info.needle_id, + compaction_revision: info.compaction_revision, + }; + + let body = Body::new(streaming); + let mut resp = Response::new(body); + *resp.status_mut() = StatusCode::OK; + *resp.headers_mut() = response_headers; + return resp; + } + + if can_handle_head_from_meta && let Some(info) = stream_info { + response_headers.insert( + header::CONTENT_LENGTH, + info.data_size.to_string().parse().unwrap(), + ); + return (StatusCode::OK, response_headers).into_response(); + } + + if can_handle_range_from_source + && let (Some(range_header), Some(info)) = (headers.get(header::RANGE), stream_info) + && let Ok(range_str) = range_header.to_str() + { + return handle_range_request_from_source( + range_str, + info, + response_headers, + track_download.then(|| state.clone()), + ); + } + + // ---- Buffered path: small files, compressed, images, range requests ---- + + // Handle compressed data: if needle is compressed, either pass through or decompress + let is_compressed = n.is_compressed(); + let mut data = n.data; + + // Check if image operations are needed — must decompress first regardless of Accept-Encoding + // Go checks resize (.webp OK) and crop (.webp NOT OK) separately. + let needs_image_ops = has_resize_ops || has_crop_ops; + + if is_compressed { + if needs_image_ops { + // Always decompress for image operations (Go decompresses before resize/crop) + match maybe_decompress_gzip(&data) { + Ok(decompressed) => data = decompressed, + Err(GunzipError::TooLarge) => { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + "compressed object exceeds decompression limit", + ) + .into_response(); + } + Err(GunzipError::Decode) => {} // not valid gzip; keep raw bytes + } + } else { + let accept_encoding = headers + .get(header::ACCEPT_ENCODING) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if accept_encoding.contains("gzip") + && data.len() >= 2 + && data[0] == 0x1f + && data[1] == 0x8b + { + // Go checks IsGzippedContent (magic bytes 0x1f 0x8b) before + // setting Content-Encoding: gzip + response_headers.insert(header::CONTENT_ENCODING, "gzip".parse().unwrap()); + } else { + // Decompress for client + match maybe_decompress_gzip(&data) { + Ok(decompressed) => data = decompressed, + Err(GunzipError::TooLarge) => { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + "compressed object exceeds decompression limit", + ) + .into_response(); + } + Err(GunzipError::Decode) => {} // not valid gzip; keep raw bytes + } + } + } + } + + // Image crop and resize — Go checks extensions separately per operation. + // Crop: .png .jpg .jpeg .gif (no .webp). Resize: .png .jpg .jpeg .gif .webp. + if is_image_crop_ext(&ext) { + data = maybe_crop_image(&data, &ext, &query); + } + if is_image_resize_ext(&ext) { + data = maybe_resize_image(&data, &ext, &query); + } + + // Accept-Ranges + response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap()); + + // Check Range header + if let Some(range_header) = headers.get(header::RANGE) + && let Ok(range_str) = range_header.to_str() + { + return handle_range_request( + range_str, + &data, + response_headers, + track_download.then(|| state.clone()), + ); + } + + if method == Method::HEAD { + response_headers.insert( + header::CONTENT_LENGTH, + data.len().to_string().parse().unwrap(), + ); + return (StatusCode::OK, response_headers).into_response(); + } + + finalize_bytes_response( + StatusCode::OK, + response_headers, + data, + track_download.then(|| state.clone()), + ) +} + +/// Handle HTTP Range requests. Returns 206 Partial Content or 416 Range Not Satisfiable. +#[derive(Clone, Copy, Debug)] +struct HttpRange { + start: i64, + length: i64, +} + +// Returned when the first-byte-pos of every byte-range-spec is at or past the content size. +const RANGE_NO_OVERLAP: &str = "invalid range: failed to overlap"; + +fn parse_range_header(s: &str, size: i64) -> Result, &'static str> { + if s.is_empty() { + return Ok(Vec::new()); + } + const PREFIX: &str = "bytes="; + if !s.starts_with(PREFIX) { + return Err("invalid range"); + } + let mut ranges = Vec::new(); + let mut no_overlap = false; + for part in s[PREFIX.len()..].split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let Some(pos) = part.find('-') else { + return Err("invalid range"); + }; + let start_str = part[..pos].trim(); + let end_str = part[pos + 1..].trim(); + let mut r = HttpRange { + start: 0, + length: 0, + }; + if start_str.is_empty() { + let mut i = end_str.parse::().map_err(|_| "invalid range")?; + if i > size { + i = size; + } + r.start = size - i; + r.length = size - r.start; + } else { + let i = start_str.parse::().map_err(|_| "invalid range")?; + if i < 0 { + return Err("invalid range"); + } + if i >= size { + no_overlap = true; + continue; + } + r.start = i; + if end_str.is_empty() { + r.length = size - r.start; + } else { + let mut i = end_str.parse::().map_err(|_| "invalid range")?; + if r.start > i { + return Err("invalid range"); + } + if i >= size { + i = size - 1; + } + r.length = i - r.start + 1; + } + } + ranges.push(r); + } + if no_overlap && ranges.is_empty() { + return Err(RANGE_NO_OVERLAP); + } + Ok(ranges) +} + +fn sum_ranges_size(ranges: &[HttpRange]) -> i64 { + ranges.iter().map(|r| r.length).sum() +} + +fn range_content_range(r: HttpRange, total: i64) -> String { + format!("bytes {}-{}/{}", r.start, r.start + r.length - 1, total) +} + +fn range_error_response(mut headers: HeaderMap, msg: &str) -> Response { + if !headers.contains_key(header::CONTENT_TYPE) { + headers.insert( + header::CONTENT_TYPE, + "text/plain; charset=utf-8".parse().unwrap(), + ); + } + let mut response = Response::new(Body::from(msg.to_string())); + *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; + *response.headers_mut() = headers; + response +} + +fn handle_range_request( + range_str: &str, + data: &[u8], + mut headers: HeaderMap, + state: Option>, +) -> Response { + let total = data.len() as i64; + let ranges = match parse_range_header(range_str, total) { + Ok(r) => r, + Err(msg) => { + if msg == RANGE_NO_OVERLAP { + headers.insert( + "Content-Range", + format!("bytes */{}", total).parse().unwrap(), + ); + } + return range_error_response(headers, msg); + } + }; + + // Go's ProcessRangeRequest returns nil (empty body) for empty or oversized ranges + if ranges.is_empty() { + return (StatusCode::OK, headers).into_response(); + } + + if sum_ranges_size(&ranges) > total { + return (StatusCode::OK, headers).into_response(); + } + + if ranges.len() == 1 { + let r = ranges[0]; + headers.insert( + "Content-Range", + range_content_range(r, total).parse().unwrap(), + ); + headers.insert( + header::CONTENT_LENGTH, + r.length.max(0).to_string().parse().unwrap(), + ); + if r.length <= 0 { + return (StatusCode::PARTIAL_CONTENT, headers).into_response(); + } + let start = r.start as usize; + let end = (r.start + r.length) as usize; + let slice = &data[start..end]; + finalize_bytes_response(StatusCode::PARTIAL_CONTENT, headers, slice.to_vec(), state) + } else { + // Multi-range: build multipart/byteranges response + let boundary = "SeaweedFSBoundary"; + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + let mut body = Vec::new(); + for (i, r) in ranges.iter().enumerate() { + // First boundary has no leading CRLF per RFC 2046 + if i == 0 { + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + } else { + body.extend_from_slice(format!("\r\n--{}\r\n", boundary).as_bytes()); + } + body.extend_from_slice(format!("Content-Type: {}\r\n", content_type).as_bytes()); + body.extend_from_slice( + format!("Content-Range: {}\r\n\r\n", range_content_range(*r, total)).as_bytes(), + ); + if r.length > 0 { + let start = r.start as usize; + let end = (r.start + r.length) as usize; + body.extend_from_slice(&data[start..end]); + } + } + body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes()); + + headers.insert( + header::CONTENT_TYPE, + format!("multipart/byteranges; boundary={}", boundary) + .parse() + .unwrap(), + ); + if !headers.contains_key(header::CONTENT_ENCODING) { + headers.insert( + header::CONTENT_LENGTH, + body.len().to_string().parse().unwrap(), + ); + } + finalize_bytes_response(StatusCode::PARTIAL_CONTENT, headers, body, state) + } +} + +fn handle_range_request_from_source( + range_str: &str, + info: crate::storage::volume::NeedleStreamInfo, + mut headers: HeaderMap, + state: Option>, +) -> Response { + let total = info.data_size as i64; + let ranges = match parse_range_header(range_str, total) { + Ok(r) => r, + Err(msg) => { + if msg == RANGE_NO_OVERLAP { + headers.insert( + "Content-Range", + format!("bytes */{}", total).parse().unwrap(), + ); + } + return range_error_response(headers, msg); + } + }; + + if ranges.is_empty() { + return (StatusCode::OK, headers).into_response(); + } + + if sum_ranges_size(&ranges) > total { + return (StatusCode::OK, headers).into_response(); + } + + let read_slice = |start: i64, length: i64| -> Result, std::io::Error> { + if length <= 0 { + return Ok(Vec::new()); + } + let mut buf = vec![0u8; length as usize]; + info.source + .read_exact_at(&mut buf, info.data_file_offset + start as u64)?; + Ok(buf) + }; + + if ranges.len() == 1 { + let r = ranges[0]; + let slice = match read_slice(r.start, r.length) { + Ok(slice) => slice, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("range read error: {}", err), + ) + .into_response(); + } + }; + headers.insert( + "Content-Range", + range_content_range(r, total).parse().unwrap(), + ); + headers.insert( + header::CONTENT_LENGTH, + slice.len().to_string().parse().unwrap(), + ); + return finalize_bytes_response(StatusCode::PARTIAL_CONTENT, headers, slice, state); + } + + let boundary = "SeaweedFSBoundary"; + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + let mut body = Vec::new(); + for (i, r) in ranges.iter().enumerate() { + let slice = match read_slice(r.start, r.length) { + Ok(slice) => slice, + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("range read error: {}", err), + ) + .into_response(); + } + }; + if i == 0 { + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + } else { + body.extend_from_slice(format!("\r\n--{}\r\n", boundary).as_bytes()); + } + body.extend_from_slice(format!("Content-Type: {}\r\n", content_type).as_bytes()); + body.extend_from_slice( + format!("Content-Range: {}\r\n\r\n", range_content_range(*r, total)).as_bytes(), + ); + body.extend_from_slice(&slice); + } + body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes()); + + headers.insert( + header::CONTENT_TYPE, + format!("multipart/byteranges; boundary={}", boundary) + .parse() + .unwrap(), + ); + if !headers.contains_key(header::CONTENT_ENCODING) { + headers.insert( + header::CONTENT_LENGTH, + body.len().to_string().parse().unwrap(), + ); + } + finalize_bytes_response(StatusCode::PARTIAL_CONTENT, headers, body, state) +} + +/// Extract filename from URL path like "/vid/fid/filename.ext" +fn extract_filename_from_path(path: &str) -> String { + let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + if parts.len() >= 3 { + parts[2].to_string() + } else { + String::new() + } +} + +fn path_base(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + trimmed + .rsplit('/') + .find(|s| !s.is_empty()) + .unwrap_or("") + .to_string() +} + +fn parse_go_bool(value: &str) -> Option { + match value { + "1" | "t" | "T" | "TRUE" | "True" | "true" => Some(true), + "0" | "f" | "F" | "FALSE" | "False" | "false" => Some(false), + _ => None, + } +} + +/// Format Content-Disposition header value per RFC 6266. +/// +/// Matches Go's `mime.FormatMediaType(dispositionType, map[string]string{"filename": filename})`: +/// - Simple ASCII names (alphanumeric, hyphen, underscore, dot): `attachment; filename=file.txt` +/// - ASCII names with spaces/special chars: `attachment; filename="my file.txt"` +/// - Non-ASCII names: `attachment; filename*=utf-8''percent-encoded-name` +fn format_content_disposition(disposition_type: &str, filename: &str) -> String { + let is_ascii = filename.bytes().all(|b| b.is_ascii()); + if is_ascii { + // Check if the filename is a simple "token" (no quoting needed). + // RFC 2616 token chars: any CHAR except CTLs or separators. + // Go's mime.FormatMediaType uses needsQuoting which checks for non-token chars. + let is_token = !filename.is_empty() + && filename.bytes().all(|b| { + b > 0x20 + && b < 0x7f + && !matches!( + b, + b'(' | b')' + | b'<' + | b'>' + | b'@' + | b',' + | b';' + | b':' + | b'\\' + | b'"' + | b'/' + | b'[' + | b']' + | b'?' + | b'=' + | b' ' + ) + }); + if is_token { + format!("{}; filename={}", disposition_type, filename) + } else { + // Quote the filename, escaping backslashes and quotes + let escaped = filename.replace('\\', "\\\\").replace('"', "\\\""); + format!("{}; filename=\"{}\"", disposition_type, escaped) + } + } else { + // Non-ASCII: use RFC 2231 encoding with filename* parameter + let encoded = percent_encode_rfc2231(filename); + format!("{}; filename*=utf-8''{}", disposition_type, encoded) + } +} + +/// Percent-encode a string for RFC 2231 filename* parameter. +/// Encodes all bytes except unreserved chars (ALPHA / DIGIT / "-" / "." / "_" / "~"). +fn percent_encode_rfc2231(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 3); + for byte in s.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + out.push(byte as char); + } else { + out.push('%'); + out.push(char::from(HEX_UPPER[byte as usize >> 4])); + out.push(char::from(HEX_UPPER[byte as usize & 0x0f])); + } + } + out +} + +const HEX_UPPER: [u8; 16] = *b"0123456789ABCDEF"; + +// ============================================================================ +// Image processing helpers +// ============================================================================ + +fn is_image_resize_ext(ext: &str) -> bool { + matches!(ext, ".png" | ".jpg" | ".jpeg" | ".gif" | ".webp") +} + +/// Go's shouldCropImages only supports these four formats (no .webp). +fn is_image_crop_ext(ext: &str) -> bool { + matches!(ext, ".png" | ".jpg" | ".jpeg" | ".gif") +} + +fn extract_extension_from_path(path: &str) -> String { + let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + if parts.len() >= 3 { + // 3-segment path: /vid/fid/filename.ext + let filename = parts[2]; + if let Some(dot_pos) = filename.rfind('.') { + return filename[dot_pos..].to_lowercase(); + } + } else if !parts.is_empty() { + // 2-segment path: /vid,fid.ext or /vid/fid.ext + // Go's parseURLPath extracts ext from the full path for all formats + let last = parts[parts.len() - 1]; + if let Some(dot_pos) = last.rfind('.') { + return last[dot_pos..].to_lowercase(); + } + } + String::new() +} + +fn maybe_resize_image(data: &[u8], ext: &str, query: &ReadQueryParams) -> Vec { + let width = query.width.unwrap_or(0); + let height = query.height.unwrap_or(0); + if width == 0 && height == 0 { + return data.to_vec(); + } + + let img = match image::load_from_memory(data) { + Ok(img) => img, + Err(_) => return data.to_vec(), + }; + + let (src_w, src_h) = (img.width(), img.height()); + // Only resize if source is larger than target + if (width == 0 || src_w <= width) && (height == 0 || src_h <= height) { + return data.to_vec(); + } + + let mode = query.mode.as_deref().unwrap_or(""); + let resized = match mode { + "fit" => img.resize(width, height, image::imageops::FilterType::Lanczos3), + "fill" => img.resize_to_fill(width, height, image::imageops::FilterType::Lanczos3), + _ => { + if width > 0 && height > 0 && width == height && src_w != src_h { + img.resize_to_fill(width, height, image::imageops::FilterType::Lanczos3) + } else { + img.resize(width, height, image::imageops::FilterType::Lanczos3) + } + } + }; + + encode_image(&resized, ext).unwrap_or_else(|| data.to_vec()) +} + +fn maybe_crop_image(data: &[u8], ext: &str, query: &ReadQueryParams) -> Vec { + let (x1, y1, x2, y2) = match (query.crop_x2, query.crop_y2) { + (Some(x2), Some(y2)) => { + let x1 = query.crop_x1.unwrap_or(0); + let y1 = query.crop_y1.unwrap_or(0); + if x2 > x1 && y2 > y1 { + (x1, y1, x2, y2) + } else { + return data.to_vec(); + } + } + _ => return data.to_vec(), + }; + + let img = match image::load_from_memory(data) { + Ok(img) => img, + Err(_) => return data.to_vec(), + }; + + let (src_w, src_h) = (img.width(), img.height()); + if x2 > src_w || y2 > src_h { + return data.to_vec(); + } + + let cropped = img.crop_imm(x1, y1, x2 - x1, y2 - y1); + encode_image(&cropped, ext).unwrap_or_else(|| data.to_vec()) +} + +fn encode_image(img: &image::DynamicImage, ext: &str) -> Option> { + use std::io::Cursor; + let mut buf = Cursor::new(Vec::new()); + let format = match ext { + ".png" => image::ImageFormat::Png, + ".jpg" | ".jpeg" => image::ImageFormat::Jpeg, + ".gif" => image::ImageFormat::Gif, + ".webp" => image::ImageFormat::WebP, + _ => return None, + }; + img.write_to(&mut buf, format).ok()?; + Some(buf.into_inner()) +} + +// ============================================================================ +// Write Handler (POST/PUT) +// ============================================================================ + +#[derive(Serialize)] +struct UploadResult { + #[serde(skip_serializing_if = "String::is_empty")] + name: String, + #[serde(skip_serializing_if = "is_zero_u32")] + size: u32, + #[serde(rename = "eTag", skip_serializing_if = "String::is_empty")] + etag: String, + #[serde(skip_serializing_if = "String::is_empty")] + mime: String, + #[serde(rename = "contentMd5", skip_serializing_if = "Option::is_none")] + content_md5: Option, +} + +fn is_zero_u32(v: &u32) -> bool { + *v == 0 +} + +pub async fn post_handler( + State(state): State>, + request: Request, +) -> Response { + let path = request.uri().path().to_string(); + let query = request.uri().query().unwrap_or("").to_string(); + let method = request.method().clone(); + let headers = request.headers().clone(); + let query_fields: Vec<(String, String)> = match serde_urlencoded::from_str(&query) { + Ok(fields) => fields, + Err(e) => { + // Go's r.ParseForm() returns 400 on malformed query strings + return json_error_with_query( + StatusCode::BAD_REQUEST, + format!("form parse error: {}", e), + Some(&query), + ); + } + }; + + let (vid, needle_id, cookie) = match parse_url_path(&path) { + Some(parsed) => parsed, + None => { + return json_error_with_query( + StatusCode::BAD_REQUEST, + "invalid URL path", + Some(&query), + ); + } + }; + + // JWT check for writes + let file_id = extract_file_id(&path); + let token = extract_jwt(&headers, request.uri()); + if state + .guard + .read() + .unwrap() + .check_jwt_for_file(token.as_deref(), &file_id, true) + .is_err() + { + return json_error_with_query(StatusCode::UNAUTHORIZED, "wrong jwt", Some(&query)); + } + + // Upload throttling: check inflight bytes against limit + let is_replicate = query.split('&').any(|p| p == "type=replicate"); + let content_length = headers + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + if !is_replicate && state.concurrent_upload_limit > 0 { + // Wait for inflight bytes to drop below limit, or timeout + let timeout = if state.inflight_upload_data_timeout.is_zero() { + std::time::Duration::from_secs(2) + } else { + state.inflight_upload_data_timeout + }; + let deadline = tokio::time::Instant::now() + timeout; + + loop { + let current = state.inflight_upload_bytes.load(Ordering::Relaxed); + if current <= state.concurrent_upload_limit { + break; + } + // Go increments UploadLimitCond on every loop iteration (L184), + // not just on timeout. + metrics::HANDLER_COUNTER + .with_label_values(&[metrics::UPLOAD_LIMIT_COND]) + .inc(); + // Wait for notification or timeout + if tokio::time::timeout_at(deadline, state.upload_notify.notified()) + .await + .is_err() + { + return json_error_with_query( + StatusCode::TOO_MANY_REQUESTS, + "upload limit exceeded", + Some(&query), + ); + } + } + let new_val = state + .inflight_upload_bytes + .fetch_add(content_length, Ordering::Relaxed) + + content_length; + metrics::INFLIGHT_UPLOAD_SIZE.set(new_val); + } + + // RAII guard to release upload throttle on any exit path + let _upload_guard = if !is_replicate && state.concurrent_upload_limit > 0 { + Some(InflightGuard { + counter: &state.inflight_upload_bytes, + bytes: content_length, + notify: &state.upload_notify, + metric: &metrics::INFLIGHT_UPLOAD_SIZE, + }) + } else { + None + }; + + let content_type_str = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + // Go only parses multipart form-data for POST requests with form-data content type. + let should_parse_multipart = method == Method::POST && content_type_str.contains("form-data"); + + // Validate multipart/form-data has a boundary + if should_parse_multipart && !content_type_str.contains("boundary=") { + return json_error_with_query( + StatusCode::BAD_REQUEST, + "no multipart boundary param in Content-Type", + Some(&query), + ); + } + + let content_md5 = headers + .get("Content-MD5") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + // Read body, bounded by the configured file-size limit so a single upload + // cannot buffer unbounded memory and OOM-kill the server (mirrors Go's + // io.LimitReader(r.Body, sizeLimit+1)). A margin covers multipart framing; + // the exact per-file limit is still enforced on the parsed data below. + let body_limit = if state.file_size_limit_bytes > 0 { + // try_from (not `as usize`) so a >usize::MAX limit on 32-bit caps at + // usize::MAX instead of silently truncating/wrapping to a tiny value. + usize::try_from(state.file_size_limit_bytes) + .unwrap_or(usize::MAX) + .saturating_add(UPLOAD_BODY_OVERHEAD) + } else { + usize::MAX + }; + let body = match axum::body::to_bytes(request.into_body(), body_limit).await { + Ok(b) => b, + Err(e) => { + // With a limit configured, an error here means the body exceeded it + // before we buffered the whole thing; report it like the size check. + let msg = if state.file_size_limit_bytes > 0 { + format!( + "file over the limited {} bytes", + state.file_size_limit_bytes + ) + } else { + format!("read body: {}", e) + }; + return json_error_with_query(StatusCode::BAD_REQUEST, msg, Some(&query)); + } + }; + + // H5: Multipart form-data parsing + let ( + body_data_raw, + parsed_filename, + parsed_content_type, + parsed_content_encoding, + parsed_content_md5, + multipart_form_fields, + ) = if should_parse_multipart { + // Extract boundary from Content-Type + let boundary = content_type_str + .split(';') + .find_map(|part| { + let part = part.trim(); + part.strip_prefix("boundary=") + .map(|val| val.trim_matches('"').to_string()) + }) + .unwrap_or_default(); + + let mut multipart = multer::Multipart::new( + futures::stream::once(async { Ok::<_, std::io::Error>(body.clone()) }), + boundary, + ); + + let mut file_data: Option> = None; + let mut first_part_data: Option> = None; + let mut file_name: Option = None; + let mut file_content_type: Option = None; + let mut file_content_encoding: Option = None; + let mut file_content_md5: Option = None; + let mut form_fields = std::collections::HashMap::new(); + + while let Ok(Some(field)) = multipart.next_field().await { + let field_name = field.name().map(|s| s.to_string()); + let fname = field.file_name().map(clean_windows_path_base); + let fct = field.content_type().map(|m| m.to_string()); + let field_headers = field.headers().clone(); + let fce = field_headers + .get(header::CONTENT_ENCODING) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let fmd5 = field_headers + .get("Content-MD5") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + if let Ok(data) = field.bytes().await { + // Go reads the first part's data unconditionally, then looks for + // a part with a filename. If no part has a filename, Go uses the + // first part's data (with empty filename). + if first_part_data.is_none() { + first_part_data = Some(data.to_vec()); + } + if file_data.is_none() && fname.is_some() { + // Found a file field — use this part's data + file_data = Some(data.to_vec()); + file_name = fname; + file_content_type = fct; + file_content_encoding = fce; + file_content_md5 = fmd5; + } else if let Some(name) = field_name { + form_fields + .entry(name) + .or_insert_with(|| String::from_utf8_lossy(&data).to_string()); + } + } + } + + if let Some(data) = file_data { + ( + data, + file_name.unwrap_or_default(), + file_content_type, + file_content_encoding, + file_content_md5, + form_fields, + ) + } else if let Some(data) = first_part_data { + // No file field found, use first part's data (matching Go behavior) + (data, String::new(), None, None, None, form_fields) + } else { + // No parts at all + (Vec::new(), String::new(), None, None, None, form_fields) + } + } else { + ( + body.to_vec(), + String::new(), + None, + None, + None, + std::collections::HashMap::new(), + ) + }; + + let form_value = |name: &str| { + query_fields + .iter() + .find_map(|(k, v)| if k == name { Some(v.clone()) } else { None }) + .or_else(|| multipart_form_fields.get(name).cloned()) + }; + + // Check for chunk manifest flag. + // Go uses r.FormValue("cm"), which falls back to multipart fields when present. + let is_chunk_manifest = matches!( + form_value("cm").as_deref(), + Some("1" | "t" | "T" | "TRUE" | "True" | "true") + ); + + // Check file size limit (matches Go: "file over the limited %d bytes") + if state.file_size_limit_bytes > 0 && body_data_raw.len() as i64 > state.file_size_limit_bytes { + return json_error_with_query( + StatusCode::BAD_REQUEST, + format!( + "file over the limited {} bytes", + state.file_size_limit_bytes + ), + Some(&query), + ); + } + + // Check if upload is pre-compressed + let is_gzipped = if should_parse_multipart { + parsed_content_encoding.as_deref() == Some("gzip") + } else { + headers + .get(header::CONTENT_ENCODING) + .and_then(|v| v.to_str().ok()) + .map(|s| s == "gzip") + .unwrap_or(false) + }; + + let uncompressed_data = if is_gzipped { + match maybe_decompress_gzip(&body_data_raw) { + Ok(d) => d, + Err(GunzipError::TooLarge) => { + return json_error_with_query( + StatusCode::PAYLOAD_TOO_LARGE, + "compressed object exceeds decompression limit", + Some(&query), + ); + } + Err(GunzipError::Decode) => body_data_raw.clone(), + } + } else { + body_data_raw.clone() + }; + let original_data_size = uncompressed_data.len() as u32; + + // Only compute and validate Content-MD5 when the client provided one + // (Go only computes MD5 when Content-MD5 header/field is present) + let content_md5 = content_md5.or(parsed_content_md5); + let original_content_md5 = if content_md5.is_some() { + Some(compute_md5_base64(&uncompressed_data)) + } else { + None + }; + if let (Some(expected_md5), Some(actual_md5)) = (&content_md5, &original_content_md5) + && expected_md5 != actual_md5 + { + return json_error_with_query( + StatusCode::BAD_REQUEST, + format!( + "Content-MD5 did not match md5 of file data expected [{}] received [{}] size {}", + expected_md5, actual_md5, original_data_size + ), + Some(&query), + ); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Parse custom timestamp from query param + let ts_str = form_value("ts").unwrap_or_default(); + let last_modified = if !ts_str.is_empty() { + ts_str.parse::().unwrap_or(now) + } else { + now + }; + + // Prefer the multipart filename before deriving MIME and other metadata. + let filename = if !parsed_filename.is_empty() { + parsed_filename + } else if !should_parse_multipart { + headers + .get(header::CONTENT_DISPOSITION) + .and_then(|v| v.to_str().ok()) + .and_then(parse_content_disposition_filename) + .unwrap_or_else(|| path_base(&path)) + } else { + extract_filename_from_path(&path) + }; + + // Extract MIME type: prefer multipart-parsed content type, else from Content-Type header + let mime_type = if let Some(ref pct) = parsed_content_type { + pct.clone() + } else { + let multipart_fallback = + if should_parse_multipart && !filename.is_empty() && !is_chunk_manifest { + mime_guess::from_path(&filename) + .first() + .map(|m| m.to_string()) + .unwrap_or_default() + } else { + String::new() + }; + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|ct| { + if should_parse_multipart && ct.starts_with("multipart/") { + multipart_fallback.clone() + } else { + ct.to_string() + } + }) + .unwrap_or(multipart_fallback) + }; + + // Parse TTL from query param (matches Go's r.FormValue("ttl")) + let ttl_str = form_value("ttl").unwrap_or_default(); + let ttl = if !ttl_str.is_empty() { + crate::storage::needle::TTL::read(&ttl_str).ok() + } else { + None + }; + + // Extract Seaweed-* custom metadata headers (pairs) + // Go's net/http canonicalizes header names to Title-Case, so after stripping + // the "Seaweed-" prefix, keys are Title-Case (e.g., "Foo-Bar"). Rust's http + // crate lowercases all header names, so we must convert the stripped key to + // Title-Case to match Go's behavior. + fn to_title_case(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut capitalize_next = true; + for c in s.chars() { + if c == '-' { + result.push('-'); + capitalize_next = true; + } else if capitalize_next { + for uc in c.to_uppercase() { + result.push(uc); + } + capitalize_next = false; + } else { + result.push(c); + } + } + result + } + let pair_map: std::collections::HashMap = headers + .iter() + .filter_map(|(k, v)| { + let key = k.as_str(); + if key.len() > 8 && key[..8].eq_ignore_ascii_case("seaweed-") { + if let Ok(val) = v.to_str() { + // Store with the prefix stripped and Title-Cased (matching Go's trimmedPairMap) + Some((to_title_case(&key[8..]), val.to_string())) + } else { + None + } + } else { + None + } + }) + .collect(); + + // Fix JPEG orientation from EXIF data before storing (matches Go behavior). + let body_data = if state.fix_jpg_orientation && crate::images::is_jpeg(&mime_type, &path) { + crate::images::fix_jpg_orientation(&body_data_raw) + } else { + body_data_raw + }; + + // Auto-compress compressible file types (matches Go's IsCompressableFileType). + // Only compress if not already gzipped and compression saves >10%. + // Go uses filepath.Base(pu.FileName) for extension detection (not the URL path). + let (final_data, final_is_gzipped) = if !is_gzipped && !is_chunk_manifest { + let ext = { + let dot_pos = filename.rfind('.'); + dot_pos + .map(|p| filename[p..].to_lowercase()) + .unwrap_or_default() + }; + if is_compressible_file_type(&ext, &mime_type) { + if let Some(compressed) = try_gzip_data(&body_data) { + if compressed.len() * 10 < body_data.len() * 9 { + (compressed, true) + } else { + (body_data, false) + } + } else { + (body_data, false) + } + } else { + (body_data, false) + } + } else { + (body_data, is_gzipped) + }; + + let mut n = Needle { + id: needle_id, + cookie, + data_size: final_data.len() as u32, + data: final_data, + last_modified, + ..Needle::default() + }; + n.set_has_last_modified_date(); + if is_chunk_manifest { + n.set_is_chunk_manifest(); + } + if final_is_gzipped { + n.set_is_compressed(); + } + + // Go sets HasMime even for empty MIME types: if len(pu.MimeType) < 256 + if mime_type.len() < 256 { + n.mime = mime_type.as_bytes().to_vec(); + n.set_has_mime(); + } + + // Set TTL on needle + if let Some(ref t) = ttl + && !t.is_empty() + { + n.ttl = Some(*t); + n.set_has_ttl(); + } + + // Set pairs on needle + if !pair_map.is_empty() + && let Ok(pairs_json) = serde_json::to_vec(&pair_map) + && pairs_json.len() < 65536 + { + n.pairs_size = pairs_json.len() as u16; + n.pairs = pairs_json; + n.set_has_pairs(); + } + + // Set filename on needle (matches Go: if len(pu.FileName) < 256) + // Go sets HasName even for empty filenames + if filename.len() < 256 { + n.name = filename.as_bytes().to_vec(); + n.name_size = filename.len() as u8; + n.set_has_name(); + } + + // A durable write flushes before it is acked. Read it the way Go's + // r.FormValue does, off the decoded fields, so a percent-encoded value is + // honored here too. ReplicatedWrite forwards the parameter, so a replica + // sees it the same way the primary did. + let fsync = form_value("fsync").as_deref() == Some("true"); + + let write_result = if let Some(wq) = state.write_queue.get() { + wq.submit(vid, n.clone(), fsync).await + } else { + let mut store = state.store.write().unwrap(); + store.write_volume_needle(vid, &mut n, fsync) + }; + + // Replicate to remote volume servers if this volume has replicas. + // Matches Go's GetWritableRemoteReplications: skip if copy_count == 1. + if !is_replicate && write_result.is_ok() && !state.master_url.is_empty() { + let needs_replication = { + let store = state.store.read().unwrap(); + store + .find_volume(vid) + .is_some_and(|(_, v)| v.super_block.replica_placement.get_copy_count() > 1) + }; + if needs_replication { + let state_clone = state.clone(); + let path_clone = path.clone(); + let query_clone = query.clone(); + let headers_clone = headers.clone(); + let body_clone = body.clone(); + let replication = tokio::spawn(async move { + do_replicated_request( + &state_clone, + vid.0, + Method::POST, + &path_clone, + &query_clone, + &headers_clone, + Some(body_clone), + ) + .await + }); + let replication_result = replication + .await + .map_err(|e| format!("replication task failed: {}", e)) + .flatten(); + if let Err(e) = replication_result { + tracing::error!("replicated write failed: {}", e); + return json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("replication failed: {}", e), + Some(&query), + ); + } + } + } + + let resp = match write_result { + Ok((_offset, _size, is_unchanged)) => { + if is_unchanged { + let etag = format!("\"{}\"", n.etag()); + (StatusCode::NO_CONTENT, [(header::ETAG, etag)]).into_response() + } else { + // Go only includes contentMd5 when the client provided Content-MD5 + let result = UploadResult { + name: if n.has_name() { + filename.clone() + } else { + String::new() + }, + size: original_data_size, // H3: use original size, not compressed + etag: n.etag(), + mime: mime_type.clone(), + content_md5: original_content_md5.clone(), + }; + let etag = n.etag(); + let etag_header = if etag.starts_with('"') { + etag.clone() + } else { + format!("\"{}\"", etag) + }; + let mut resp = json_result_with_query(StatusCode::CREATED, &result, &query); + resp.headers_mut() + .insert(header::ETAG, etag_header.parse().unwrap()); + if let Some(ref md5_value) = original_content_md5 { + resp.headers_mut() + .insert("Content-MD5", md5_value.parse().unwrap()); + } + resp + } + } + Err(e) => { + metrics::HANDLER_COUNTER + .with_label_values(&[metrics::ERROR_WRITE_TO_LOCAL_DISK]) + .inc(); + json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("{}", e), + Some(&query), + ) + } + }; + + // _upload_guard drops here, releasing inflight bytes + resp +} + +// ============================================================================ +// Delete Handler +// ============================================================================ + +#[derive(Serialize)] +struct DeleteResult { + size: i64, +} + +pub async fn delete_handler( + State(state): State>, + request: Request, +) -> Response { + let path = request.uri().path().to_string(); + let del_query = request.uri().query().unwrap_or("").to_string(); + let del_params: ReadQueryParams = serde_urlencoded::from_str(&del_query).unwrap_or_default(); + let headers = request.headers().clone(); + + let (vid, needle_id, cookie) = match parse_url_path(&path) { + Some(parsed) => parsed, + None => { + return json_error_with_query( + StatusCode::BAD_REQUEST, + "invalid URL path", + Some(&del_query), + ); + } + }; + + // JWT check for writes (deletes use write key) + let file_id = extract_file_id(&path); + let token = extract_jwt(&headers, request.uri()); + if state + .guard + .read() + .unwrap() + .check_jwt_for_file(token.as_deref(), &file_id, true) + .is_err() + { + return json_error_with_query(StatusCode::UNAUTHORIZED, "wrong jwt", Some(&del_query)); + } + + // Check for EC volume first (Go checks hasEcVolume before regular volume in DeleteHandler). + // Go's flow: FindEcVolume -> DeleteEcShardNeedle(ecVolume, n, cookie) -> writeDeleteResult + // DeleteEcShardNeedle: reads needle (for size + cookie validation), validates cookie, journals delete. + { + let has_ec = state.store.read().unwrap().has_ec_volume(vid); + if has_ec { + // Step 1: Read the EC needle to get its size and validate cookie + let ec_read_result = { + let store = state.store.read().unwrap(); + store + .find_ec_volume(vid) + .map(|ecv| ecv.read_ec_shard_needle(needle_id)) + }; + match ec_read_result { + Some(Ok(Some(ec_needle))) => { + // Step 2: Validate cookie (Go: cookie != 0 && cookie != n.Cookie) + if cookie.0 != 0 && ec_needle.cookie != cookie { + return json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Deletion Failed: unexpected cookie {:x}", cookie.0), + Some(&del_query), + ); + } + let count = ec_needle.data_size as i64; + // Step 3: Journal the delete + let mut store = state.store.write().unwrap(); + if let Some(ecv) = store.find_ec_volume_mut(vid) + && let Err(e) = ecv.journal_delete(needle_id) + { + return json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Deletion Failed: {}", e), + Some(&del_query), + ); + } + let result = DeleteResult { size: count }; + return json_response_with_params( + StatusCode::ACCEPTED, + &result, + Some(&del_params), + ); + } + Some(Ok(None)) => { + // Needle not found in EC volume + let result = DeleteResult { size: 0 }; + return json_response_with_params( + StatusCode::NOT_FOUND, + &result, + Some(&del_params), + ); + } + Some(Err(e)) => { + return json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Deletion Failed: {}", e), + Some(&del_query), + ); + } + None => { + // EC volume disappeared between has_ec check and find + let result = DeleteResult { size: 0 }; + return json_response_with_params( + StatusCode::NOT_FOUND, + &result, + Some(&del_params), + ); + } + } + } + } + + // H9: Parse custom timestamp from query param; default to now (not 0) + let del_ts_str = del_query + .split('&') + .find_map(|p| p.strip_prefix("ts=")) + .unwrap_or(""); + let del_last_modified = if !del_ts_str.is_empty() { + del_ts_str.parse::().unwrap_or_else(|_| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + } else { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }; + + let mut n = Needle { + id: needle_id, + cookie, + ..Needle::default() + }; + + // Read needle first to validate cookie (matching Go behavior) + let original_cookie = cookie; + { + let store = state.store.read().unwrap(); + match store.read_volume_needle(vid, &mut n) { + Ok(_) => {} + Err(_) => { + let result = DeleteResult { size: 0 }; + return json_response_with_params( + StatusCode::NOT_FOUND, + &result, + Some(&del_params), + ); + } + } + } + if n.cookie != original_cookie { + return json_error_with_query( + StatusCode::BAD_REQUEST, + "File Random Cookie does not match.", + Some(&del_query), + ); + } + + // Apply custom timestamp (always set — defaults to now per H9) + n.last_modified = del_last_modified; + n.set_has_last_modified_date(); + + let mut delete_size_override = None; + + // If this is a chunk manifest, delete child chunks first + if n.is_chunk_manifest() { + let manifest_data = if n.is_compressed() { + match maybe_decompress_gzip(&n.data) { + Ok(d) => d, + Err(GunzipError::TooLarge) => { + return json_error_with_query( + StatusCode::PAYLOAD_TOO_LARGE, + "compressed manifest exceeds decompression limit", + Some(&del_query), + ); + } + Err(GunzipError::Decode) => n.data.clone(), + } + } else { + n.data.clone() + }; + + let manifest = match serde_json::from_slice::(&manifest_data) { + Ok(manifest) => manifest, + Err(e) => { + return json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Load chunks manifest error: {}", e), + Some(&del_query), + ); + } + }; + + let child_fids: Vec = manifest + .chunks + .iter() + .map(|chunk| chunk.fid.clone()) + .collect(); + if let Err(e) = batch_delete_file_ids(&state, &child_fids).await { + return json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Delete chunks error: {}", e), + Some(&del_query), + ); + } + delete_size_override = Some(manifest.size as i64); + } + + let delete_result = { + let mut store = state.store.write().unwrap(); + store.delete_volume_needle(vid, &mut n) + }; + + let is_replicate = del_query.split('&').any(|p| p == "type=replicate"); + if !is_replicate && delete_result.is_ok() && !state.master_url.is_empty() { + let needs_replication = { + let store = state.store.read().unwrap(); + store + .find_volume(vid) + .is_some_and(|(_, v)| v.super_block.replica_placement.get_copy_count() > 1) + }; + if needs_replication + && let Err(e) = do_replicated_request( + &state, + vid.0, + Method::DELETE, + &path, + &del_query, + &headers, + None, + ) + .await + { + tracing::error!("replicated delete failed: {}", e); + return json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("replication failed: {}", e), + Some(&del_query), + ); + } + } + + match delete_result { + Ok(size) => { + let result = DeleteResult { + size: delete_size_override.unwrap_or(size.0 as i64), + }; + json_response_with_params(StatusCode::ACCEPTED, &result, Some(&del_params)) + } + Err(crate::storage::volume::VolumeError::NotFound) => { + let result = DeleteResult { size: 0 }; + json_response_with_params(StatusCode::NOT_FOUND, &result, Some(&del_params)) + } + Err(e) => json_error_with_query( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Deletion Failed: {}", e), + Some(&del_query), + ), + } +} + +// ============================================================================ +// Status Handler +// ============================================================================ + +pub async fn status_handler( + Query(params): Query, + State(state): State>, +) -> Response { + let store = state.store.read().unwrap(); + let mut volumes = Vec::new(); + + for loc in &store.locations { + for (_vid, vol) in loc.volumes() { + let mut vol_info = serde_json::Map::new(); + vol_info.insert("Id".to_string(), serde_json::Value::from(vol.id.0)); + vol_info.insert( + "Collection".to_string(), + serde_json::Value::from(vol.collection.clone()), + ); + vol_info.insert( + "Size".to_string(), + serde_json::Value::from(vol.content_size()), + ); + vol_info.insert( + "FileCount".to_string(), + serde_json::Value::from(vol.file_count()), + ); + vol_info.insert( + "DeleteCount".to_string(), + serde_json::Value::from(vol.deleted_count()), + ); + vol_info.insert( + "DeletedByteCount".to_string(), + serde_json::Value::from(vol.deleted_size()), + ); + vol_info.insert( + "ReadOnly".to_string(), + serde_json::Value::from(vol.is_read_only()), + ); + vol_info.insert( + "Version".to_string(), + serde_json::Value::from(vol.version().0), + ); + vol_info.insert( + "CompactRevision".to_string(), + serde_json::Value::from(vol.super_block.compaction_revision), + ); + vol_info.insert( + "ModifiedAtSecond".to_string(), + serde_json::Value::from(vol.last_modified_ts()), + ); + vol_info.insert( + "DiskType".to_string(), + serde_json::Value::from(loc.disk_type.to_string()), + ); + + let replica = &vol.super_block.replica_placement; + let mut replica_value = serde_json::Map::new(); + if replica.diff_data_center_count > 0 { + replica_value.insert( + "dc".to_string(), + serde_json::Value::from(replica.diff_data_center_count), + ); + } + if replica.diff_rack_count > 0 { + replica_value.insert( + "rack".to_string(), + serde_json::Value::from(replica.diff_rack_count), + ); + } + if replica.same_rack_count > 0 { + replica_value.insert( + "node".to_string(), + serde_json::Value::from(replica.same_rack_count), + ); + } + vol_info.insert( + "ReplicaPlacement".to_string(), + serde_json::Value::Object(replica_value), + ); + + let ttl = vol.super_block.ttl; + let mut ttl_value = serde_json::Map::new(); + if ttl.count > 0 { + ttl_value.insert("Count".to_string(), serde_json::Value::from(ttl.count)); + } + if ttl.unit > 0 { + ttl_value.insert("Unit".to_string(), serde_json::Value::from(ttl.unit)); + } + vol_info.insert("Ttl".to_string(), serde_json::Value::Object(ttl_value)); + + let (remote_storage_name, remote_storage_key) = vol.remote_storage_name_key(); + vol_info.insert( + "RemoteStorageName".to_string(), + serde_json::Value::from(remote_storage_name), + ); + vol_info.insert( + "RemoteStorageKey".to_string(), + serde_json::Value::from(remote_storage_key), + ); + volumes.push(serde_json::Value::Object(vol_info)); + } + } + volumes.sort_by(|a, b| { + let left = a.get("Id").and_then(|v| v.as_u64()).unwrap_or_default(); + let right = b.get("Id").and_then(|v| v.as_u64()).unwrap_or_default(); + left.cmp(&right) + }); + + let mut m = serde_json::Map::new(); + m.insert( + "Version".to_string(), + serde_json::Value::from(crate::version::version()), + ); + m.insert("Volumes".to_string(), serde_json::Value::Array(volumes)); + m.insert( + "DiskStatuses".to_string(), + serde_json::Value::Array(build_disk_statuses(&store)), + ); + json_response_with_params(StatusCode::OK, &serde_json::Value::Object(m), Some(¶ms)) +} + +// ============================================================================ +// Health Check Handler +// ============================================================================ + +pub async fn healthz_handler(State(state): State>) -> Response { + // Go's healthzHandler returns only status codes with no body text. + let is_stopping = *state.is_stopping.read().unwrap(); + if is_stopping { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + // If not heartbeating, return 503 (matches Go health check behavior) + if !state.is_heartbeating.load(Ordering::Relaxed) { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + // A server with quarantined local replicas has faulty storage media; + // report degraded so a load balancer can drain it. + if state.store.read().unwrap().has_io_quarantine() { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + StatusCode::OK.into_response() +} + +// ============================================================================ +// Metrics Handler +// ============================================================================ + +pub async fn metrics_handler() -> Response { + let body = metrics::gather_metrics(); + ( + StatusCode::OK, + [( + header::CONTENT_TYPE, + "text/plain; version=0.0.4; charset=utf-8", + )], + body, + ) + .into_response() +} + +// ============================================================================ +// Stats Handlers +// ============================================================================ + +pub async fn stats_counter_handler(Query(params): Query) -> Response { + let payload = serde_json::json!({ + "Version": crate::version::version(), + "Counters": super::server_stats::snapshot(), + }); + json_response_with_params(StatusCode::OK, &payload, Some(¶ms)) +} + +pub async fn stats_memory_handler(Query(params): Query) -> Response { + let mem = super::memory_status::collect_mem_status(); + let payload = serde_json::json!({ + "Version": crate::version::version(), + "Memory": { + "goroutines": mem.goroutines, + "all": mem.all, + "used": mem.used, + "free": mem.free, + "self": mem.self_, + "heap": mem.heap, + "stack": mem.stack, + }, + }); + json_response_with_params(StatusCode::OK, &payload, Some(¶ms)) +} + +pub async fn stats_disk_handler( + Query(params): Query, + State(state): State>, +) -> Response { + let store = state.store.read().unwrap(); + let payload = serde_json::json!({ + "Version": crate::version::version(), + "DiskStatuses": build_disk_statuses(&store), + }); + json_response_with_params(StatusCode::OK, &payload, Some(¶ms)) +} + +// ============================================================================ +// Static Asset Handlers +// ============================================================================ + +pub async fn favicon_handler() -> Response { + let asset = super::ui::favicon_asset(); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, asset.content_type)], + asset.bytes, + ) + .into_response() +} + +pub async fn static_asset_handler(Path(path): Path) -> Response { + match super::ui::lookup_static_asset(&path) { + Some(asset) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, asset.content_type)], + asset.bytes, + ) + .into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + +pub async fn ui_handler(State(state): State>) -> Response { + let html = super::ui::render_volume_server_html(&state); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + html, + ) + .into_response() +} + +// ============================================================================ +// Chunk Manifest +// ============================================================================ + +#[derive(Deserialize)] +struct ChunkManifest { + #[serde(default)] + name: String, + #[serde(default)] + mime: String, + #[serde(default)] + size: i64, + #[serde(default)] + chunks: Vec, +} + +#[derive(Deserialize)] +struct ChunkInfo { + fid: String, + offset: i64, + size: i64, +} + +/// Try to expand a chunk manifest needle. Returns None if manifest can't be parsed. +async fn try_expand_chunk_manifest( + state: &Arc, + n: &Needle, + method: &Method, + path: &str, + query: &ReadQueryParams, + etag: &str, + last_modified_str: &Option, +) -> Option { + let data = if n.is_compressed() { + match maybe_decompress_gzip(&n.data) { + Ok(d) => d, + Err(GunzipError::TooLarge) => { + return Some( + ( + StatusCode::PAYLOAD_TOO_LARGE, + "compressed manifest exceeds decompression limit", + ) + .into_response(), + ); + } + Err(GunzipError::Decode) => return None, + } + } else { + n.data.clone() + }; + + let manifest: ChunkManifest = match serde_json::from_slice(&data) { + Ok(m) => m, + Err(_) => return None, + }; + + // Guard the attacker-controlled manifest size before allocating: a negative + // value would wrap to a huge usize (capacity-overflow panic) and an oversized + // one would OOM-kill the server. + if manifest.size < 0 || manifest.size as u64 > MAX_EXPANSION_BYTES { + return None; + } + + // Read and concatenate all chunks. Each chunk is resolved to wherever it + // lives — a local regular volume, a local EC volume (reconstruct-on-read), + // or a peer via master lookup — mirroring Go's ChunkedFileReader, which + // never assumes chunks are local regular needles. + let mut result = vec![0u8; manifest.size as usize]; + for chunk in &manifest.chunks { + // Validate the attacker-controlled chunk offset before indexing: a + // negative value would wrap to a huge usize, and an out-of-range one has + // nowhere to land. + if chunk.offset < 0 || chunk.size < 0 { + return Some( + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("invalid negative chunk offset/size in {}", chunk.fid), + ) + .into_response(), + ); + } + let data = match read_chunk_needle(state, &chunk.fid).await { + Ok(d) => d, + Err(e) => { + return Some( + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("read chunk {}: {}", chunk.fid, e), + ) + .into_response(), + ); + } + }; + let offset = chunk.offset as usize; + if offset >= result.len() { + continue; + } + // Clamp to the chunk's declared size so an over-long chunk can't bleed + // into the next chunk's window; also drop bytes past the buffer end. + let bound = (chunk.size as usize).min(result.len() - offset); + let copy_len = data.len().min(bound); + result[offset..offset + copy_len].copy_from_slice(&data[..copy_len]); + } + + // Determine filename: URL path filename, then manifest name + // (Go's tryHandleChunkedFile does NOT fall back to needle name) + let mut filename = extract_filename_from_path(path); + if filename.is_empty() && !manifest.name.is_empty() { + filename = manifest.name.clone(); + } + + // Determine MIME type: manifest mime, but fall back to extension detection + // if empty or application/octet-stream (matching Go behavior) + let content_type = { + let mime_str = &manifest.mime; + if !mime_str.is_empty() && !mime_str.starts_with("application/octet-stream") { + mime_str.clone() + } else { + // Try to detect from filename extension + let ext = if !filename.is_empty() { + if let Some(dot_pos) = filename.rfind('.') { + filename[dot_pos..].to_lowercase() + } else { + String::new() + } + } else { + String::new() + }; + if !ext.is_empty() { + mime_guess::from_ext(ext.trim_start_matches('.')) + .first() + .map(|m| m.to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()) + } else if !mime_str.is_empty() { + mime_str.clone() + } else { + "application/octet-stream".to_string() + } + } + }; + + let mut response_headers = HeaderMap::new(); + // Preserve ETag from the needle (matches Go: ETag is set before tryHandleChunkedFile) + if let Ok(etag_val) = etag.parse() { + response_headers.insert(header::ETAG, etag_val); + } + response_headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap()); + response_headers.insert("X-File-Store", "chunked".parse().unwrap()); + response_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap()); + + // Last-Modified — Go sets this on the response writer before tryHandleChunkedFile + if let Some(lm) = last_modified_str + && let Ok(hval) = lm.parse() + { + response_headers.insert(header::LAST_MODIFIED, hval); + } + + // Pairs — Go sets needle pairs on the response writer before tryHandleChunkedFile + if n.has_pairs() + && !n.pairs.is_empty() + && let Ok(pair_map) = + serde_json::from_slice::>(&n.pairs) + { + for (k, v) in &pair_map { + if let (Ok(hname), Ok(hval)) = ( + axum::http::HeaderName::from_bytes(k.as_bytes()), + axum::http::HeaderValue::from_str(v), + ) { + response_headers.insert(hname, hval); + } + } + } + + // S3 response passthrough headers — Go sets these via AdjustPassthroughHeaders + if let Some(ref cc) = query.response_cache_control + && let Ok(hval) = cc.parse() + { + response_headers.insert(header::CACHE_CONTROL, hval); + } + if let Some(ref ce) = query.response_content_encoding + && let Ok(hval) = ce.parse() + { + response_headers.insert(header::CONTENT_ENCODING, hval); + } + if let Some(ref exp) = query.response_expires + && let Ok(hval) = exp.parse() + { + response_headers.insert(header::EXPIRES, hval); + } + if let Some(ref cl) = query.response_content_language + && let Ok(hval) = cl.parse() + { + response_headers.insert("Content-Language", hval); + } + if let Some(ref cd) = query.response_content_disposition + && let Ok(hval) = cd.parse() + { + response_headers.insert(header::CONTENT_DISPOSITION, hval); + } + + // Content-Disposition + if !filename.is_empty() { + let disposition_type = if let Some(ref dl_val) = query.dl { + if parse_go_bool(dl_val).unwrap_or(false) { + "attachment" + } else { + "inline" + } + } else { + "inline" + }; + let disposition = format_content_disposition(disposition_type, &filename); + if let Ok(hval) = disposition.parse() { + response_headers.insert(header::CONTENT_DISPOSITION, hval); + } + } + + // Go's tryHandleChunkedFile applies crop then resize to expanded chunk data + // (L344-345: conditionallyCropImages, conditionallyResizeImages). + let cm_ext = if !filename.is_empty() { + if let Some(dot_pos) = filename.rfind('.') { + filename[dot_pos..].to_lowercase() + } else { + String::new() + } + } else { + String::new() + }; + if is_image_crop_ext(&cm_ext) { + result = maybe_crop_image(&result, &cm_ext, query); + } + if is_image_resize_ext(&cm_ext) { + result = maybe_resize_image(&result, &cm_ext, query); + } + + if *method == Method::HEAD { + response_headers.insert( + header::CONTENT_LENGTH, + result.len().to_string().parse().unwrap(), + ); + return Some((StatusCode::OK, response_headers).into_response()); + } + + Some((StatusCode::OK, response_headers, result).into_response()) +} + +/// Read one chunk-manifest chunk's final (decompressed) content bytes from +/// wherever it lives: a local regular volume, a local EC volume +/// (reconstruct-on-read from surviving shards), or a peer resolved via the +/// master. Mirrors Go's ChunkedFileReader, which looks every chunk up through +/// the master instead of assuming a local regular needle. +async fn read_chunk_needle(state: &Arc, fid: &str) -> Result, String> { + let (vid, nid, cookie) = + parse_url_path(fid).ok_or_else(|| format!("invalid chunk fid: {}", fid))?; + + // Decide where the chunk lives under one store read lock; drop it before any + // await (the EC and remote paths are async). + enum Placement { + Ec, + Remote, + } + let placement = { + let store = state.store.read().unwrap(); + if store.find_volume(vid).is_some() { + let mut n = Needle { + id: nid, + cookie, + ..Needle::default() + }; + return store + .read_volume_needle(vid, &mut n) + .map_err(|e| format!("{}", e)) + .and_then(|_| cookie_checked_chunk(n, cookie)); + } else if store.find_ec_volume(vid).is_some() { + Placement::Ec + } else { + Placement::Remote + } + }; + + match placement { + Placement::Ec => { + match crate::server::store_ec::read_ec_shard_needle_distributed(state, vid, nid).await { + Ok(Some(n)) => cookie_checked_chunk(n, cookie), + Ok(None) => Err("not found".to_string()), + Err(e) => Err(format!("{}", e)), + } + } + // The peer serves through its own GET handler, which validates the cookie. + Placement::Remote => read_remote_chunk_needle(state, vid, fid).await, + } +} + +/// Validate a locally-read chunk's cookie against the one in its fid, then return +/// its content bytes. The main GET paths check the cookie after a read; a chunk +/// read must do the same so a stale/guessed id can't serve another needle's data. +fn cookie_checked_chunk(n: Needle, cookie: Cookie) -> Result, String> { + if n.cookie != cookie { + return Err("not found".to_string()); + } + decompress_chunk(n) +} + +/// Return a needle's content bytes, decompressing gzip payloads the way the read +/// handler does for a client that did not ask for gzip. +fn decompress_chunk(n: Needle) -> Result, String> { + if n.is_compressed() { + match maybe_decompress_gzip(&n.data) { + Ok(d) => Ok(d), + Err(GunzipError::TooLarge) => { + Err("compressed chunk exceeds decompression limit".to_string()) + } + // Not valid gzip; keep the raw bytes, matching the prior fallback. + Err(GunzipError::Decode) => Ok(n.data), + } + } else { + Ok(n.data) + } +} + +/// Fetch a chunk that is not hosted locally from a peer volume server. The peer +/// serves the final (decompressed) bytes whether the chunk is on a regular or EC +/// volume, so a chunk whose EC shards live elsewhere is still reconstructed on +/// the holder's side. Mirrors Go's ChunkedFileReader.readChunkNeedle. +async fn read_remote_chunk_needle( + state: &Arc, + vid: VolumeId, + fid: &str, +) -> Result, String> { + let locations = lookup_volume( + &state.http_client, + &state.outgoing_http_scheme, + &state.master_url, + vid.0, + ) + .await?; + if locations.is_empty() { + return Err("not found".to_string()); + } + + let mut last_err = String::new(); + for loc in &locations { + // Skip self: the local paths already ruled this server out. + if loc.url.contains(&state.self_url) { + continue; + } + let target_http = to_http_address(&loc.url); + let url = match normalize_outgoing_http_url( + &state.outgoing_http_scheme, + &format!("{}/{}?proxied=true", target_http, fid), + ) { + Ok(u) => u, + Err(e) => { + last_err = e; + continue; + } + }; + match state.http_client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => match resp.bytes().await { + Ok(b) => return Ok(b.to_vec()), + Err(e) => last_err = format!("read body from {}: {}", url, e), + }, + Ok(resp) => last_err = format!("{} returned {}", url, resp.status()), + Err(e) => last_err = format!("request to {} failed: {}", url, e), + } + } + Err(if last_err.is_empty() { + "not found".to_string() + } else { + last_err + }) +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn absolute_display_path(path: &str) -> String { + let p = std::path::Path::new(path); + if p.is_absolute() { + return path.to_string(); + } + std::env::current_dir() + .map(|cwd| cwd.join(p).to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string()) +} + +fn build_disk_statuses(store: &crate::storage::store::Store) -> Vec { + let mut disk_statuses = Vec::new(); + for loc in &store.locations { + let resolved_dir = absolute_display_path(&loc.directory); + let (all, free) = crate::storage::disk_location::get_disk_stats(&resolved_dir); + let used = all.saturating_sub(free); + let percent_free = if all > 0 { + (free as f64 / all as f64) * 100.0 + } else { + 0.0 + }; + let percent_used = if all > 0 { + (used as f64 / all as f64) * 100.0 + } else { + 0.0 + }; + + // Match Go encoding/json on protobuf struct (snake_case json tags) + disk_statuses.push(serde_json::json!({ + "dir": resolved_dir, + "all": all, + "used": used, + "free": free, + "percent_free": percent_free, + "percent_used": percent_used, + "disk_type": loc.disk_type.to_string(), + })); + } + disk_statuses +} + +/// Serialize to JSON with 1-space indent (matches Go's `json.MarshalIndent(obj, "", " ")`). +fn to_pretty_json(value: &T) -> String { + let mut buf = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + value.serialize(&mut ser).unwrap(); + String::from_utf8(buf).unwrap() +} + +fn json_response_with_params( + status: StatusCode, + body: &T, + params: Option<&ReadQueryParams>, +) -> Response { + let is_pretty = params + .and_then(|params| params.pretty.as_ref()) + .is_some_and(|value| !value.is_empty()); + + let json_body = if is_pretty { + to_pretty_json(body) + } else { + serde_json::to_string(body).unwrap() + }; + + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .header("X-Content-Type-Options", "nosniff") + .body(Body::from(json_body)) + .unwrap() +} + +/// Return a JSON error response, honoring `?pretty=` for pretty-printed JSON. +pub(super) fn json_error_with_query( + status: StatusCode, + msg: impl Into, + query: Option<&str>, +) -> Response { + let body = serde_json::json!({"error": msg.into()}); + + let is_pretty = query.is_some_and(|q| { + q.split('&') + .any(|p| p.starts_with("pretty=") && p.len() > "pretty=".len()) + }); + + let json_body = if is_pretty { + to_pretty_json(&body) + } else { + serde_json::to_string(&body).unwrap() + }; + + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .header("X-Content-Type-Options", "nosniff") + .body(Body::from(json_body)) + .unwrap() +} + +/// Return a JSON response honoring `?pretty=` from a raw query string. +fn json_result_with_query(status: StatusCode, body: &T, query: &str) -> Response { + let is_pretty = query + .split('&') + .any(|p| p.starts_with("pretty=") && p.len() > "pretty=".len()); + + let json_body = if is_pretty { + to_pretty_json(body) + } else { + serde_json::to_string(body).unwrap() + }; + + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .header("X-Content-Type-Options", "nosniff") + .body(Body::from(json_body)) + .unwrap() +} + +/// Extract JWT token from query param, Authorization header, or Cookie. +/// Query param takes precedence over header, header over cookie. +fn extract_jwt(headers: &HeaderMap, uri: &axum::http::Uri) -> Option { + // 1. Check ?jwt= query parameter + if let Some(query) = uri.query() { + for pair in query.split('&') { + if let Some(value) = pair.strip_prefix("jwt=") + && !value.is_empty() + { + return Some(value.to_string()); + } + } + } + + // 2. Check Authorization: Bearer (case-insensitive prefix) + if let Some(auth) = headers.get(header::AUTHORIZATION) + && let Ok(auth_str) = auth.to_str() + && auth_str.len() > 7 + && auth_str[..7].eq_ignore_ascii_case("bearer ") + { + return Some(auth_str[7..].to_string()); + } + + // 3. Check Cookie + if let Some(cookie_header) = headers.get(header::COOKIE) + && let Ok(cookie_str) = cookie_header.to_str() + { + for cookie in cookie_str.split(';') { + let cookie = cookie.trim(); + if let Some(value) = cookie.strip_prefix("AT=") + && !value.is_empty() + { + return Some(value.to_string()); + } + } + } + + None +} + +// ============================================================================ +// Auto-compression helpers (matches Go's util.IsCompressableFileType) +// ============================================================================ + +/// Check if a file type should be compressed based on extension and MIME type. +/// Returns true only when we are sure the type is compressible. +fn is_compressible_file_type(ext: &str, mtype: &str) -> bool { + // text/* + if mtype.starts_with("text/") { + return true; + } + // Compressible image/audio formats + match ext { + ".svg" | ".bmp" | ".wav" => return true, + _ => {} + } + // Most image/* formats are already compressed + if mtype.starts_with("image/") { + return false; + } + // By file extension + match ext { + ".zip" | ".rar" | ".gz" | ".bz2" | ".xz" | ".zst" | ".br" => return false, + ".pdf" | ".txt" | ".html" | ".htm" | ".css" | ".js" | ".json" => return true, + ".php" | ".java" | ".go" | ".rb" | ".c" | ".cpp" | ".h" | ".hpp" => return true, + ".png" | ".jpg" | ".jpeg" => return false, + _ => {} + } + // By MIME type + if mtype.starts_with("application/") { + if mtype.ends_with("zstd") { + return false; + } + if mtype.ends_with("xml") { + return true; + } + if mtype.ends_with("script") { + return true; + } + if mtype.ends_with("vnd.rar") { + return false; + } + } + if mtype.starts_with("audio/") { + let sub = mtype.strip_prefix("audio/").unwrap_or(""); + if matches!(sub, "wave" | "wav" | "x-wav" | "x-pn-wav") { + return true; + } + } + false +} + +/// Try to gzip data. Returns None on error. +fn try_gzip_data(data: &[u8]) -> Option> { + use flate2::Compression; + use flate2::write::GzEncoder; + use std::io::Write; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(data).ok()?; + encoder.finish().ok() +} + +fn maybe_decompress_gzip(data: &[u8]) -> Result, GunzipError> { + use flate2::read::GzDecoder; + use std::io::Read; + // Cap the output so a crafted, highly-compressible (gzip-bomb) needle cannot + // OOM the server when decompressed on read or upload. take(limit+1) lets us + // tell "exactly at the limit" apart from "over it", which we reject as + // TooLarge so callers fail the request instead of silently using raw bytes. + let mut decoder = GzDecoder::new(data).take(MAX_EXPANSION_BYTES + 1); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .map_err(|_| GunzipError::Decode)?; + if decompressed.len() as u64 > MAX_EXPANSION_BYTES { + return Err(GunzipError::TooLarge); + } + Ok(decompressed) +} + +fn compute_md5_base64(data: &[u8]) -> String { + use base64::Engine; + use md5::{Digest, Md5}; + let mut hasher = Md5::new(); + hasher.update(data); + base64::engine::general_purpose::STANDARD.encode(hasher.finalize()) +} + +fn clean_windows_path_base(value: &str) -> String { + let cleaned = value.replace('\\', "/"); + cleaned.rsplit('/').next().unwrap_or(&cleaned).to_string() +} + +fn parse_content_disposition_filename(value: &str) -> Option { + let mut filename: Option = None; + let mut name: Option = None; + + for segment in value.split(';') { + let segment = segment.trim(); + if segment.is_empty() { + continue; + } + let lower = segment.to_ascii_lowercase(); + if lower.starts_with("filename=") { + let raw = segment[9..].trim(); + let trimmed = raw + .strip_prefix('\"') + .and_then(|s| s.strip_suffix('\"')) + .unwrap_or(raw); + filename = Some(clean_windows_path_base(trimmed)); + } else if lower.starts_with("name=") { + let raw = segment[5..].trim(); + let trimmed = raw + .strip_prefix('\"') + .and_then(|s| s.strip_suffix('\"')) + .unwrap_or(raw); + name = Some(clean_windows_path_base(trimmed)); + } + } + + let candidate = filename.or(name); + candidate.filter(|s| !s.is_empty()) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + /// The upload handler reads fsync off the decoded query fields rather than + /// matching the raw string, because Go's r.FormValue decodes and a raw + /// match would silently drop a percent-encoded value. + #[test] + fn test_encoded_query_field_decodes() { + let raw = "fsync=%74rue"; + assert!( + !raw.split('&').any(|p| p == "fsync=true"), + "a raw match is exactly what misses this" + ); + let fields: Vec<(String, String)> = serde_urlencoded::from_str(raw).unwrap(); + assert_eq!( + fields + .iter() + .find(|(k, _)| k == "fsync") + .map(|(_, v)| v.as_str()), + Some("true") + ); + } + + #[test] + fn test_parse_url_path_comma() { + let (vid, nid, cookie) = parse_url_path("/3,01637037d6").unwrap(); + assert_eq!(vid, VolumeId(3)); + assert_eq!(nid, NeedleId(0x01)); + assert_eq!(cookie, Cookie(0x637037d6)); + } + + #[test] + fn test_parse_url_path_with_ext() { + let (vid, _, _) = parse_url_path("/3,01637037d6.jpg").unwrap(); + assert_eq!(vid, VolumeId(3)); + } + + #[test] + fn test_parse_url_path_slash() { + let result = parse_url_path("3/01637037d6"); + assert!(result.is_some()); + } + + #[test] + fn test_parse_url_path_slash_with_filename() { + let result = parse_url_path("3/01637037d6/report.txt"); + assert!(result.is_some()); + let (vid, _, _) = result.unwrap(); + assert_eq!(vid, VolumeId(3)); + } + + #[test] + fn test_parse_url_path_invalid() { + assert!(parse_url_path("/invalid").is_none()); + assert!(parse_url_path("").is_none()); + } + + #[test] + fn test_parse_range_header_no_overlap() { + assert_eq!( + parse_range_header("bytes=10-", 10).unwrap_err(), + RANGE_NO_OVERLAP + ); + assert_eq!( + parse_range_header("bytes=100-", 10).unwrap_err(), + RANGE_NO_OVERLAP + ); + // 416 only when every range fails to overlap + let ranges = parse_range_header("bytes=10-,0-1", 10).unwrap(); + assert_eq!(ranges.len(), 1); + assert_eq!((ranges[0].start, ranges[0].length), (0, 2)); + // an end past the size is clamped, still satisfiable + let ranges = parse_range_header("bytes=5-100", 10).unwrap(); + assert_eq!((ranges[0].start, ranges[0].length), (5, 5)); + } + + #[test] + fn test_extract_jwt_bearer() { + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, "Bearer abc123".parse().unwrap()); + let uri: axum::http::Uri = "/test".parse().unwrap(); + assert_eq!(extract_jwt(&headers, &uri), Some("abc123".to_string())); + } + + #[test] + fn test_extract_jwt_query_param() { + let headers = HeaderMap::new(); + let uri: axum::http::Uri = "/test?jwt=mytoken".parse().unwrap(); + assert_eq!(extract_jwt(&headers, &uri), Some("mytoken".to_string())); + } + + #[test] + fn test_extract_jwt_query_over_header() { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + "Bearer header_token".parse().unwrap(), + ); + let uri: axum::http::Uri = "/test?jwt=query_token".parse().unwrap(); + assert_eq!(extract_jwt(&headers, &uri), Some("query_token".to_string())); + } + + #[test] + fn test_extract_jwt_none() { + let headers = HeaderMap::new(); + let uri: axum::http::Uri = "/test".parse().unwrap(); + assert_eq!(extract_jwt(&headers, &uri), None); + } + + #[test] + fn test_handle_range_single() { + let data = b"hello world"; + let headers = HeaderMap::new(); + let resp = handle_range_request("bytes=0-4", data, headers, None); + assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); + } + + #[test] + fn test_handle_range_invalid() { + let data = b"hello"; + let headers = HeaderMap::new(); + let resp = handle_range_request("bytes=999-1000", data, headers, None); + assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE); + } + + #[tokio::test] + async fn test_stats_memory_handler_matches_go_memstatus_shape() { + let response = stats_memory_handler(Query(ReadQueryParams::default())).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let memory = payload.get("Memory").unwrap(); + + for key in ["goroutines", "all", "used", "free", "self", "heap", "stack"] { + assert!(memory.get(key).is_some(), "missing key {}", key); + } + } + + #[tokio::test] + async fn test_stats_counter_handler_matches_go_json_shape() { + super::super::server_stats::reset_for_tests(); + super::super::server_stats::record_read_request(); + + let response = stats_counter_handler(Query(ReadQueryParams::default())).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!( + payload.get("Version").and_then(|value| value.as_str()), + Some(crate::version::version()) + ); + let counters = payload.get("Counters").unwrap(); + assert!(counters.get("ReadRequests").is_some()); + assert!(counters.get("Requests").is_some()); + } + + #[test] + fn test_is_compressible_file_type() { + // Text types + assert!(is_compressible_file_type("", "text/html")); + assert!(is_compressible_file_type("", "text/plain")); + assert!(is_compressible_file_type("", "text/css")); + + // Compressible by extension + assert!(is_compressible_file_type(".svg", "")); + assert!(is_compressible_file_type(".bmp", "")); + assert!(is_compressible_file_type(".js", "")); + assert!(is_compressible_file_type(".json", "")); + assert!(is_compressible_file_type(".html", "")); + assert!(is_compressible_file_type(".css", "")); + assert!(is_compressible_file_type(".c", "")); + assert!(is_compressible_file_type(".go", "")); + + // Already compressed — should NOT compress + assert!(!is_compressible_file_type(".zip", "")); + assert!(!is_compressible_file_type(".gz", "")); + assert!(!is_compressible_file_type(".jpg", "")); + assert!(!is_compressible_file_type(".png", "")); + assert!(!is_compressible_file_type("", "image/jpeg")); + assert!(!is_compressible_file_type("", "image/png")); + + // Application subtypes + assert!(is_compressible_file_type("", "application/xml")); + assert!(is_compressible_file_type("", "application/javascript")); + assert!(!is_compressible_file_type("", "application/zstd")); + assert!(!is_compressible_file_type("", "application/vnd.rar")); + + // Audio + assert!(is_compressible_file_type(".wav", "audio/wav")); + assert!(!is_compressible_file_type("", "audio/mpeg")); + + // Unknown + assert!(!is_compressible_file_type( + ".xyz", + "application/octet-stream" + )); + } + + #[test] + fn test_try_gzip_data() { + let data = b"hello world hello world hello world"; + let compressed = try_gzip_data(data); + assert!(compressed.is_some()); + let compressed = compressed.unwrap(); + // Compressed data should be different from original + assert!(!compressed.is_empty()); + + // Verify we can decompress it + use flate2::read::GzDecoder; + use std::io::Read; + let mut decoder = GzDecoder::new(&compressed[..]); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed).unwrap(); + assert_eq!(decompressed, data); + } + + #[test] + fn test_maybe_decompress_gzip() { + let data = b"gzip me"; + let compressed = try_gzip_data(data).unwrap(); + let decompressed = maybe_decompress_gzip(&compressed).unwrap(); + assert_eq!(decompressed, data); + // Non-gzip input is reported as a decode error (callers fall back to raw). + assert!(matches!( + maybe_decompress_gzip(data), + Err(GunzipError::Decode) + )); + } + + #[test] + fn test_parse_content_disposition_filename() { + assert_eq!( + parse_content_disposition_filename("attachment; filename=\"report.txt\""), + Some("report.txt".to_string()) + ); + assert_eq!( + parse_content_disposition_filename("inline; name=\"hello.txt\""), + Some("hello.txt".to_string()) + ); + assert_eq!( + parse_content_disposition_filename("name=foo.txt"), + Some("foo.txt".to_string()) + ); + assert_eq!( + parse_content_disposition_filename("attachment; filename=\"C:\\\\path\\\\file.jpg\""), + Some("file.jpg".to_string()) + ); + assert_eq!(parse_content_disposition_filename("inline"), None); + } + + #[test] + fn test_streaming_chunk_size_respects_configured_read_buffer() { + assert_eq!( + streaming_chunk_size(4 * 1024 * 1024, 8 * 1024 * 1024), + 4 * 1024 * 1024 + ); + assert_eq!( + streaming_chunk_size(32 * 1024, 512 * 1024), + DEFAULT_STREAMING_CHUNK_SIZE + ); + assert_eq!( + streaming_chunk_size(8 * 1024 * 1024, 128 * 1024), + 128 * 1024 + ); + } + + #[test] + fn test_normalize_outgoing_http_url_rewrites_scheme() { + let url = normalize_outgoing_http_url( + "https", + "http://master.example.com:9333/dir/lookup?volumeId=7", + ) + .unwrap(); + assert_eq!(url, "https://master.example.com:9333/dir/lookup?volumeId=7"); + } + + #[test] + fn test_redirect_request_uses_outgoing_http_scheme() { + let info = ProxyRequestInfo { + original_headers: HeaderMap::new(), + original_query: "collection=photos&readDeleted=true".to_string(), + path: "/3,01637037d6".to_string(), + vid_str: "3".to_string(), + fid_str: "01637037d6".to_string(), + }; + let target = VolumeLocation { + url: "volume.internal:8080".to_string(), + public_url: "volume.public:8080".to_string(), + grpc_port: 18080, + read_only: false, + read_only_can_delete: false, + }; + + let response = redirect_request(&info, &target, "https"); + assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "https://volume.internal:8080/3,01637037d6?collection=photos&proxied=true" + ); + } + + /// Defensive coverage for peer-to-peer redirects: if a master ever returns + /// `Location.url` already encoded in `host:httpPort.grpcPort` form, + /// `redirect_request` must still emit a valid HTTP Location header. + #[test] + fn test_redirect_request_strips_grpc_port_from_target_url() { + let info = ProxyRequestInfo { + original_headers: HeaderMap::new(), + original_query: String::new(), + path: "/3,01637037d6".to_string(), + vid_str: "3".to_string(), + fid_str: "01637037d6".to_string(), + }; + let target = VolumeLocation { + url: "volume.internal:8080.18080".to_string(), + public_url: "volume.public:8080.18080".to_string(), + grpc_port: 18080, + read_only: false, + read_only_can_delete: false, + }; + + let response = redirect_request(&info, &target, "http"); + assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "http://volume.internal:8080/3,01637037d6?proxied=true" + ); + } + + /// Master /dir/lookup often omits publicUrl when empty (Go `json:"publicUrl,omitempty"`). + /// Replication must still parse locations or every cross-DC write fails. + #[test] + fn test_lookup_result_deserializes_without_public_url() { + let body = r#"{ + "volumeOrFileId": "9", + "locations": [ + {"url": "volume-a.example:8080", "dataCenter": "dc-a", "grpcPort": 18080}, + {"url": "volume-b.example:8080", "dataCenter": "dc-b", "grpcPort": 18080} + ] + }"#; + let result: LookupResult = serde_json::from_str(body).expect("parse lookup JSON"); + let locations = result.locations.expect("locations"); + assert_eq!(locations.len(), 2); + assert_eq!(locations[0].url, "volume-a.example:8080"); + assert!(locations[0].public_url.is_empty()); + assert_eq!(locations[0].public_or_url(), "volume-a.example:8080"); + assert_eq!(locations[0].grpc_port, 18080); + } + + /// Regression test for issue #9274. + /// + /// SeaweedFS encodes a server's gRPC port by appending `.grpcPort` to + /// the HTTP port: e.g. `master.local:9333.19333` (mirrors Go's + /// `pb.ServerAddress`). When the volume server's `--mserver` flag is + /// configured this way, replication writes look up the peer locations + /// from the master over HTTP. Prior to the fix, the lookup URL became + /// `http://host:9333.19333/dir/lookup?volumeId=...` — an invalid port + /// that reqwest rejects with a "builder error", causing every + /// replicated write to fail. + #[tokio::test] + async fn test_lookup_volume_strips_grpc_port_from_master_url() { + use axum::{Router, routing::get}; + + let app = Router::new().route( + "/dir/lookup", + get( + |axum::extract::Query(params): axum::extract::Query< + std::collections::HashMap, + >| async move { + assert_eq!(params.get("volumeId").map(String::as_str), Some("31")); + axum::Json(serde_json::json!({ + "volumeOrFileId": "31", + "locations": [ + {"url": "10.0.0.2:5301", "publicUrl": "10.0.0.2:5301", "grpcPort": 5311} + ] + })) + }, + ), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + // Mirrors how main.rs receives masters: a `host:httpPort.grpcPort` token. + // The grpc-port part is fictitious here (the master is HTTP-only in + // this test) — what matters is that the volume server must not pass + // it through to reqwest. + let master_url = format!("127.0.0.1:{}.{}", addr.port(), 19999); + let client = reqwest::Client::new(); + + let locations = super::lookup_volume(&client, "http", &master_url, 31) + .await + .expect("lookup_volume should succeed even when master_url carries a .grpcPort suffix"); + + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].url, "10.0.0.2:5301"); + assert_eq!(locations[0].grpc_port, 5311); + + server.abort(); + } +} diff --git a/seaweed-volume/src/storage/erasure_coding/ec_volume.rs b/seaweed-volume/src/storage/erasure_coding/ec_volume.rs new file mode 100644 index 0000000..aaec531 --- /dev/null +++ b/seaweed-volume/src/storage/erasure_coding/ec_volume.rs @@ -0,0 +1,4398 @@ +//! EcVolume: an erasure-coded volume with up to MAX_SHARD_COUNT shards. +//! +//! Each EcVolume has a sorted index (.ecx) and a deletion journal (.ecj). +//! Shards (.ec00-.ec13) may be distributed across multiple servers. + +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::sync::RwLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::pb::master_pb; +use crate::storage::erasure_coding::ec_locate; +use crate::storage::erasure_coding::ec_shard::*; +use crate::storage::needle::needle::{Needle, NeedleError, get_actual_size}; +use crate::storage::types::*; +use crate::storage::volume_open::open_volume_file; + +/// An erasure-coded volume managing its local shards and index. +pub const IO_ERROR_TOLERANCE: i32 = 3; + +pub struct EcVolume { + pub volume_id: VolumeId, + pub collection: String, + pub dir: String, + pub dir_idx: String, + pub version: Version, + pub shards: Vec>, // indexed by ShardId (0..MAX_SHARD_COUNT) + pub dat_file_size: i64, + pub data_shards: u32, + pub parity_shards: u32, + /// Uniform block layout: each shard is one contiguous block of this many + /// bytes. 0 = legacy 1GiB/1MiB two-tier layout. Loaded from .vif. + pub block_size: i64, + ecx_file: Option, + ecx_file_size: i64, + ecj_file: Option, + /// On-disk size of the .ecj deletion journal. Used only by IO helpers + /// (seek / set_len on partial writes) — the authoritative runtime + /// delete count comes from `deleted_needles.len()`. + ecj_file_size: i64, + /// In-memory set of needle ids that have been deleted since the volume + /// was encoded. .ecx is immutable at runtime — it only stores the + /// sorted (id, offset, size) index written at encode time — and runtime + /// deletes are journaled to .ecj + tracked here. Reads consult this + /// set to mask out deleted needles on top of the sealed .ecx lookup. + /// Seeded from .ecj in `new()` and updated by `journal_delete`. + deleted_needles: RwLock>, + pub disk_type: DiskType, + /// Directory where .ecx/.ecj were actually found (may differ from dir_idx after fallback). + ecx_actual_dir: String, + /// Maps shard ID -> list of server addresses where that shard exists. + /// Used for distributed EC reads across the cluster. Wrapped in + /// `RwLock` so the read path can refresh the map (under master + /// lookup) without holding the Store write lock — mirrors Go's + /// `ShardLocationsLock sync.RWMutex` in `weed/storage/erasure_coding/ec_volume.go`. + pub shard_locations: std::sync::RwLock>>, + /// Wall-clock timestamp of the most recent successful + /// `LookupEcVolume` refresh of `shard_locations`. `None` until the + /// first refresh. Drives the staleness heuristic in + /// `cached_lookup_ec_shard_locations` (mirrors Go's + /// `ShardLocationsRefreshTime`). + pub shard_locations_refresh_time: std::sync::Mutex>, + /// Marks the map for a prompt re-check: a read that failed against a cached + /// location has disproved what the map claims, and the normal freshness + /// window is far too long to serve from a map known to be wrong. Mirrors the + /// invalidation Go's `forgetShardId` performs. A mutex rather than an atomic + /// so the refresh can judge the map and consume the mark in one critical + /// section, and a mark raised meanwhile survives for the next refresh. + pub shard_locations_stale: std::sync::Mutex, + /// EC volume expiration time (unix epoch seconds), set during EC encode from TTL. + pub expire_at_sec: u64, + /// Encode-run identity (unix nanos) loaded from the .vif EcShardConfig. A read + /// served from a shard of a different encode run is rejected (server- and + /// client-side); 0 for a pre-feature volume, which is treated leniently. + pub encode_ts_ns: i64, + /// Active-generation EC bitrot checksum sidecar (`.ecsum`), loaded and + /// validated at mount. `None` unless `bitrot_status == On`. + pub(crate) bitrot: Option, + /// Resolved protection state of the loaded sidecar. Cached alongside `bitrot` + /// so `bitrot_protection()` can return the `Off`/`Invalid` distinction without + /// re-reading, mirroring Go's `EcVolume.bitrotStatus`. + pub(crate) bitrot_status: crate::storage::erasure_coding::ec_bitrot::BitrotStatus, + /// Directory the active sidecar was actually resolved from. The search in + /// `load_bitrot_for_generation` spans sibling disks, and a `.ecsum` records + /// no encode identity, so provenance is the only signal that a loaded + /// manifest may describe a different encode run. Same purpose as + /// `ecx_actual_dir`. Empty when no sidecar was found. Always committed in + /// the same step as `bitrot`/`bitrot_status`, so a reload whose `Err` gets + /// swallowed by `reload_bitrot_sidecar` cannot leave this describing a + /// sidecar other than the one those two fields actually hold. + pub(crate) bitrot_source_dir: String, + + io_error_count: std::sync::atomic::AtomicI32, + io_error_quarantined: std::sync::atomic::AtomicBool, + last_io_error: std::sync::Mutex>, +} + +/// Locate the `.vif` for a (collection, vid) by preferring the data dir +/// and falling back to the idx dir when it lives there instead. The +/// fallback covers the cross-disk reconcile path: when a volume's +/// shards live on one disk but its `.ecx` / `.ecj` / `.vif` live on a +/// sibling disk (seaweedfs/seaweedfs#9212 / #9244), we want to read the +/// real `.vif` from the sibling rather than write a stub on the shard +/// disk and lose the EC config + dat file size. +fn locate_vif_path(dir: &str, dir_idx: &str, collection: &str, volume_id: VolumeId) -> String { + let data_vif = format!( + "{}.vif", + crate::storage::volume::volume_file_name(dir, collection, volume_id), + ); + if dir_idx != dir && !std::path::Path::new(&data_vif).exists() { + let idx_vif = format!( + "{}.vif", + crate::storage::volume::volume_file_name(dir_idx, collection, volume_id), + ); + if std::path::Path::new(&idx_vif).exists() { + return idx_vif; + } + } + data_vif +} + +/// Load the volume's `.vif` (data dir first, then idx dir, then the active +/// generation's versioned sidecar — see [`locate_vif_path`]). Absent +/// everywhere is legal — legacy volumes predate the sidecar — and returns +/// `None`; so does a zero-byte stub (an ec.decode copy from a source +/// without one), mirroring Go's MaybeLoadVolumeInfo. A +/// present-but-unreadable or malformed `.vif` is an ERROR: silently +/// defaulting would mount a uniform-layout volume with legacy offset math +/// and serve wrong bytes with a straight face. +pub fn load_vif_info( + dir: &str, + dir_idx: &str, + collection: &str, + volume_id: VolumeId, +) -> io::Result> { + let vif_path = locate_vif_path(dir, dir_idx, collection, volume_id); + match std::fs::read_to_string(&vif_path) { + Ok(content) if content.trim().is_empty() => Ok(None), + Ok(content) => serde_json::from_str(&content).map(Some).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("parse {}: {}", vif_path, e), + ) + }), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(io::Error::new( + e.kind(), + format!("read {}: {}", vif_path, e), + )), + } +} + +/// Read the EC data/parity shard counts and shard block size from `.vif`, +/// defaulting to the build's standard ratio and the legacy block layout when +/// no `.vif` is present. A present-but-unreadable or malformed `.vif` +/// fails instead of defaulting (see [`load_vif_info`]). +/// Looks at the data dir first, then the idx dir — see [`locate_vif_path`]. +pub fn read_ec_shard_config( + dir: &str, + dir_idx: &str, + collection: &str, + volume_id: VolumeId, +) -> io::Result<(u32, u32, i64)> { + let vif = load_vif_info(dir, dir_idx, collection, volume_id)?; + ec_shard_config_from(vif.as_ref(), &[dir, dir_idx], collection, volume_id) +} + +/// Load the volume's `.vif` from the selected location or, failing that, from +/// any sibling disk — returning the directory it came from so the caller can +/// resolve the rest of the volume's metadata against the same place. A rebuild +/// picks one disk to write into, but a multi-disk server may hold that +/// volume's metadata on another. +pub fn load_vif_info_across_dirs( + dir: &str, + dir_idx: &str, + other_dirs: &[String], + collection: &str, + volume_id: VolumeId, +) -> io::Result> { + if let Some(vif) = load_vif_info(dir, dir_idx, collection, volume_id)? { + // Report where it actually came from. load_vif_info probes the data + // directory then the index directory, so naming `dir` unconditionally + // pointed callers at the wrong disk whenever the vif lived with the + // index — and a caller resolving the rest of the volume's metadata + // against that answer would look in a directory holding none of it. + let data_vif = format!( + "{}.vif", + crate::storage::volume::volume_file_name(dir, collection, volume_id) + ); + let found_in = if std::path::Path::new(&data_vif).exists() { + dir.to_string() + } else { + dir_idx.to_string() + }; + return Ok(Some((vif, found_in))); + } + for other in other_dirs { + if let Some(vif) = load_vif_info(other, other, collection, volume_id)? { + return Ok(Some((vif, other.clone()))); + } + } + Ok(None) +} + +/// [`read_ec_shard_config`] for a caller that may find the volume's metadata on +/// any of the server's disks. Without this a rebuild that lands on a disk +/// holding only shards reads the default 10+4 and the legacy block layout, +/// then reconstructs a custom-ratio or uniform volume through the wrong matrix +/// and de-striping geometry. +pub fn read_ec_shard_config_across_dirs( + dir: &str, + dir_idx: &str, + other_dirs: &[String], + collection: &str, + volume_id: VolumeId, +) -> io::Result<(u32, u32, i64)> { + // Every directory this volume's metadata could be in. A split -dir/-dir.idx + // location keeps .vif and .ecsum with the INDEX, and callers exclude their + // own index directory from other_dirs on the understanding that it is + // passed here separately — so it is chained explicitly, exactly as the .vif + // lookup and Go's findBitrotSidecar both do. + let mut candidates: Vec<&str> = Vec::with_capacity(2 + other_dirs.len()); + candidates.push(dir); + if !dir_idx.is_empty() && dir_idx != dir { + candidates.push(dir_idx); + } + candidates.extend(other_dirs.iter().map(|s| s.as_str())); + + // A vif that carries no ecShardConfig answers nothing about the layout, so + // it must NOT short-circuit the sidecar search: a legacy config-free vif + // and the generation-0 sidecar can sit in different directories, and + // stopping at the vif resolved a 12+4 uniform volume as 10+4 legacy. Pass + // the whole candidate list so the fallback covers the same ground the vif + // lookup did. + let vif = load_vif_info_across_dirs(dir, dir_idx, other_dirs, collection, volume_id)?; + ec_shard_config_from( + vif.as_ref().map(|(v, _)| v), + &candidates, + collection, + volume_id, + ) +} + +/// Resolve (data_shards, parity_shards, block_size) from an already-loaded +/// `.vif`. With no vif — or one carrying no EC config — the bitrot sidecar +/// records the same config at encode time and answers the layout question the +/// vif cannot: defaulting a uniform-layout volume to the legacy block sizes +/// maps every read to the wrong shard offset. `weed fix -ecx` reads the +/// sidecar for the same reason. Absent both, the build's standard ratio and +/// the legacy layout. +pub fn ec_shard_config_from( + vif: Option<&crate::storage::volume::VifVolumeInfo>, + dirs: &[&str], + collection: &str, + volume_id: VolumeId, +) -> io::Result<(u32, u32, i64)> { + let default_ds = crate::storage::erasure_coding::ec_shard::DATA_SHARDS_COUNT as u32; + let default_ps = crate::storage::erasure_coding::ec_shard::PARITY_SHARDS_COUNT as u32; + // Sum as u64: counts near the u32 ceiling would wrap and pass the bound. + let usable = + |ds: u32, ps: u32| ds > 0 && ps > 0 && (ds as u64 + ps as u64) <= MAX_SHARD_COUNT as u64; + + if let Some(ec) = vif.and_then(|v| v.ec_shard_config.as_ref()) { + // A config that is PRESENT but records an impossible ratio is not a + // volume to fall back on: dropping through to the sidecar or the + // defaults would read uniform shards with the legacy offset math and + // answer with the wrong bytes. Only an ENTIRELY absent config means + // "this predates the record". + if !usable(ec.data_shards, ec.parity_shards) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "vif for volume {} records invalid shard counts {}+{}", + volume_id.0, ec.data_shards, ec.parity_shards + ), + )); + } + // A recorded block size that no encoder could have produced maps + // every read to the wrong shard offset. Refuse the mount rather + // than serve those bytes or silently pick a layout. + validate_block_size(ec.block_size)?; + return Ok((ec.data_shards, ec.parity_shards, ec.block_size)); + } + // With no usable vif config the sidecar is the ONLY record of this volume's + // layout, so the three answers stay distinct: absent EVERYWHERE means the + // volume may genuinely predate the sidecar and legacy is the right guess; + // present-but-unusable means the record is corrupt, and answering reads + // from a guessed layout returns wrong bytes rather than none. + // + // Every candidate directory is searched, not just the first: a split + // -dir/-dir.idx location keeps the sidecar with the INDEX, and a multi-disk + // server may keep it on a sibling. Stopping at the data directory resolved + // a 12+4 uniform volume as 10+4 legacy. + let mut path = String::new(); + for candidate in dirs.iter().filter(|d| !d.is_empty()) { + let base = crate::storage::volume::volume_file_name(candidate, collection, volume_id); + let probe = crate::storage::erasure_coding::ec_bitrot::bitrot_sidecar_path(&base, 0); + match std::fs::metadata(&probe) { + Err(e) if e.kind() == io::ErrorKind::NotFound => continue, + Err(e) => { + return Err(io::Error::new(e.kind(), format!("stat {}: {}", probe, e))); + } + Ok(_) => { + path = probe; + break; + } + } + } + if path.is_empty() { + return Ok((default_ds, default_ps, 0)); + } + let prot = crate::storage::erasure_coding::ec_bitrot::load_bitrot_sidecar(&path) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("read {}: {}", path, e)))?; + // The un-suffixed sidecar describes generation 0 and nothing else. + if prot.generation != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{} records generation {}, not generation 0", + path, prot.generation + ), + )); + } + let ec = prot.ec_shard_config.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{} records no EC config", path), + ) + })?; + if !usable(ec.data_shards, ec.parity_shards) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "{} records invalid shard counts {}+{}", + path, ec.data_shards, ec.parity_shards + ), + )); + } + validate_block_size(ec.block_size) + .map_err(|e| io::Error::new(e.kind(), format!("{}: {}", path, e)))?; + Ok((ec.data_shards, ec.parity_shards, ec.block_size)) +} + +/// Reports whether a `.vif`-recorded shard block size is one an encoder could +/// have produced. 0 means the legacy two-tier layout, which is always valid; +/// anything positive must be a whole number of small blocks, because that is +/// what `uniform_block_size` rounds to. Mirrors Go's `ValidateBlockSize`. +pub fn validate_block_size(block_size: i64) -> io::Result<()> { + if block_size == 0 { + return Ok(()); + } + if block_size < 0 + || block_size + % crate::storage::erasure_coding::ec_shard::ERASURE_CODING_SMALL_BLOCK_SIZE as i64 + != 0 + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "invalid shard block size {}: expected 0 (legacy) or a multiple of {}", + block_size, + crate::storage::erasure_coding::ec_shard::ERASURE_CODING_SMALL_BLOCK_SIZE + ), + )); + } + Ok(()) +} + +impl EcVolume { + /// Create a new EcVolume. Loads .ecx index and .ecj journal if present. + pub fn new( + dir: &str, + dir_idx: &str, + collection: &str, + volume_id: VolumeId, + ) -> io::Result { + // One load of the volume's `.vif`, used for both the shard config and + // the version / dat-size fields below. + let vif = load_vif_info(dir, dir_idx, collection, volume_id)?; + // Both directories: a split -dir/-dir.idx layout keeps the .ecsum with + // the INDEX, and it is the only layout record a volume whose vif is + // absent or config-free still has. + let (data_shards, parity_shards, block_size) = + ec_shard_config_from(vif.as_ref(), &[dir, dir_idx], collection, volume_id)?; + + let total_shards = (data_shards + parity_shards) as usize; + let mut shards = Vec::with_capacity(total_shards); + for _ in 0..total_shards { + shards.push(None); + } + + // Read expire_at_sec, version, and dat_file_size from .vif if + // present (matches Go's MaybeLoadVolumeInfo). Prefer the data + // dir; fall back to the idx dir for the cross-disk reconcile + // case (#9212 / #9244). `dat_file_size` is the source .dat + // size at encode time, used both by `locate_ec_shard_needle` + // for shard-size math and by the Store-level prune in + // `store_ec_reconcile.rs` to verify a sibling-disk .dat is + // plausibly the encoding source (#9478). + let (expire_at_sec, vif_version, vif_dat_file_size, encode_ts_ns) = + // Absent everywhere = legacy defaults; a present-but-unreadable or + // malformed vif already failed the mount in load_vif_info above. + match vif.as_ref() { + Some(vif_info) => { + let ver = if vif_info.version > 0 { + Version(vif_info.version as u8) + } else { + Version::current() + }; + let cfg_encode_ts_ns = vif_info + .ec_shard_config + .as_ref() + .map_or(0, |c| c.encode_ts_ns); + ( + vif_info.expire_at_sec, + ver, + vif_info.dat_file_size, + cfg_encode_ts_ns, + ) + } + None => (0, Version::current(), 0, 0), + }; + + let mut vol = EcVolume { + volume_id, + collection: collection.to_string(), + dir: dir.to_string(), + dir_idx: dir_idx.to_string(), + version: vif_version, + shards, + dat_file_size: vif_dat_file_size, + data_shards, + parity_shards, + block_size, + ecx_file: None, + ecx_file_size: 0, + ecj_file: None, + ecj_file_size: 0, + deleted_needles: RwLock::new(HashSet::new()), + disk_type: DiskType::default(), + ecx_actual_dir: dir_idx.to_string(), + shard_locations: std::sync::RwLock::new(HashMap::new()), + shard_locations_refresh_time: std::sync::Mutex::new(None), + shard_locations_stale: std::sync::Mutex::new(false), + expire_at_sec, + encode_ts_ns, + bitrot: None, + bitrot_status: crate::storage::erasure_coding::ec_bitrot::BitrotStatus::Off, + bitrot_source_dir: String::new(), + io_error_count: std::sync::atomic::AtomicI32::new(0), + io_error_quarantined: std::sync::atomic::AtomicBool::new(false), + last_io_error: std::sync::Mutex::new(None), + }; + + // Open .ecx file (sorted index) in read/write mode for in-place deletion marking. + // Matches Go which opens ecx for writing via MarkNeedleDeleted. + let ecx_path = vol.ecx_file_name(); + if std::path::Path::new(&ecx_path).exists() { + let file = open_volume_file(OpenOptions::new().read(true).write(true), &ecx_path)?; + vol.ecx_file_size = file.metadata()?.len() as i64; + vol.ecx_file = Some(file); + } else if dir_idx != dir { + // Fall back to data directory if .ecx was created before -dir.idx was configured + let data_base = crate::storage::volume::volume_file_name(dir, collection, volume_id); + let fallback_ecx = format!("{}.ecx", data_base); + if std::path::Path::new(&fallback_ecx).exists() { + tracing::info!( + volume_id = volume_id.0, + "ecx file not found in idx dir, falling back to data dir" + ); + let file = + open_volume_file(OpenOptions::new().read(true).write(true), &fallback_ecx)?; + vol.ecx_file_size = file.metadata()?.len() as i64; + vol.ecx_file = Some(file); + vol.ecx_actual_dir = dir.to_string(); + } + } + + // Open .ecj file (deletion journal) — use ecx_actual_dir for consistency. + // Note: Go does NOT replay .ecj into .ecx at volume load (RebuildEcxFile + // is only invoked from specific decode/rebuild gRPC handlers), so we + // don't either. Tombstones from prior sessions were already written + // in-place in .ecx, and the journal grows monotonically until a + // decode/rebuild operation folds it in. + let ecj_base = + crate::storage::volume::volume_file_name(&vol.ecx_actual_dir, collection, volume_id); + let ecj_path = format!("{}.ecj", ecj_base); + let ecj_file = open_volume_file( + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .append(true), + &ecj_path, + )?; + vol.ecj_file_size = ecj_file.metadata()?.len() as i64; + vol.ecj_file = Some(ecj_file); + + // Seed the in-memory deleted set from the journal. + vol.load_deleted_needles_from_ecj()?; + + // Load the generation-0 EC bitrot checksum sidecar. Optional, except + // when it contradicts the volume's own geometry — see + // load_bitrot_for_generation. + vol.load_active_bitrot_sidecar(&[])?; + + Ok(vol) + } + + /// Re-resolve the checksum sidecar for a volume that is already mounted. A + /// shard delivery can bring the manifest with it, and the receive path only + /// writes the file, so without this the in-memory volume keeps the + /// protection state it resolved at mount (off) until a remount. + pub fn reload_bitrot_sidecar(&mut self, additional_dirs: &[String]) { + if let Err(e) = self.load_active_bitrot_sidecar(additional_dirs) { + tracing::warn!( + volume_id = self.volume_id.0, + error = %e, + "reload bitrot sidecar", + ); + } + } + + /// Load the generation-0 checksum sidecar into `self.bitrot`/`self.bitrot_status`. + /// OSS only produces generation-0 (fresh-encode) sidecars, mirroring Go's + /// `loadActiveBitrotSidecar`. + fn load_active_bitrot_sidecar(&mut self, additional_dirs: &[String]) -> io::Result<()> { + self.load_bitrot_for_generation(0, additional_dirs) + } + + /// Load and validate the sidecar describing `generation`, setting + /// `self.bitrot`/`self.bitrot_status`. Absent or generation/config-mismatched + /// => `Off` (protection off, not corruption); self-integrity or manifest + /// failure => `Invalid` with a warning (protection off pending repair); usable + /// => `On`. Mirrors Go's `loadBitrotForGeneration`. + fn load_bitrot_for_generation( + &mut self, + generation: u32, + additional_dirs: &[String], + ) -> io::Result<()> { + use crate::storage::erasure_coding::ec_bitrot; + // Data base then index base, matching Go's findBitrotSidecar. A split + // -dir/-dir.idx location keeps the sidecar with the INDEX, and on a + // multi-disk server sharing one index directory that is the only copy + // the per-disk runtimes other than the first can see — searching the + // data base alone left them reporting no protection at all. + let data_path = ec_bitrot::bitrot_sidecar_path(&self.base_name(), generation); + let idx_path = ec_bitrot::bitrot_sidecar_path(&self.idx_base_name(), generation); + // Sibling disks last: startup mirroring gives each shard-bearing disk + // its own .ecx/.ecj/.vif but not the sidecar, and a delivery lands + // exactly one copy, so a runtime restricted to its own two directories + // would report no protection however often it reloaded. + let path = std::iter::once(data_path.clone()) + .chain(std::iter::once(idx_path)) + .chain(additional_dirs.iter().filter(|d| !d.is_empty()).map(|d| { + ec_bitrot::bitrot_sidecar_path( + &crate::storage::volume::volume_file_name(d, &self.collection, self.volume_id), + generation, + ) + })) + .find(|p| std::path::Path::new(p).exists()) + .unwrap_or(data_path); + let loaded = ec_bitrot::load_bitrot_sidecar(&path); + // A sidecar written for THIS generation that contradicts the volume's + // geometry is not "no protection" — it says the layout the volume is + // about to serve reads with is wrong. Fail the mount. + if let Ok(prot) = &loaded + && prot.generation == generation + && !ec_bitrot::geometry_matches( + prot, + self.data_shards as usize, + self.parity_shards as usize, + self.block_size, + ) + { + let cfg = prot.ec_shard_config.as_ref(); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "ec volume {} generation {}: {} records layout {}+{} block {} but the volume is mounted as {}+{} block {}; refusing to serve one of the two layouts", + self.volume_id.0, + generation, + path, + cfg.map(|c| c.data_shards).unwrap_or(0), + cfg.map(|c| c.parity_shards).unwrap_or(0), + cfg.map(|c| c.block_size).unwrap_or(0), + self.data_shards, + self.parity_shards, + self.block_size, + ), + )); + } + let status = ec_bitrot::resolve_status( + &loaded, + generation, + self.data_shards as usize, + self.parity_shards as usize, + ); + // Provenance is committed together with the sidecar it describes, so a + // swallowed reload (`reload_bitrot_sidecar` logs and discards the `Err` + // above) cannot leave them disagreeing: the geometry-mismatch return + // happens before this point, so `bitrot`/`bitrot_status` below and + // `bitrot_source_dir` here always describe the same load attempt. + // Only a path that actually exists is a real source: `path` falls back + // to the data path when nothing was found, and recording that would + // claim a provenance the volume does not have. + self.bitrot_source_dir = if std::path::Path::new(&path).exists() { + std::path::Path::new(&path) + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() + } else { + String::new() + }; + self.bitrot = None; + self.bitrot_status = status; + match status { + ec_bitrot::BitrotStatus::On => self.bitrot = loaded.ok(), + ec_bitrot::BitrotStatus::Off => {} + ec_bitrot::BitrotStatus::Invalid => { + tracing::warn!( + volume_id = self.volume_id.0, + path = %path, + generation, + "ec volume: bitrot sidecar present but invalid; protection off pending repair", + ); + } + } + Ok(()) + } + + /// The active-generation bitrot protection AND its status (cached at mount), + /// mirroring Go's `EcVolume.BitrotProtection()`. Preserves the distinction + /// `checksum_scrub` needs: an absent/generation-mismatched sidecar is `Off` + /// (a clean no-op), a present-but-malformed one is `Invalid` (a real integrity + /// error). Returns `Some(prot)` only for `On`. + pub(crate) fn bitrot_protection( + &self, + ) -> ( + Option, + crate::storage::erasure_coding::ec_bitrot::BitrotStatus, + ) { + (self.bitrot.clone(), self.bitrot_status) + } + + /// Read-only EC bitrot checksum scrub of the LOCAL shards of this volume's + /// active generation. + /// + /// Loads the active-generation `.ecsum` sidecar and, for each locally-held + /// shard, reads the on-disk shard file in `block_size` chunks and compares + /// each block's CRC32C against the sidecar. Returns + /// `(blocks_scanned, mismatched_shards, errors)`. + /// + /// If more than `parity_shards` shards mismatch wholesale (i.e. every block + /// of those shards is wrong — the signature of a stale/wrong sidecar rather + /// than localized disk rot), the result is classified as a suspect sidecar: + /// `mismatched_shards` is cleared and an integrity note is added to `errors` + /// instead. A genuine multi-shard disk failure of that magnitude is + /// already unrecoverable, so treating it as a sidecar-integrity issue avoids + /// raising false shard-corruption alarms. + /// + /// A third outcome sits ahead of both of the above: if the identity fence + /// excluded a runtime AND the sidecar that supplied the protection was + /// resolved from that excluded runtime's directory, the checksums cannot be + /// trusted against + /// the merged shards at all. That case returns `(0, [], [errors])` with an + /// "unverifiable" note and never touches a shard handle — no scan, no + /// wholesale-mismatch classification, no blamed shard. + /// + /// This method NEVER deletes or mutates anything — it is purely diagnostic. + /// Duplicates the mounted shard handles so `run()` can scan with the store + /// guard released; see `EcChecksumScrubPlan` for why that matters. + pub fn checksum_scrub_plan(&self) -> EcChecksumScrubPlan { + EcChecksumScrubPlan::for_volumes(&[self]).expect("a single runtime is never an empty slice") + } + + /// Convenience wrapper preserving the original call shape. Callers that + /// hold the store lock MUST use `checksum_scrub_plan()` + `run()` instead. + pub fn checksum_scrub(&self) -> (u64, Vec, Vec) { + self.checksum_scrub_plan().run() + } + + /// Walk the .ecj journal and populate `deleted_needles`. Called once + /// from `new()` under exclusive ownership of the just-constructed + /// EcVolume, so locking is not strictly required — but we take the + /// write lock anyway for symmetry with later mutations. + fn load_deleted_needles_from_ecj(&mut self) -> io::Result<()> { + let ecj_file = match self.ecj_file.as_ref() { + Some(f) => f, + None => return Ok(()), + }; + if self.ecj_file_size < NEEDLE_ID_SIZE as i64 { + return Ok(()); + } + let mut buf = [0u8; NEEDLE_ID_SIZE]; + let mut set = self + .deleted_needles + .write() + .map_err(|_| io::Error::other("deleted_needles lock poisoned"))?; + let mut off: i64 = 0; + while off + NEEDLE_ID_SIZE as i64 <= self.ecj_file_size { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + ecj_file.read_exact_at(&mut buf, off as u64)?; + } + set.insert(NeedleId::from_bytes(&buf)); + off += NEEDLE_ID_SIZE as i64; + } + Ok(()) + } + + /// Returns (file_count, delete_count) for this EC volume. Mirrors Go's + /// `EcVolume.FileAndDeleteCount`: + /// + /// file_count = ecx_file_size / NEEDLE_MAP_ENTRY_SIZE — total + /// entries in the sealed sorted .ecx index. + /// delete_count = deleted_needles.len() — unique + /// runtime deletes tracked in memory (seeded from + /// .ecj on load and updated by `journal_delete`). + /// + /// Because each needle delete is applied on exactly one shard holder, + /// the admin aggregation sums delete_count across nodes while taking + /// file_count from a single holder (they are identical per volume). + pub fn file_and_delete_count(&self) -> (u64, u64) { + let file_count = (self.ecx_file_size as u64) / (NEEDLE_MAP_ENTRY_SIZE as u64); + let delete_count = self + .deleted_needles + .read() + .map(|s| s.len() as u64) + .unwrap_or(0); + (file_count, delete_count) + } + + /// Reports whether the given needle id is in the in-memory deleted set. + pub fn is_needle_deleted(&self, needle_id: NeedleId) -> bool { + self.deleted_needles + .read() + .map(|s| s.contains(&needle_id)) + .unwrap_or(false) + } + + // ---- File names ---- + + fn base_name(&self) -> String { + crate::storage::volume::volume_file_name(&self.dir, &self.collection, self.volume_id) + } + + /// Base path for the .ecx / .ecj index pair. Resolved from + /// `ecx_actual_dir` (initialized to `dir_idx` and only updated after a + /// successful idx-dir → data-dir fallback in `new()`), so every call site + /// agrees on the same file regardless of whether the fallback fired. + fn idx_base_name(&self) -> String { + crate::storage::volume::volume_file_name( + &self.ecx_actual_dir, + &self.collection, + self.volume_id, + ) + } + + pub fn ecx_file_name(&self) -> String { + format!("{}.ecx", self.idx_base_name()) + } + + pub fn ecj_file_name(&self) -> String { + format!("{}.ecj", self.idx_base_name()) + } + + /// Sync the EC volume's journal and index files to disk (matching Go's ecv.Sync()). + /// Go flushes both .ecj and .ecx to ensure in-place deletion marks are persisted. + pub fn sync_to_disk(&self) -> io::Result<()> { + if let Some(ref ecj_file) = self.ecj_file { + ecj_file.sync_all()?; + } + if let Some(ref ecx_file) = self.ecx_file { + ecx_file.sync_all()?; + } + Ok(()) + } + + // ---- Shard management ---- + + /// Add a shard to this volume. + pub fn add_shard(&mut self, mut shard: EcVolumeShard) -> io::Result<()> { + let id = shard.shard_id as usize; + let total_shards = (self.data_shards + self.parity_shards) as usize; + if id >= total_shards { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid shard id: {} (max {})", id, total_shards - 1), + )); + } + shard.open()?; + // A 0-byte shard file beside an index with entries is residue of a + // failed copy or a truncation, not a mountable shard: registering + // it would advertise a size-0 claim that serves nothing and, since + // placement pins re-copies to the owning disk, would keep + // attracting repairs to a file that was never valid. A 0-byte shard + // beside a 0-byte index is different — that is the legitimate + // layout of a volume encoded with no live needles, and it must keep + // mounting. The startup scan already skips 0-byte shard files; this + // covers the mount path. Mirrors AddEcVolumeShard in + // weed/storage/erasure_coding/ec_volume.go. + if shard.file_size() == 0 && self.ecx_file_size > 0 { + let path = shard.file_name(); + shard.close(); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "ec volume shard {} is empty (0 bytes) but the index has entries: residue of a failed copy, not a mountable shard", + path + ), + )); + } + self.shards[id] = Some(shard); + Ok(()) + } + + /// Override the disk type the EC volume (and its already-mounted + /// shards) reports under. Used by the `VolumeEcShardsMount` handler + /// so the source volume's disk type is preserved across encoding + /// (#9423). Not persisted across restarts — disk-scan reload paths + /// default to the physical location's disk type. + pub fn set_disk_type(&mut self, d: DiskType) { + self.disk_type = d.clone(); + for shard in self.shards.iter_mut().flatten() { + shard.disk_type = d.clone(); + } + } + + /// Remove and close a shard. + pub fn remove_shard(&mut self, shard_id: ShardId) { + if let Some(ref mut shard) = self.shards[shard_id as usize] { + shard.close(); + } + self.shards[shard_id as usize] = None; + } + + /// Get a ShardBits bitmap of locally available shards. + pub fn shard_bits(&self) -> ShardBits { + let mut bits = ShardBits::default(); + for (i, shard) in self.shards.iter().enumerate() { + if shard.is_some() { + bits.add_shard_id(i as ShardId); + } + } + bits + } + + /// Count of locally available shards. + pub fn shard_count(&self) -> usize { + self.shards.iter().filter(|s| s.is_some()).count() + } + + /// Reports whether `shard_id` is currently registered to this + /// EcVolume (used by the cross-disk reconcile to skip already- + /// loaded shards). + pub fn has_shard(&self, shard_id: u8) -> bool { + self.shards + .get(shard_id as usize) + .map(|s| s.is_some()) + .unwrap_or(false) + } + + /// Directory where this EcVolume's `.ecx` was actually opened + /// (may differ from `dir_idx` when the legacy "written before + /// -dir.idx was set" fallback or the cross-disk reconcile path + /// pointed it elsewhere). + pub fn ecx_actual_dir(&self) -> &str { + &self.ecx_actual_dir + } + + pub fn is_time_to_destroy(&self) -> bool { + self.expire_at_sec > 0 + && SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + > self.expire_at_sec + } + + pub fn to_volume_ec_shard_information_messages( + &self, + disk_id: u32, + ) -> Vec { + let mut ec_index_bits: u32 = 0; + let mut shard_sizes = Vec::new(); + for shard in self.shards.iter().flatten() { + ec_index_bits |= 1u32 << shard.shard_id; + shard_sizes.push(shard.file_size()); + } + + if ec_index_bits == 0 { + return Vec::new(); + } + + let (file_count, delete_count) = self.file_and_delete_count(); + + vec![master_pb::VolumeEcShardInformationMessage { + id: self.volume_id.0, + collection: self.collection.clone(), + ec_index_bits, + shard_sizes, + disk_type: self.disk_type.to_string(), + expire_at_sec: self.expire_at_sec, + disk_id, + file_count, + delete_count, + encode_ts_ns: self.encode_ts_ns, + ..Default::default() + }] + } + + // ---- Shard locations (distributed tracking) ---- + + /// Set the list of server addresses for a single shard ID. Does + /// NOT touch `shard_locations_refresh_time` — a per-shard write + /// from inside a multi-shard population (e.g. iterating the + /// `LookupEcVolume` response shard-by-shard) would otherwise + /// flip the staleness flag while the map is still incomplete, + /// letting a concurrent reader observe `needs_refresh == false` + /// against a half-populated cache and return NotFound for the + /// not-yet-inserted shards. + /// + /// Callers writing back a whole `LookupEcVolume` reply should use + /// [`Self::merge_shard_locations`] instead — it upserts the reply's + /// shards under the write lock and advances the refresh timestamp in + /// one step, retaining cached shards the reply omits. + pub fn set_shard_locations(&self, shard_id: ShardId, locations: Vec) { + self.shard_locations + .write() + .unwrap() + .insert(shard_id, locations); + } + + /// Atomically replace the entire shard-locations map and stamp + /// the refresh time. Used by the distributed-read path's + /// post-`LookupEcVolume` write-back so the cache transitions + /// from old → fresh in a single observable step — concurrent + /// readers either see the full prior map or the full new map, + /// never an intermediate state with the freshness flag flipped. + pub fn replace_shard_locations(&self, locations: HashMap>) { + *self.shard_locations.write().unwrap() = locations; + *self.shard_locations_refresh_time.lock().unwrap() = Some(std::time::Instant::now()); + } + + /// Merge a fresh `LookupEcVolume` reply into the shard-locations cache and + /// stamp the refresh time, returning a clone of the resulting map. + /// + /// Per-shard upsert (mirrors Go's `cachedLookupEcShardLocations`): each shard + /// id present in the reply overwrites its cached entry, while shard ids absent + /// from the reply keep their previously-cached locations — so a reply that + /// passes the data-shard completeness guard but omits a shard already in cache + /// does not drop that shard's known location (unlike a full replace). + pub fn merge_shard_locations( + &self, + locations: HashMap>, + ) -> HashMap> { + let merged = { + let mut guard = self.shard_locations.write().unwrap(); + for (shard_id, addrs) in locations { + guard.insert(shard_id, addrs); + } + guard.clone() + }; + *self.shard_locations_refresh_time.lock().unwrap() = Some(std::time::Instant::now()); + merged + } + + /// Get a cloned list of server addresses for a given shard ID. + pub fn get_shard_locations(&self, shard_id: ShardId) -> Vec { + self.shard_locations + .read() + .unwrap() + .get(&shard_id) + .cloned() + .unwrap_or_default() + } + + // ---- I/O error tracking (mirrors Go's EcVolume IoErrorTracker) ---- + + pub fn check_read_write_error(&self, err: Option<&io::Error>) { + use std::sync::atomic::Ordering; + if let Some(e) = err + && crate::storage::volume::is_storage_io_error(e) + { + self.io_error_count.fetch_add(1, Ordering::Relaxed); + if let Ok(mut guard) = self.last_io_error.lock() { + *guard = Some(e.to_string()); + } + crate::metrics::STORAGE_IO_ERROR_COUNTER.inc(); + return; + } + self.io_error_count.store(0, Ordering::Relaxed); + if let Ok(mut guard) = self.last_io_error.lock() + && guard.is_some() + { + *guard = None; + } + } + + pub fn get_io_error_state(&self) -> (Option, i32, bool) { + use std::sync::atomic::Ordering; + let err = self.last_io_error.lock().ok().and_then(|g| g.clone()); + let count = self.io_error_count.load(Ordering::Relaxed); + let quarantined = self.io_error_quarantined.load(Ordering::Relaxed); + (err, count, quarantined) + } + + pub fn mark_io_quarantined(&self) { + self.io_error_quarantined + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + pub fn reset_io_error_state(&self) { + use std::sync::atomic::Ordering; + self.io_error_count.store(0, Ordering::Relaxed); + self.io_error_quarantined.store(false, Ordering::Relaxed); + if let Ok(mut guard) = self.last_io_error.lock() { + *guard = None; + } + } + + // ---- Index operations ---- + + /// Find a needle's offset and size in the sorted .ecx index via binary search. + pub fn find_needle_from_ecx(&self, needle_id: NeedleId) -> io::Result> { + let ecx_file = self + .ecx_file + .as_ref() + .ok_or_else(|| io::Error::other("ecx file not open"))?; + + let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE; + if entry_count == 0 { + return Ok(None); + } + + // Binary search + let mut lo: usize = 0; + let mut hi: usize = entry_count; + let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + + while lo < hi { + let mid = lo + (hi - lo) / 2; + let file_offset = (mid * NEEDLE_MAP_ENTRY_SIZE) as u64; + + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + if let Err(e) = ecx_file.read_exact_at(&mut entry_buf, file_offset) { + self.check_read_write_error(Some(&e)); + return Err(e); + } + } + #[cfg(windows)] + { + // Positional read so concurrent find_needle_from_ecx calls on + // the shared .ecx handle don't interleave seek/read and corrupt + // each other's binary search. Mirrors the read_exact_at helper + // in storage::volume. + if let Err(e) = read_exact_at(ecx_file, &mut entry_buf, file_offset) { + self.check_read_write_error(Some(&e)); + return Err(e); + } + } + #[cfg(not(any(unix, windows)))] + { + compile_error!("Platform not supported: only unix and windows are supported"); + } + + let (key, offset, size) = idx_entry_from_bytes(&entry_buf); + if key == needle_id { + // Apply runtime deletion state on top of the sealed .ecx + // lookup: a needle in the in-memory deleted set is + // reported with TOMBSTONE_FILE_SIZE even though the .ecx + // record itself is untouched. + if self.is_needle_deleted(needle_id) { + self.check_read_write_error(None); + return Ok(Some((offset, TOMBSTONE_FILE_SIZE))); + } + self.check_read_write_error(None); + return Ok(Some((offset, size))); + } else if key < needle_id { + lo = mid + 1; + } else { + hi = mid; + } + } + + self.check_read_write_error(None); + Ok(None) + } + + /// Large-block length of this volume's shard layout (the uniform block + /// size when set, the legacy 1GiB otherwise). Mirrors Go's + /// ECContext.LargeBlockSize. + pub fn large_block_size(&self) -> i64 { + if self.block_size > 0 { + self.block_size + } else { + ERASURE_CODING_LARGE_BLOCK_SIZE as i64 + } + } + + /// Small-block length of this volume's shard layout. + pub fn small_block_size(&self) -> i64 { + if self.block_size > 0 { + self.block_size + } else { + ERASURE_CODING_SMALL_BLOCK_SIZE as i64 + } + } + + /// Locate the EC shard intervals needed to read a needle. + /// Locate the EC shard intervals covering a needle at `actual_offset` whose + /// index size is `size`. Mirrors Go's EcVolume.LocateEcShardNeedleInterval. + pub fn locate_ec_shard_needle_interval( + &self, + actual_offset: i64, + size: Size, + ) -> Vec { + // shardSize = datFileSize / DataShards when known, else ecdFileSize - 1 + // (shards are padded to the small block size; the -1 avoids an + // off-by-one in the large-block row count). + let shard_size = if self.dat_file_size > 0 { + self.dat_file_size / self.data_shards as i64 + } else { + self.shard_file_size() - 1 + }; + // locate_data wants the on-disk size (header+body+checksum+timestamp+padding). + let actual = get_actual_size(size, self.version); + ec_locate::locate_data( + actual_offset, + Size(actual as i32), + shard_size, + self.data_shards, + self.large_block_size(), + self.small_block_size(), + ) + } + + /// Resolve an interval against this volume's shard block layout. Mirrors + /// Go's EcVolume.IntervalToShardIdAndOffset. + pub fn interval_to_shard_id_and_offset( + &self, + interval: &ec_locate::Interval, + ) -> (ShardId, i64) { + interval.to_shard_id_and_offset( + self.data_shards, + self.large_block_size(), + self.small_block_size(), + ) + } + + pub fn locate_needle( + &self, + needle_id: NeedleId, + ) -> io::Result)>> { + let (offset, size) = match self.find_needle_from_ecx(needle_id)? { + Some((o, s)) => (o, s), + None => return Ok(None), + }; + + if size.is_deleted() || offset.is_zero() { + return Ok(None); + } + + let intervals = self.locate_ec_shard_needle_interval(offset.to_actual_offset(), size); + Ok(Some((offset, size, intervals))) + } + + /// Read a full needle from locally available EC shards. + /// + /// Locates the needle in the .ecx index, determines which shard intervals + /// contain its data, reads from local shards, and parses the result into + /// a fully populated Needle (including last_modified, checksum, ttl). + /// + /// Returns `Ok(None)` if the needle is not found or is deleted. + /// Returns an error if a required shard is not available locally. + pub fn read_ec_shard_needle(&self, needle_id: NeedleId) -> io::Result> { + let (offset, size, intervals) = match self.locate_needle(needle_id)? { + Some(v) => v, + None => return Ok(None), + }; + + if intervals.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "no intervals for needle", + )); + } + + // Compute the total bytes we need to read (full needle on disk) + let actual_size = get_actual_size(size, self.version) as usize; + let mut bytes = Vec::with_capacity(actual_size); + + for interval in &intervals { + let (shard_id, shard_offset) = self.interval_to_shard_id_and_offset(interval); + let shard = self + .shards + .get(shard_id as usize) + .and_then(|s| s.as_ref()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("ec shard {} not available locally", shard_id), + ) + })?; + + let mut buf = vec![0u8; interval.size as usize]; + let n = shard.read_at(&mut buf, shard_offset as u64)?; + if n != buf.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "short read on ec shard {}: read {} of {} bytes for needle {}", + shard_id, + n, + buf.len(), + needle_id + ), + )); + } + bytes.extend_from_slice(&buf); + } + + // Truncate to exact actual_size (intervals may span more than needed) + bytes.truncate(actual_size); + + if bytes.len() < actual_size { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "read {} bytes but need {} for needle {}", + bytes.len(), + actual_size, + needle_id + ), + )); + } + + let mut n = Needle::default(); + n.read_bytes(&bytes, offset.to_actual_offset(), size, self.version) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("{}", e)))?; + + Ok(Some(n)) + } + + /// Get the size of a single shard (all shards are the same size). + fn shard_file_size(&self) -> i64 { + if let Some(s) = self.shards.iter().flatten().next() { + return s.file_size(); + } + 0 + } + + /// Walk the .ecx index and return (file_count, file_deleted_count, total_size). + /// total_size sums size.Raw() for all entries (including deleted), matching Go's WalkIndex. + pub fn walk_ecx_stats(&self) -> io::Result<(u64, u64, u64)> { + let ecx_file = match self.ecx_file.as_ref() { + Some(f) => f, + None => return Ok((0, 0, 0)), + }; + + let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE; + let mut files: u64 = 0; + let mut files_deleted: u64 = 0; + let mut total_size: u64 = 0; + let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + + for i in 0..entry_count { + let file_offset = (i * NEEDLE_MAP_ENTRY_SIZE) as u64; + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + ecx_file.read_exact_at(&mut entry_buf, file_offset)?; + } + let (_key, _offset, size) = idx_entry_from_bytes(&entry_buf); + // Match Go's Size.Raw(): tombstone (-1) returns 0, other negatives return abs + if !size.is_tombstone() { + total_size += size.0.unsigned_abs() as u64; + } + if size.is_deleted() { + files_deleted += 1; + } else { + files += 1; + } + } + + Ok((files, files_deleted, total_size)) + } + + /// ScrubIndex verifies index integrity of an EC volume. + /// Matches Go's `(ev *EcVolume) ScrubIndex()` → `idx.CheckIndexFile()`. + /// Returns (entry_count, errors). + /// Snapshot for `scrub_index`, so the index walk can run with the store + /// lock released. Same rationale as `checksum_scrub_plan`. + pub fn scrub_index_plan(&self) -> EcIndexScrubPlan { + let ecx_path = self.ecx_file_name(); + // Opened under the guard, for the same reason the checksum plan + // duplicates its shard handles. A fresh open rather than a clone of the + // cached handle: `check_index_file` seeks, and `dup` would share the + // cursor the shared handle is read from elsewhere. + let ecx_handle = File::open(&ecx_path); + EcIndexScrubPlan { + volume_id: self.volume_id, + has_ecx_file: self.ecx_file.is_some(), + ecx_path, + ecx_file_size: self.ecx_file_size, + version: self.version, + ecx_handle, + } + } + + /// Convenience wrapper preserving the original call shape. Callers holding + /// the store lock MUST use `scrub_index_plan()` + `run()` instead. + pub fn scrub_index(&self) -> (u64, Vec) { + self.scrub_index_plan().run() + } + + /// Snapshot for `scrub_local`, so the needle walk can run with the store + /// lock released. Same rationale as `checksum_scrub_plan`: this reads every + /// local needle's bytes, which is GB-scale on a real volume. + /// + /// The shard vector keeps its SLOT structure — index is the shard id, gaps + /// are the shards this node does not hold. `scrub_local` reads that + /// distinction to decide whether a needle can be reassembled locally at + /// all, so compacting it would silently change which needles get verified. + pub fn scrub_local_plan(&self) -> EcLocalScrubPlan { + EcLocalScrubPlan::for_volumes(&[self]).expect("a single runtime is never an empty slice") + } + + /// ScrubLocal verifies each needle against the LOCAL shards only; it cannot + /// CRC-check a needle whose intervals span shards held on other servers. + /// Mirrors Go's EcVolume.ScrubLocal. Returns (rows walked, broken shards, errors). + /// + /// Convenience wrapper preserving the original call shape. Callers that + /// hold the store lock MUST use `scrub_local_plan()` + `run()` instead. + pub fn scrub_local( + &self, + ) -> ( + u64, + Vec, + Vec, + ) { + self.scrub_local_plan().run() + } + + // ---- Deletion ---- + + /// Write `TOMBSTONE_FILE_SIZE` over the Size field of an existing .ecx + /// entry, matching Go's `MarkNeedleDeleted`. Only used by the offline + /// `rebuild_ecx_from_journal` path — the runtime delete path does not + /// touch .ecx because the index is treated as an immutable sorted + /// (id, offset, size) table. Returns `false` if the needle is not in + /// the index (ignored by callers) and an error on IO failure. + fn tombstone_ecx_entry(&self, needle_id: NeedleId) -> io::Result { + let ecx_file = self.ecx_file.as_ref().ok_or_else(|| { + io::Error::other(format!( + "ec volume {} has no open .ecx file (closed or corrupt)", + self.volume_id.0 + )) + })?; + + let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE; + if entry_count == 0 { + return Ok(false); + } + + let mut lo: usize = 0; + let mut hi: usize = entry_count; + let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + while lo < hi { + let mid = lo + (hi - lo) / 2; + let file_offset = (mid * NEEDLE_MAP_ENTRY_SIZE) as u64; + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + ecx_file.read_exact_at(&mut entry_buf, file_offset)?; + } + let (key, _offset, _old_size) = idx_entry_from_bytes(&entry_buf); + if key == needle_id { + let size_offset = file_offset + NEEDLE_ID_SIZE as u64 + OFFSET_SIZE as u64; + let mut size_buf = [0u8; SIZE_SIZE]; + TOMBSTONE_FILE_SIZE.to_bytes(&mut size_buf); + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + ecx_file.write_all_at(&size_buf, size_offset)?; + } + return Ok(true); + } else if key < needle_id { + lo = mid + 1; + } else { + hi = mid; + } + } + Ok(false) + } + + /// Replay .ecj journal entries into .ecx: for each needle id in .ecj, + /// overwrite its .ecx size field with a tombstone, then remove the + /// journal file. Mirrors Go's `RebuildEcxFile`, which is invoked from + /// specific decode / rebuild gRPC handlers — it is intentionally + /// **not** called on volume load (runtime reads consult + /// `deleted_needles` instead). The rebuild is atomic with respect to + /// the journal: if any individual write fails the .ecj file is left + /// in place and the error is propagated so tombstones are not lost. + #[expect(dead_code, reason = "no caller yet; see the doc comment")] + fn rebuild_ecx_from_journal(&mut self) -> io::Result<()> { + let ecj_path = self.ecj_file_name(); + if !std::path::Path::new(&ecj_path).exists() { + return Ok(()); + } + + let data = fs::read(&ecj_path)?; + if data.is_empty() { + return Ok(()); + } + + let count = data.len() / NEEDLE_ID_SIZE; + for i in 0..count { + let start = i * NEEDLE_ID_SIZE; + if start + NEEDLE_ID_SIZE > data.len() { + break; + } + let needle_id = NeedleId::from_bytes(&data[start..start + NEEDLE_ID_SIZE]); + // A needle that never made it into .ecx is fine (e.g. the + // delete raced against encode). Any other IO error aborts the + // rebuild so the journal survives to be retried later. + self.tombstone_ecx_entry(needle_id)?; + } + + // Durably flush the newly-written .ecx tombstones before dropping + // the journal: the writes went through write_all_at and may still + // be in page cache. + if let Some(ref ecx_file) = self.ecx_file { + ecx_file.sync_all()?; + } + + // Fold successful — drop and recreate the journal, clear the + // in-memory deleted set (all of its contents are now materialized + // in .ecx), and reset the cached size. + fs::remove_file(&ecj_path)?; + let ecj_file = open_volume_file( + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .append(true), + &ecj_path, + )?; + self.ecj_file = Some(ecj_file); + self.ecj_file_size = 0; + if let Ok(mut set) = self.deleted_needles.write() { + set.clear(); + } + + Ok(()) + } + + // ---- Deletion journal ---- + + /// Record a needle delete: append the id to the .ecj deletion journal + /// and insert it into the in-memory deleted set. `.ecx` is not touched + /// at runtime — it is a sealed sorted (id, offset, size) index and + /// runtime deletion state lives exclusively in .ecj + `deleted_needles`. + /// A lookup via `find_needle_from_ecx` masks the id out by returning + /// `TOMBSTONE_FILE_SIZE` on a subsequent read. + /// + /// The .ecj append is the durable commit point. On any failure the + /// file is truncated back to the pre-append length so the on-disk + /// journal and in-memory state cannot drift. Only after the sync + /// succeeds is the id published into the set, so a failure leaves + /// the delete invisible to readers. + pub fn journal_delete(&mut self, needle_id: NeedleId) -> io::Result<()> { + // Look the needle up read-only. Missing is a silent no-op; a + // pre-existing .ecx tombstone (from a prior decode/rebuild) is + // mirrored into the in-memory set so delete_count stays accurate + // without needing to walk .ecx on every heartbeat. + match self.find_needle_from_ecx_raw(needle_id)? { + None => return Ok(()), + Some((_, size)) if size.is_deleted() => { + if let Ok(mut set) = self.deleted_needles.write() { + set.insert(needle_id); + } + return Ok(()); + } + Some(_) => {} + } + + // Idempotent fast path for repeat deletes — avoids the journal + // append entirely so the derived delete_count stays stable. + if self.is_needle_deleted(needle_id) { + return Ok(()); + } + + let prev_ecj_size = self.ecj_file_size; + let append_result: io::Result<()> = { + let ecj_file = self + .ecj_file + .as_mut() + .ok_or_else(|| io::Error::other("ecj file not open"))?; + let mut buf = [0u8; NEEDLE_ID_SIZE]; + needle_id.to_bytes(&mut buf); + ecj_file.write_all(&buf).and_then(|_| ecj_file.sync_all()) + }; + + match append_result { + Ok(()) => { + self.ecj_file_size += NEEDLE_ID_SIZE as i64; + if let Ok(mut set) = self.deleted_needles.write() { + set.insert(needle_id); + } + Ok(()) + } + Err(e) => { + // write_all may have extended the file on disk before + // sync_all failed; truncate back to the known-good size so + // the on-disk journal never drifts past `deleted_needles`. + if let Some(ecj) = self.ecj_file.as_mut() + && let Err(trunc_err) = ecj.set_len(prev_ecj_size as u64) + { + tracing::error!( + volume_id = self.volume_id.0, + needle_id = needle_id.0, + truncate_error = %trunc_err, + "failed to truncate ecj after append failure" + ); + } + Err(e) + } + } + } + + /// Internal: binary search .ecx without masking by `deleted_needles`. + /// Used by `journal_delete` so a repeat delete can still see the raw + /// pre-existing .ecx tombstone from a prior rebuild. + fn find_needle_from_ecx_raw(&self, needle_id: NeedleId) -> io::Result> { + let ecx_file = self + .ecx_file + .as_ref() + .ok_or_else(|| io::Error::other("ecx file not open"))?; + let entry_count = self.ecx_file_size as usize / NEEDLE_MAP_ENTRY_SIZE; + if entry_count == 0 { + return Ok(None); + } + let mut lo: usize = 0; + let mut hi: usize = entry_count; + let mut entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + while lo < hi { + let mid = lo + (hi - lo) / 2; + let file_offset = (mid * NEEDLE_MAP_ENTRY_SIZE) as u64; + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + ecx_file.read_exact_at(&mut entry_buf, file_offset)?; + } + let (key, offset, size) = idx_entry_from_bytes(&entry_buf); + if key == needle_id { + return Ok(Some((offset, size))); + } else if key < needle_id { + lo = mid + 1; + } else { + hi = mid; + } + } + Ok(None) + } + + /// Append a deleted needle ID to the .ecj journal, validating the cookie first. + /// Matches Go's DeleteEcShardNeedle which validates cookie before journaling. + /// A cookie of 0 means skip cookie check (e.g., orphan cleanup). + pub fn journal_delete_with_cookie( + &mut self, + needle_id: NeedleId, + cookie: crate::storage::types::Cookie, + ) -> io::Result<()> { + // cookie == 0 indicates SkipCookieCheck was requested + if cookie.0 != 0 { + // Try to read the needle's cookie from the EC shards to validate + // Look up the needle in ecx index to find its offset, then read header from shard + if let Ok(Some((offset, size))) = self.find_needle_from_ecx(needle_id) + && !size.is_deleted() + && !offset.is_zero() + { + let actual_offset = offset.to_actual_offset() as u64; + // Determine which shard contains this offset and read the cookie + let shard_size = self + .shards + .iter() + .filter_map(|s| s.as_ref()) + .map(|s| s.file_size()) + .next() + .unwrap_or(0) as u64; + if let Some(shard_id) = actual_offset.checked_div(shard_size) { + let shard_id = shard_id as usize; + let shard_offset = actual_offset % shard_size; + if let Some(Some(shard)) = self.shards.get(shard_id) { + let mut header_buf = [0u8; 4]; // cookie is first 4 bytes of needle + if shard.read_at(&mut header_buf, shard_offset).is_ok() { + let needle_cookie = + crate::storage::types::Cookie(u32::from_be_bytes(header_buf)); + if needle_cookie != cookie { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected cookie {:x}", cookie.0), + )); + } + } + } + } + } + } + self.journal_delete(needle_id) + } + + /// Read all deleted needle IDs from the .ecj journal. + pub fn read_deleted_needles(&self) -> io::Result> { + let ecj_path = self.ecj_file_name(); + if !std::path::Path::new(&ecj_path).exists() { + return Ok(Vec::new()); + } + + let data = fs::read(&ecj_path)?; + let count = data.len() / NEEDLE_ID_SIZE; + let mut needles = Vec::with_capacity(count); + for i in 0..count { + let start = i * NEEDLE_ID_SIZE; + let id = NeedleId::from_bytes(&data[start..start + NEEDLE_ID_SIZE]); + needles.push(id); + } + Ok(needles) + } + + // ---- Lifecycle ---- + + pub fn close(&mut self) { + for shard in &mut self.shards { + if let Some(s) = shard { + s.close(); + } + *shard = None; + } + // Sync .ecx before closing to flush in-place deletion marks (matches Go's ev.ecxFile.Sync()) + if let Some(ref ecx_file) = self.ecx_file { + let _ = ecx_file.sync_all(); + } + self.ecx_file = None; + self.ecj_file = None; + } + + pub fn destroy(&mut self) { + for shard in &mut self.shards { + if let Some(s) = shard { + s.destroy(); + } + *shard = None; + } + // Remove .ecx/.ecj/.vif from ecx_actual_dir (where they were found) + // Go's Destroy() removes .ecx, .ecj, and .vif files. + let actual_base = crate::storage::volume::volume_file_name( + &self.ecx_actual_dir, + &self.collection, + self.volume_id, + ); + let _ = fs::remove_file(format!("{}.ecx", actual_base)); + let _ = fs::remove_file(format!("{}.ecj", actual_base)); + let _ = fs::remove_file(format!("{}.vif", actual_base)); + // Also sweep the originally-configured idx dir in case stale files + // exist there (ecx_file_name() / ecj_file_name() now resolve from + // ecx_actual_dir, so we have to build the idx-dir paths explicitly). + if self.ecx_actual_dir != self.dir_idx { + let idx_base = crate::storage::volume::volume_file_name( + &self.dir_idx, + &self.collection, + self.volume_id, + ); + let _ = fs::remove_file(format!("{}.ecx", idx_base)); + let _ = fs::remove_file(format!("{}.ecj", idx_base)); + let _ = fs::remove_file(format!("{}.vif", idx_base)); + } + if self.ecx_actual_dir != self.dir && self.dir_idx != self.dir { + let data_base = crate::storage::volume::volume_file_name( + &self.dir, + &self.collection, + self.volume_id, + ); + let _ = fs::remove_file(format!("{}.ecx", data_base)); + let _ = fs::remove_file(format!("{}.ecj", data_base)); + let _ = fs::remove_file(format!("{}.vif", data_base)); + } + // Go's Destroy() also removes bitrot checksum sidecars so a later + // volume-id reuse cannot load stale protection, and so + // collection.delete does not leave orphaned .ecsum files. + // ecx_actual_dir is always one of these two dirs. + let _ = + crate::storage::erasure_coding::ec_bitrot::remove_bitrot_sidecars(&self.base_name()); + if self.dir_idx != self.dir { + let idx_base = crate::storage::volume::volume_file_name( + &self.dir_idx, + &self.collection, + self.volume_id, + ); + let _ = crate::storage::erasure_coding::ec_bitrot::remove_bitrot_sidecars(&idx_base); + } + self.ecx_file = None; + self.ecj_file = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// `destroy()` must remove co-located `.ecsum` sidecars (Go Destroy parity). + /// Without this, `collection.delete` leaves orphaned bitrot files that + /// inflate EC-health scanners after the shards are gone. + #[test] + fn test_destroy_removes_bitrot_sidecar() { + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::volume::{Volume, VolumeSpec}; + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = Volume::new( + dir, + dir, + VolumeId(2074), + NeedleMapKind::InMemory, + &VolumeSpec { + collection: "ec1c", + ..Default::default() + }, + ) + .unwrap(); + for i in 1..=3 { + let data = format!("needle {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + v.close(); + crate::storage::erasure_coding::ec_encoder::write_ec_files( + dir, + dir, + "ec1c", + VolumeId(2074), + 10, + 4, + ) + .unwrap(); + + let base = crate::storage::volume::volume_file_name(dir, "ec1c", VolumeId(2074)); + let ecsum = format!("{}.ecsum", base); + assert!( + std::path::Path::new(&ecsum).exists(), + "precondition: encode must write generation-0 .ecsum" + ); + assert!( + std::path::Path::new(&format!("{}.ec00", base)).exists(), + "precondition: encode must write shards" + ); + + let mut vol = EcVolume::new(dir, dir, "ec1c", VolumeId(2074)).unwrap(); + // Mount at least one local shard so destroy's shard loop has work. + vol.add_shard(EcVolumeShard::new(dir, "ec1c", VolumeId(2074), 0)) + .unwrap(); + vol.destroy(); + + assert!( + !std::path::Path::new(&format!("{}.ec00", base)).exists(), + "destroy should remove shards" + ); + assert!( + !std::path::Path::new(&ecsum).exists(), + "destroy should remove co-located .ecsum (Go Destroy parity)" + ); + assert!( + !std::path::Path::new(&format!("{}.ecx", base)).exists(), + "destroy should remove .ecx" + ); + } + + /// Mounting an EC volume loads and validates its generation-0 `.ecsum` + /// sidecar, so `bitrot_protection()` reports `On` with the parsed manifest. + #[test] + fn test_mount_loads_bitrot_sidecar() { + use crate::storage::erasure_coding::ec_bitrot::BitrotStatus; + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::volume::{Volume, VolumeSpec}; + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + for i in 1..=5 { + let data = format!("test data for needle {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + v.close(); + crate::storage::erasure_coding::ec_encoder::write_ec_files( + dir, + dir, + "", + VolumeId(1), + 10, + 4, + ) + .unwrap(); + + let vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + assert!( + vol.bitrot.is_some(), + "mount should load the generation-0 .ecsum sidecar" + ); + let (prot, status) = vol.bitrot_protection(); + assert_eq!(status, BitrotStatus::On); + assert_eq!(prot.unwrap().shards.len(), 14); + } + + /// REGRESSION: the scrub plans must be SELF-CONTAINED, so the handler can + /// drop the store lock before the scan runs — see `EcChecksumScrubPlan`. + /// + /// The volume is DROPPED before the plans run, and the plans are moved to + /// another thread. A plan that borrowed from `EcVolume` could do neither, so + /// this stops COMPILING if the snapshot ever regresses to a borrow. + #[test] + fn test_scrub_plans_are_self_contained_and_match_direct_call() { + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::volume::{Volume, VolumeSpec}; + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + for i in 1..=8 { + let data = format!("test data for needle {} with a bit more length", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + v.close(); + crate::storage::erasure_coding::ec_encoder::write_ec_files( + dir, + dir, + "", + VolumeId(1), + 10, + 4, + ) + .unwrap(); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + for id in 0..14u8 { + vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), id)) + .unwrap(); + } + + // Baseline via the original call shape, with the volume still alive. + let direct_checksum = vol.checksum_scrub(); + let direct_index = vol.scrub_index(); + let direct_local = vol.scrub_local(); + assert!( + direct_checksum.0 > 0 && direct_local.0 > 0, + "fixture scanned nothing, so the equalities below would be vacuous" + ); + + let checksum_plan = vol.checksum_scrub_plan(); + let index_plan = vol.scrub_index_plan(); + let local_plan = vol.scrub_local_plan(); + + // The lock (and here the whole volume) is gone before the scan runs. + drop(vol); + + let (from_plan_checksum, from_plan_index, from_plan_local) = + std::thread::spawn(move || (checksum_plan.run(), index_plan.run(), local_plan.run())) + .join() + .expect("scrub plans must be runnable off the owning thread"); + + assert_eq!( + from_plan_checksum, direct_checksum, + "checksum scrub result changed when run from a released-lock plan" + ); + assert_eq!( + from_plan_index, direct_index, + "index scrub result changed when run from a released-lock plan" + ); + assert_eq!( + from_plan_local, direct_local, + "local scrub result changed when run from a released-lock plan" + ); + } + + /// REGRESSION: a malformed `.ecx` row must not abort the scrub TASK. + /// + /// `EcLocalScrubPlan::run()` skips only the -1 tombstone, matching Go's + /// `ScrubLocal`, so any OTHER negative size reaches the reassembly buffer + /// with a negative `get_actual_size()`. Go pays nothing for that (it + /// appends to a nil slice); Rust sizes a per-needle `Vec` from it, and + /// `Vec::with_capacity(negative as usize)` aborts the process. Now that the + /// plan runs under `spawn_blocking`, that abort comes back as a JoinError + /// and would take the whole node-wide scrub RPC down with every result + /// already collected. The row must be REPORTED, as Go reports it. + #[test] + fn test_local_scrub_plan_reports_negative_size_ecx_row() { + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::volume::{Volume, VolumeSpec}; + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + for i in 1..=8 { + let data = format!("test data for needle {} with a bit more length", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + v.close(); + crate::storage::erasure_coding::ec_encoder::write_ec_files( + dir, + dir, + "", + VolumeId(1), + 10, + 4, + ) + .unwrap(); + + // Rewrite the first .ecx row's size as -1000: a negative that is NOT + // the -1 tombstone the walk skips. A scrub is what you point at an + // index you already suspect, so an arbitrary i32 in the size field is + // in-scope input, whatever wrote it. + let ecx = format!( + "{}.ecx", + crate::storage::volume::volume_file_name(dir, "", VolumeId(1)) + ); + let mut raw = std::fs::read(&ecx).unwrap(); + assert!( + raw.len() >= NEEDLE_MAP_ENTRY_SIZE, + "fixture must write at least one .ecx row" + ); + let (key, offset, _) = idx_entry_from_bytes(&raw[..NEEDLE_MAP_ENTRY_SIZE]); + idx_entry_to_bytes(&mut raw[..NEEDLE_MAP_ENTRY_SIZE], key, offset, Size(-1000)); + std::fs::write(&ecx, &raw).unwrap(); + assert!( + get_actual_size(Size(-1000), Version::current()) < 0, + "precondition: the row must drive get_actual_size negative" + ); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + for id in 0..14u8 { + vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), id)) + .unwrap(); + } + let plan = vol.scrub_local_plan(); + drop(vol); + + // The join IS the assertion: a panic here is the JoinError that used to + // fail the whole ScrubEcVolume RPC. + let (_count, _broken, errs) = std::thread::spawn(move || plan.run()) + .join() + .expect("a malformed .ecx row must not abort the scrub task"); + + assert!( + errs.iter() + .any(|e| e.contains(&format!("bytes for needle {}", key.0))), + "the malformed row must be reported, got {:?}", + errs + ); + } + + /// REGRESSION: a scrub running with the store lock RELEASED must not turn a + /// concurrent, intentional removal into a corruption report — see + /// `EcChecksumScrubPlan`. + /// + /// Deleting every file after the plans are built is the whole test: reads + /// that resolve a path diverge from the direct call, reads through the + /// captured descriptors are identical. + #[test] + fn test_scrub_plans_survive_files_removed_after_snapshot() { + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::volume::{Volume, VolumeSpec}; + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + for i in 1..=8 { + let data = format!("test data for needle {} with a bit more length", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + v.close(); + crate::storage::erasure_coding::ec_encoder::write_ec_files( + dir, + dir, + "", + VolumeId(1), + 10, + 4, + ) + .unwrap(); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + for id in 0..14u8 { + vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), id)) + .unwrap(); + } + + // Baseline with every file present and the volume still mounted. + let direct_checksum = vol.checksum_scrub(); + let direct_index = vol.scrub_index(); + let direct_local = vol.scrub_local(); + assert!( + direct_checksum.0 > 0 && direct_local.0 > 0, + "fixture scanned nothing, so the equalities below would be vacuous" + ); + assert!( + direct_checksum.2.is_empty() && direct_index.1.is_empty() && direct_local.2.is_empty(), + "fixture is not clean, so a false error could not be told apart: {:?} {:?} {:?}", + direct_checksum.2, + direct_index.1, + direct_local.2 + ); + + let checksum_plan = vol.checksum_scrub_plan(); + let index_plan = vol.scrub_index_plan(); + let local_plan = vol.scrub_local_plan(); + + // The teardown a store writer would perform, after the plans exist. + drop(vol); + let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1)); + for id in 0..14u8 { + std::fs::remove_file(format!("{}.ec{:02}", base, id)).unwrap(); + } + std::fs::remove_file(format!("{}.ecx", base)).unwrap(); + assert!( + !std::path::Path::new(&format!("{}.ec00", base)).exists(), + "the removal under test did not happen" + ); + + assert_eq!( + checksum_plan.run(), + direct_checksum, + "shard files unlinked after the snapshot were reported as corruption" + ); + assert_eq!( + index_plan.run(), + direct_index, + "the .ecx unlinked after the snapshot was reported as an index error" + ); + assert_eq!( + local_plan.run(), + direct_local, + "files unlinked after the snapshot were reported as local-scrub errors" + ); + } + + /// CHECKSUM scrub verifies clean shards against the sidecar and flags a shard + /// whose bytes are corrupted after encode. + #[test] + fn test_checksum_scrub_clean_and_detects_corruption() { + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::volume::{Volume, VolumeSpec}; + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + for i in 1..=8 { + let data = format!("test data for needle {} with a bit more length", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + v.close(); + crate::storage::erasure_coding::ec_encoder::write_ec_files( + dir, + dir, + "", + VolumeId(1), + 10, + 4, + ) + .unwrap(); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + for id in 0..14u8 { + vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), id)) + .unwrap(); + } + + // Clean scrub: no mismatches, no errors, blocks scanned > 0. + let (scanned, broken, errs) = vol.checksum_scrub(); + assert!(errs.is_empty(), "unexpected scrub errors: {:?}", errs); + assert!(broken.is_empty(), "unexpected mismatches: {:?}", broken); + assert!(scanned > 0, "scrub should scan at least one block"); + + // Corrupt one byte of shard 3 on disk; re-scrub must report shard 3. + let shard3 = format!("{}/1.ec03", dir); + let mut bytes = std::fs::read(&shard3).unwrap(); + assert!(!bytes.is_empty()); + bytes[0] ^= 0xFF; + std::fs::write(&shard3, &bytes).unwrap(); + + let (_, broken2, _) = vol.checksum_scrub(); + assert!( + broken2.contains(&3), + "corrupted shard 3 should be flagged, got {:?}", + broken2 + ); + } + + fn write_ecx_file( + dir: &str, + collection: &str, + vid: VolumeId, + entries: &[(NeedleId, Offset, Size)], + ) { + let base = crate::storage::volume::volume_file_name(dir, collection, vid); + let ecx_path = format!("{}.ecx", base); + let mut file = File::create(&ecx_path).unwrap(); + + // Write sorted entries + for &(key, offset, size) in entries { + let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + idx_entry_to_bytes(&mut buf, key, offset, size); + file.write_all(&buf).unwrap(); + } + } + + #[test] + fn test_ec_volume_find_needle() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + // Write sorted ecx entries + let entries = vec![ + (NeedleId(1), Offset::from_actual_offset(8), Size(100)), + (NeedleId(5), Offset::from_actual_offset(200), Size(200)), + (NeedleId(10), Offset::from_actual_offset(500), Size(300)), + ]; + write_ecx_file(dir, "", VolumeId(1), &entries); + + let vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + + // Found + let result = vol.find_needle_from_ecx(NeedleId(5)).unwrap(); + assert!(result.is_some()); + let (offset, size) = result.unwrap(); + assert_eq!(offset.to_actual_offset(), 200); + assert_eq!(size, Size(200)); + + // Not found + let result = vol.find_needle_from_ecx(NeedleId(7)).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_ec_volume_journal() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + // .ecj append is gated on a live->tombstone transition in .ecx, so + // the fixture must contain the needles we are about to delete. + let entries = vec![ + (NeedleId(10), Offset::from_actual_offset(8), Size(100)), + (NeedleId(20), Offset::from_actual_offset(200), Size(200)), + ]; + write_ecx_file(dir, "", VolumeId(1), &entries); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + let (fc0, dc0) = vol.file_and_delete_count(); + assert_eq!((fc0, dc0), (2, 0)); + + vol.journal_delete(NeedleId(10)).unwrap(); + vol.journal_delete(NeedleId(20)).unwrap(); + + let deleted = vol.read_deleted_needles().unwrap(); + assert_eq!(deleted, vec![NeedleId(10), NeedleId(20)]); + + let (fc, dc) = vol.file_and_delete_count(); + assert_eq!((fc, dc), (2, 2)); + + // Idempotent re-delete must not bump delete_count. + vol.journal_delete(NeedleId(10)).unwrap(); + // Deleting a missing needle must not bump delete_count either. + vol.journal_delete(NeedleId(999)).unwrap(); + let (fc, dc) = vol.file_and_delete_count(); + assert_eq!((fc, dc), (2, 2)); + } + + #[test] + fn test_ec_volume_shard_bits() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_ecx_file(dir, "", VolumeId(1), &[]); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + assert_eq!(vol.shard_count(), 0); + + // Create a shard file so we can add it + let mut shard = EcVolumeShard::new(dir, "", VolumeId(1), 3); + shard.create().unwrap(); + shard.write_all(&[0u8; 100]).unwrap(); + shard.close(); + + vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 3)) + .unwrap(); + assert_eq!(vol.shard_count(), 1); + assert!(vol.shard_bits().has_shard_id(3)); + } + + /// A 0-byte shard file beside an index WITH entries is residue of a + /// failed copy: `add_shard` must refuse it and leave the slot + /// unregistered, so no size-0 claim is advertised (and no re-copy is + /// attracted to a file that was never valid). Beside a 0-byte index it + /// is the legitimate empty-volume layout and must keep mounting. The + /// startup scan already skips such files; this covers the mount path. + /// Mirrors TestLoadEcShardRefusesEmptyShardFile in Go. + #[test] + fn test_add_shard_refuses_empty_shard_file() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_ecx_file( + dir, + "", + VolumeId(1), + &[(NeedleId(1), Offset::from_actual_offset(8), Size(100))], + ); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + + let mut shard = EcVolumeShard::new(dir, "", VolumeId(1), 4); + shard.create().unwrap(); + shard.close(); + + let err = vol + .add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 4)) + .expect_err("adding a 0-byte shard file must fail when the index has entries"); + assert!( + err.to_string().contains("empty (0 bytes)"), + "a 0-byte shard should be refused as empty, got: {}", + err + ); + assert_eq!(vol.shard_count(), 0, "a refused shard must not register"); + assert!(!vol.shard_bits().has_shard_id(4)); + } + + /// The legitimate empty-volume layout: a volume encoded with no live + /// needles has a 0-byte .ecx AND 0-byte shards, and mounting it must + /// keep working. + #[test] + fn test_add_shard_accepts_empty_shard_of_empty_volume() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_ecx_file(dir, "", VolumeId(1), &[]); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + + let mut shard = EcVolumeShard::new(dir, "", VolumeId(1), 4); + shard.create().unwrap(); + shard.close(); + + vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 4)) + .expect("a 0-byte shard of an empty volume (0-byte index) must mount"); + assert!(vol.shard_bits().has_shard_id(4)); + } + + #[test] + fn test_ec_volume_uses_collection_prefixed_vif_config() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_ecx_file(dir, "pics", VolumeId(1), &[]); + + let vif = crate::storage::volume::VifVolumeInfo { + ec_shard_config: Some(crate::storage::volume::VifEcShardConfig { + data_shards: 6, + parity_shards: 3, + ..Default::default() + }), + ..Default::default() + }; + let base = crate::storage::volume::volume_file_name(dir, "pics", VolumeId(1)); + std::fs::write( + format!("{}.vif", base), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + let vol = EcVolume::new(dir, dir, "pics", VolumeId(1)).unwrap(); + assert_eq!(vol.data_shards, 6); + assert_eq!(vol.parity_shards, 3); + } + + /// A vif that RECORDS a ratio no volume could have must fail the mount. + /// Substituting the default 10+4 (and with it the legacy block layout) + /// would read a uniform volume's shards at the wrong offsets and answer + /// with the wrong bytes; only an entirely absent config means "this + /// predates the record", which + /// `test_ec_volume_absent_vif_config_uses_defaults` covers. + #[test] + fn test_ec_volume_invalid_vif_config_fails_the_mount() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_ecx_file(dir, "pics", VolumeId(1), &[]); + + // data + parity exceeds MAX_SHARD_COUNT, so the config is rejected. + let vif = crate::storage::volume::VifVolumeInfo { + ec_shard_config: Some(crate::storage::volume::VifEcShardConfig { + data_shards: 20, + parity_shards: 20, + ..Default::default() + }), + ..Default::default() + }; + let base = crate::storage::volume::volume_file_name(dir, "pics", VolumeId(1)); + std::fs::write( + format!("{}.vif", base), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + // EcVolume has no Debug impl, so match rather than expect_err. + match EcVolume::new(dir, dir, "pics", VolumeId(1)) { + Ok(_) => panic!("an impossible ratio must fail the mount"), + Err(e) => assert_eq!(e.kind(), io::ErrorKind::InvalidData, "got {e}"), + } + } + + /// The compatibility case the check above must not swallow: a vif with no + /// EC config at all predates the record, and mounts on the defaults. + #[test] + fn test_ec_volume_absent_vif_config_uses_defaults() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_ecx_file(dir, "pics", VolumeId(1), &[]); + let base = crate::storage::volume::volume_file_name(dir, "pics", VolumeId(1)); + std::fs::write(format!("{}.vif", base), r#"{"version":3}"#).unwrap(); + + let vol = EcVolume::new(dir, dir, "pics", VolumeId(1)).unwrap(); + assert_eq!(vol.data_shards, DATA_SHARDS_COUNT as u32); + assert_eq!(vol.parity_shards, PARITY_SHARDS_COUNT as u32); + assert_eq!(vol.block_size, 0, "legacy layout for a pre-record volume"); + } + + #[test] + fn test_ec_volume_wide_ratio_vif_config() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_ecx_file(dir, "pics", VolumeId(1), &[]); + + // A wider-than-default ratio within MAX_SHARD_COUNT must load as-is. + let vif = crate::storage::volume::VifVolumeInfo { + ec_shard_config: Some(crate::storage::volume::VifEcShardConfig { + data_shards: 16, + parity_shards: 4, + ..Default::default() + }), + ..Default::default() + }; + let base = crate::storage::volume::volume_file_name(dir, "pics", VolumeId(1)); + std::fs::write( + format!("{}.vif", base), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + let vol = EcVolume::new(dir, dir, "pics", VolumeId(1)).unwrap(); + assert_eq!(vol.data_shards, 16); + assert_eq!(vol.parity_shards, 4); + } + + #[test] + fn test_scrub_local_skips_tombstones() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let entries = vec![(NeedleId(1), Offset::from_actual_offset(0), Size(-1))]; + write_ecx_file(dir, "", VolumeId(1), &entries); + + let vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + let (count, broken, errs) = vol.scrub_local(); + assert_eq!(count, 1); + assert!(broken.is_empty(), "{:?}", broken); + assert!(errs.is_empty(), "{:?}", errs); + } + + #[test] + fn test_scrub_local_clean_when_no_local_shards() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let entries = vec![(NeedleId(1), Offset::from_actual_offset(0), Size(100))]; + write_ecx_file(dir, "", VolumeId(1), &entries); + + // dat_file_size makes the shard-size math well-defined. + let vif = crate::storage::volume::VifVolumeInfo { + dat_file_size: 14000, + ..Default::default() + }; + let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1)); + std::fs::write( + format!("{}.vif", base), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + // No shard files present: nothing to verify locally, so no errors. + let vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + let (count, broken, errs) = vol.scrub_local(); + assert_eq!(count, 1); + assert!(broken.is_empty(), "{:?}", broken); + assert!(errs.is_empty(), "{:?}", errs); + } + + #[test] + fn test_scrub_local_suppresses_delete_state_disagreement() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + // Live index entry (size > 0) whose reassembled on-disk header reports size 0 + // (deleted-on-shards but live-in-index) — a delete-state disagreement, not corruption. + let entries = vec![(NeedleId(1), Offset::from_actual_offset(0), Size(100))]; + write_ecx_file(dir, "", VolumeId(1), &entries); + + let vif = crate::storage::volume::VifVolumeInfo { + dat_file_size: 14000, + ..Default::default() + }; + let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1)); + std::fs::write( + format!("{}.vif", base), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + // Shard 0 holds the needle's bytes; all-zero so the parsed header size is 0. + let mut shard0 = EcVolumeShard::new(dir, "", VolumeId(1), 0); + shard0.create().unwrap(); + shard0.write_all(&[0u8; 256]).unwrap(); + shard0.close(); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 0)) + .unwrap(); + + let (count, broken, errs) = vol.scrub_local(); + assert_eq!(count, 1); + assert!(broken.is_empty(), "{:?}", broken); + assert!( + errs.is_empty(), + "delete-state disagreement must be suppressed, got {:?}", + errs + ); + } + + #[test] + fn test_scrub_local_reports_genuine_size_corruption() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let entries = vec![(NeedleId(1), Offset::from_actual_offset(0), Size(100))]; + write_ecx_file(dir, "", VolumeId(1), &entries); + + let vif = crate::storage::volume::VifVolumeInfo { + dat_file_size: 14000, + ..Default::default() + }; + let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1)); + std::fs::write( + format!("{}.vif", base), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + // On-disk header reports a non-zero size (50) that disagrees with the live + // index size (100): genuine corruption, not a delete-state race — must report. + let mut bytes = vec![0u8; 256]; + bytes[15] = 50; // header size field (big-endian u32) = 50 + let mut shard0 = EcVolumeShard::new(dir, "", VolumeId(1), 0); + shard0.create().unwrap(); + shard0.write_all(&bytes).unwrap(); + shard0.close(); + + let mut vol = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + vol.add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 0)) + .unwrap(); + + let (_count, _broken, errs) = vol.scrub_local(); + assert!( + !errs.is_empty(), + "a non-zero size mismatch is genuine corruption and must be reported" + ); + } + + /// Build one real encoded EC volume in `dir`, then hand back N runtimes over + /// it, each holding the shard ids it was given. Models the reconciled + /// split-disk mount without needing N directories. + fn split_runtimes(dir: &str, vid: VolumeId, subsets: &[&[u8]]) -> Vec { + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::volume::{Volume, VolumeSpec}; + + let mut v = Volume::new( + dir, + dir, + vid, + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + for i in 1..=8 { + let data = format!("test data for needle {} with a bit more length", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + v.close(); + crate::storage::erasure_coding::ec_encoder::write_ec_files(dir, dir, "", vid, 10, 4) + .unwrap(); + + subsets + .iter() + .map(|ids| { + let mut ev = EcVolume::new(dir, dir, "", vid).unwrap(); + for &id in ids.iter() { + ev.add_shard(EcVolumeShard::new(dir, "", vid, id)).unwrap(); + } + ev + }) + .collect() + } + + /// The union must reach every disk's shards, and first-disk-wins must + /// resolve a shard mounted on two disks — the same rule + /// `collect_ec_shard_dirs` and Go's `CollectEcShards` already use. + #[test] + fn test_merge_ec_runtimes_unions_slots_first_disk_wins() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + // Disk 0: 0..=6 plus 9 (9 is also on disk 1 — a duplicate mount). + // Disk 1: 7..=13. + let runtimes = split_runtimes( + dir, + VolumeId(1), + &[&[0, 1, 2, 3, 4, 5, 6, 9], &[7, 8, 9, 10, 11, 12, 13]], + ); + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let merged = merge_ec_runtimes(&refs).expect("non-empty input merges"); + assert_eq!(merged.merged.len(), 2, "both runtimes share generation 0"); + assert!( + merged.skipped.is_empty(), + "nothing to skip: {:?}", + merged.skipped + ); + + // Every shard 0..=13 is reachable from the merged slots. + for id in 0..14usize { + assert!( + merged.slots[id].is_some(), + "shard {} missing from the union", + id + ); + } + // Shard 9 is held by both; the first disk wins. + let (owner, _) = merged.slots[9].unwrap(); + assert!( + std::ptr::eq(owner, refs[0]), + "duplicate shard must resolve to the first disk" + ); + // Slots are shard-id indexed across the volume's whole shard space, + // never compacted: index N is shard N or nothing. + assert_eq!( + merged.slots.len(), + 14, + "slots must span the full 10+4 shard space" + ); + } + + /// Leniency is keyed on the ANCHOR, never the holder: a known identity must + /// not accept an unstamped holder (store_ec.rs:667-673, + /// grpc_server.rs:3743-3748). Merging leftover legacy shards beside a + /// current encode is what produces false corruption reports. + #[test] + fn test_merge_ec_runtimes_excludes_incompatible_identities() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut runtimes = split_runtimes( + dir, + VolumeId(1), + &[&[0, 1, 2, 3, 4, 5, 6], &[7, 8, 9], &[10, 11, 12, 13]], + ); + runtimes[0].encode_ts_ns = 500; // stale, but stamped + runtimes[1].encode_ts_ns = 0; // legacy, unstamped + runtimes[2].encode_ts_ns = 900; // the live encode run + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let merged = merge_ec_runtimes(&refs).unwrap(); + + // Anchor is the newest known identity; only exact matches merge. + assert_eq!(merged.anchor.encode_ts_ns, 900); + assert_eq!(merged.merged.len(), 1); + assert!( + merged.slots[10].is_some(), + "the anchor's own shards must merge" + ); + assert!( + merged.slots[0].is_none(), + "an older known generation must not merge" + ); + assert!( + merged.slots[7].is_none(), + "an unstamped holder must not merge" + ); + + // Exclusions are REPORTED, never silent — that is what keeps this from + // regressing to the silent-clean bug this whole change fixes. + assert_eq!(merged.skipped.len(), 2, "got {:?}", merged.skipped); + assert!(merged.skipped.iter().all(|s| s.contains("not verified"))); + } + + /// When no runtime carries an identity there is nothing to fence on, so + /// behavior stays exactly as it is today: everything merges. + #[test] + fn test_merge_ec_runtimes_is_lenient_when_no_identity_is_known() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]); + assert!(runtimes.iter().all(|r| r.encode_ts_ns == 0)); + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let merged = merge_ec_runtimes(&refs).unwrap(); + assert_eq!(merged.merged.len(), 2); + assert!(merged.skipped.is_empty()); + assert!(merged.slots[3].is_some()); + } + + /// The anchor supplies the volume-level metadata, so prefer a shard-bearing + /// runtime at the anchor generation over an empty one. + #[test] + fn test_merge_ec_runtimes_anchor_prefers_a_shard_bearing_runtime() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let runtimes = split_runtimes(dir, VolumeId(1), &[&[], &[0, 1, 2]]); + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let merged = merge_ec_runtimes(&refs).unwrap(); + assert!( + std::ptr::eq(merged.anchor, refs[1]), + "anchor must hold shards" + ); + assert!( + merged.skipped.is_empty(), + "same generation: nothing is excluded" + ); + } + + /// Empty input is the vanished-volume case and the ONLY None. + #[test] + fn test_merge_ec_runtimes_none_only_when_empty() { + assert!(merge_ec_runtimes(&[]).is_none()); + } + + /// The vanished-volume case: callers' `let ... else` guards depend on this + /// being the ONLY None. + #[test] + fn test_for_volumes_is_none_only_for_an_empty_slice() { + assert!(EcChecksumScrubPlan::for_volumes(&[]).is_none()); + assert!(EcLocalScrubPlan::for_volumes(&[]).is_none()); + } + + /// The whole point: corruption on a shard that only a sibling runtime holds + /// must be reported. Before this change the scrub saw disk 0 alone and + /// returned clean. + #[test] + fn test_checksum_scrub_for_volumes_catches_sibling_disk_corruption() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + // Shard 11 is held ONLY by the second runtime. + let runtimes = split_runtimes( + dir, + VolumeId(1), + &[&[0, 1, 2, 3, 4, 5, 6], &[7, 8, 9, 10, 11, 12, 13]], + ); + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + // Clean to start. + let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run(); + assert!(errs.is_empty(), "unexpected errors: {:?}", errs); + assert!(broken.is_empty(), "unexpected mismatches: {:?}", broken); + assert!(scanned > 0, "merged plan scanned nothing"); + + // Corrupt shard 11 — reachable only through the second runtime. + let shard11 = format!("{}/1.ec11", dir); + let mut bytes = std::fs::read(&shard11).unwrap(); + bytes[0] ^= 0xFF; + std::fs::write(&shard11, &bytes).unwrap(); + + let (_, broken2, _) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run(); + assert!( + broken2.contains(&11), + "sibling-disk corruption must be flagged, got {:?}", + broken2 + ); + + // The single-runtime path is exactly the old blind spot: disk 0 alone + // still reports clean, which is why aggregation was needed. + let (_, broken_disk0, _) = refs[0].checksum_scrub(); + assert!(!broken_disk0.contains(&11)); + } + + /// An excluded runtime is reported through the plan's errors, but ONLY on + /// the On path — the Off arm's clean-empty contract is Go parity + /// (`case BitrotOff: return 0, nil, nil`) and must not grow errors. + #[test] + fn test_checksum_scrub_skips_are_reported_but_never_break_the_off_arm() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]); + runtimes[0].encode_ts_ns = 100; // excluded: older known identity + runtimes[1].encode_ts_ns = 200; // anchor + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + // With protection ON, the skip surfaces as an error line, and the + // surviving (anchor) runtime is still actually scanned. + let (scanned, _, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run(); + assert!( + scanned > 0, + "excluding one runtime must not stop the rest from being scanned" + ); + assert!( + errs.iter().any(|e| e.contains("not verified")), + "excluded runtime must be reported, got {:?}", + errs + ); + + // With protection OFF, the arm stays byte-for-byte clean. + let mut off = runtimes; + for r in off.iter_mut() { + r.bitrot_status = crate::storage::erasure_coding::ec_bitrot::BitrotStatus::Off; + r.bitrot = None; + } + let off_refs: Vec<&EcVolume> = off.iter().collect(); + assert_eq!( + EcChecksumScrubPlan::for_volumes(&off_refs).unwrap().run(), + (0, Vec::new(), Vec::new()), + "BitrotOff must stay clean-empty for Go parity" + ); + } + + /// The Invalid arm returns a non-empty error vector of its own, so the + /// Go-parity contract that silences the Off arm does not reach it. A volume + /// with BOTH a malformed sidecar and a fenced-out disk must report BOTH: + /// reporting only the sidecar hides an unscanned disk behind an unrelated + /// integrity error, which is the failure mode this whole change exists to + /// close. + #[test] + fn test_checksum_scrub_reports_skips_on_a_malformed_sidecar() { + use crate::storage::erasure_coding::ec_bitrot::BitrotStatus; + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]); + runtimes[0].encode_ts_ns = 100; // excluded: older known identity + runtimes[1].encode_ts_ns = 200; // anchor + for r in runtimes.iter_mut() { + r.bitrot_status = BitrotStatus::Invalid; + } + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run(); + assert_eq!( + scanned, 0, + "a malformed sidecar must not be scanned against" + ); + assert!(broken.is_empty(), "must never blame shards: {:?}", broken); + assert_eq!( + errs.len(), + 2, + "expected the sidecar error AND the skip: {:?}", + errs + ); + assert!( + errs[0].contains("malformed/unverifiable"), + "the sidecar error stays first: {:?}", + errs + ); + assert!( + errs[1].contains("belong to encode run 100") && errs[1].contains("not verified"), + "the excluded disk must still be named: {:?}", + errs + ); + } + + /// When the anchor's sidecar came from a directory belonging to a runtime + /// the fence excluded, the checksums cannot be trusted against the anchor's + /// shards. Report that, never "shard N is corrupt". + #[test] + fn test_checksum_scrub_reports_unverifiable_sidecar_from_excluded_runtime() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]); + + // Mount actually resolved the real sidecar from `dir` (both subsets + // share one physical directory in this fixture), confirming + // `load_bitrot_for_generation` populates the field before the test + // overwrites it below to simulate a cross-disk borrow. + assert_eq!( + runtimes[1].bitrot_source_dir, dir, + "mount must record the dir the sidecar was actually resolved from" + ); + + // Runtime 0 is an older encode run, and the anchor resolved its sidecar + // from runtime 0's directory. + runtimes[0].encode_ts_ns = 100; + runtimes[0].dir = "/disk-old".to_string(); + runtimes[0].dir_idx = "/disk-old".to_string(); + runtimes[1].encode_ts_ns = 200; + runtimes[1].dir = "/disk-new".to_string(); + runtimes[1].dir_idx = "/disk-new".to_string(); + runtimes[1].bitrot_source_dir = "/disk-old".to_string(); + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run(); + assert_eq!( + scanned, 0, + "an unverifiable sidecar must not be scanned against" + ); + assert!(broken.is_empty(), "must never blame shards: {:?}", broken); + assert!( + errs.iter().any(|e| e.contains("unverifiable")), + "expected an unverifiable-protection note, got {:?}", + errs + ); + assert!( + errs.iter().any(|e| e.contains("were not verified")), + "the fenced-out runtime must still be reported alongside the unverifiable note: {:?}", + errs + ); + } + + /// The provenance rule fires only when a runtime was actually excluded. A + /// healthy single-encode volume whose sidecar lives on a sibling disk is the + /// normal mirrored case and must scrub normally. + #[test] + fn test_checksum_scrub_provenance_rule_does_not_fire_without_exclusions() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut runtimes = split_runtimes( + dir, + VolumeId(1), + &[&[0, 1, 2, 3, 4, 5, 6], &[7, 8, 9, 10, 11, 12, 13]], + ); + // Same generation: nothing is excluded, so provenance is irrelevant even + // though the anchor's sidecar appears to have come from somewhere + // neither runtime owns. (The anchor is runtimes[0]: `merge_ec_runtimes` + // picks the first shard-bearing runtime at the anchor generation.) + runtimes[0].bitrot_source_dir = "/somewhere-else".to_string(); + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run(); + assert!(scanned > 0, "a normal volume must still be scanned"); + assert!(broken.is_empty(), "{:?}", broken); + assert!( + !errs.iter().any(|e| e.contains("unverifiable")), + "{:?}", + errs + ); + } + + /// The identity fence keys on `encode_ts_ns` ALONE, never on geometry, so + /// two same-generation runtimes whose `.vif`s disagree about the layout do + /// merge -- and `slots` is then sized to the WIDER of them while the + /// volume's actual shard-id range is the ANCHOR's `data + parity`. + /// + /// The two consumers disagreed about exactly that range: the mode 2|5 arm + /// builds `dirs` over `0..anchor.data_shards + anchor.parity_shards` and + /// silently drops the surplus slots, while `EcChecksumScrubPlan` iterated + /// the full width and reported each one "present but missing from sidecar + /// manifest". Nothing in this volume describes those ids -- the sidecar + /// manifest and the Reed-Solomon matrix are both the anchor's -- so that + /// message is the width disagreement talking, not a finding. + #[test] + fn test_checksum_scrub_truncates_slots_to_the_anchors_geometry() { + use crate::pb::volume_server_pb::{ + ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums, + }; + use crate::storage::erasure_coding::ec_bitrot; + use crate::storage::volume::{VifEcShardConfig, VifVolumeInfo}; + + let tmp = TempDir::new().unwrap(); + let vid = VolumeId(1); + + // One dir per geometry: `EcVolume::new` reads `data_shards`/ + // `parity_shards` from the `.vif` beside the volume, so two runtimes can + // only disagree if they mount from different directories. + let seed = |name: &str, ds: u32, ps: u32, shard_id: u8| -> String { + let dir = tmp.path().join(name); + std::fs::create_dir_all(&dir).unwrap(); + let d = dir.to_str().unwrap().to_string(); + let base = crate::storage::volume::volume_file_name(&d, "", vid); + std::fs::write(format!("{}.ecx", base), b"").unwrap(); + std::fs::write(format!("{}.ecj", base), b"").unwrap(); + std::fs::write( + format!("{}.vif", base), + serde_json::to_string(&VifVolumeInfo { + version: 3, + ec_shard_config: Some(VifEcShardConfig { + data_shards: ds, + parity_shards: ps, + encode_ts_ns: 500, + ..Default::default() + }), + ..Default::default() + }) + .unwrap(), + ) + .unwrap(); + std::fs::write( + format!("{}.ec{:02}", base, shard_id), + b"shard data nonempty", + ) + .unwrap(); + d + }; + + // 10+4, and the only disk with a sidecar -- so it supplies `prot`, and + // its manifest covers shard ids 0..=13 and nothing else. + let narrow = seed("narrow", 10, 4, 0); + let prot = EcBitrotProtection { + algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32, + block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32, + generation: 0, + ec_shard_config: Some(ec_bitrot::ec_shard_config(10, 4, 0)), + shards: (0..14u32) + .map(|shard_id| EcShardChecksums { + shard_id, + covered_size: 4, + block_crc32c: vec![0u8; 4], + }) + .collect(), + encode_uuid: vec![0u8; 16], + }; + ec_bitrot::save_bitrot_sidecar( + &ec_bitrot::bitrot_sidecar_path( + &crate::storage::volume::volume_file_name(&narrow, "", vid), + 0, + ), + &prot, + ) + .unwrap(); + + // 12+4: 16 slots, and it holds shard 14 -- an id the anchor's layout + // does not contain at all. Same encode_ts_ns, but the geometry fence + // now excludes it rather than merging incompatible shards. + let wide = seed("wide", 12, 4, 14); + + let mut narrow_v = EcVolume::new(&narrow, &narrow, "", vid).unwrap(); + narrow_v + .add_shard(EcVolumeShard::new(&narrow, "", vid, 0)) + .unwrap(); + let mut wide_v = EcVolume::new(&wide, &wide, "", vid).unwrap(); + wide_v + .add_shard(EcVolumeShard::new(&wide, "", vid, 14)) + .unwrap(); + + assert_eq!(narrow_v.shards.len(), 14); + assert_eq!(wide_v.shards.len(), 16); + assert_eq!( + narrow_v.encode_ts_ns, wide_v.encode_ts_ns, + "the two runtimes must share a generation" + ); + assert_eq!( + narrow_v.bitrot_status, + crate::storage::erasure_coding::ec_bitrot::BitrotStatus::On, + "the narrow disk must carry usable protection" + ); + + let refs: Vec<&EcVolume> = vec![&narrow_v, &wide_v]; + let merged = merge_ec_runtimes(&refs).unwrap(); + assert!( + std::ptr::eq(merged.anchor, refs[0]), + "the NARROW runtime must anchor" + ); + assert!( + !merged.skipped.is_empty(), + "the wide runtime must be excluded by the geometry fence: {:?}", + merged.skipped + ); + assert_eq!( + merged.slots.len(), + 14, + "slots are sized to the anchor's geometry, not the excluded wide runtime" + ); + assert!( + merged.slots.get(14).is_none() || merged.slots.get(14).unwrap().is_none(), + "shard 14 from the excluded runtime must not appear in slots" + ); + + let (scanned, broken, errs) = EcChecksumScrubPlan::for_volumes(&refs).unwrap().run(); + assert!( + !errs.iter().any(|e| e.contains("shard 14")), + "shard 14 belongs to an excluded runtime and must not be scanned: {:?}", + errs + ); + assert!( + scanned > 0, + "the anchor's shard 0 must still be scanned: {:?}", + errs + ); + assert_eq!( + broken, + vec![0], + "shard 0 is inside the anchor's geometry and its bytes do not match \ + the manifest, so it must still be reported: errs={:?}", + errs + ); + } + + /// `for_volumes` must reach every runtime's shards, not just the first's. + /// A needle walk alone can't prove that with this fixture: ~500 bytes of + /// data under a legacy 1GiB large block puts every needle interval on + /// shard 0, so the slot vector is asserted directly. The walk-level checks + /// below additionally show the merged plan verifies cleanly and then + /// detects corruption reachable through the first runtime, while a + /// single-runtime plan stays blind to it. + #[test] + fn test_scrub_local_for_volumes_merges_slots_across_runtimes() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[7, 8, 9]]); + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let (count, broken, errs) = EcLocalScrubPlan::for_volumes(&refs).unwrap().run(); + assert!(count > 0, "the merged plan walked no needles"); + assert!( + broken.is_empty(), + "clean volume reported broken shards: {:?}", + broken + ); + assert!(errs.is_empty(), "clean volume reported errors: {:?}", errs); + + // The property this task exists to deliver, asserted directly on the + // plan rather than through a needle walk. With ~500 bytes of fixture + // data and a legacy 1GiB large block every needle interval lands in + // shard 0, so no needle walk can ever reach a sibling runtime's + // shards -- but the slot vector can. Neither runtime holds every + // shard here, so the gap at shard 3 makes compaction load-bearing: + // a compacting implementation could still satisfy a "populated slots + // are reachable" check while silently reindexing the vector. + let merged_plan = EcLocalScrubPlan::for_volumes(&refs).unwrap(); + assert_eq!( + merged_plan.shards.len(), + 14, + "slots must span the full 10+4 shard space" + ); + assert!( + merged_plan.shards[0].is_some(), + "disk 0's shard 0 must be reachable" + ); + assert!( + merged_plan.shards[3].is_none(), + "a shard no runtime holds must stay an empty slot -- compaction would fill it" + ); + assert!( + merged_plan.shards[7].is_some(), + "disk 1's shard 7 must be reachable -- the bug being fixed" + ); + + // A single-runtime plan still sees only its own disk, which is exactly + // what made aggregation necessary. + let solo = EcLocalScrubPlan::for_volumes(&[refs[0]]).unwrap(); + assert!(solo.shards[0].is_some()); + assert!( + solo.shards[7].is_none(), + "refs[0] alone must not see the sibling's shard 7" + ); + + // This fixture's few hundred bytes of needle data all land inside + // shard 0's legacy 1GiB large block (the tiny volume never fills even + // one), so shard 0 alone — held only by the first runtime — carries + // every needle. Corrupting a needle body byte there is the LOCAL + // analogue of Task 3's sibling-disk corruption: the whole point is + // that a partial-shard `scrub_local` cannot see it at all (it just + // silently skips a needle it can't reassemble locally — that is not + // an error), while the merged plan reaches it through refs[0]. + let shard0 = format!("{}/1.ec00", dir); + let mut bytes = std::fs::read(&shard0).unwrap(); + assert!( + bytes.len() > 268, + "fixture shrank below the corruption offset; split_runtimes's \ + needle payloads changed, so this offset needs picking again" + ); + bytes[268] ^= 0xFF; // inside needle 4's data payload + std::fs::write(&shard0, &bytes).unwrap(); + + let (_, _, merged_errs) = EcLocalScrubPlan::for_volumes(&refs).unwrap().run(); + assert!( + !merged_errs.is_empty(), + "merged plan must detect corruption on shard 0, reachable through refs[0]" + ); + + // refs[1] never mounted shard 0, so it cannot see this corruption at + // all — that silent blind spot is exactly the bug aggregation fixes. + let (_, _, refs1_errs) = refs[1].scrub_local(); + assert!( + refs1_errs.is_empty(), + "runtime without shard 0 has no way to detect this corruption: {:?}", + refs1_errs + ); + } + + /// LOCAL's legacy `shard_size` fallback (`dat_file_size == 0`) is now a + /// NODE-WIDE input: it feeds `locate_data`'s offset math for every merged + /// sibling's shards, not just the anchor's. `anchor.shard_file_size()` + /// returns the anchor's FIRST held shard rather than a maximum, so one + /// truncated shard on the anchor disk would mis-offset every needle read + /// across every disk and manufacture corruption reports wholesale. Take the + /// max over the merged slots, the way `verify_ec_shards` already answers the + /// same question (`if size > shard_size { shard_size = size }`). + #[test] + fn test_scrub_local_shard_size_fallback_takes_the_max_across_disks() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + // Mount two shardless runtimes first so the shard files can be doctored + // before `add_shard` caches their sizes. + let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[], &[]]); + + // Disk 0's shard 0 is truncated -- a partially-copied shard, which is + // exactly the containment this fallback used to have and now must not + // lose. + let shard0_path = format!("{}/1.ec00", dir); + let full = std::fs::metadata(&shard0_path).unwrap().len(); + assert!(full > 1, "fixture shard is too small to truncate"); + std::fs::OpenOptions::new() + .write(true) + .open(&shard0_path) + .unwrap() + .set_len(full / 2) + .unwrap(); + + runtimes[0] + .add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 0)) + .unwrap(); + runtimes[1] + .add_shard(EcVolumeShard::new(dir, "", VolumeId(1), 1)) + .unwrap(); + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let intact = std::fs::metadata(format!("{}/1.ec01", dir)).unwrap().len() as i64; + let truncated = (full / 2) as i64; + assert!( + truncated < intact, + "fixture must leave disk 0's shard SHORTER than disk 1's" + ); + + let merged = merge_ec_runtimes(&refs).unwrap(); + assert!( + std::ptr::eq(merged.anchor, refs[0]), + "the truncated disk must anchor, or the old sizing was already right" + ); + assert_eq!( + refs[0].dat_file_size, 0, + "this fixture writes no .vif, so the legacy fallback is the path \ + under test; with a dat_file_size the branch is never reached" + ); + assert_eq!( + refs[0].shard_file_size(), + truncated, + "the old source -- the anchor's first held shard -- is the \ + truncated one, which is what makes this observable" + ); + + let plan = EcLocalScrubPlan::for_volumes(&refs).unwrap(); + assert_eq!( + plan.shard_size, + intact - 1, + "one disk's truncated shard must not size every merged sibling's \ + shards; locate_data would then mis-offset every needle on every \ + disk and report corruption that is not there" + ); + } + + /// LOCAL has no bitrot status gate, so an excluded runtime is always + /// reported. + #[test] + fn test_scrub_local_reports_skipped_runtimes() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut runtimes = split_runtimes(dir, VolumeId(1), &[&[0, 1, 2], &[3, 4, 5]]); + runtimes[0].encode_ts_ns = 100; + runtimes[1].encode_ts_ns = 200; + let refs: Vec<&EcVolume> = runtimes.iter().collect(); + + let (_, _, errs) = EcLocalScrubPlan::for_volumes(&refs).unwrap().run(); + assert!( + errs.iter().any(|e| e.contains("not verified")), + "excluded runtime must be reported, got {:?}", + errs + ); + } +} + +#[cfg(test)] +mod uniform_layout_tests { + use super::*; + use crate::storage::needle_map::NeedleMapKind; + use crate::storage::volume::{VifEcShardConfig, VifVolumeInfo, Volume, VolumeSpec}; + use tempfile::TempDir; + + // Write ~26MB of needles so the uniform block size (3MB) diverges from the + // legacy layout, encode, and verify EcVolume reads every needle back + // through the .vif-recorded geometry. A legacy-encoded fixture (no .vif + // block size) must keep reading through the legacy interpretation. + #[test] + fn test_read_needles_uniform_and_legacy_layouts() { + for legacy in [false, true] { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let vid = VolumeId(8); + + let mut v = Volume::new( + dir, + dir, + vid, + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + let mut expected: Vec<(NeedleId, Vec)> = Vec::new(); + for i in 1u64..=11 { + let size = if i <= 5 { 4 << 20 } else { 1 << 20 }; + let data: Vec = (0..size) + .map(|b| ((b as u64).wrapping_mul(2654435761).wrapping_add(i) >> 8) as u8) + .collect(); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.clone(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + expected.push((NeedleId(i), data)); + } + v.sync_to_disk().unwrap(); + let dat_size = v.dat_file_size().unwrap() as i64; + v.close(); + + let block_size = if legacy { + // Legacy fixture: two-tier encode plus a .vif without a block + // size, the state every pre-upgrade EC volume is in. + use crate::storage::erasure_coding::ec_bitrot::{ + DEFAULT_BITROT_BLOCK_SIZE, ShardChecksumBuilder, + }; + use reed_solomon_erasure::galois_8::ReedSolomon; + let base = crate::storage::volume::volume_file_name(dir, "", vid); + crate::storage::erasure_coding::ec_encoder::write_sorted_ecx_from_idx( + &format!("{}.idx", base), + &format!("{}.ecx", base), + ) + .unwrap(); + let dat_file = std::fs::File::open(format!("{}.dat", base)).unwrap(); + let rs = ReedSolomon::new(10, 4).unwrap(); + let mut shards: Vec = (0..14u8) + .map(|i| EcVolumeShard::new(dir, "", vid, i)) + .collect(); + for shard in &mut shards { + shard.create().unwrap(); + } + let mut builders: Vec = (0..14) + .map(|_| ShardChecksumBuilder::new(DEFAULT_BITROT_BLOCK_SIZE as i64)) + .collect(); + crate::storage::erasure_coding::ec_encoder::encode_dat_file( + &dat_file, + dat_size, + &rs, + &mut shards, + &mut builders, + crate::storage::erasure_coding::ec_encoder::EcEncodeLayout { + data_shards: 10, + parity_shards: 4, + buffer_size: 256 * 1024, + large_block_size: ERASURE_CODING_LARGE_BLOCK_SIZE, + small_block_size: ERASURE_CODING_SMALL_BLOCK_SIZE, + }, + ) + .unwrap(); + for shard in &mut shards { + shard.close(); + } + 0 + } else { + let bs = crate::storage::erasure_coding::ec_encoder::write_ec_files( + dir, dir, "", vid, 10, 4, + ) + .unwrap(); + assert!( + bs > ERASURE_CODING_SMALL_BLOCK_SIZE as i64, + "block size {} does not diverge from the legacy layout", + bs + ); + bs + }; + + let base = crate::storage::volume::volume_file_name(dir, "", vid); + let vif = VifVolumeInfo { + version: Version::current().0 as u32, + dat_file_size: dat_size, + ec_shard_config: Some(VifEcShardConfig { + data_shards: 10, + parity_shards: 4, + block_size, + ..Default::default() + }), + ..Default::default() + }; + std::fs::write( + format!("{}.vif", base), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + std::fs::remove_file(format!("{}.dat", base)).unwrap(); + std::fs::remove_file(format!("{}.idx", base)).unwrap(); + + let mut vol = EcVolume::new(dir, dir, "", vid).unwrap(); + assert_eq!( + vol.block_size, block_size, + "block size not loaded from .vif" + ); + for i in 0..10u8 { + vol.add_shard(EcVolumeShard::new(dir, "", vid, i)).unwrap(); + } + + for (id, data) in &expected { + let n = vol + .read_ec_shard_needle(*id) + .unwrap() + .unwrap_or_else(|| panic!("needle {} not found (legacy={})", id.0, legacy)); + assert_eq!( + n.data, *data, + "needle {} data mismatch (legacy={})", + id.0, legacy + ); + } + } + } + + // A present-but-malformed .vif must FAIL the mount: every new encode + // records a positive uniform block size there, and silently defaulting + // to the legacy layout would serve those shards with the wrong offset + // math. Absence stays legal — legacy volumes predate the sidecar. + #[test] + fn new_fails_on_malformed_vif() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1)); + std::fs::write(format!("{}.ecx", base), b"").unwrap(); + std::fs::write(format!("{}.vif", base), b"not json").unwrap(); + let res = EcVolume::new(dir, dir, "", VolumeId(1)); + assert!(res.is_err(), "mount over a malformed .vif must fail"); + } + + #[test] + fn new_absent_vif_mounts_with_defaults() { + let tmp = tempfile::TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(1)); + std::fs::write(format!("{}.ecx", base), b"").unwrap(); + let ev = EcVolume::new(dir, dir, "", VolumeId(1)).unwrap(); + assert_eq!(ev.block_size, 0, "legacy mount must use the legacy layout"); + } + + /// A rebuild lands on one disk, but the volume's metadata may live on + /// another. Reading only the selected directory returns the default 10+4 + /// and the legacy block layout, which reconstructs a custom-ratio or + /// uniform volume through the wrong matrix. + fn seed_uniform_sidecar(base: &str, ds: u32, ps: u32, block: i64) { + use crate::pb::volume_server_pb::{ + ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums, + }; + use crate::storage::erasure_coding::ec_bitrot; + let prot = EcBitrotProtection { + algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32, + block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32, + generation: 0, + ec_shard_config: Some(ec_bitrot::ec_shard_config(ds, ps, block)), + shards: (0..(ds + ps)) + .map(|shard_id| EcShardChecksums { + shard_id, + covered_size: 4, + block_crc32c: vec![0u8; 4], + }) + .collect(), + encode_uuid: vec![0u8; 16], + }; + ec_bitrot::save_bitrot_sidecar(&ec_bitrot::bitrot_sidecar_path(base, 0), &prot).unwrap(); + } + + fn seed_config_free_vif(base: &str) { + std::fs::write(format!("{}.vif", base), r#"{"version":3,"datFileSize":0}"#).unwrap(); + } + + // A .vif that omits ecShardConfig answers nothing about the layout, so it + // must not short-circuit the sidecar search. Returning as soon as any + // parseable vif turned up resolved a 12+4 uniform volume as 10+4 legacy. + #[test] + fn read_ec_shard_config_falls_back_when_the_vif_has_no_config() { + let d = tempfile::TempDir::new().unwrap(); + let dir = d.path().to_str().unwrap(); + let base = crate::storage::volume::volume_file_name(dir, "", VolumeId(11)); + seed_config_free_vif(&base); + seed_uniform_sidecar(&base, 12, 4, 3 * 1024 * 1024); + + let got = read_ec_shard_config(dir, dir, "", VolumeId(11)).unwrap(); + assert_eq!(got, (12, 4, 3 * 1024 * 1024)); + } + + // Split -dir/-dir.idx: the vif and the sidecar both live with the INDEX, + // and the resolver was told to report the DATA directory, so the fallback + // looked in a directory holding neither. + #[test] + fn read_ec_shard_config_finds_a_config_free_vifs_sidecar_in_the_index_dir() { + let data = tempfile::TempDir::new().unwrap(); + let idx = tempfile::TempDir::new().unwrap(); + let (dir, dir_idx) = (data.path().to_str().unwrap(), idx.path().to_str().unwrap()); + let idx_base = crate::storage::volume::volume_file_name(dir_idx, "", VolumeId(12)); + seed_config_free_vif(&idx_base); + seed_uniform_sidecar(&idx_base, 12, 4, 3 * 1024 * 1024); + + let got = read_ec_shard_config(dir, dir_idx, "", VolumeId(12)).unwrap(); + assert_eq!(got, (12, 4, 3 * 1024 * 1024)); + } + + // Same gap in the cross-disk resolver the rebuild uses. + #[test] + fn read_ec_shard_config_across_dirs_falls_back_when_the_vif_has_no_config() { + let data = tempfile::TempDir::new().unwrap(); + let sib = tempfile::TempDir::new().unwrap(); + let (dir, sibling) = (data.path().to_str().unwrap(), sib.path().to_str().unwrap()); + seed_config_free_vif(&crate::storage::volume::volume_file_name( + dir, + "", + VolumeId(13), + )); + seed_uniform_sidecar( + &crate::storage::volume::volume_file_name(sibling, "", VolumeId(13)), + 12, + 4, + 3 * 1024 * 1024, + ); + + let got = + read_ec_shard_config_across_dirs(dir, dir, &[sibling.to_string()], "", VolumeId(13)) + .unwrap(); + assert_eq!(got, (12, 4, 3 * 1024 * 1024)); + } + + // Absence stays legal: a volume predating both records is genuinely legacy. + #[test] + fn read_ec_shard_config_config_free_vif_without_a_sidecar_is_legacy() { + let d = tempfile::TempDir::new().unwrap(); + let dir = d.path().to_str().unwrap(); + seed_config_free_vif(&crate::storage::volume::volume_file_name( + dir, + "", + VolumeId(14), + )); + let got = read_ec_shard_config(dir, dir, "", VolumeId(14)).unwrap(); + assert_eq!(got, (10, 4, 0)); + } + + #[test] + fn read_ec_shard_config_finds_a_sibling_disks_vif() { + let a = tempfile::TempDir::new().unwrap(); + let b = tempfile::TempDir::new().unwrap(); + let (rebuild, sibling) = (a.path().to_str().unwrap(), b.path().to_str().unwrap()); + let base = crate::storage::volume::volume_file_name(sibling, "", VolumeId(3)); + std::fs::write( + format!("{}.vif", base), + r#"{"version":3,"ecShardConfig":{"dataShards":12,"parityShards":4,"blockSize":3145728}}"#, + ) + .unwrap(); + + let (ds, ps, bs) = read_ec_shard_config_across_dirs( + rebuild, + rebuild, + &[sibling.to_string()], + "", + VolumeId(3), + ) + .unwrap(); + assert_eq!((ds, ps, bs), (12, 4, 3 * 1024 * 1024)); + } + + /// Same, for the sidecar: with no .vif anywhere it is the surviving record + /// of the geometry, wherever it sits. + // A split -dir/-dir.idx location keeps its metadata with the INDEX, and + // callers leave their own index directory out of other_dirs because it is + // passed separately. With no .vif anywhere the generation-0 .ecsum is the + // only record of the layout, so missing that directory resolves a 12+4 + // uniform volume to 10+4 legacy and reconstructs through the wrong matrix. + #[test] + fn read_ec_shard_config_finds_the_sidecar_in_its_own_index_dir() { + use crate::pb::volume_server_pb::{ + ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums, + }; + use crate::storage::erasure_coding::ec_bitrot; + + let data = tempfile::TempDir::new().unwrap(); + let idx = tempfile::TempDir::new().unwrap(); + let (dir, dir_idx) = (data.path().to_str().unwrap(), idx.path().to_str().unwrap()); + let base = crate::storage::volume::volume_file_name(dir_idx, "", VolumeId(7)); + let prot = EcBitrotProtection { + algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32, + block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32, + generation: 0, + ec_shard_config: Some(ec_bitrot::ec_shard_config(12, 4, 3 * 1024 * 1024)), + shards: (0..16u32) + .map(|shard_id| EcShardChecksums { + shard_id, + covered_size: 4, + block_crc32c: vec![0u8; 4], + }) + .collect(), + encode_uuid: vec![0u8; 16], + }; + ec_bitrot::save_bitrot_sidecar(&ec_bitrot::bitrot_sidecar_path(&base, 0), &prot).unwrap(); + + let (ds, ps, bs) = + read_ec_shard_config_across_dirs(dir, dir_idx, &[], "", VolumeId(7)).unwrap(); + assert_eq!((ds, ps, bs), (12, 4, 3 * 1024 * 1024)); + } + + #[test] + fn read_ec_shard_config_finds_a_sibling_disks_sidecar() { + use crate::pb::volume_server_pb::{ + ChecksumAlgorithm, EcBitrotProtection, EcShardChecksums, + }; + use crate::storage::erasure_coding::ec_bitrot; + + let a = tempfile::TempDir::new().unwrap(); + let b = tempfile::TempDir::new().unwrap(); + let (rebuild, sibling) = (a.path().to_str().unwrap(), b.path().to_str().unwrap()); + let base = crate::storage::volume::volume_file_name(sibling, "", VolumeId(4)); + let prot = EcBitrotProtection { + algorithm: ChecksumAlgorithm::ChecksumCrc32c as i32, + block_size: ec_bitrot::DEFAULT_BITROT_BLOCK_SIZE as u32, + generation: 0, + ec_shard_config: Some(ec_bitrot::ec_shard_config(12, 4, 3 * 1024 * 1024)), + shards: (0..16u32) + .map(|shard_id| EcShardChecksums { + shard_id, + covered_size: 4, + block_crc32c: vec![0u8; 4], + }) + .collect(), + encode_uuid: vec![0u8; 16], + }; + ec_bitrot::save_bitrot_sidecar(&ec_bitrot::bitrot_sidecar_path(&base, 0), &prot).unwrap(); + + let (ds, ps, bs) = read_ec_shard_config_across_dirs( + rebuild, + rebuild, + &[sibling.to_string()], + "", + VolumeId(4), + ) + .unwrap(); + assert_eq!((ds, ps, bs), (12, 4, 3 * 1024 * 1024)); + } +} + +/// One volume id's per-disk runtimes resolved into a single scrubbable view. +/// Every scrub mode builds from this, so there is exactly one answer to +/// "which disks count" per volume. +pub(crate) struct MergedEcRuntimes<'a> { + /// Volume-level metadata source: geometry, `.ecx` handles, version. NOT + /// bitrot protection — the `.ecsum` sidecar is per-DISK state, so + /// `EcChecksumScrubPlan::for_volumes` sources it from the first `merged` + /// runtime that has any. + pub anchor: &'a EcVolume, + /// Runtimes whose shards are safe to verify together. + pub merged: Vec<&'a EcVolume>, + /// Indexed BY SHARD ID. `None` is a shard no merged runtime holds. The + /// volume's shard-id range is the anchor's geometry + /// (`0..data_shards + parity_shards`); consumers truncate to it. + pub slots: Vec>, + /// One line per runtime excluded by the identity or geometry fence. + /// Reported, never dropped. + pub skipped: Vec, +} + +/// Resolve `runtimes` (one vid's mounts, in location order) into a merged view. +/// `None` for an empty slice (the vanished-volume case). +/// +/// Two fences admit a runtime into `merged`: the anchor's `encode_ts_ns` (the +/// maximum, so `0` implies every runtime is `0` and nothing is excluded — +/// legacy leniency), and the anchor's geometry (`data_shards`, `parity_shards`, +/// `block_size`). Equal timestamps do not guarantee equal layouts, so a +/// same-generation runtime whose `.vif` disagrees is excluded and reported +/// rather than merged into a plan that would apply the anchor's offsets and +/// checksums to incompatible shards. +pub(crate) fn merge_ec_runtimes<'a>(runtimes: &[&'a EcVolume]) -> Option> { + let anchor_gen = runtimes.iter().map(|v| v.encode_ts_ns).max()?; + + let gen_matches: Vec<&'a EcVolume> = runtimes + .iter() + .copied() + .filter(|v| v.encode_ts_ns == anchor_gen) + .collect(); + + let anchor = gen_matches + .iter() + .copied() + .find(|v| v.shards.iter().any(|s| s.is_some())) + .unwrap_or(gen_matches[0]); + + let merged: Vec<&'a EcVolume> = gen_matches + .iter() + .copied() + .filter(|v| { + v.data_shards == anchor.data_shards + && v.parity_shards == anchor.parity_shards + && v.block_size == anchor.block_size + }) + .collect(); + + let width = merged.iter().map(|v| v.shards.len()).max().unwrap_or(0); + let mut slots: Vec> = vec![None; width]; + for v in &merged { + for (id, slot) in v.shards.iter().enumerate() { + if let Some(shard) = slot.as_ref() + && slots[id].is_none() + { + slots[id] = Some((*v, shard)); + } + } + } + + let skipped: Vec = runtimes + .iter() + .enumerate() + .filter(|(_, v)| { + v.encode_ts_ns != anchor_gen + || v.data_shards != anchor.data_shards + || v.parity_shards != anchor.parity_shards + || v.block_size != anchor.block_size + }) + .map(|(pos, v)| { + if v.encode_ts_ns != anchor_gen { + format!( + "EC volume {} shards at {} (position {}) belong to encode run {} but the scrub anchors on {}; they were not verified", + v.volume_id.0, v.dir, pos, v.encode_ts_ns, anchor_gen + ) + } else { + format!( + "EC volume {} shards at {} (position {}) share encode run {} but disagree on geometry ({}+{} bs {} vs {}+{} bs {}); they were not verified", + v.volume_id.0, v.dir, pos, v.encode_ts_ns, + v.data_shards, v.parity_shards, v.block_size, + anchor.data_shards, anchor.parity_shards, anchor.block_size + ) + } + }) + .collect(); + + Some(MergedEcRuntimes { + anchor, + merged, + slots, + skipped, + }) +} + +/// Self-contained input for an EC checksum scrub: the sidecar plus a duplicate +/// of every mounted local shard handle, taken under the store read guard. +/// +/// Two things follow from holding descriptors rather than paths. `run()` needs +/// no store access, so the guard is released before a scan that reads every +/// byte of every local shard — under the guard that scan parks the periodic +/// heartbeat's `store.write()`, and `std::sync::RwLock` is write-preferring, so +/// every later reader queues behind it and the node stops serving. And a +/// teardown that legitimately unlinks the shards mid-scan (the heartbeat's +/// `delete_expired_ec_volumes`, `volume_ec_shards_delete`) cannot masquerade as +/// bitrot, because the descriptor outlives the name. Go reads the same way: +/// `ChecksumScrub` goes through `shard.ReadAt`. +/// +/// Not `Clone`: it owns those descriptors. +#[derive(Debug)] +pub struct EcChecksumScrubPlan { + pub volume_id: VolumeId, + pub prot: Option, + pub status: crate::storage::erasure_coding::ec_bitrot::BitrotStatus, + pub parity_shards: u32, + /// One entry per LOCAL shard: its id and a duplicate of the mounted + /// handle (or the error to report). Private so a plan can only be built + /// (via `checksum_scrub_plan()` or `for_volumes()`) from `&EcVolume` + /// references, which cannot be obtained without the store guard — which + /// is what makes "captured under the guard" an invariant rather than a + /// convention. + /// + /// `dup` shares the kernel file offset, so every read here is positional. + shards: Vec<(u32, std::io::Result)>, + /// Runtimes the identity/geometry fence excluded, one line each. Surfaced + /// through `run()`'s errors on the `On`/`Invalid` paths. + skipped: Vec, + /// `Some(source_dir)` when the sidecar that supplied `prot`/`status` was + /// resolved from a directory belonging to a runtime the fence excluded. + /// `run()` reports unverifiable protection instead of blaming shards. + unverifiable_sidecar: Option, + /// One line per merged runtime whose sidecar resolved `Invalid`. Reported + /// even when protection is taken from a sibling that is `On`, so a + /// malformed sidecar is never silently discarded. + invalid_sidecar_errors: Vec, +} + +impl EcChecksumScrubPlan { + /// Build one plan over every per-disk runtime of a volume id. `None` only + /// when `runtimes` is empty (the vanished-volume case). + pub fn for_volumes(runtimes: &[&EcVolume]) -> Option { + use crate::storage::erasure_coding::ec_bitrot::BitrotStatus; + + let merged = merge_ec_runtimes(runtimes)?; + let anchor = merged.anchor; + + // Protection is per-DISK state: the `.ecsum` sidecar is not mirrored, + // and at mount each runtime resolves it from its own directories only, + // so after a restart the runtime without the sidecar mounts `Off`. + // Take the first merged runtime that has protection: `On` if any, else + // `Invalid`, else the anchor's `Off`. This only moves toward more + // verification (`Off->On`, `Off->Invalid`, `Invalid->On`), never toward + // silence — the anchor is itself in `merged`, so the fallback is `Off` + // only when every merged runtime is `Off`. + let protection_source = merged + .merged + .iter() + .copied() + .find(|v| matches!(v.bitrot_protection().1, BitrotStatus::On)) + .or_else(|| { + merged + .merged + .iter() + .copied() + .find(|v| matches!(v.bitrot_protection().1, BitrotStatus::Invalid)) + }) + .unwrap_or(anchor); + let (prot, status) = protection_source.bitrot_protection(); + + // Collect every merged runtime whose sidecar resolved Invalid, excluding + // the protection_source (whose error the Invalid arm of run() already + // reports). These surface when protection is taken from a sibling that + // is On, so a malformed sidecar is never silently discarded. + let invalid_sidecar_errors: Vec = merged + .merged + .iter() + .filter(|v| { + let v_ptr: *const EcVolume = **v; + let src_ptr: *const EcVolume = protection_source; + !std::ptr::eq(v_ptr, src_ptr) + && matches!(v.bitrot_protection().1, BitrotStatus::Invalid) + }) + .map(|v| { + format!( + "EC volume {} bitrot sidecar at {} is malformed/unverifiable (sidecar integrity)", + v.volume_id.0, v.dir + ) + }) + .collect(); + + let total = (anchor.data_shards + anchor.parity_shards) as usize; + + let shards = match status { + crate::storage::erasure_coding::ec_bitrot::BitrotStatus::On => merged + .slots + .iter() + .take(total) + .enumerate() + .filter_map(|(id, slot)| slot.map(|(_, shard)| (id as u32, shard.try_clone_file()))) + .collect(), + _ => Vec::new(), + }; + + // If the fence excluded a runtime AND the sidecar that supplied + // protection was resolved from that excluded runtime's directory, the + // checksums cannot be trusted against the merged shards. + let unverifiable_sidecar = if merged.skipped.is_empty() { + None + } else { + let own_dirs: Vec = merged + .merged + .iter() + .flat_map(|v| [v.dir.as_str(), v.dir_idx.as_str()]) + .map(|d| d.trim_end_matches('/').to_string()) + .collect(); + let src = protection_source.bitrot_source_dir.trim_end_matches('/'); + if src.is_empty() || own_dirs.iter().any(|d| d == src) { + None + } else { + Some(protection_source.bitrot_source_dir.clone()) + } + }; + + Some(EcChecksumScrubPlan { + volume_id: anchor.volume_id, + prot, + status, + parity_shards: anchor.parity_shards, + shards, + skipped: merged.skipped, + unverifiable_sidecar, + invalid_sidecar_errors, + }) + } + + /// The byte-verification pass. Touches only the filesystem — no store, no + /// lock — so it is safe to hand to `spawn_blocking`. + pub fn run(self) -> (u64, Vec, Vec) { + use crate::storage::erasure_coding::ec_bitrot; + use crate::storage::erasure_coding::ec_bitrot::BitrotStatus; + + let mut errors: Vec = Vec::new(); + + // Mirrors Go's `ChecksumScrub` (`prot, status := ecv.BitrotProtection()`): + // - Off: sidecars are optional; return clean (Go: `case BitrotOff: return 0, nil, nil`). + // - Invalid: sidecar is present but malformed; report an integrity error. + // - On: scan local shards against it. + let prot = match (self.prot, self.status) { + (_, BitrotStatus::Off) => { + return (0, Vec::new(), Vec::new()); + } + (_, BitrotStatus::Invalid) => { + let mut errs = vec![format!( + "EC volume {} bitrot sidecar is malformed/unverifiable (sidecar integrity)", + self.volume_id.0 + )]; + errs.extend(self.skipped); + errs.extend(self.invalid_sidecar_errors); + return (0, Vec::new(), errs); + } + (Some(p), BitrotStatus::On) => p, + (None, BitrotStatus::On) => { + return (0, Vec::new(), Vec::new()); + } + }; + + errors.extend(self.skipped); + errors.extend(self.invalid_sidecar_errors); + + if let Some(src) = self.unverifiable_sidecar { + errors.push(format!( + "EC volume {} bitrot sidecar was resolved from {}, which belongs to an excluded encode run; protection unverifiable", + self.volume_id.0, src + )); + return (0, Vec::new(), errors); + } + + let block_size = prot.block_size as i64; + + let mut blocks_scanned: u64 = 0; + let mut mismatched_shards: Vec = Vec::new(); + // Track shards whose blocks ALL mismatch (wholesale) to detect a + // stale/wrong sidecar. + let mut wholesale_mismatch = 0usize; + + for (shard_id, handle) in self.shards { + let Some(entry) = ec_bitrot::shard_checksums(&prot, shard_id) else { + errors.push(format!( + "EC volume {} shard {} present but missing from sidecar manifest", + self.volume_id.0, shard_id + )); + continue; + }; + + // The handle was opened under the store lock; reading through it + // means a concurrent unmount/unlink cannot masquerade as bitrot. + let file = match &handle { + Ok(f) => f, + Err(e) => { + errors.push(format!( + "EC volume {} shard {} scrub read error: {}", + self.volume_id.0, shard_id, e + )); + continue; + } + }; + + let expected_blocks = entry.block_crc32c.len() / 4; + match ec_bitrot::verify_shard_blocks(file, entry, block_size) { + Ok(mismatched) => { + blocks_scanned += expected_blocks as u64; + if !mismatched.is_empty() { + mismatched_shards.push(shard_id); + if expected_blocks > 0 && mismatched.len() == expected_blocks { + wholesale_mismatch += 1; + } + } + } + Err(e) => { + errors.push(format!( + "EC volume {} shard {} scrub read error: {}", + self.volume_id.0, shard_id, e + )); + } + } + } + + // If more shards mismatch wholesale than parity can mask, the sidecar + // itself is the likely culprit (stale generation / wrong volume), so + // suppress the shard-corruption verdict and flag a sidecar-integrity + // issue instead. + if wholesale_mismatch > self.parity_shards as usize { + errors.push(format!( + "EC volume {}: {} shards mismatch wholesale (> {} parity); suspect stale/wrong sidecar, not shard corruption", + self.volume_id.0, wholesale_mismatch, self.parity_shards + )); + mismatched_shards.clear(); + } + + mismatched_shards.sort_unstable(); + (blocks_scanned, mismatched_shards, errors) + } +} + +/// Self-contained input for an EC index scrub. +/// +/// Not `Clone`: it owns the .ecx handle opened under the store lock. +#[derive(Debug)] +pub struct EcIndexScrubPlan { + pub volume_id: VolumeId, + pub has_ecx_file: bool, + pub ecx_path: String, + pub ecx_file_size: i64, + pub version: Version, + /// The .ecx handle opened while the store lock was held. Private, so the + /// plan can only come from `EcVolume::scrub_index_plan`. + ecx_handle: std::io::Result, +} + +impl EcIndexScrubPlan { + /// Structural walk of the .ecx index. Filesystem only — no store, no lock. + pub fn run(self) -> (u64, Vec) { + if !self.has_ecx_file { + return ( + 0, + vec![format!( + "no ECX file associated with EC volume {}", + self.volume_id.0 + )], + ); + } + if self.ecx_file_size == 0 { + return ( + 0, + vec![format!( + "zero-size ECX file for EC volume {}", + self.volume_id.0 + )], + ); + } + + // A private fd, so the structural scan never moves the shared ecx_file + // cursor (the cached handle is read positionally elsewhere). Checked + // after the two guards above so the error ordering is unchanged. + let mut ecx_file = match self.ecx_handle { + Ok(f) => f, + Err(e) => return (0, vec![format!("open ECX file {}: {}", self.ecx_path, e)]), + }; + crate::storage::idx::check_index_file(&mut ecx_file, self.ecx_file_size, self.version) + } +} + +/// One local shard as `EcLocalScrubPlan` sees it: the mounted descriptor, the +/// size the scan compares against, and the identity a broken-shard report needs. +#[derive(Debug)] +pub struct EcLocalShard { + file: std::io::Result, + /// The shard's cached size, as `scrub_local` has always compared against — + /// deliberately not a live `metadata()` call in `run()`. + file_size: i64, + info: crate::pb::volume_server_pb::EcShardInfo, +} + +impl EcLocalShard { + /// Positional read through the duplicated handle. `dup` shares the kernel + /// offset with the mounted shard, so this must never seek. + fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result { + let file = self + .file + .as_ref() + .map_err(|e| io::Error::new(e.kind(), e.to_string()))?; + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + file.read_at(buf, offset) + } + #[cfg(not(unix))] + { + use std::io::{Read, Seek, SeekFrom}; + let mut f = file.try_clone()?; + f.seek(SeekFrom::Start(offset))?; + f.read(buf) + } + } +} + +/// Self-contained input for an EC LOCAL scrub: the index snapshot, a private +/// .ecx descriptor for the needle walk, and the mounted local shard handles. +/// +/// Same reasoning as `EcChecksumScrubPlan` — `scrub_local` reads every local +/// needle's bytes, so it must not run under the store read guard. +/// +/// Not `Clone`: it owns descriptors. +#[derive(Debug)] +pub struct EcLocalScrubPlan { + volume_id: VolumeId, + version: Version, + data_shards: u32, + shard_size: i64, + large_block_size: i64, + small_block_size: i64, + index: EcIndexScrubPlan, + ecx_path: String, + ecx_walk: std::io::Result, + /// Indexed BY SHARD ID; `None` is a shard no merged runtime holds. + shards: Vec>, + /// Runtimes the identity fence excluded, one line each. LOCAL has no status + /// gate, so `run()` always reports these. + skipped: Vec, +} + +impl EcLocalScrubPlan { + /// Build one plan over every per-disk runtime of a volume id. `None` only + /// when `runtimes` is empty. + /// + /// The shard vector keeps its SLOT structure — index is the shard id, gaps + /// are shards no merged runtime holds. `run()` reads that distinction to + /// decide whether a needle can be reassembled locally at all, so compacting + /// it would silently change which needles get verified. + pub fn for_volumes(runtimes: &[&EcVolume]) -> Option { + let merged = merge_ec_runtimes(runtimes)?; + let anchor = merged.anchor; + + Some(EcLocalScrubPlan { + volume_id: anchor.volume_id, + version: anchor.version, + data_shards: anchor.data_shards, + // locate_data wants shardSize = datFileSize / DataShards when known, + // else ecdFileSize - 1 (shards are padded to the small block size; + // the -1 avoids an off-by-one in the large-block row count). + // + // The fallback takes the MAX over every merged slot, not + // `anchor.shard_file_size()` -- which returns the anchor's FIRST + // held shard, not a maximum. Before aggregation the plan only read + // the anchor's own shards, so a truncated shard there mis-sized only + // its own runtime; now one disk's truncated shard would set + // `shard_size` for every merged sibling's shards, mis-offset + // `locate_data` and manufacture needle corruption across the whole + // node. `verify_ec_shards` already answers this same question the + // same way (`if size > shard_size { shard_size = size }`), so this + // is the in-tree convention rather than a preference. Reached only + // on the legacy `dat_file_size == 0` path. + // + // `.take(..)` honors the `slots` contract: the volume's shard-id + // range is the ANCHOR's geometry, and a same-generation runtime with + // a disagreeing `.vif` can populate slots beyond it. Scanning those + // would let an OUT-OF-GEOMETRY shard -- one nothing in this volume's + // layout describes -- set the offset math for every shard that IS in + // it, which is a narrower path to exactly the node-wide mis-sizing + // this max exists to close. + shard_size: if anchor.dat_file_size > 0 { + anchor.dat_file_size / anchor.data_shards as i64 + } else { + merged + .slots + .iter() + .take((anchor.data_shards + anchor.parity_shards) as usize) + .flatten() + .map(|(_, s)| s.file_size()) + .max() + .unwrap_or(0) + - 1 + }, + large_block_size: anchor.large_block_size(), + small_block_size: anchor.small_block_size(), + index: anchor.scrub_index_plan(), + ecx_path: anchor.ecx_file_name(), + // A second descriptor: the index plan's is consumed by its own walk, + // and both seek. + ecx_walk: File::open(anchor.ecx_file_name()), + shards: merged + .slots + .iter() + .map(|slot| { + slot.map(|(_, s)| EcLocalShard { + file: s.try_clone_file(), + file_size: s.file_size(), + info: s.to_ec_shard_info(), + }) + }) + .collect(), + skipped: merged.skipped, + }) + } + + /// The needle walk. Filesystem only — no store, no lock. + pub fn run( + self, + ) -> ( + u64, + Vec, + Vec, + ) { + let EcLocalScrubPlan { + volume_id, + version, + data_shards, + shard_size, + large_block_size, + small_block_size, + index, + ecx_path, + ecx_walk, + shards, + skipped, + } = self; + + // Local scan also verifies the index. + let (_, mut errs) = index.run(); + + // LOCAL has no protection status to gate on, so an excluded runtime is + // always reported rather than silently unscanned. + errs.extend(skipped); + + let mut broken_shards: HashSet = HashSet::new(); + let mut count: u64 = 0; + + let mut ecx_file = match ecx_walk { + Ok(f) => f, + Err(e) => { + errs.push(format!("open ECX file {}: {}", ecx_path, e)); + return (count, Vec::new(), errs); + } + }; + + // Reused across every needle/chunk to avoid a per-chunk allocation. + let mut chunk_buf: Vec = Vec::new(); + let walk = crate::storage::idx::walk_index_file(&mut ecx_file, 0, |id, offset, size| { + count += 1; + if size.is_tombstone() { + return Ok(()); + } + + // Go recomputes this at the size check below; hoisted because Rust + // also sizes the reassembly buffer from it. Any negative size other + // than the -1 tombstone skipped above drives it negative. + let want = get_actual_size(size, version); + + let locations = ec_locate::locate_data( + offset.to_actual_offset(), + Size(want as i32), + shard_size, + data_shards, + large_block_size, + small_block_size, + ); + // A needle is verifiable locally only if every shard it spans is local; + // when any is remote, skip the reassembly buffer entirely. + let has_remote_chunks = locations.iter().any(|iv| { + let (sid, _) = + iv.to_shard_id_and_offset(data_shards, large_block_size, small_block_size); + shards.get(sid as usize).and_then(|s| s.as_ref()).is_none() + }); + let mut read: i64 = 0; + // `want <= 0` means the row is malformed. Go pays nothing for it: + // it appends to a nil slice and has no capacity hint here. Rust's + // per-needle buffer does, and `Vec::with_capacity(negative as usize)` + // aborts the process -- inside spawn_blocking that surfaces as a + // JoinError and takes the whole node-wide scrub RPC with it. Fall + // through with an empty buffer instead: locate_data already returns + // no intervals for a non-positive size, so read stays 0 and the + // `read != want` check below reports the row, exactly as Go does. + let mut data: Vec = if has_remote_chunks || want <= 0 { + Vec::new() + } else { + Vec::with_capacity(want as usize) + }; + let mut local_shard_ids: Vec = Vec::new(); + + for (i, iv) in locations.iter().enumerate() { + let (sid, soffset) = + iv.to_shard_id_and_offset(data_shards, large_block_size, small_block_size); + let ssize = iv.size; + let shard = match shards.get(sid as usize).and_then(|s| s.as_ref()) { + Some(s) => s, + None => { + // Shard is not local; we can't verify it without decoding. + read += ssize; + continue; + } + }; + local_shard_ids.push(sid); + + if soffset + ssize > shard.file_size { + broken_shards.insert(sid); + errs.push(format!( + "local shard {} for needle {} is too short ({}), cannot read chunk {}/{}", + sid, + id.0, + shard.file_size, + i + 1, + locations.len() + )); + continue; + } + + chunk_buf.resize(ssize as usize, 0); + match shard.read_at(&mut chunk_buf, soffset as u64) { + Err(e) => { + broken_shards.insert(sid); + errs.push(format!( + "failed to read chunk {}/{} for needle {} from local shard {} at offset {}: {}", + i + 1, + locations.len(), + id.0, + sid, + soffset, + e + )); + continue; + } + Ok(got) if got as i64 != ssize => { + broken_shards.insert(sid); + errs.push(format!( + "expected {} bytes for chunk {}/{} for needle {} from local shard {}, got {}", + ssize, + i + 1, + locations.len(), + id.0, + sid, + got + )); + continue; + } + Ok(_) => {} + } + + if !has_remote_chunks { + data.extend_from_slice(&chunk_buf); + } + read += ssize; + } + + local_shard_ids.sort_unstable(); + + if read != want { + // Like Go, returning from the walk callback aborts the scan. + return Err(io::Error::other(format!( + "expected {} bytes for needle {} on volume {}, got {}", + want, id.0, volume_id.0, read + ))); + } + + // Only a fully-local needle can be reassembled and CRC-checked. + if !has_remote_chunks { + let mut n = Needle::default(); + if let Err(e) = n.read_bytes(&data, 0, size, version) { + // A delete-state disagreement between the .ecx index and the reassembled + // on-disk header (live index vs zero header size) is not corruption. + let delete_state_disagrees = matches!( + &e, + NeedleError::SizeMismatch { found, .. } if size.is_deleted() != (found.0 == 0) + ); + if !delete_state_disagrees { + errs.push(format!( + "needle {} on volume {}, shards {:?}: {}", + id.0, volume_id.0, local_shard_ids, e + )); + } + } + } + Ok(()) + }); + if let Err(e) = walk { + // Go appends the walk/callback error verbatim. + errs.push(e.to_string()); + } + + let mut broken: Vec = broken_shards + .iter() + .filter_map(|sid| shards.get(*sid as usize).and_then(|s| s.as_ref())) + .map(|s| s.info.clone()) + .collect(); + broken.sort_by_key(|a| a.shard_id); + + (count, broken, errs) + } +} + +/// Windows helper: loop `seek_read` until the buffer is fully filled. +/// +/// `seek_read` is positional (it passes the offset through `OVERLAPPED` and +/// never touches the shared file cursor), so concurrent callers reading the +/// same `&File` — as `find_needle_from_ecx` does on the cached `.ecx` handle — +/// can't interleave their reads. Mirrors the helper in `storage::volume`. +#[cfg(windows)] +fn read_exact_at(file: &File, buf: &mut [u8], mut offset: u64) -> io::Result<()> { + use std::os::windows::fs::FileExt; + let mut filled = 0; + while filled < buf.len() { + let n = file.seek_read(&mut buf[filled..], offset)?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "unexpected EOF in seek_read", + )); + } + filled += n; + offset += n as u64; + } + Ok(()) +} diff --git a/seaweed-volume/src/storage/volume.rs b/seaweed-volume/src/storage/volume.rs new file mode 100644 index 0000000..e6f000c --- /dev/null +++ b/seaweed-volume/src/storage/volume.rs @@ -0,0 +1,8464 @@ +//! Volume: the core storage unit — a .dat file + .idx index. +//! +//! Each volume contains many needles (files). It manages: +//! - Reading/writing/deleting needles from the .dat file +//! - Maintaining the in-memory NeedleMap (NeedleId → Offset+Size) +//! - SuperBlock at offset 0 of the .dat file +//! - Metrics (file count, content size, deleted count) +//! +//! Matches Go's storage/volume.go, volume_loading.go, volume_read.go, +//! volume_write.go, volume_super_block.go. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::ops::ControlFlow; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Condvar, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tracing::{error, info, warn}; + +use crate::storage::idx; +use crate::storage::needle::needle::{self, Needle, NeedleError, get_actual_size}; +use crate::storage::needle_map::sorted_file::SortedFileNeedleMap; +use crate::storage::needle_map::{CompactNeedleMap, NeedleMap, NeedleMapKind, RedbNeedleMap}; +use crate::storage::super_block::{ReplicaPlacement, SUPER_BLOCK_SIZE, SuperBlock}; +use crate::storage::types::*; +use crate::storage::volume_open::open_volume_file; + +// ============================================================================ +// Errors +// ============================================================================ + +#[derive(Debug, thiserror::Error)] +pub enum VolumeError { + #[error("not found")] + NotFound, + + #[error("already deleted")] + Deleted, + + #[error("needle size mismatch")] + SizeMismatch, + + #[error("unsupported version: {0}")] + UnsupportedVersion(u8), + + #[error("cookie mismatch: {0:#x}")] + CookieMismatch(u32), + + #[error("volume not empty")] + NotEmpty, + + #[error("volume already exists")] + AlreadyExists, + + #[error("volume is read-only")] + ReadOnly, + + #[error("volume size limit exceeded: current {current}, limit {limit}")] + SizeLimitExceeded { current: u64, limit: u64 }, + + #[error("volume not initialized")] + NotInitialized, + + #[error("needle error: {0}")] + Needle(#[from] NeedleError), + + #[error("super block error: {0}")] + SuperBlock(#[from] crate::storage::super_block::SuperBlockError), + + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("streaming from remote-backed volume requires buffered fallback")] + StreamingUnsupported, +} + +/// Returns true when a needle read failed because the on-disk bytes are +/// unreadable in a permanent way (offset past EOF, header corruption, CRC +/// mismatch, malformed v2/v3/v4 fields). Vacuum can safely drop such entries +/// during compaction. Anything else (real disk EIO, ERROR_CRC on Windows, +/// network timeouts, EROFS, etc.) is transient or environmental and must +/// abort the compaction so an operator notices. +fn is_skippable_needle_read_error(e: &VolumeError) -> bool { + match e { + VolumeError::Io(io_err) => io_err.kind() == io::ErrorKind::UnexpectedEof, + VolumeError::Needle(NeedleError::SizeMismatch { .. }) => true, + VolumeError::Needle(NeedleError::CrcMismatch { .. }) => true, + VolumeError::Needle(NeedleError::IndexOutOfRange(_)) => true, + VolumeError::Needle(NeedleError::TailTooShort) => true, + VolumeError::SizeMismatch => true, + _ => false, + } +} + +/// Reports whether the compacted .dat is short of the live bytes the +/// pre-compaction index snapshot expected. +fn exceeds_expected_compacted_size(expected_live_bytes: u64, dst_dat_size: u64) -> bool { + expected_live_bytes > dst_dat_size +} + +/// Returns true for I/O errors that indicate faulty storage media, not +/// transient/network failures. On Unix this is EIO; on Windows it covers +/// ERROR_CRC and ERROR_IO_DEVICE, which the kernel returns for failing disks. +pub fn is_storage_io_error(e: &io::Error) -> bool { + #[cfg(unix)] + { + e.raw_os_error() == Some(libc::EIO) + } + #[cfg(windows)] + { + const ERROR_CRC: i32 = 23; + const ERROR_IO_DEVICE: i32 = 1117; + return e.raw_os_error() == Some(ERROR_CRC) || e.raw_os_error() == Some(ERROR_IO_DEVICE); + } + #[cfg(not(any(unix, windows)))] + { + false + } +} + +// ============================================================================ +// VolumeInfo (.vif persistence) +// ============================================================================ + +/// Legacy simple VolumeInfo for backward compat with old .vif files. +#[derive(serde::Serialize, serde::Deserialize)] +struct VolumeInfo { + read_only: bool, +} + +pub use crate::pb::volume_server_pb::RemoteFile as PbRemoteFile; +/// Protobuf VolumeInfo type alias. +pub use crate::pb::volume_server_pb::VolumeInfo as PbVolumeInfo; + +/// Helper module for deserializing protojson uint64 fields that may be strings or numbers. +mod string_or_u64 { + use serde::{self, Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &u64, serializer: S) -> Result + where + S: Serializer, + { + // Emit as string to match Go's protojson format for uint64 + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNum { + Str(String), + Num(u64), + } + match StringOrNum::deserialize(deserializer)? { + StringOrNum::Str(s) => s.parse::().map_err(serde::de::Error::custom), + StringOrNum::Num(n) => Ok(n), + } + } +} + +mod string_or_i64 { + use serde::{self, Deserialize, Deserializer, Serializer}; + + pub fn serialize(value: &i64, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrNum { + Str(String), + Num(i64), + } + match StringOrNum::deserialize(deserializer)? { + StringOrNum::Str(s) => s.parse::().map_err(serde::de::Error::custom), + StringOrNum::Num(n) => Ok(n), + } + } +} + +/// Serde-compatible representation of RemoteFile for .vif JSON serialization. +/// Field names use snake_case to match Go's protobuf JSON output (jsonpb). +#[derive(serde::Serialize, serde::Deserialize, Default, Clone)] +pub struct VifRemoteFile { + #[serde(default, rename = "backendType")] + pub backend_type: String, + #[serde(default, rename = "backendId")] + pub backend_id: String, + #[serde(default)] + pub key: String, + #[serde(default, with = "string_or_u64")] + pub offset: u64, + #[serde(default, rename = "fileSize", with = "string_or_u64")] + pub file_size: u64, + #[serde(default, rename = "modifiedTime", with = "string_or_u64")] + pub modified_time: u64, + #[serde(default)] + pub extension: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Default, Clone)] +pub struct VifEcShardConfig { + #[serde(default, rename = "dataShards")] + pub data_shards: u32, + #[serde(default, rename = "parityShards")] + pub parity_shards: u32, + /// Encode time (unix nanos). Shards and .ecx of one encode run share it, + /// so a read served from a different run's shard is rejected. + #[serde(default, rename = "encodeTsNs", with = "string_or_i64")] + pub encode_ts_ns: i64, + /// Uniform block layout: each shard is a single contiguous block of this + /// many bytes. 0 = legacy 1GiB/1MiB two-tier layout. + #[serde(default, rename = "blockSize", with = "string_or_i64")] + pub block_size: i64, +} + +/// Serde-compatible representation of OldVersionVolumeInfo for legacy .vif JSON deserialization. +/// Matches Go's protobuf OldVersionVolumeInfo where `DestroyTime` maps to `expire_at_sec`. +#[derive(serde::Deserialize, Default)] +struct OldVersionVifVolumeInfo { + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub version: u32, + #[serde(default)] + pub replication: String, + #[serde(default, alias = "bytesOffset", alias = "BytesOffset")] + pub bytes_offset: u32, + #[serde( + default, + alias = "datFileSize", + alias = "dat_file_size", + with = "string_or_i64" + )] + pub dat_file_size: i64, + #[serde( + default, + alias = "destroyTime", + alias = "DestroyTime", + with = "string_or_u64" + )] + pub destroy_time: u64, + #[serde(default, alias = "readOnly", alias = "read_only")] + pub read_only: bool, +} + +impl OldVersionVifVolumeInfo { + /// Convert to the standard VifVolumeInfo, mapping destroy_time -> expire_at_sec. + fn into_vif(self) -> VifVolumeInfo { + VifVolumeInfo { + files: self.files, + version: self.version, + replication: self.replication, + bytes_offset: self.bytes_offset, + dat_file_size: self.dat_file_size, + expire_at_sec: self.destroy_time, + read_only: self.read_only, + ec_shard_config: None, + read_only_can_delete: false, + } + } +} + +/// Serde-compatible representation of VolumeInfo for .vif JSON serialization. +/// Matches Go's protobuf JSON format (jsonpb with EmitUnpopulated=true). +#[derive(serde::Serialize, serde::Deserialize, Default, Clone)] +pub struct VifVolumeInfo { + #[serde(default)] + pub files: Vec, + #[serde(default)] + pub version: u32, + #[serde(default)] + pub replication: String, + #[serde(default, rename = "bytesOffset")] + pub bytes_offset: u32, + #[serde(default, rename = "datFileSize", with = "string_or_i64")] + pub dat_file_size: i64, + #[serde(default, rename = "expireAtSec", with = "string_or_u64")] + pub expire_at_sec: u64, + #[serde(default, rename = "readOnly")] + pub read_only: bool, + #[serde( + default, + rename = "ecShardConfig", + skip_serializing_if = "Option::is_none" + )] + pub ec_shard_config: Option, + #[serde(default, rename = "readOnlyCanDelete")] + pub read_only_can_delete: bool, +} + +impl VifVolumeInfo { + /// Convert from protobuf VolumeInfo to the serde-compatible struct. + pub fn from_pb(pb: &PbVolumeInfo) -> Self { + Self { + files: pb + .files + .iter() + .map(|f| VifRemoteFile { + backend_type: f.backend_type.clone(), + backend_id: f.backend_id.clone(), + key: f.key.clone(), + offset: f.offset, + file_size: f.file_size, + modified_time: f.modified_time, + extension: f.extension.clone(), + }) + .collect(), + version: pb.version, + replication: pb.replication.clone(), + bytes_offset: pb.bytes_offset, + dat_file_size: pb.dat_file_size, + expire_at_sec: pb.expire_at_sec, + read_only: pb.read_only, + ec_shard_config: pb.ec_shard_config.as_ref().map(|c| VifEcShardConfig { + data_shards: c.data_shards, + parity_shards: c.parity_shards, + encode_ts_ns: c.encode_ts_ns, + block_size: c.block_size, + }), + read_only_can_delete: pb.read_only_can_delete, + } + } + + /// Convert to protobuf VolumeInfo. + pub fn to_pb(&self) -> PbVolumeInfo { + PbVolumeInfo { + files: self + .files + .iter() + .map(|f| PbRemoteFile { + backend_type: f.backend_type.clone(), + backend_id: f.backend_id.clone(), + key: f.key.clone(), + offset: f.offset, + file_size: f.file_size, + modified_time: f.modified_time, + extension: f.extension.clone(), + }) + .collect(), + version: self.version, + replication: self.replication.clone(), + bytes_offset: self.bytes_offset, + dat_file_size: self.dat_file_size, + expire_at_sec: self.expire_at_sec, + read_only: self.read_only, + ec_shard_config: self.ec_shard_config.as_ref().map(|c| { + crate::pb::volume_server_pb::EcShardConfig { + data_shards: c.data_shards, + parity_shards: c.parity_shards, + encode_ts_ns: c.encode_ts_ns, + block_size: c.block_size, + } + }), + read_only_can_delete: self.read_only_can_delete, + } + } +} + +// ============================================================================ +// Streaming read support +// ============================================================================ + +#[derive(Default)] +struct DataFileAccessState { + readers: usize, + writer_active: bool, +} + +#[derive(Default)] +pub struct DataFileAccessControl { + state: Mutex, + condvar: Condvar, +} + +pub struct DataFileReadLease { + control: Arc, +} + +pub struct DataFileWriteLease { + control: Arc, +} + +impl DataFileAccessControl { + pub fn read_lock(self: &Arc) -> DataFileReadLease { + let mut state = self.state.lock().unwrap(); + while state.writer_active { + state = self.condvar.wait(state).unwrap(); + } + state.readers += 1; + drop(state); + DataFileReadLease { + control: self.clone(), + } + } + + pub fn write_lock(self: &Arc) -> DataFileWriteLease { + let mut state = self.state.lock().unwrap(); + while state.writer_active || state.readers > 0 { + state = self.condvar.wait(state).unwrap(); + } + state.writer_active = true; + drop(state); + DataFileWriteLease { + control: self.clone(), + } + } +} + +impl Drop for DataFileReadLease { + fn drop(&mut self) { + let mut state = self.control.state.lock().unwrap(); + state.readers -= 1; + if state.readers == 0 { + self.control.condvar.notify_all(); + } + } +} + +impl Drop for DataFileWriteLease { + fn drop(&mut self) { + let mut state = self.control.state.lock().unwrap(); + state.writer_active = false; + self.control.condvar.notify_all(); + } +} + +/// Information needed to stream needle data directly from the dat file +/// without loading the entire payload into memory. +pub(crate) enum NeedleStreamSource { + Local(File), + Remote(RemoteDatFile), +} + +impl NeedleStreamSource { + pub(crate) fn clone_for_read(&self) -> io::Result { + match self { + NeedleStreamSource::Local(file) => Ok(NeedleStreamSource::Local(file.try_clone()?)), + NeedleStreamSource::Remote(remote) => Ok(NeedleStreamSource::Remote(remote.clone())), + } + } + + pub(crate) fn read_exact_at(&self, buf: &mut [u8], offset: u64) -> io::Result<()> { + match self { + NeedleStreamSource::Local(file) => { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + file.read_exact_at(buf, offset)?; + } + #[cfg(windows)] + { + read_exact_at(file, buf, offset)?; + } + #[cfg(not(any(unix, windows)))] + { + compile_error!("Platform not supported: only unix and windows are supported"); + } + Ok(()) + } + NeedleStreamSource::Remote(remote) => remote.read_exact_at(buf, offset), + } + } +} + +/// A `.dat` scan that runs with the store lock released, built by +/// `Volume::dat_scan_plan` while the caller holds a store guard. +/// +/// The start offset, the handle and the end bound are captured together +/// while the guard excludes every writer. The handle pins the inode the +/// offset was resolved against: a vacuum commit replaces `.dat` by rename, +/// and unmount or destroy close and unlink it, none of which rewrites the +/// pinned bytes. `.dat` is otherwise append-only and a failed append +/// truncates only its own bytes, so `[from, end)` holds complete records +/// that nothing rewrites while the scan runs. A short read below `end` can +/// therefore only mean the inode was truncated under the plan (an unmount +/// followed by a `VolumeCopy` of the same id reopens `.dat` with +/// `truncate(true)`), and the scan fails rather than ending as if the +/// snapshot had been read. The fields are private so a plan can only come +/// from `dat_scan_plan`. +pub(crate) struct DatScanPlan { + source: NeedleStreamSource, + version: Version, + from: u64, + end: u64, +} + +/// One record visited by `DatScanPlan::scan`: the raw on-disk bytes and the +/// parsed append timestamp. +pub(crate) struct RawNeedle<'a> { + pub header: &'a [u8], + pub body: &'a [u8], + pub append_at_ns: u64, +} + +impl DatScanPlan { + /// Visit the records in `[from, end)` in file order, the way Go's + /// `ScanVolumeFileFrom` feeds a `VolumeFileScanner`: only the record being + /// visited is in memory. Reads are positional and never touch the + /// `Volume`. The pass ends `Ok` at the end of the data, at a record that + /// does not fit before `end`, at a corrupt header, or when `visit` breaks. + /// It fails on any read error, including a short read below `end`: every + /// byte below `end` existed when the plan was taken, so a short read means + /// the file was truncated under the plan and the pass is incomplete. + pub(crate) fn scan( + &self, + mut visit: impl FnMut(RawNeedle<'_>) -> ControlFlow<()>, + ) -> Result<(), VolumeError> { + let mut offset = self.from; + while offset + NEEDLE_HEADER_SIZE as u64 <= self.end { + let mut header = [0u8; NEEDLE_HEADER_SIZE]; + self.source + .read_exact_at(&mut header, offset) + .map_err(|e| self.read_error(e, offset))?; + + let (_cookie, id, size) = Needle::parse_header(&header); + if size.0 == 0 && id.is_empty() { + break; + } + // A negative size is a corrupt header: body_length would size the + // buffer from a negative length or walk the scan from a wrong + // offset. Go's scanners stop here by returning io.EOF. + if size.0 < 0 { + break; + } + + // Nothing past `end` was a complete record when the plan was + // taken. The size is checked against the bytes left, and the + // whole record after it. Both run before allocating, so a corrupt + // size cannot size the buffer. (The size check alone does not + // keep a corrupt size out of the padding arithmetic on a volume + // with more than 2 GiB left, which is why `padding_length` sums + // in i64.) + if size.0 as u64 > self.end - offset - NEEDLE_HEADER_SIZE as u64 { + break; + } + let body_length = needle::needle_body_length(size, self.version) as u64; + let total_size = NEEDLE_HEADER_SIZE as u64 + body_length; + if offset + total_size > self.end { + break; + } + + // Match Go's ScanVolumeFileFrom: visit ALL needles including deleted ones. + // This is critical for incremental copy where tombstones must be propagated. + let mut record = vec![0u8; total_size as usize]; + record[..NEEDLE_HEADER_SIZE].copy_from_slice(&header); + self.source + .read_exact_at( + &mut record[NEEDLE_HEADER_SIZE..], + offset + NEEDLE_HEADER_SIZE as u64, + ) + .map_err(|e| self.read_error(e, offset + NEEDLE_HEADER_SIZE as u64))?; + + let append_at_ns = { + let mut n = Needle::default(); + n.read_bytes(&record, offset as i64, size, self.version)?; + n.append_at_ns + }; + let needle = RawNeedle { + header: &record[..NEEDLE_HEADER_SIZE], + body: &record[NEEDLE_HEADER_SIZE..], + append_at_ns, + }; + if visit(needle).is_break() { + break; + } + offset += total_size; + } + Ok(()) + } + + /// Name the snapshot in a read failure. A short read is the truncation + /// case described on the type; every other error is passed through with + /// the offset. + fn read_error(&self, e: io::Error, offset: u64) -> VolumeError { + let what = if e.kind() == io::ErrorKind::UnexpectedEof { + "short read below the snapshot end, the dat file was truncated under the scan" + } else { + "read failed" + }; + VolumeError::Io(io::Error::new( + e.kind(), + format!( + "{} (offset {}, snapshot end {}): {}", + what, offset, self.end, e + ), + )) + } +} + +pub struct NeedleStreamInfo { + /// Stream source for the dat file, local or remote. + pub(crate) source: NeedleStreamSource, + /// Absolute byte offset within the dat file where needle data starts. + pub data_file_offset: u64, + /// Size of the data payload in bytes. + pub data_size: u32, + /// Per-volume file access lock used to match Go's slow-read behavior. + pub data_file_access_control: Arc, + /// Volume ID — used to re-lookup needle offset if compaction occurs during streaming. + pub volume_id: VolumeId, + /// Needle ID — used to re-lookup needle offset if compaction occurs during streaming. + pub needle_id: NeedleId, + /// Compaction revision at the time of the initial read. If this changes during + /// streaming, the needle's disk offset must be re-read from the needle map because + /// compaction may have moved the needle to a different location. + pub compaction_revision: u16, +} + +#[derive(Clone)] +pub(crate) struct RemoteDatFile { + backend: Arc, + key: String, + file_size: u64, + modified_time: u64, +} + +impl RemoteDatFile { + /// Read up to `size` bytes at `offset`, bounded by the remote file size. + /// Mirrors `Volume::read_dat_slice` for a remote-only backend, but holds no + /// volume/store lock so callers can stream off the store lock entirely. + pub(crate) fn read_slice(&self, offset: u64, size: usize) -> io::Result> { + if size == 0 || offset >= self.file_size { + return Ok(Vec::new()); + } + let read_len = std::cmp::min(size as u64, self.file_size - offset) as usize; + let mut buf = vec![0u8; read_len]; + self.read_exact_at(&mut buf, offset)?; + Ok(buf) + } + + pub(crate) fn read_exact_at(&self, buf: &mut [u8], offset: u64) -> io::Result<()> { + let data = self + .backend + .read_range_blocking(&self.key, offset, buf.len()) + .map_err(io::Error::other)?; + if data.len() != buf.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "remote read short read at offset {}: got {}, expected {}", + offset, + data.len(), + buf.len() + ), + )); + } + buf.copy_from_slice(&data); + Ok(()) + } +} + +// ============================================================================ +// Volume +// ============================================================================ + +pub struct Volume { + pub id: VolumeId, + dir: String, + dir_idx: String, + pub collection: String, + + dat_file: Option, + remote_dat_file: Option, + nm: Option, + /// Make the next .dat or .idx flush fail, so tests can exercise the durable + /// write's failure paths without a real disk fault. + #[cfg(test)] + fail_fsync_for_test: bool, + #[cfg(test)] + fail_idx_sync_for_test: bool, + needle_map_kind: NeedleMapKind, + data_file_access_control: Arc, + + pub super_block: SuperBlock, + + no_write_or_delete: bool, + no_write_can_delete: bool, + + /// Shared flag from the parent DiskLocation indicating low disk space. + /// Matches Go's `v.location.isDiskSpaceLow` checked in `IsReadOnly()`. + pub location_disk_space_low: Arc, + + last_modified_ts_seconds: u64, + last_append_at_ns: u64, + pub last_disk_check_ns: Arc, // for phantom volume detection cache + + last_compact_index_offset: u64, + last_compact_revision: u16, + + is_compacting: bool, + + /// Compaction speed limit in bytes per second (0 = unlimited). + pub compaction_byte_per_second: i64, + + /// Tracks the last I/O error (EIO) for volume health monitoring. + /// Uses Mutex for interior mutability so reads (&self) can clear/set it. + last_io_error: Mutex>, + /// Consecutive EIO count; reset on success or non-EIO errors. + io_error_count: std::sync::atomic::AtomicI32, + /// Sticky quarantine flag set after sustained EIO; cleared only by + /// explicit recovery (mirrors Go's markIoQuarantined). + io_error_quarantined: std::sync::atomic::AtomicBool, + + /// Protobuf VolumeInfo for tiered storage (.vif file). + pub volume_info: PbVolumeInfo, + + /// Whether this volume has a remote file reference. + pub has_remote_file: bool, +} + +/// Windows helper: loop seek_read until buffer is fully filled. +#[cfg(windows)] +fn read_exact_at(file: &File, buf: &mut [u8], mut offset: u64) -> io::Result<()> { + use std::os::windows::fs::FileExt; + let mut filled = 0; + while filled < buf.len() { + let n = file.seek_read(&mut buf[filled..], offset)?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "unexpected EOF in seek_read", + )); + } + filled += n; + offset += n as u64; + } + Ok(()) +} + +/// What a volume is created with beyond its id, directories and index kind: +/// the tail of Go's `NewVolume` argument list. The default is an empty +/// collection with no replication, no TTL and no preallocation at the +/// current needle version, which is what most tests want. +#[derive(Clone, Copy, Debug)] +pub struct VolumeSpec<'a> { + pub collection: &'a str, + pub replica_placement: Option, + pub ttl: Option, + /// Bytes to reserve for the .dat up front; 0 grows on demand. + pub preallocate: u64, + pub version: Version, +} + +impl Default for VolumeSpec<'_> { + fn default() -> Self { + VolumeSpec { + collection: "", + replica_placement: None, + ttl: None, + preallocate: 0, + version: Version::current(), + } + } +} + +impl Volume { + /// Create and load a volume from disk. + pub fn new( + dirname: &str, + dir_idx: &str, + id: VolumeId, + needle_map_kind: NeedleMapKind, + spec: &VolumeSpec<'_>, + ) -> Result { + let VolumeSpec { + collection, + replica_placement, + ttl, + preallocate, + version, + } = *spec; + let mut v = Volume { + id, + dir: dirname.to_string(), + dir_idx: dir_idx.to_string(), + collection: collection.to_string(), + dat_file: None, + remote_dat_file: None, + #[cfg(test)] + fail_fsync_for_test: false, + #[cfg(test)] + fail_idx_sync_for_test: false, + nm: None, + needle_map_kind, + data_file_access_control: Arc::new(DataFileAccessControl::default()), + super_block: SuperBlock { + replica_placement: replica_placement.unwrap_or_default(), + ttl: ttl.unwrap_or(crate::storage::needle::ttl::TTL::EMPTY), + ..SuperBlock::default() + }, + no_write_or_delete: false, + no_write_can_delete: false, + location_disk_space_low: Arc::new(AtomicBool::new(false)), + last_modified_ts_seconds: 0, + last_append_at_ns: 0, + last_disk_check_ns: Arc::new(std::sync::atomic::AtomicI64::new(0)), + last_compact_index_offset: 0, + last_compact_revision: 0, + is_compacting: false, + compaction_byte_per_second: 0, + last_io_error: Mutex::new(None), + io_error_count: std::sync::atomic::AtomicI32::new(0), + io_error_quarantined: std::sync::atomic::AtomicBool::new(false), + volume_info: PbVolumeInfo::default(), + has_remote_file: false, + }; + + v.load(true, true, preallocate, version)?; + Ok(v) + } + + /// Build a minimal, unloaded Volume holding only the identity/path fields + /// reconcile_compact_state needs. Used by the disk-location load pre-pass to + /// recover an interrupted commit before the volume itself is loaded. + pub fn new_unloaded(dirname: &str, dir_idx: &str, collection: &str, id: VolumeId) -> Self { + Volume { + id, + dir: dirname.to_string(), + dir_idx: dir_idx.to_string(), + collection: collection.to_string(), + dat_file: None, + remote_dat_file: None, + #[cfg(test)] + fail_fsync_for_test: false, + #[cfg(test)] + fail_idx_sync_for_test: false, + nm: None, + needle_map_kind: NeedleMapKind::InMemory, + data_file_access_control: Arc::new(DataFileAccessControl::default()), + super_block: SuperBlock::default(), + no_write_or_delete: false, + no_write_can_delete: false, + location_disk_space_low: Arc::new(AtomicBool::new(false)), + last_modified_ts_seconds: 0, + last_append_at_ns: 0, + last_disk_check_ns: Arc::new(std::sync::atomic::AtomicI64::new(0)), + last_compact_index_offset: 0, + last_compact_revision: 0, + is_compacting: false, + compaction_byte_per_second: 0, + last_io_error: Mutex::new(None), + io_error_count: std::sync::atomic::AtomicI32::new(0), + io_error_quarantined: std::sync::atomic::AtomicBool::new(false), + volume_info: PbVolumeInfo::default(), + has_remote_file: false, + } + } + + /// Returns true if the volume is currently being compacted. + pub fn is_compacting(&self) -> bool { + self.is_compacting + } + + // ---- File naming (matching Go) ---- + + /// Base filename: dir/collection_id or dir/id + pub fn data_file_name(&self) -> String { + volume_file_name(&self.dir, &self.collection, self.id) + } + + pub fn index_file_name(&self) -> String { + volume_file_name(&self.dir_idx, &self.collection, self.id) + } + + pub fn file_name(&self, ext: &str) -> String { + match ext { + ".idx" | ".cpx" | ".ldb" | ".cpldb" | ".rdb" => { + format!("{}{}", self.index_file_name(), ext) + } + _ => { + format!("{}{}", self.data_file_name(), ext) + } + } + } + + pub fn version(&self) -> Version { + if self.volume_info.version != 0 { + Version(self.volume_info.version as u8) + } else { + self.super_block.version + } + } + + // ---- Loading ---- + + fn load( + &mut self, + also_load_index: bool, + create_dat_if_missing: bool, + preallocate: u64, + version: Version, + ) -> Result<(), VolumeError> { + let dat_path = self.file_name(".dat"); + let mut already_has_super_block = false; + + let has_volume_info_file = self.load_vif()?; + + if self.volume_info.read_only && !self.has_remote_file { + if self.volume_info.read_only_can_delete { + self.no_write_can_delete = true; + } else { + self.no_write_or_delete = true; + } + } + + if self.has_remote_file { + self.load_remote_dat_file()?; + if let Some(remote_file) = self.volume_info.files.first() { + if remote_file.modified_time > 0 { + self.last_modified_ts_seconds = remote_file.modified_time; + } else if let Ok(metadata) = fs::metadata(self.vif_path()) { + self.last_modified_ts_seconds = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + } + } + already_has_super_block = true; + } else if Path::new(&dat_path).exists() { + let metadata = fs::metadata(&dat_path)?; + + // Try to open read-write; fall back to read-only + match open_volume_file(OpenOptions::new().read(true).write(true), &dat_path) { + Ok(file) => { + self.dat_file = Some(file); + } + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + self.dat_file = + Some(open_volume_file(OpenOptions::new().read(true), &dat_path)?); + self.no_write_or_delete = true; + } + Err(e) => return Err(e.into()), + } + + self.last_modified_ts_seconds = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + if metadata.len() >= SUPER_BLOCK_SIZE as u64 { + already_has_super_block = true; + } + } else if create_dat_if_missing { + // Create directory if needed + if let Some(parent) = Path::new(&dat_path).parent() { + fs::create_dir_all(parent)?; + } + let file = open_volume_file( + OpenOptions::new().read(true).write(true).create(true), + &dat_path, + )?; + if preallocate > 0 { + preallocate_file(&file, preallocate); + } + self.dat_file = Some(file); + } else { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::NotFound, + format!("volume data file {} does not exist", dat_path), + ))); + } + + if already_has_super_block { + match self.read_super_block() { + Ok(()) => { + if !self.super_block.version.is_supported() { + return Err(VolumeError::UnsupportedVersion(self.super_block.version.0)); + } + // Match Go: v.volumeInfo.Version = uint32(v.SuperBlock.Version) + self.volume_info.version = self.super_block.version.0 as u32; + } + Err(e) if self.has_remote_file => { + warn!( + volume_id = self.id.0, + error = %e, + "failed to read remote super block during load" + ); + } + Err(e) => return Err(e), + } + } else { + self.maybe_write_super_block(version)?; + } + + if also_load_index { + // Adjust for existing volumes with .idx together with .dat files: + // an index already beside the data keeps serving after --dir.idx + // named a different directory. + if self.dir_idx != self.dir + && Path::new(&format!("{}.idx", self.data_file_name())).exists() + { + self.dir_idx = self.dir.clone(); + } + + // A changed --dir.idx leaves the new directory without an index. + // The .dat still holds every row, so rebuild rather than mount the + // volume with every needle invisible. + if !self.has_remote_file + && !Path::new(&self.file_name(".idx")).exists() + && self.current_dat_file_size()? > SUPER_BLOCK_SIZE as u64 + { + self.rebuild_idx_file()?; + info!( + volume_id = self.id.0, + idx = %self.file_name(".idx"), + "rebuilt the index from the data file" + ); + } + + // Recover rows that deletes on a tiered read-only volume overwrote + // at the front of .idx. Best effort: a volume that cannot be + // repaired is still servable for everything the surviving rows + // index. + match self.repair_idx_head_tombstones() { + Ok(0) => {} + Ok(restored) => info!( + volume_id = self.id.0, + restored, "recovered overwritten .idx rows from .dat" + ), + Err(e) => warn!( + volume_id = self.id.0, + error = %e, + "recover overwritten .idx rows" + ), + } + + self.load_index()?; + + // Match Go: CheckVolumeDataIntegrity after loading index (volume_loading.go L154-159) + // Only for non-remote volumes (remote storage may not have local .dat) + if !self.has_remote_file { + if let Err(e) = self.check_volume_data_integrity() { + self.no_write_or_delete = true; + warn!( + volume_id = self.id.0, + error = %e, + "volumeDataIntegrityChecking failed" + ); + } + self.recover_last_modified_ts(); + + // Structural check: no .idx entry may reference bytes past the + // end of .dat. The needle map's load walk above already + // populated max_needle_end, so this is a numeric comparison + // — no extra disk I/O. A violation marks the volume read-only + // so vacuum doesn't silently drop reachable data based on a + // corrupt .idx left over from a crashed batched write. + // See issue #8928. + if let Some(ref nm) = self.nm + && let Ok(dat_size) = self.current_dat_file_size() + { + let max_end = nm.max_needle_end(); + if dat_size > 0 && max_end > dat_size as i64 { + self.no_write_or_delete = true; + warn!( + volume_id = self.id.0, + max_needle_end = max_end, + dat_size, + "idx references bytes past end of .dat; marking volume read-only" + ); + } + } + } + } + + // Match Go: if no .vif file existed, create one with version and bytes_offset + if !has_volume_info_file { + self.volume_info.version = self.super_block.version.0 as u32; + self.volume_info.bytes_offset = OFFSET_SIZE as u32; + if let Err(e) = self.save_volume_info() { + warn!( + volume_id = self.id.0, + error = %e, + "failed to save volume info" + ); + } + } + + Ok(()) + } + + fn nm_or_not_found(&self) -> Result<&NeedleMap, VolumeError> { + self.nm.as_ref().ok_or_else(|| { + tracing::warn!(volume_id = self.id.0, "needle map not loaded"); + VolumeError::NotFound + }) + } + + fn load_index(&mut self) -> Result<(), VolumeError> { + let use_redb = matches!( + self.needle_map_kind, + NeedleMapKind::Redb | NeedleMapKind::RedbMedium | NeedleMapKind::RedbLarge + ); + + let idx_path = self.file_name(".idx"); + + // Ensure idx directory exists + if let Some(parent) = Path::new(&idx_path).parent() { + fs::create_dir_all(parent)?; + } + + // Go picks the sorted-file map for both read-only modes, which is every + // cloud-tiered volume: the index stays on disk in .sdx instead of in + // RAM, and nothing is held open between lookups. + if self.use_sorted_index() { + self.load_index_sorted_file(&idx_path)?; + } else if use_redb { + self.load_index_redb(&idx_path)?; + } else { + self.load_index_inmemory(&idx_path)?; + } + + Ok(()) + } + + /// Whether this volume's index belongs in a `.sdx`. Mirrors Go's + /// `v.noWriteOrDelete || v.noWriteCanDelete` branch in volume_loading.go — + /// a tiered volume is always the second of the two. + fn use_sorted_index(&self) -> bool { + self.no_write_or_delete || self.no_write_can_delete + } + + /// Load the on-disk sorted index for a read-only or cloud-tiered volume. + fn load_index_sorted_file(&mut self, idx_path: &str) -> Result<(), VolumeError> { + if !Path::new(idx_path).exists() { + // Missing .idx with existing .dat could orphan needles + let dat_path = self.file_name(".dat"); + if Path::new(&dat_path).exists() { + let dat_size = fs::metadata(&dat_path).map(|m| m.len()).unwrap_or(0); + if dat_size > SUPER_BLOCK_SIZE as u64 { + warn!( + volume_id = self.id.0, + ".idx file missing but .dat exists with data; needles may be orphaned" + ); + } + } + // Go opens .idx with O_CREATE only where deletes are allowed; an + // empty index yields an empty .sdx and a volume that still mounts. + // A volume that takes neither writes nor deletes must not write + // here, since its index directory can sit on a read-only mount. + if !self.no_write_or_delete { + File::create(idx_path)?; + } + } + // Building .sdx writes to the index directory. Where that cannot be + // done — a read-only mount, a full disk — keep this volume's index in + // memory rather than failing the load and taking its data offline. + match SortedFileNeedleMap::open(&self.index_file_name(), self.version()) { + Ok(nm) => { + self.nm = Some(NeedleMap::SortedFile(nm)); + Ok(()) + } + Err(e) => { + warn!( + volume_id = self.id.0, + error = %e, + "cannot build the sorted index; keeping this volume's index in memory" + ); + match self.load_index_inmemory(idx_path) { + Ok(()) => Ok(()), + // Where deletes are allowed the in-memory loader opens .idx + // read-write, which fails on the same directory that just + // refused the .sdx. Give up the deletes rather than the + // volume: without a writer no tombstone could be recorded + // anyway, so refusing them is the honest answer, and a + // remount on a writable directory restores them. + Err(retry_err) if !self.no_write_or_delete => { + warn!( + volume_id = self.id.0, + error = %retry_err, + "cannot open the index for write; serving this volume without deletes" + ); + self.no_write_or_delete = true; + // Deletes are gone with the writer, so stop advertising + // them: leaving no_write_can_delete set would report + // this volume as delete-capable in metrics and mode + // checks while every delete is refused. + self.no_write_can_delete = false; + self.load_index_inmemory(idx_path) + } + Err(retry_err) => Err(retry_err), + } + } + } + } + + /// Load index using in-memory CompactNeedleMap. + fn load_index_inmemory(&mut self, idx_path: &str) -> Result<(), VolumeError> { + if self.no_write_or_delete { + // Open read-only + if Path::new(&idx_path).exists() { + let mut idx_file = open_volume_file(OpenOptions::new().read(true), idx_path)?; + let nm = CompactNeedleMap::load_from_idx(&mut idx_file, self.version())?; + self.nm = Some(NeedleMap::InMemory(nm)); + } else { + // Missing .idx with existing .dat could orphan needles + let dat_path = self.file_name(".dat"); + if Path::new(&dat_path).exists() { + let dat_size = fs::metadata(&dat_path).map(|m| m.len()).unwrap_or(0); + if dat_size > SUPER_BLOCK_SIZE as u64 { + warn!( + volume_id = self.id.0, + ".idx file missing but .dat exists with data; needles may be orphaned" + ); + } + } + self.nm = Some(NeedleMap::InMemory(CompactNeedleMap::new())); + } + } else { + // Open read-write (create if missing) + let idx_file = open_volume_file( + OpenOptions::new().read(true).write(true).create(true), + idx_path, + )?; + + let idx_size = trim_torn_idx_tail(&idx_file, idx_path)?; + let mut idx_reader = io::BufReader::new(&idx_file); + let mut nm = CompactNeedleMap::load_from_idx(&mut idx_reader, self.version())?; + + // Re-open for append-only writes + let write_file = OpenOptions::new().append(true).open(idx_path)?; + nm.set_idx_file(Box::new(write_file), idx_size); + self.nm = Some(NeedleMap::InMemory(nm)); + } + + Ok(()) + } + + /// Load index using disk-backed RedbNeedleMap. + fn load_index_redb(&mut self, idx_path: &str) -> Result<(), VolumeError> { + // The redb database file is stored alongside the volume files + let rdb_path = self.file_name(".rdb"); + // One redb database per volume: keep its page cache small, or the + // process grows by up to redb's 1 GiB default per volume (#11179). + let cache_bytes = self.needle_map_kind.redb_cache_bytes(); + + if self.no_write_or_delete { + // Open read-only + if Path::new(&idx_path).exists() { + let mut idx_file = open_volume_file(OpenOptions::new().read(true), idx_path)?; + let nm = RedbNeedleMap::load_from_idx( + &rdb_path, + &mut idx_file, + self.version(), + cache_bytes, + )?; + self.nm = Some(NeedleMap::Redb(nm)); + } else { + // Missing .idx with existing .dat could orphan needles + let dat_path = self.file_name(".dat"); + if Path::new(&dat_path).exists() { + let dat_size = fs::metadata(&dat_path).map(|m| m.len()).unwrap_or(0); + if dat_size > SUPER_BLOCK_SIZE as u64 { + warn!( + volume_id = self.id.0, + ".idx file missing but .dat exists with data; needles may be orphaned" + ); + } + } + self.nm = Some(NeedleMap::Redb(RedbNeedleMap::new(&rdb_path, cache_bytes)?)); + } + } else { + // Open read-write (create if missing) + let idx_file = open_volume_file( + OpenOptions::new().read(true).write(true).create(true), + idx_path, + )?; + + let idx_size = trim_torn_idx_tail(&idx_file, idx_path)?; + let mut idx_reader = io::BufReader::new(&idx_file); + let mut nm = RedbNeedleMap::load_from_idx( + &rdb_path, + &mut idx_reader, + self.version(), + cache_bytes, + )?; + + // Re-open for append-only writes + let write_file = OpenOptions::new().append(true).open(idx_path)?; + nm.set_idx_file(Box::new(write_file), idx_size); + self.nm = Some(NeedleMap::Redb(nm)); + } + + Ok(()) + } + + pub(crate) fn load_remote_dat_file(&mut self) -> Result<(), VolumeError> { + let (storage_name, storage_key) = self.remote_storage_name_key(); + let backend = crate::remote_storage::s3_tier::global_s3_tier_registry() + .read() + .unwrap() + .get(&storage_name) + .ok_or_else(|| { + VolumeError::Io(io::Error::new( + io::ErrorKind::NotFound, + format!("remote tier backend {} not found", storage_name), + )) + })?; + + let remote_file = self.volume_info.files.first().ok_or_else(|| { + VolumeError::Io(io::Error::new( + io::ErrorKind::NotFound, + "remote volume has no remote file entries", + )) + })?; + + let file_size = if remote_file.file_size > 0 { + remote_file.file_size + } else if self.volume_info.dat_file_size > 0 { + self.volume_info.dat_file_size as u64 + } else { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!("remote volume {} is missing file size metadata", self.id.0), + ))); + }; + + self.dat_file = None; + self.remote_dat_file = Some(RemoteDatFile { + backend, + key: storage_key, + file_size, + modified_time: remote_file.modified_time, + }); + Ok(()) + } + + pub(crate) fn read_exact_at_backend( + &self, + buf: &mut [u8], + offset: u64, + ) -> Result<(), VolumeError> { + if let Some(dat_file) = self.dat_file.as_ref() { + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + dat_file.read_exact_at(buf, offset)?; + } + #[cfg(windows)] + { + read_exact_at(dat_file, buf, offset)?; + } + #[cfg(not(any(unix, windows)))] + { + compile_error!("Platform not supported: only unix and windows are supported"); + } + Ok(()) + } else if let Some(remote_dat_file) = self.remote_dat_file.as_ref() { + remote_dat_file.read_exact_at(buf, offset)?; + Ok(()) + } else { + Err(VolumeError::Io(io::Error::other("dat file not open"))) + } + } + + /// Returns true when the volume has a data backend (local .dat file or + /// remote tiered storage). Mirrors Go's `v.DataBackend != nil` check. + pub fn has_data_backend(&self) -> bool { + self.dat_file.is_some() || self.remote_dat_file.is_some() + } + + fn current_dat_file_size(&self) -> io::Result { + if let Some(ref f) = self.dat_file { + Ok(f.metadata()?.len()) + } else if let Some(ref remote_dat_file) = self.remote_dat_file { + Ok(remote_dat_file.file_size) + } else { + Ok(0) + } + } + + /// Read a raw byte range from the current .dat backend. + /// + /// This matches Go paths that stream directly from `DataBackend`, including + /// remote-only tiered volumes whose `.dat` is no longer present locally. + pub fn read_dat_slice(&self, offset: u64, size: usize) -> Result, VolumeError> { + let _guard = self.data_file_access_control.read_lock(); + let dat_size = self.current_dat_file_size()?; + if size == 0 || offset >= dat_size { + return Ok(Vec::new()); + } + + let read_len = std::cmp::min(size as u64, dat_size - offset) as usize; + let mut buf = vec![0u8; read_len]; + self.read_exact_at_backend(&mut buf, offset)?; + Ok(buf) + } + + /// Clone the remote backend handle (cheap: an `Arc` plus key/size) so a + /// caller can stream a tiered `.dat` from S3 after dropping the store lock. + /// Returns `None` for local volumes. `has_remote_file` implies `dat_file` + /// is `None`, so this selects the same backend `read_dat_slice` would. + pub(crate) fn remote_dat_file(&self) -> Option { + self.remote_dat_file.clone() + } + + // ---- SuperBlock I/O ---- + + fn read_super_block(&mut self) -> Result<(), VolumeError> { + let mut header = [0u8; SUPER_BLOCK_SIZE]; + self.read_exact_at_backend(&mut header, 0)?; + + let extra_size = u16::from_be_bytes([header[6], header[7]]); + let total_size = SUPER_BLOCK_SIZE + extra_size as usize; + + let mut full_buf = vec![0u8; total_size]; + full_buf[..SUPER_BLOCK_SIZE].copy_from_slice(&header); + if extra_size > 0 { + self.read_exact_at_backend(&mut full_buf[SUPER_BLOCK_SIZE..], SUPER_BLOCK_SIZE as u64)?; + } + + self.super_block = SuperBlock::from_bytes(&full_buf)?; + + // Match Go: if volumeInfo.Replication is set, override super block's ReplicaPlacement + if !self.volume_info.replication.is_empty() { + let rp = ReplicaPlacement::from_string(&self.volume_info.replication)?; + self.super_block.replica_placement = rp; + } + + Ok(()) + } + + fn maybe_write_super_block(&mut self, version: Version) -> Result<(), VolumeError> { + let dat_file = self + .dat_file + .as_mut() + .ok_or_else(|| VolumeError::Io(io::Error::other("dat file not open")))?; + + let dat_size = dat_file.metadata()?.len(); + if dat_size == 0 { + if !version.is_supported() { + return Err(VolumeError::UnsupportedVersion(version.0)); + } + self.super_block.version = version; + let bytes = self.super_block.to_bytes(); + dat_file.seek(SeekFrom::Start(0))?; + dat_file.write_all(&bytes)?; + dat_file.sync_all()?; + } + Ok(()) + } + + // ---- Read ---- + + /// Read a needle by its ID from the volume. + pub fn read_needle(&self, n: &mut Needle) -> Result { + let mut read_option = ReadOption::default(); + self.read_needle_with_option(n, &mut read_option) + } + + pub fn read_needle_opt(&self, n: &mut Needle, read_deleted: bool) -> Result { + let mut read_option = ReadOption { + read_deleted, + ..ReadOption::default() + }; + self.read_needle_with_option(n, &mut read_option) + } + + pub fn read_needle_with_option( + &self, + n: &mut Needle, + read_option: &mut ReadOption, + ) -> Result { + let _guard = self.data_file_access_control.read_lock(); + let nm = self.nm_or_not_found()?; + let nv = nm.get(n.id)?.ok_or(VolumeError::NotFound)?; + + if nv.offset.is_zero() { + return Err(VolumeError::NotFound); + } + + let mut read_size = nv.size; + if read_size.is_deleted() { + if read_option.read_deleted && !read_size.is_tombstone() { + // Negate to get original size + read_size = Size(-read_size.0); + } else { + return Err(VolumeError::Deleted); + } + } + if read_size.0 == 0 { + return Ok(0); + } + + match self.read_needle_data_at_unlocked( + n, + nv.offset.to_actual_offset(), + read_size, + read_option, + ) { + Ok(()) => self.check_read_write_error(None), + Err(VolumeError::Io(ref e)) => { + self.check_read_write_error(Some(e)); + return Err(VolumeError::Io(io::Error::new(e.kind(), e.to_string()))); + } + Err(e) => return Err(e), + } + + // TTL expiry check + if n.has_ttl() + && let Some(ref ttl) = n.ttl + { + let ttl_minutes = ttl.minutes(); + if ttl_minutes > 0 && n.has_last_modified_date() { + let expire_at_ns = n.append_at_ns + (ttl_minutes as u64) * 60 * 1_000_000_000; + let now_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + if now_ns >= expire_at_ns { + return Err(VolumeError::NotFound); + } + } + } + + Ok(n.data_size as i32) + } + + /// Read needle data from .dat file at given offset. + pub fn read_needle_data_at( + &self, + n: &mut Needle, + offset: i64, + size: Size, + ) -> Result<(), VolumeError> { + let _guard = self.data_file_access_control.read_lock(); + let mut read_option = ReadOption::default(); + self.read_needle_data_at_unlocked(n, offset, size, &mut read_option) + } + + fn read_needle_data_at_unlocked( + &self, + n: &mut Needle, + offset: i64, + size: Size, + _read_option: &mut ReadOption, + ) -> Result<(), VolumeError> { + match self.read_needle_blob_and_parse(n, offset, size) { + Ok(()) => Ok(()), + #[cfg(not(feature = "5bytes"))] + Err(VolumeError::Needle(NeedleError::SizeMismatch { offset: o, .. })) + if o < MAX_POSSIBLE_VOLUME_SIZE as i64 => + { + // Double-read: in 4-byte offset mode, the actual data may be + // beyond 32GB due to offset wrapping. Retry at offset + 32GB. + self.read_needle_blob_and_parse(n, offset + MAX_POSSIBLE_VOLUME_SIZE as i64, size) + } + Err(e) => Err(e), + } + } + + fn read_needle_blob_and_parse( + &self, + n: &mut Needle, + offset: i64, + size: Size, + ) -> Result<(), VolumeError> { + let version = self.version(); + let actual_size = get_actual_size(size, version); + + let mut buf = vec![0u8; actual_size as usize]; + self.read_exact_at_backend(&mut buf, offset as u64)?; + + n.read_bytes(&buf, offset, size, version)?; + Ok(()) + } + + /// Read raw needle blob at a specific offset. + pub fn read_needle_blob(&self, offset: i64, size: Size) -> Result, VolumeError> { + let _guard = self.data_file_access_control.read_lock(); + self.read_needle_blob_unlocked(offset, size) + } + + fn read_needle_blob_unlocked(&self, offset: i64, size: Size) -> Result, VolumeError> { + let version = self.version(); + let actual_size = get_actual_size(size, version); + let mut buf = vec![0u8; actual_size as usize]; + self.read_exact_at_backend(&mut buf, offset as u64)?; + + Ok(buf) + } + + /// Read needle metadata at a specific offset without loading the data payload. + /// + /// Matches Go's `readNeedleMetaAt`, including the tombstone path where a + /// deleted idx entry passes a negative size and the tombstone record itself + /// is read as size 0 metadata. + pub fn read_needle_meta_at( + &self, + n: &mut Needle, + offset: i64, + size: Size, + ) -> Result<(), VolumeError> { + let _guard = self.data_file_access_control.read_lock(); + self.read_needle_meta_at_unlocked(n, offset, size) + } + + fn read_needle_meta_at_unlocked( + &self, + n: &mut Needle, + offset: i64, + size: Size, + ) -> Result<(), VolumeError> { + let normalized_size = if size.is_deleted() { Size(0) } else { size }; + match self.read_needle_meta_blob_and_parse(n, offset, normalized_size) { + Ok(()) => Ok(()), + #[cfg(not(feature = "5bytes"))] + Err(VolumeError::Needle(NeedleError::SizeMismatch { offset: o, .. })) + if o < MAX_POSSIBLE_VOLUME_SIZE as i64 => + { + self.read_needle_meta_blob_and_parse( + n, + offset + MAX_POSSIBLE_VOLUME_SIZE as i64, + normalized_size, + ) + } + Err(e) => Err(e), + } + } + + fn read_needle_meta_blob_and_parse( + &self, + n: &mut Needle, + offset: i64, + size: Size, + ) -> Result<(), VolumeError> { + let version = self.version(); + + // Step 1: Read only the first 20 bytes (header + DataSize). + // Matches Go's ReadNeedleMeta which reads NeedleHeaderSize+DataSizeSize first. + const HEADER_PREFIX: usize = NEEDLE_HEADER_SIZE + DATA_SIZE_SIZE; // 20 + let mut header_buf = [0u8; HEADER_PREFIX]; + self.read_exact_at_backend(&mut header_buf, offset as u64)?; + + // Parse header to get the needle's Size field for validation + let (_, _, found_size) = Needle::parse_header(&header_buf); + if found_size != size { + return Err(VolumeError::Needle(NeedleError::SizeMismatch { + offset, + id: n.id, + found: found_size, + expected: size, + })); + } + + // Step 2: Calculate how much meta tail to read (skip the data payload) + let actual_size = get_actual_size(size, version); + + if size.0 == 0 || version == VERSION_1 { + // Tombstone or V1: no body data section, tail starts right after header + let meta_size = actual_size - NEEDLE_HEADER_SIZE as i64; + if !(0..=128 * 1024).contains(&meta_size) { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "invalid needle meta size {}: DataSize=0, size={}, offset={}", + meta_size, size.0, offset + ), + ))); + } + let mut meta_buf = vec![0u8; meta_size as usize]; + self.read_exact_at_backend(&mut meta_buf, (offset + NEEDLE_HEADER_SIZE as i64) as u64)?; + n.read_paged_meta(&header_buf, &meta_buf, offset, size, version)?; + } else { + // V2/V3: extract DataSize from bytes 16..20 + let data_size = u32::from_be_bytes([ + header_buf[NEEDLE_HEADER_SIZE], + header_buf[NEEDLE_HEADER_SIZE + 1], + header_buf[NEEDLE_HEADER_SIZE + 2], + header_buf[NEEDLE_HEADER_SIZE + 3], + ]); + + // Skip past: header(16) + DataSize(4) + data(data_size) + let start_offset = + offset + NEEDLE_HEADER_SIZE as i64 + DATA_SIZE_SIZE as i64 + data_size as i64; + let stop_offset = offset + actual_size; + let meta_size = stop_offset - start_offset; + + // Sanity check: reject metadata sizes > 128KB (matching Go's ReadNeedleMeta guard) + if !(0..=128 * 1024).contains(&meta_size) { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "invalid needle meta size {}: DataSize={}, size={}, offset={}", + meta_size, data_size, size.0, offset + ), + ))); + } + + // Step 3: Read only the meta tail (skip the data payload entirely) + let mut meta_buf = vec![0u8; meta_size as usize]; + self.read_exact_at_backend(&mut meta_buf, start_offset as u64)?; + n.read_paged_meta(&header_buf, &meta_buf, offset, size, version)?; + } + + Ok(()) + } + + /// Read needle metadata (header + flags/name/mime/etc) without loading the data payload, + /// and return a `NeedleStreamInfo` that can be used to stream data directly from the dat file. + /// + /// This is used for large needles to avoid loading the entire payload into memory. + pub fn read_needle_stream_info( + &self, + n: &mut Needle, + read_deleted: bool, + ) -> Result { + let _guard = self.data_file_access_control.read_lock(); + let nm = self.nm_or_not_found()?; + let nv = nm.get(n.id)?.ok_or(VolumeError::NotFound)?; + + if nv.offset.is_zero() { + return Err(VolumeError::NotFound); + } + + let mut read_size = nv.size; + if read_size.is_deleted() { + if read_deleted && !read_size.is_tombstone() { + read_size = Size(-read_size.0); + } else { + return Err(VolumeError::Deleted); + } + } + if read_size.0 == 0 { + return Err(VolumeError::NotFound); + } + + #[cfg_attr(feature = "5bytes", allow(unused_mut))] + let mut offset = nv.offset.to_actual_offset(); + let version = self.version(); + let actual_size = get_actual_size(read_size, version); + + // Read the full needle bytes (including data) for metadata parsing. + // We use read_bytes_meta_only which skips copying the data payload. + #[cfg_attr(feature = "5bytes", allow(unused_mut))] + let mut read_and_parse = |off: i64| -> Result<(), VolumeError> { + let mut buf = vec![0u8; actual_size as usize]; + self.read_exact_at_backend(&mut buf, off as u64)?; + n.read_bytes_meta_only(&buf, off, read_size, version)?; + Ok(()) + }; + + match read_and_parse(offset) { + Ok(()) => {} + #[cfg(not(feature = "5bytes"))] + Err(VolumeError::Needle(NeedleError::SizeMismatch { offset: o, .. })) + if o < MAX_POSSIBLE_VOLUME_SIZE as i64 => + { + offset += MAX_POSSIBLE_VOLUME_SIZE as i64; + read_and_parse(offset)?; + } + Err(e) => return Err(e), + } + + // TTL expiry check + if n.has_ttl() + && let Some(ref ttl) = n.ttl + { + let ttl_minutes = ttl.minutes(); + if ttl_minutes > 0 && n.has_last_modified_date() { + let expire_at_ns = n.append_at_ns + (ttl_minutes as u64) * 60 * 1_000_000_000; + let now_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + if now_ns >= expire_at_ns { + return Err(VolumeError::NotFound); + } + } + } + + // For V1, data starts right after the header + // For V2/V3, data starts at header + 4 (DataSize field) + let data_file_offset = if version == VERSION_1 { + offset as u64 + NEEDLE_HEADER_SIZE as u64 + } else { + offset as u64 + NEEDLE_HEADER_SIZE as u64 + 4 // skip DataSize (4 bytes) + }; + + let source = match (self.dat_file.as_ref(), self.remote_dat_file.as_ref()) { + (Some(dat_file), _) => { + NeedleStreamSource::Local(dat_file.try_clone().map_err(VolumeError::Io)?) + } + (None, Some(remote_dat_file)) => NeedleStreamSource::Remote(remote_dat_file.clone()), + (None, None) => return Err(VolumeError::StreamingUnsupported), + }; + + Ok(NeedleStreamInfo { + source, + data_file_offset, + data_size: n.data_size, + data_file_access_control: self.data_file_access_control.clone(), + volume_id: self.id, + needle_id: n.id, + compaction_revision: self.super_block.compaction_revision, + }) + } + + /// Re-lookup a needle's data-file offset after compaction may have moved it. + /// + /// Returns `(new_data_file_offset, current_compaction_revision)` or an error + /// if the needle is no longer present / has been deleted. + /// + /// This matches Go's `readNeedleDataInto` behaviour: when the volume's + /// `CompactionRevision` changes between streaming chunks, the needle offset + /// is re-read from the needle map because compaction may have relocated it. + pub fn re_lookup_needle_data_offset( + &self, + needle_id: NeedleId, + ) -> Result<(u64, u16), VolumeError> { + let nm = self.nm_or_not_found()?; + let nv = nm.get(needle_id)?.ok_or(VolumeError::NotFound)?; + if nv.offset.is_zero() { + return Err(VolumeError::NotFound); + } + if nv.size.is_deleted() { + return Err(VolumeError::Deleted); + } + + let offset = nv.offset.to_actual_offset(); + let version = self.version(); + + let data_file_offset = if version == VERSION_1 { + offset as u64 + NEEDLE_HEADER_SIZE as u64 + } else { + offset as u64 + NEEDLE_HEADER_SIZE as u64 + 4 // skip DataSize (4 bytes) + }; + + Ok((data_file_offset, self.super_block.compaction_revision)) + } + + // ---- Write ---- + + /// Write a needle to the volume (synchronous path). + /// + /// With `fsync` the write is only reported successful once both the .dat + /// record and the .idx row that indexes it are on disk, so an ack survives a + /// crash rather than sitting in the page cache. The two flushes fail + /// differently: a failed .dat flush happens before anything is published, so + /// it takes its own append back off the end and nobody else's, while a + /// failed .idx flush cannot be undone and stops the volume taking writes. + pub fn write_needle( + &mut self, + n: &mut Needle, + check_cookie: bool, + fsync: bool, + ) -> Result<(u64, Size, bool), VolumeError> { + let _guard = self.data_file_access_control.write_lock(); + if self.is_read_only() { + return Err(VolumeError::ReadOnly); + } + + self.do_write_request(n, check_cookie, fsync) + } + + /// Flush the .dat, the first half of a durable write. The .idx is flushed + /// separately by flush_idx once the row is published; the two are split so + /// nothing is indexed before the bytes it points at are down. + fn flush_dat(&self) -> io::Result<()> { + #[cfg(test)] + if self.fail_fsync_for_test { + return Err(io::Error::other("injected fsync failure")); + } + match self.dat_file.as_ref() { + Some(dat_file) => dat_file.sync_all(), + None => Err(io::Error::other("dat file not open")), + } + } + + /// Flush the .idx, the second half of a durable write. load() rebuilds the + /// map from .idx, not from .dat, so the row has to be down before the write + /// is acked: lose it and the volume comes back with a .dat tail the + /// integrity check cannot account for, and loads read only. + /// + /// By the time this runs the row is published and cannot be taken back - + /// undoing it would mean writing to a disk that is already failing, and + /// would leave an .idx row pointing past a truncated .dat. So a failure + /// stops the volume taking writes instead: nothing more gets appended past a + /// record whose index may not survive, and the master routes writes + /// elsewhere once the volume heartbeats read only. + fn flush_idx(&mut self) -> Result<(), VolumeError> { + #[cfg(test)] + if self.fail_idx_sync_for_test { + let e = io::Error::other("injected idx sync failure"); + self.no_write_or_delete = true; + return Err(VolumeError::Io(e)); + } + let synced = match self.nm.as_ref() { + Some(nm) => nm.sync(), + None => Ok(()), + }; + if let Err(e) = synced { + self.check_read_write_error(Some(&e)); + self.no_write_or_delete = true; + tracing::error!( + "volume {}: failed to flush the index after a durable write, \ + marking read only: {}", + self.id.0, + e + ); + return Err(VolumeError::Io(e)); + } + Ok(()) + } + + fn do_write_request( + &mut self, + n: &mut Needle, + check_cookie: bool, + fsync: bool, + ) -> Result<(u64, Size, bool), VolumeError> { + // TTL inheritance from volume (matching Go's writeNeedle2) + { + use crate::storage::needle::ttl::TTL; + let needle_ttl = n.ttl.unwrap_or(TTL::EMPTY); + if needle_ttl == TTL::EMPTY && self.super_block.ttl != TTL::EMPTY { + n.set_has_ttl(); + n.ttl = Some(self.super_block.ttl); + } + } + + // Ensure checksum is computed before dedup check + if n.checksum == crate::storage::needle::crc::CRC(0) && !n.data.is_empty() { + n.checksum = crate::storage::needle::crc::CRC::new(&n.data); + } + + // Dedup check (matches Go: n.DataSize = oldNeedle.DataSize on dedup) + if let Some(old_data_size) = self.is_file_unchanged(n) { + n.data_size = old_data_size; + // Nothing to append, but the write this matched may have been + // non-durable, and the caller is asking for the content to be on + // disk. Its .idx row can be sitting in the page cache too, so both + // files get flushed exactly as they would for a fresh append. + if fsync { + self.flush_dat().map_err(|e| { + self.check_read_write_error(Some(&e)); + VolumeError::Io(e) + })?; + self.flush_idx()?; + } + return Ok((0, Size(n.data_size as i32), true)); + } + + // Cookie validation for existing needle (matches Go: check whenever nm.Get returns ok) + if let Some(nm) = &self.nm + && let Some(nv) = nm.get(n.id)? + { + let mut existing = Needle::default(); + // Read only the header to check cookie + self.read_needle_header_unlocked(&mut existing, nv.offset.to_actual_offset())?; + + if n.cookie.0 == 0 && !check_cookie { + n.cookie = existing.cookie; + } + if existing.cookie != n.cookie { + return Err(VolumeError::CookieMismatch(n.cookie.0)); + } + } + + // Update append timestamp + n.append_at_ns = get_append_at_ns(self.last_append_at_ns); + + // Append to .dat file + let (offset, _body_size, _actual_size) = self.append_needle(n)?; + + // Nothing is published until the bytes are down: an index entry for an + // unflushed append would resolve past the end of the file after a crash, + // and undoing it afterwards would double-count the volume's metrics. + if fsync && let Err(e) = self.flush_dat() { + self.check_read_write_error(Some(&e)); + let truncated = match self.dat_file.as_ref() { + Some(dat_file) => dat_file.set_len(offset), + None => Ok(()), + }; + if let Err(te) = truncated { + // The rejected record is still on the end. A later append + // would bury it mid-file, where the .dat tail check cannot + // see it, so stop taking writes instead. + self.no_write_or_delete = true; + tracing::error!( + "volume {}: failed to truncate back to {} after a failed fsync, \ + marking read only: {}", + self.id.0, + offset, + te + ); + } + return Err(VolumeError::Io(e)); + } + + self.last_append_at_ns = n.append_at_ns; + + // Update needle map (uses n.size = full body size, matching Go's nm.Put) + let prior = match self.nm.as_ref() { + Some(nm) => nm.get(n.id), + None => Ok(None), + }; + let should_update = match prior { + Ok(Some(nv)) => (nv.offset.to_actual_offset() as u64) < offset, + Ok(None) => true, + Err(e) => { + if fsync { + // Leaves the same durable-but-unindexed record a failed put + // below does, so it gets the same treatment. + self.no_write_or_delete = true; + tracing::error!( + "volume {}: failed to read the prior mapping for a durable write at {}, \ + marking read only: {}", + self.id.0, + offset, + e + ); + } + return Err(VolumeError::Io(e)); + } + }; + + if should_update { + let indexed = match self.nm.as_mut() { + Some(nm) => nm.put(n.id, Offset::from_actual_offset(offset as i64), n.size), + None => Ok(()), + }; + if let Err(e) = indexed { + if fsync { + // The record is already down but nothing indexes it, the + // same state a failed .idx flush leaves behind, so it gets + // the same treatment rather than another write on top. + self.no_write_or_delete = true; + tracing::error!( + "volume {}: failed to index a durable write at {}, \ + marking read only: {}", + self.id.0, + offset, + e + ); + } + return Err(VolumeError::Io(e)); + } + } + + if fsync { + self.flush_idx()?; + } + + if self.last_modified_ts_seconds < n.last_modified { + self.last_modified_ts_seconds = n.last_modified; + } + + let checkpoint_ok = self.maybe_checkpoint_index(fsync); + + // Clear the EIO streak only after the full write (data + flush + + // index + checkpoint) succeeds, so a failed fsync or checkpoint + // does not get its EIO erased by the success reset. + if checkpoint_ok { + self.check_read_write_error(None); + } + + // Return Size(n.DataSize) as the logical size, matching Go's doWriteRequest + Ok((offset, Size(n.data_size as i32), false)) + } + + /// Take the index checkpoint the needle map asked for, data first: the + /// checkpoint makes the index durable, and an index row that outlives + /// the bytes it points at loads read-only (see load()). A failed .dat + /// flush therefore skips the checkpoint; the map keeps asking, so it is + /// retried on the next write. + /// + /// When `idx_already_synced` is true the .idx has already been fsynced by + /// `flush_idx` on the fsync=true write path, so the checkpoint skips its + /// own .idx fsync to avoid a redundant one. + fn maybe_checkpoint_index(&mut self, idx_already_synced: bool) -> bool { + let due = self.nm.as_ref().is_some_and(|nm| nm.checkpoint_due()); + if !due { + return true; + } + if let Err(e) = self.flush_dat() { + self.check_read_write_error(Some(&e)); + tracing::warn!( + "volume {}: skipping index checkpoint, .dat flush failed: {}", + self.id.0, + e + ); + return false; + } + let checkpointed = match self.nm.as_mut() { + Some(nm) => nm.checkpoint(!idx_already_synced), + None => Ok(()), + }; + if let Err(e) = checkpointed { + self.check_read_write_error(Some(&e)); + tracing::warn!("volume {}: index checkpoint failed: {}", self.id.0, e); + return false; + } + true + } + + fn read_needle_header_unlocked(&self, n: &mut Needle, offset: i64) -> Result<(), VolumeError> { + let mut header = [0u8; NEEDLE_HEADER_SIZE]; + self.read_exact_at_backend(&mut header, offset as u64)?; + + n.read_header(&header); + Ok(()) + } + + /// Check if the needle is unchanged from the existing one on disk. + /// Returns `Some(old_data_size)` if unchanged, `None` otherwise. + /// Matches Go's isFileUnchanged which also sets n.DataSize = oldNeedle.DataSize. + fn is_file_unchanged(&self, n: &Needle) -> Option { + // Don't dedup for volumes with TTL + if self.super_block.ttl != crate::storage::needle::ttl::TTL::EMPTY { + return None; + } + + if let Some(nm) = &self.nm { + let existing = match nm.get(n.id) { + Ok(existing) => existing, + Err(e) => { + // Not a lookup miss: the index could not be read. Report + // "unknown" so the caller writes the needle again rather + // than treating an unreadable index as proof of a change. + warn!(volume_id = self.id.0, error = %e, "needle map lookup failed"); + return None; + } + }; + if let Some(nv) = existing + && !nv.offset.is_zero() + && nv.size.is_valid() + { + let mut old = Needle::default(); + let mut ro = ReadOption::default(); + if self + .read_needle_data_at_unlocked( + &mut old, + nv.offset.to_actual_offset(), + nv.size, + &mut ro, + ) + .is_ok() + && old.cookie == n.cookie + && old.checksum == n.checksum + && old.data == n.data + { + return Some(old.data_size); + } + } + } + None + } + + /// Append a needle to the .dat file. Returns (offset, size, actual_size). + fn append_needle(&mut self, n: &mut Needle) -> Result<(u64, Size, i64), VolumeError> { + let version = self.version(); + let bytes = n.write_bytes(version); + let actual_size = bytes.len() as i64; + + let dat_file = self + .dat_file + .as_mut() + .ok_or_else(|| VolumeError::Io(io::Error::other("dat file not open")))?; + + let offset = dat_file.seek(SeekFrom::End(0))?; + + // Check volume size limit before writing (matching Go's Append) + if offset >= MAX_POSSIBLE_VOLUME_SIZE && !n.data.is_empty() { + return Err(VolumeError::SizeLimitExceeded { + current: offset, + limit: MAX_POSSIBLE_VOLUME_SIZE, + }); + } + + if let Err(e) = dat_file.write_all(&bytes) { + // Truncate back to pre-write position on error (matching Go) + let _ = dat_file.set_len(offset); + self.check_read_write_error(Some(&e)); + return Err(VolumeError::Io(e)); + } + + Ok((offset, n.size, actual_size)) + } + + // ---- Delete ---- + + /// Delete a needle from the volume. + pub fn delete_needle(&mut self, n: &mut Needle) -> Result { + let _guard = self.data_file_access_control.write_lock(); + if self.no_write_or_delete { + return Err(VolumeError::ReadOnly); + } + self.do_delete_request(n) + } + + fn do_delete_request(&mut self, n: &mut Needle) -> Result { + let (found, size, _stored_offset) = if let Some(nm) = &self.nm { + // Propagate: acknowledging the delete as a no-op because the index + // could not be read would drop the tombstone on the floor while the + // caller believes the needle is gone. + if let Some(nv) = nm.get(n.id)? { + if !nv.size.is_deleted() { + (true, nv.size, nv.offset) + } else { + (false, Size(0), Offset::default()) + } + } else { + (false, Size(0), Offset::default()) + } + } else { + return Ok(Size(0)); + }; + + if !found { + return Ok(Size(0)); + } + + // Write tombstone: append needle with empty data + n.data = vec![]; + n.append_at_ns = get_append_at_ns(self.last_append_at_ns); + + let offset = if !self.has_remote_file { + // Normal volume: append tombstone to .dat file + let (offset, _, _) = self.append_needle(n)?; + offset + } else { + // Remote-tiered volume: skip .dat append, use offset 0 + 0 + }; + self.last_append_at_ns = n.append_at_ns; + + // Update index + if let Some(nm) = &mut self.nm { + nm.delete(n.id, Offset::from_actual_offset(offset as i64))?; + } + let checkpoint_ok = self.maybe_checkpoint_index(false); + + // Clear the EIO streak after a successful delete (tombstone append + + // index update + checkpoint), mirroring do_write_request. + if checkpoint_ok { + self.check_read_write_error(None); + } + + Ok(size) + } + + // ---- Metrics ---- + + pub fn content_size(&self) -> u64 { + self.nm.as_ref().map_or(0, |nm| nm.content_size()) + } + + pub fn deleted_size(&self) -> u64 { + self.nm.as_ref().map_or(0, |nm| nm.deleted_size()) + } + + pub fn file_count(&self) -> i64 { + self.nm.as_ref().map_or(0, |nm| nm.file_count()) + } + + pub fn deleted_count(&self) -> i64 { + self.nm.as_ref().map_or(0, |nm| nm.deleted_count()) + } + + pub fn max_file_key(&self) -> NeedleId { + self.nm.as_ref().map_or(NeedleId(0), |nm| nm.max_file_key()) + } + + pub fn is_read_only(&self) -> bool { + self.no_write_or_delete + || self.no_write_can_delete + || self.location_disk_space_low.load(Ordering::Relaxed) + } + + pub fn is_no_write_or_delete(&self) -> bool { + self.no_write_or_delete + } + + pub fn set_no_write_or_delete(&mut self, value: bool) { + self.no_write_or_delete = value; + } + + pub fn is_no_write_can_delete(&self) -> bool { + self.no_write_can_delete + } + + pub fn last_compact_revision(&self) -> u16 { + self.last_compact_revision + } + + pub fn last_modified_ts(&self) -> u64 { + self.last_modified_ts_seconds + } + + pub fn is_expired(&self, volume_size: u64, volume_size_limit: u64) -> bool { + if volume_size_limit == 0 { + return false; + } + if volume_size <= SUPER_BLOCK_SIZE as u64 { + return false; + } + let ttl_minutes = self.super_block.ttl.minutes(); + if ttl_minutes == 0 { + return false; + } + let lived_minutes = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_sub(self.last_modified_ts_seconds) + / 60; + (ttl_minutes as u64) < lived_minutes + } + + /// When this volume's data becomes garbage, counted from its last write. + /// Counting from the current time instead let every .vif rewrite — a + /// read-only mark, a tier upload, an EC encode — hand an already expiring + /// volume another full TTL. Zero when the volume has no TTL. Mirrors Go's + /// ExpireAtSec. + pub fn expire_at_sec(&self) -> u64 { + let ttl_seconds = self.super_block.ttl.to_seconds(); + if ttl_seconds == 0 { + return 0; + } + let mut last_write_sec = self.last_modified_ts_seconds; + if last_write_sec == 0 { + last_write_sec = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + } + last_write_sec + ttl_seconds + } + + pub fn is_expired_long_enough(&self, max_delay_minutes: u32) -> bool { + let ttl_minutes = self.super_block.ttl.minutes(); + if ttl_minutes == 0 { + return false; + } + let removal_delay = std::cmp::min(ttl_minutes / 10, max_delay_minutes); + ((ttl_minutes + removal_delay) as u64) * 60 + self.last_modified_ts_seconds + < SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + /// Read all live needles from the volume (for ReadAllNeedles streaming RPC). + pub fn read_all_needles(&self) -> Result, VolumeError> { + let _guard = self.data_file_access_control.read_lock(); + let nm = self.nm_or_not_found()?; + let version = self.version(); + let dat_size = self.current_dat_file_size()? as i64; + let mut needles = Vec::new(); + let mut offset = self.super_block.block_size() as i64; + + while offset < dat_size { + let mut header = [0u8; NEEDLE_HEADER_SIZE]; + match self.read_exact_at_backend(&mut header, offset as u64) { + Ok(()) => {} + Err(VolumeError::Io(e)) if e.kind() == io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(e), + } + + let (_cookie, key, size) = Needle::parse_header(&header); + if size.0 == 0 && key.is_empty() { + break; + } + + let body_length = needle::needle_body_length(size, version); + let total_size = NEEDLE_HEADER_SIZE as i64 + body_length; + + if size.is_deleted() || size.0 <= 0 { + offset += total_size; + continue; + } + + let Some(nv) = nm.get(key)? else { + offset += total_size; + continue; + }; + if nv.offset.to_actual_offset() != offset { + offset += total_size; + continue; + } + + let mut n = Needle { + id: key, + ..Needle::default() + }; + let mut read_option = ReadOption::default(); + self.read_needle_data_at_unlocked(&mut n, offset, size, &mut read_option)?; + needles.push(n); + + offset += total_size; + } + Ok(needles) + } + + /// Check volume data integrity by verifying the last index entries against the .dat file. + /// Matches Go's CheckVolumeDataIntegrity (volume_checking.go L117-141). + /// Reads the last few index entries, verifies each needle header is readable and + /// consistent. On failure, marks the volume read-only. + fn check_volume_data_integrity(&mut self) -> Result<(), VolumeError> { + let idx_path = self.file_name(".idx"); + if !Path::new(&idx_path).exists() { + return Ok(()); + } + + let idx_size = fs::metadata(&idx_path).map(|m| m.len()).unwrap_or(0) as i64; + if idx_size == 0 { + return Ok(()); + } + if idx_size % NEEDLE_MAP_ENTRY_SIZE as i64 != 0 { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!("index file's size is {} bytes, maybe corrupted", idx_size), + ))); + } + + let version = self.version(); + + // The deeper-than-tail structural check (every (offset + actual size) + // fits inside .dat — issue #8928) is now handled in load() via the + // needle map's max_needle_end accumulator, so we don't pay for a + // second linear scan of the .idx here. + + // Verify the needle physically last in the .dat (the highest offset) + // and that the .dat ends exactly at it. The .idx is not always in .dat + // append order: weed fix and other rebuilds could rewrite it sorted by + // key, which puts the highest-key needle last instead of the .dat-tail + // needle. Picking the last file-position entry there compares a + // mid-file needle's tail against the full .dat size and falsely flips + // the volume read-only on every load. Mirrors Go's + // findDatTailEntryOffset + doCheckAndFixVolumeData. + let mut idx_file = File::open(&idx_path)?; + let tail_entry_pos = self.find_dat_tail_entry_offset(&mut idx_file, idx_size, version)?; + if tail_entry_pos < 0 { + // pre-allocated / all-zero index, nothing to verify against the .dat + return Ok(()); + } + + let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + idx_file.seek(SeekFrom::Start(tail_entry_pos as u64))?; + idx_file.read_exact(&mut buf)?; + let (key, offset, size) = idx_entry_from_bytes(&buf); + if offset.is_zero() { + return Ok(()); + } + if size.is_deleted() { + // Deletion tombstone: the .idx carries TombstoneFileSize (-1) but + // the appended needle in .dat carries Size=0, so verify with + // Size=0 — comparing against -1 would mismatch the on-disk header + // and send every volume with a trailing deletion read-only. + self.verify_deleted_needle_integrity(offset.to_actual_offset(), key, version) + } else { + self.verify_needle_integrity(offset.to_actual_offset(), key, size, version) + } + } + + /// Bounds the work a vacuumed volume's recovery does, where key order makes + /// every write a candidate for the newest one. A volume with more live + /// needles than this keeps the .dat mtime: reading a subset could recover a + /// timestamp older than the newest write and expire data still inside its + /// TTL, so a scan that will not fit declines instead of guessing. + const VACUUMED_LAST_WRITE_SCAN_ENTRIES: usize = 1 << 16; + + /// Point the TTL clock at the newest write recorded in the volume, replacing + /// the .dat mtime the loader starts from. A delete appends a tombstone and + /// vacuum rewrites the .dat wholesale, so the mtime moves without any write + /// ever landing: every restart of a volume taking delete traffic re-armed + /// is_expired() for another full TTL and the volume was never reclaimed. + /// Mirrors Go's recoverLastModifiedTs. + fn recover_last_modified_ts(&mut self) { + if self.super_block.ttl.minutes() == 0 { + return; + } + match self.find_last_write_append_at_ns() { + Ok(0) => {} + Ok(append_at_ns) => self.last_modified_ts_seconds = append_at_ns / 1_000_000_000, + Err(e) => warn!( + volume_id = self.id.0, + error = %e, + "recover the last write from the index" + ), + } + } + + /// Scan the .idx backwards for the newest write — an entry that is not a + /// deletion tombstone — and return that needle's append timestamp. The .idx + /// and the .dat share an order, so an append-ordered volume answers with the + /// first write the scan reaches. Vacuum rewrites both in key order, which + /// tracks write order only because the master issues keys increasing: an + /// overwrite keeps its original, lower key, so a vacuumed volume has to take + /// the maximum over every write it indexes. Returns 0 when the .idx holds + /// nothing but tombstones, when a vacuumed volume holds more needles than + /// the scan budget, or for a volume older than version 3, whose needles + /// carry no append timestamp. Mirrors Go's findLastWriteAppendAtNs. + fn find_last_write_append_at_ns(&self) -> Result { + let version = self.version(); + if version != VERSION_3 { + return Ok(0); + } + let idx_path = self.file_name(".idx"); + let idx_size = fs::metadata(&idx_path).map(|m| m.len()).unwrap_or(0) as i64; + if idx_size == 0 || idx_size % NEEDLE_MAP_ENTRY_SIZE as i64 != 0 { + return Ok(0); + } + let scan_every_write = self.super_block.compaction_revision > 0; + let mut entry_budget = Self::VACUUMED_LAST_WRITE_SCAN_ENTRIES; + let mut last_write_append_at_ns = 0u64; + let mut idx_file = File::open(&idx_path)?; + let mut block = vec![0u8; NEEDLE_MAP_ENTRY_SIZE * idx::ROWS_TO_READ]; + let mut end = idx_size; + while end > 0 { + let start = (end - block.len() as i64).max(0); + let entries = &mut block[..(end - start) as usize]; + idx_file.seek(SeekFrom::Start(start as u64))?; + idx_file.read_exact(entries)?; + for entry in entries.as_chunks::().0.iter().rev() { + let (key, offset, size) = idx_entry_from_bytes(entry); + if offset.is_zero() || size.is_deleted() { + continue; + } + let Some(needle_offset) = + self.find_needle_offset(offset.to_actual_offset(), key, size) + else { + continue; + }; + last_write_append_at_ns = last_write_append_at_ns + .max(self.read_needle_append_at_ns(needle_offset, size)?); + if !scan_every_write { + return Ok(last_write_append_at_ns); + } + entry_budget -= 1; + if entry_budget == 0 { + warn!( + volume_id = self.id.0, + budget = Self::VACUUMED_LAST_WRITE_SCAN_ENTRIES, + "too many needles to scan for the last write, keeping the .dat mtime" + ); + return Ok(0); + } + } + end = start; + } + Ok(last_write_append_at_ns) + } + + /// The .dat offset holding the needle an .idx entry describes, or None when + /// no needle there matches it. A .dat past MAX_POSSIBLE_VOLUME_SIZE wraps the + /// offsets in its .idx, so the needle can sit one volume size further in; + /// verify_needle_integrity retries the same way. Mirrors Go's + /// findNeedleOffset. + fn find_needle_offset(&self, actual_offset: i64, key: NeedleId, size: Size) -> Option { + for at in [ + actual_offset, + actual_offset + MAX_POSSIBLE_VOLUME_SIZE as i64, + ] { + let mut header = [0u8; NEEDLE_HEADER_SIZE]; + if self.read_exact_at_backend(&mut header, at as u64).is_err() { + continue; + } + let (_, needle_id, needle_size) = Needle::parse_header(&header); + if needle_id == key && needle_size == size { + return Some(at); + } + } + None + } + + /// Read the append timestamp a version 3 needle carries past its checksum. + fn read_needle_append_at_ns(&self, actual_offset: i64, size: Size) -> Result { + let ts_offset = actual_offset as u64 + + NEEDLE_HEADER_SIZE as u64 + + size.0 as u64 + + NEEDLE_CHECKSUM_SIZE as u64; + let mut ts = [0u8; TIMESTAMP_SIZE]; + self.read_exact_at_backend(&mut ts, ts_offset)?; + Ok(u64::from_be_bytes(ts)) + } + + /// .idx file position of the entry whose needle is physically last in the + /// .dat (highest offset). The common case — an append-ordered .idx — is + /// resolved in O(1): the last entry's on-disk end equals the .dat size. A + /// key-sorted .idx (e.g. legacy weed fix output) falls back to a single + /// linear scan for the maximum offset. Returns -1 when no entry has a + /// non-zero offset (a pre-allocated / all-zero index). + fn find_dat_tail_entry_offset( + &self, + idx_file: &mut File, + idx_size: i64, + version: Version, + ) -> Result { + if let Ok(dat_size) = self.dat_file_size() + && dat_size > 0 + { + let last_pos = idx_size - NEEDLE_MAP_ENTRY_SIZE as i64; + let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + idx_file.seek(SeekFrom::Start(last_pos as u64))?; + idx_file.read_exact(&mut buf)?; + let (_, offset, size) = idx_entry_from_bytes(&buf); + if !offset.is_zero() && needle_disk_end(offset, size, version) == dat_size as i64 { + return Ok(last_pos); + } + } + + // Slow path: scan the whole .idx for the entry at the maximum offset. + // Tombstone entries are not skipped: a trailing deletion is a real + // on-disk record and can be the needle physically last in the .dat. + let mut max_entry_pos: i64 = -1; + let mut max_actual_offset: i64 = -1; + let mut entry_pos: i64 = 0; + let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + idx_file.seek(SeekFrom::Start(0))?; + let mut reader = io::BufReader::new(idx_file); + while entry_pos < idx_size { + reader.read_exact(&mut buf)?; + let (_, offset, _) = idx_entry_from_bytes(&buf); + if !offset.is_zero() { + let ao = offset.to_actual_offset(); + if ao > max_actual_offset { + max_actual_offset = ao; + max_entry_pos = entry_pos; + } + } + entry_pos += NEEDLE_MAP_ENTRY_SIZE as i64; + } + Ok(max_entry_pos) + } + + /// Verify the live needle at the .dat tail: its header must match the + /// .idx entry and, on v3, the .dat must end exactly at its on-disk end. + /// Extra bytes mean an unindexed trailing record (a torn append); + /// appending after one would place the next needle at an offset the + /// 8-byte .idx encoding may not represent, so the volume is quarantined + /// read-only instead. Mirrors Go's verifyNeedleIntegrity, whose tail + /// check is v3-only -- a v1/v2 volume Go serves read-write must not go + /// read-only here. + fn verify_needle_integrity( + &mut self, + actual_offset: i64, + key: NeedleId, + size: Size, + version: Version, + ) -> Result<(), VolumeError> { + let mut header = [0u8; NEEDLE_HEADER_SIZE]; + self.read_exact_at_backend(&mut header, actual_offset as u64)?; + let (_cookie, needle_id, needle_size) = Needle::parse_header(&header); + + let mut checked_offset = actual_offset; + let mut checked_id = needle_id; + if needle_size != size { + // Go parity: on a size mismatch, retry past the 32GB offset + // wrap-around before giving up. + let alt_offset = actual_offset + MAX_POSSIBLE_VOLUME_SIZE as i64; + let mut alt_header = [0u8; NEEDLE_HEADER_SIZE]; + self.read_exact_at_backend(&mut alt_header, alt_offset as u64) + .map_err(|_| size_mismatch_error(actual_offset, needle_id, needle_size, size))?; + let (_, alt_id, alt_size) = Needle::parse_header(&alt_header); + if alt_size != size { + return Err(size_mismatch_error( + actual_offset, + needle_id, + needle_size, + size, + )); + } + checked_offset = alt_offset; + checked_id = alt_id; + } + + if !key.is_empty() && checked_id != key { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "index key {:?} does not match needle Id {:?} at offset {}", + key, checked_id, checked_offset + ), + ))); + } + + if version != VERSION_3 { + return Ok(()); + } + + let ts = self.read_needle_append_at_ns(checked_offset, size)?; + if ts > 0 { + self.last_append_at_ns = ts; + } + + self.verify_dat_ends_at(checked_offset + get_actual_size(size, version)) + } + + /// Verify the deletion tombstone at the .dat tail. Tombstones are appended + /// with DataSize=0, so the on-disk header carries Size=0; verify with that + /// and then require the .dat to end exactly at the tombstone. Mirrors Go's + /// verifyDeletedNeedleIntegrity. + fn verify_deleted_needle_integrity( + &mut self, + actual_offset: i64, + key: NeedleId, + version: Version, + ) -> Result<(), VolumeError> { + let record_size = get_actual_size(Size(0), version) as usize; + let mut record = vec![0u8; record_size]; + self.read_exact_at_backend(&mut record, actual_offset as u64)?; + + let mut n = Needle::default(); + n.read_bytes(&record, actual_offset, Size(0), version) + .map_err(|e| { + VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!("read tombstone at offset {}: {}", actual_offset, e), + )) + })?; + if !key.is_empty() && n.id != key { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "index key {:?} does not match needle Id {:?} at offset {}", + key, n.id, actual_offset + ), + ))); + } + if n.append_at_ns > 0 { + self.last_append_at_ns = n.append_at_ns; + } + + self.verify_dat_ends_at(actual_offset + get_actual_size(Size(0), version)) + } + + /// The checked needle is the one physically last in the .dat, so the file + /// must end exactly at it; extra bytes mean an unindexed trailing record. + fn verify_dat_ends_at(&self, file_tail_offset: i64) -> Result<(), VolumeError> { + let file_size = self.dat_file_size().map_err(VolumeError::Io)? as i64; + if file_size == file_tail_offset { + return Ok(()); + } + let dat_path = self.file_name(".dat"); + if file_size > file_tail_offset { + warn!( + "data file {} actual {} bytes expected {} bytes!", + dat_path, file_size, file_tail_offset + ); + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "data file {} actual {} bytes expected {} bytes", + dat_path, file_size, file_tail_offset + ), + ))); + } + warn!( + "data file {} has {} bytes, less than expected {} bytes!", + dat_path, file_size, file_tail_offset + ); + Err(VolumeError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "data file {} has {} bytes, less than expected {} bytes", + dat_path, file_size, file_tail_offset + ), + ))) + } + + /// Open the on-disk .idx for scrubbing and apply Go openIndex's zero-size + /// guard: a zero-size index is only legal for a pre-allocated volume whose + /// .dat is superblock-only; an empty index over a populated .dat is + /// corruption. Returns the open file + size, or the messages to report. + fn open_index_for_scrub(&self) -> Result<(File, i64), Vec> { + let idx_path = self.file_name(".idx"); + let idx_file = File::open(&idx_path) + .map_err(|e| vec![format!("open index file {}: {}", idx_path, e)])?; + let idx_file_size = idx_file + .metadata() + .map_err(|e| vec![format!("stat index file {}: {}", idx_path, e)])? + .len() as i64; + if idx_file_size == 0 { + match self.dat_file_size() { + Ok(dat_size) if dat_size > SUPER_BLOCK_SIZE as u64 => { + return Err(vec![format!( + "zero-size IDX file for volume {} with store size {}", + self.id.0, dat_size + )]); + } + Ok(_) => {} + Err(e) => { + return Err(vec![format!( + "stat data file for volume {}: {}", + self.id.0, e + )]); + } + } + } + Ok((idx_file, idx_file_size)) + } + + /// Scrub the volume index: verify the on-disk .idx for overlapping needles + /// and a size that is a whole number of entries. Mirrors Go's Volume.ScrubIndex + /// → idx.CheckIndexFile. Index-only; does not read the .dat. + pub fn scrub_index(&self) -> Result<(u64, Vec), VolumeError> { + let _guard = self.data_file_access_control.read_lock(); + let (mut idx_file, idx_file_size) = match self.open_index_for_scrub() { + Ok(v) => v, + Err(msgs) => return Ok((0, msgs)), + }; + Ok(crate::storage::idx::check_index_file( + &mut idx_file, + idx_file_size, + self.version(), + )) + } + + /// Scrub the volume: walk the on-disk .idx (every physical row, including + /// superseded and deleted entries), CRC-verify each live needle, and reconcile + /// the .dat size against the needles it indexes. Mirrors Go's Volume.Scrub → + /// scrubVolumeData. Returns (rows_walked, broken_needles). + pub fn scrub(&self) -> Result<(u64, Vec), VolumeError> { + // Matches Go's scrubVolumeData: a volume with no data backend can't be + // scrubbed (and a zero dat_size would flag every needle out of range). + if !self.has_data_backend() { + return Err(VolumeError::NotFound); + } + let _guard = self.data_file_access_control.read_lock(); + let (mut idx_file, idx_file_size) = match self.open_index_for_scrub() { + Ok(v) => v, + Err(msgs) => return Ok((0, msgs)), + }; + + let version = self.version(); + let dat_size = self.dat_file_size().map_err(VolumeError::Io)?; + + // Full scrub also runs the index structural check. + let (_, mut broken) = + crate::storage::idx::check_index_file(&mut idx_file, idx_file_size, version); + + let mut count: u64 = 0; + let mut total_read: i64 = 0; + let walk = + crate::storage::idx::walk_index_file(&mut idx_file, 0, |needle_id, offset, size| { + count += 1; + // A remote-tier delete records an offset-0 tombstone with no physical + // .dat bytes, so it must not contribute to total_read. + if offset.is_zero() && size.is_deleted() { + return Ok(()); + } + // Deleted needles still occupy .dat space: count their size, don't read. + total_read += get_actual_size(size, version); + if size.is_deleted() { + return Ok(()); + } + let actual_offset = offset.to_actual_offset(); + if actual_offset < 0 || actual_offset as u64 >= dat_size { + broken.push(format!( + "needle {} offset {} out of range (dat_size={})", + needle_id.0, actual_offset, dat_size + )); + return Ok(()); + } + // Lock held above; read directly via the unlocked path (matches Go). + let mut n = Needle { + id: needle_id, + ..Needle::default() + }; + let mut read_option = ReadOption::default(); + if let Err(e) = + self.read_needle_data_at_unlocked(&mut n, actual_offset, size, &mut read_option) + { + broken.push(format!( + "failed to read needle {} on volume {}: {}", + needle_id.0, self.id.0, e + )); + } + Ok(()) + }); + if let Err(e) = walk { + broken.push(format!("walk index file: {}", e)); + } + + // Reconcile the physical .dat size against the needles read. + let want_size = total_read + SUPER_BLOCK_SIZE as i64; + if (dat_size as i64) < want_size { + broken.push(format!( + "data file for volume {} is smaller ({}) than the {} needles it contains ({})", + self.id.0, dat_size, count, want_size + )); + } else if dat_size as i64 != want_size { + broken.push(format!( + "data file size for volume {} ({}) doesn't match the size for {} needles read ({})", + self.id.0, dat_size, count, want_size + )); + } + + Ok((count, broken)) + } + + /// Capture a `.dat` scan from `from_offset` that the caller can run after + /// dropping its store guard. See `DatScanPlan` for why the offset, the + /// handle and the end bound must come from the same guard. + pub(crate) fn dat_scan_plan(&self, from_offset: u64) -> Result { + let source = if self.dat_file.is_some() { + // A fresh open, not `try_clone`: a duplicated handle shares the + // file position, and on Windows `read_exact_at` goes through + // `seek_read`, which moves it under a concurrent append. Opened + // while the caller's guard excludes a vacuum swap, the path names + // the inode `dat_file` holds. + NeedleStreamSource::Local(open_volume_file( + OpenOptions::new().read(true), + self.file_name(".dat"), + )?) + } else if let Some(remote) = self.remote_dat_file() { + NeedleStreamSource::Remote(remote) + } else { + return Err(VolumeError::Io(io::Error::other("dat file not open"))); + }; + Ok(DatScanPlan { + source, + version: self.version(), + from: from_offset, + end: self.current_dat_file_size()?, + }) + } + + /// Insert or update a needle index entry (for low-level blob writes). + pub fn put_needle_index( + &mut self, + key: NeedleId, + offset: Offset, + size: Size, + ) -> Result<(), VolumeError> { + if let Some(ref mut nm) = self.nm { + nm.put(key, offset, size).map_err(VolumeError::Io)?; + } + self.maybe_checkpoint_index(false); + Ok(()) + } + + /// Mark this volume as read-only (no writes or deletes) and persist. + pub fn set_read_only(&mut self) -> Result<(), VolumeError> { + self.set_read_only_persist(false, true) + } + + /// Mark this volume as read-only, optionally persisting to .vif. + /// With `can_delete`, deletes still land so expiring data drains the volume. + pub fn set_read_only_persist( + &mut self, + can_delete: bool, + persist: bool, + ) -> Result<(), VolumeError> { + if can_delete && !self.has_remote_file { + // deletes append tombstones to .idx; a read-only boot attached no writer + self.attach_idx_writer_if_missing()?; + } + self.no_write_or_delete = !can_delete; + if can_delete { + self.no_write_can_delete = true; + } else if !self.has_remote_file { + // downgrading a canDelete mark; remote volumes keep their derived flag + self.no_write_can_delete = false; + } + if persist { + self.save_vif()?; + } + Ok(()) + } + + /// Attach an append-only .idx writer when missing (read-only boot). Runs + /// before any flag flips so a failure cannot leave puts silently skipping + /// the on-disk append. + fn attach_idx_writer_if_missing(&mut self) -> Result<(), VolumeError> { + let needs_idx_writer = self + .nm + .as_ref() + .map(|nm| !nm.has_idx_writer()) + .unwrap_or(false); + if needs_idx_writer { + let idx_path = self.file_name(".idx"); + let write_file = OpenOptions::new() + .append(true) + .create(true) + .open(&idx_path)?; + let idx_size = trim_torn_idx_tail(&write_file, &idx_path)?; + if let Some(ref mut nm) = self.nm { + nm.set_idx_file(Box::new(write_file), idx_size); + } + } + Ok(()) + } + + /// Mark this volume as writable (allow writes and deletes). + /// + /// If the volume booted with .vif ReadOnly=true, `load_index` built the + /// needle map without an .idx writer attached, so subsequent puts would + /// silently skip the on-disk append and only mutate in-memory state — + /// surviving until the next restart, then vanishing. Re-attach a writer + /// here so writes persist again. + pub fn set_writable(&mut self) -> Result<(), VolumeError> { + let was_no_write_or_delete = self.no_write_or_delete; + let was_no_write_can_delete = self.no_write_can_delete; + self.no_write_or_delete = false; + // Remote-tiered volumes must stay no_write_can_delete regardless of marks. + if !self.has_remote_file { + self.no_write_can_delete = false; + } + + // Everything below keys off the flags above, so it runs with the volume + // already marked writable. Any failure has to put them back: a volume + // advertising writable whose needle map cannot reach .idx acknowledges + // writes that are gone after a restart. + if let Err(e) = self.publish_writable() { + self.no_write_or_delete = was_no_write_or_delete; + self.no_write_can_delete = was_no_write_can_delete; + return Err(e); + } + self.reset_io_error_state(); + Ok(()) + } + + /// The steps that must all succeed before a volume can be left marked + /// writable: swap out the read-only map, attach the .idx writer, persist the + /// mark. Split out so set_writable has one rollback point rather than three. + fn publish_writable(&mut self) -> Result<(), VolumeError> { + self.reconcile_index_mode()?; + self.attach_idx_writer_if_missing()?; + self.save_vif() + } + + /// Swap the read-only sorted map for the configured writable one once the + /// current mode says the volume is no longer read-only. Mirrors Go's + /// reopenIdxForWrite, and is the reason a volume that stops being read-only + /// can accept writes at all: SortedFileNeedleMap::put always fails, so + /// publishing a writable volume without this leaves every write appending to + /// .dat and then failing to index it. A no-op unless the map is the sorted + /// one and the flags have already moved. + fn reconcile_index_mode(&mut self) -> Result<(), VolumeError> { + let wants_sorted = self.use_sorted_index(); + let has_sorted = matches!(self.nm, Some(NeedleMap::SortedFile(_))); + if wants_sorted == has_sorted { + return Ok(()); + } + // Both directions matter. Tiering DOWN has to trade the read-only + // sorted map for a writable one before the volume is published as + // writable. Tiering UP has to install the sorted map, or a volume + // tiered while the server runs keeps its whole index in RAM and its + // .idx descriptor pinned until the next restart — the cost this map + // exists to avoid. + // + // load_index only publishes self.nm once the new map is built, so a + // failure leaves the previous map in place and the caller can recover. + self.load_index() + } + + /// Recompute the Go-style write/delete mode from the current remote tier + /// state, and bring the needle map in line with it — a volume that stops + /// being remote also stops using the read-only sorted index. + /// + /// If the map cannot be rebuilt the volume is pinned read-only rather than + /// published as writable with an index that rejects every put; a restart + /// recovers it. + pub fn refresh_remote_write_mode(&mut self) -> Result<(), VolumeError> { + self.has_remote_file = !self.volume_info.files.is_empty(); + if self.has_remote_file { + self.no_write_can_delete = true; + self.no_write_or_delete = false; + } else if !self.volume_info.read_only_can_delete { + // only clear the remoteness-derived flag, not an operator mark + self.no_write_can_delete = false; + } + if let Err(e) = self.reconcile_index_mode() { + self.no_write_or_delete = true; + return Err(e); + } + Ok(()) + } + + /// Open the local .dat as the data backend, dropping any remote backend, so reads + /// are served from local disk. Mirrors Go's swapToLocalDatBackend after a tier-down. + pub(crate) fn open_local_dat_backend(&mut self) -> Result<(), VolumeError> { + let file = open_volume_file(OpenOptions::new().read(true).write(true), self.dat_path())?; + self.remote_dat_file = None; + self.dat_file = Some(file); + Ok(()) + } + + /// Path to .vif file. + fn vif_path(&self) -> String { + format!("{}.vif", self.data_file_name()) + } + + /// Load volume info from .vif file. + /// Supports both the protobuf-JSON format (Go-compatible) and legacy JSON. + /// Returns true if a .vif file was found and successfully loaded. + fn load_vif(&mut self) -> Result { + let path = self.vif_path(); + if let Ok(content) = fs::read_to_string(&path) { + if content.trim().is_empty() { + return Ok(false); + } + // Try protobuf-JSON (Go-compatible VolumeInfo via VifVolumeInfo) + if let Ok(vif_info) = serde_json::from_str::(&content) { + let pb_info = vif_info.to_pb(); + if pb_info.read_only { + if pb_info.read_only_can_delete { + self.no_write_can_delete = true; + } else { + self.no_write_or_delete = true; + } + } + self.volume_info = pb_info; + self.refresh_remote_write_mode()?; + if self.volume_info.version == 0 { + self.volume_info.version = Version::current().0 as u32; + } + if !self.has_remote_file && self.volume_info.bytes_offset == 0 { + self.volume_info.bytes_offset = OFFSET_SIZE as u32; + } + if self.volume_info.bytes_offset != 0 + && self.volume_info.bytes_offset != OFFSET_SIZE as u32 + { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "bytes_offset mismatch in {}: found {}, expected {}", + path, self.volume_info.bytes_offset, OFFSET_SIZE + ), + ))); + } + return Ok(true); + } + // Fall back to OldVersionVolumeInfo (Go's tryOldVersionVolumeInfo): + // maps DestroyTime -> expire_at_sec + if let Ok(old_info) = serde_json::from_str::(&content) { + let vif_info = old_info.into_vif(); + let pb_info = vif_info.to_pb(); + if pb_info.read_only { + self.no_write_or_delete = true; + } + self.volume_info = pb_info; + self.refresh_remote_write_mode()?; + if self.volume_info.version == 0 { + self.volume_info.version = Version::current().0 as u32; + } + if !self.has_remote_file && self.volume_info.bytes_offset == 0 { + self.volume_info.bytes_offset = OFFSET_SIZE as u32; + } + if self.volume_info.bytes_offset != 0 + && self.volume_info.bytes_offset != OFFSET_SIZE as u32 + { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "bytes_offset mismatch in {}: found {}, expected {}", + path, self.volume_info.bytes_offset, OFFSET_SIZE + ), + ))); + } + return Ok(true); + } + // Fall back to legacy format + if let Ok(info) = serde_json::from_str::(&content) { + if info.read_only { + self.no_write_or_delete = true; + } + return Ok(true); + } + } + Ok(false) + } + + /// Save volume info to .vif file in protobuf-JSON format (Go-compatible). + /// Matches Go's SaveVolumeInfo: checks writability before writing and propagates errors. + fn save_vif(&mut self) -> Result<(), VolumeError> { + let vif_path = self.vif_path(); + + // Match Go: if file exists but is not writable, return an error + let path = std::path::Path::new(&vif_path); + if path.exists() { + let metadata = fs::metadata(path)?; + if metadata.permissions().readonly() { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("failed to check {} not writable", vif_path), + ))); + } + } + + // remoteness-derived no_write_can_delete is not an operator mark; sync + // volume_info so refresh_remote_write_mode sees a real mark + let marked_can_delete = + self.no_write_can_delete && !self.has_remote_file && !self.no_write_or_delete; + self.volume_info.read_only = self.no_write_or_delete || marked_can_delete; + self.volume_info.read_only_can_delete = marked_can_delete; + let mut vif = VifVolumeInfo::from_pb(&self.volume_info); + + // Match Go's SaveVolumeInfo: compute ExpireAtSec from TTL + let expire_at_sec = self.expire_at_sec(); + if expire_at_sec > 0 { + vif.expire_at_sec = expire_at_sec; + } + + let content = serde_json::to_string_pretty(&vif) + .map_err(|e| VolumeError::Io(io::Error::other(e.to_string())))?; + fs::write(&vif_path, content)?; + Ok(()) + } + + /// Save full VolumeInfo to .vif file (for tiered storage). + /// Matches Go's SaveVolumeInfo which computes ExpireAtSec from TTL. + pub fn save_volume_info(&mut self) -> Result<(), VolumeError> { + let marked_can_delete = + self.no_write_can_delete && !self.has_remote_file && !self.no_write_or_delete; + self.volume_info.read_only = self.no_write_or_delete || marked_can_delete; + self.volume_info.read_only_can_delete = marked_can_delete; + + // Compute ExpireAtSec from TTL (matches Go's SaveVolumeInfo) + let expire_at_sec = self.expire_at_sec(); + if expire_at_sec > 0 { + self.volume_info.expire_at_sec = expire_at_sec; + } + + let vif = VifVolumeInfo::from_pb(&self.volume_info); + let content = serde_json::to_string_pretty(&vif) + .map_err(|e| VolumeError::Io(io::Error::other(e.to_string())))?; + // fsync the .vif so a tiered volume's remote reference is durable before the + // caller acts on it, e.g. deletes the remote object (matches Go util.WriteFile). + let mut f = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(self.vif_path())?; + f.write_all(content.as_bytes())?; + f.sync_all()?; + Ok(()) + } + + /// Get the remote storage backend name and key from this volume .vif. + pub fn remote_storage_name_key(&self) -> (String, String) { + if self.volume_info.files.is_empty() { + return (String::new(), String::new()); + } + let rf = &self.volume_info.files[0]; + let backend_name = if rf.backend_id.is_empty() { + rf.backend_type.clone() + } else { + format!("{}.{}", rf.backend_type, rf.backend_id) + }; + (backend_name, rf.key.clone()) + } + + /// Get the dat file path for this volume. + pub fn dat_path(&self) -> String { + self.file_name(".dat") + } + + /// Get the directory this volume is stored in. + pub fn dir(&self) -> &str { + &self.dir + } + + /// Throttle IO during compaction to avoid saturating disk. + pub fn maybe_throttle_compaction(&self, bytes_written: u64) { + if self.compaction_byte_per_second <= 0 || !self.is_compacting { + return; + } + // Simple throttle: sleep based on bytes written vs allowed rate + let sleep_us = + (bytes_written as f64 / self.compaction_byte_per_second as f64 * 1_000_000.0) as u64; + if sleep_us > 0 { + std::thread::sleep(std::time::Duration::from_micros(sleep_us)); + } + } + + /// Change the replication placement and rewrite the super block. + pub fn set_replica_placement(&mut self, rp: ReplicaPlacement) -> Result<(), VolumeError> { + self.super_block.replica_placement = rp; + let bytes = self.super_block.to_bytes(); + let dat_file = self + .dat_file + .as_mut() + .ok_or_else(|| VolumeError::Io(io::Error::other("dat file not open")))?; + dat_file.seek(SeekFrom::Start(0))?; + dat_file.write_all(&bytes)?; + dat_file.sync_all()?; + Ok(()) + } + + // ---- Binary search for incremental copy ---- + + /// Read a single index entry's offset from the .idx file by entry index. + fn read_offset_from_index(&self, m: i64) -> Result { + let idx_path = self.file_name(".idx"); + let idx_file = File::open(&idx_path)?; + let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + let file_offset = m as u64 * NEEDLE_MAP_ENTRY_SIZE as u64; + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + idx_file.read_exact_at(&mut buf, file_offset)?; + } + #[cfg(not(unix))] + { + let mut f = idx_file; + f.seek(SeekFrom::Start(file_offset))?; + std::io::Read::read_exact(&mut f, &mut buf)?; + } + let (_key, offset, _size) = idx_entry_from_bytes(&buf); + Ok(offset) + } + + /// Read the append_at_ns timestamp from a needle at the given offset in the .dat file. + /// Go reads the full needle body for ALL entries including tombstones to get the + /// actual AppendAtNs timestamp, which is needed for correct binary search during + /// incremental copy. + fn read_append_at_ns(&self, offset: Offset) -> Result { + let actual_offset = offset.to_actual_offset() as u64; + let version = self.version(); + + let mut header_buf = [0u8; NEEDLE_HEADER_SIZE]; + self.read_exact_at_backend(&mut header_buf, actual_offset)?; + + let (_cookie, _id, size) = Needle::parse_header(&header_buf); + + let actual_size = get_actual_size(size, version); + if actual_size <= 0 { + return Ok(0); + } + let mut buf = vec![0u8; actual_size as usize]; + self.read_exact_at_backend(&mut buf, actual_offset)?; + + let mut n = Needle::default(); + n.read_bytes_meta_only(&buf, offset.to_actual_offset(), size, version)?; + Ok(n.append_at_ns) + } + + /// Search right from position m to find the first non-deleted entry. + fn read_right_ns(&self, m: i64, max: i64) -> Result<(i64, Offset, u64), VolumeError> { + let mut index = m; + loop { + index += 1; + if index >= max { + return Ok((index, Offset::default(), 0)); + } + let offset = self.read_offset_from_index(index)?; + if !offset.is_zero() { + let ts = self.read_append_at_ns(offset)?; + return Ok((index, offset, ts)); + } + } + } + + /// Search left from position m to find the first non-deleted entry. + fn read_left_ns(&self, m: i64) -> Result<(i64, Offset, u64), VolumeError> { + let mut index = m; + loop { + index -= 1; + if index < 0 { + return Ok((index, Offset::default(), 0)); + } + let offset = self.read_offset_from_index(index)?; + if !offset.is_zero() { + let ts = self.read_append_at_ns(offset)?; + return Ok((index, offset, ts)); + } + } + } + + /// Binary search through the .idx file to find the first needle + /// with append_at_ns > since_ns. Returns (offset, is_last). + /// Matches Go's BinarySearchByAppendAtNs in volume_backup.go. + pub fn binary_search_by_append_at_ns( + &self, + since_ns: u64, + ) -> Result<(Offset, bool), VolumeError> { + let file_size = self.idx_file_size() as i64; + if file_size % NEEDLE_MAP_ENTRY_SIZE as i64 != 0 { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected idx file size: {}", file_size), + ))); + } + + let entry_count = file_size / NEEDLE_MAP_ENTRY_SIZE as i64; + let mut l: i64 = 0; + let mut h: i64 = entry_count; + + while l < h { + let m = (l + h) / 2; + + if m == entry_count { + return Ok((Offset::default(), true)); + } + + let offset = self.read_offset_from_index(m)?; + + if offset.is_zero() { + let (left_index, _left_offset, left_ns) = self.read_left_ns(m)?; + let (right_index, right_offset, right_ns) = self.read_right_ns(m, entry_count)?; + + if right_ns <= since_ns { + l = right_index; + if l == entry_count { + return Ok((Offset::default(), true)); + } else { + continue; + } + } + if since_ns < left_ns { + h = left_index + 1; + continue; + } + return Ok((right_offset, false)); + } + + let m_ns = self.read_append_at_ns(offset)?; + + if m_ns <= since_ns { + l = m + 1; + } else { + h = m; + } + } + + if l == entry_count { + return Ok((Offset::default(), true)); + } + + let offset = self.read_offset_from_index(l)?; + Ok((offset, false)) + } + + /// Write a raw needle blob at a specific offset in the .dat file. + pub fn write_needle_blob( + &mut self, + offset: i64, + needle_blob: &[u8], + ) -> Result<(), VolumeError> { + if self.is_read_only() { + return Err(VolumeError::ReadOnly); + } + let dat_file = self + .dat_file + .as_mut() + .ok_or_else(|| VolumeError::Io(io::Error::other("dat file not open")))?; + dat_file.seek(SeekFrom::Start(offset as u64))?; + dat_file.write_all(needle_blob)?; + Ok(()) + } + + /// Write a needle blob and update the needle map index. + /// Matches Go's Volume.WriteNeedleBlob which appends to dat and calls nm.Put. + pub fn write_needle_blob_and_index( + &mut self, + needle_id: NeedleId, + needle_blob: &[u8], + size: Size, + ) -> Result<(), VolumeError> { + // nm.put on a read-only volume fails only after the blob is appended to .dat. + if self.is_read_only() { + return Err(VolumeError::ReadOnly); + } + + // size indexes the needle and places the v3 append timestamp, so a caller using + // the payload-only DataSize corrupts both, silently until the needle is read back. + if needle_blob.len() < NEEDLE_HEADER_SIZE { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "needle {} blob of {} bytes is shorter than a needle header", + needle_id.0, + needle_blob.len() + ), + ))); + } + let (_, _, header_size) = Needle::parse_header(needle_blob); + if header_size != size { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "needle {} size {} does not match its blob header size {}", + needle_id.0, size.0, header_size.0 + ), + ))); + } + + // Dedup check: if the same needle already exists with matching content, skip the write. + // Matches Go's WriteNeedleBlob which reads existing needle and compares cookie+checksum+data. + if let Some(nm) = &self.nm + && let Some(nv) = nm.get(needle_id)? + && nv.size == size + { + let version = self.version(); + // Read existing needle from disk + let mut old_needle = Needle::default(); + let mut ro = ReadOption::default(); + if self + .read_needle_data_at_unlocked( + &mut old_needle, + nv.offset.to_actual_offset(), + nv.size, + &mut ro, + ) + .is_ok() + { + // Parse the incoming blob into a needle + let mut new_needle = Needle::default(); + if new_needle + .read_bytes(needle_blob, nv.offset.to_actual_offset(), size, version) + .is_ok() + && old_needle.cookie == new_needle.cookie + && old_needle.checksum == new_needle.checksum + && old_needle.data == new_needle.data + { + return Ok(()); + } + } + } + + // Check volume size limit + let content_size = self.content_size(); + if MAX_POSSIBLE_VOLUME_SIZE < content_size + needle_blob.len() as u64 { + return Err(VolumeError::Io(io::Error::other(format!( + "volume size limit {} exceeded! current size is {}", + MAX_POSSIBLE_VOLUME_SIZE, content_size + )))); + } + + // Compute monotonic appendAtNs (matches Go: needle.GetAppendAtNs(v.lastAppendAtNs)) + let append_at_ns = get_append_at_ns(self.last_append_at_ns); + + // Patch appendAtNs timestamp into V3 blobs (matches Go WriteNeedleBlob L64-77) + let mut blob_buf; + let blob_to_write = if self.version() == VERSION_3 { + let ts_offset = NEEDLE_HEADER_SIZE + size.0 as usize + NEEDLE_CHECKSUM_SIZE; + if ts_offset + TIMESTAMP_SIZE > needle_blob.len() { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "needle blob buffer too small: need {} bytes, have {}", + ts_offset + TIMESTAMP_SIZE, + needle_blob.len() + ), + ))); + } + blob_buf = needle_blob.to_vec(); + blob_buf[ts_offset..ts_offset + TIMESTAMP_SIZE] + .copy_from_slice(&append_at_ns.to_be_bytes()); + &blob_buf[..] + } else { + needle_blob + }; + + // Append blob at end of dat file + let dat_size = self.dat_file_size()? as i64; + self.write_needle_blob(dat_size, blob_to_write)?; + + // Update lastAppendAtNs (matches Go L352: v.lastAppendAtNs = appendAtNs) + self.last_append_at_ns = append_at_ns; + + // Update needle map index + let offset = Offset::from_actual_offset(dat_size); + if let Some(ref mut nm) = self.nm { + nm.put(needle_id, offset, size)?; + } + self.maybe_checkpoint_index(false); + + Ok(()) + } + + pub fn needs_replication(&self) -> bool { + self.super_block.replica_placement.get_copy_count() > 1 + } + + /// Garbage ratio: deleted_size / content_size (matching Go's garbageLevel). + /// content_size is the additive-only FileByteCounter. + /// + /// When DeletedCount > 0 but DeletedSize == 0 (e.g. .sdx converted back to + /// normal .idx where deleted entry sizes are missing), falls back to + /// computing deleted bytes as (datFileSize - contentSize - SuperBlockSize) + /// and uses datFileSize as the denominator. + pub fn garbage_level(&self) -> f64 { + let content = self.content_size(); + if content == 0 { + return 0.0; + } + let mut deleted = self.deleted_size(); + let mut file_size = content; + + if self.deleted_count() > 0 && deleted == 0 { + // This happens for .sdx converted back to normal .idx + // where deleted entry size is missing + let dat_file_size = self.dat_file_size().unwrap_or(0); + deleted = dat_file_size + .saturating_sub(content) + .saturating_sub(SUPER_BLOCK_SIZE as u64); + file_size = dat_file_size; + } + + if file_size == 0 { + return 0.0; + } + deleted as f64 / file_size as f64 + } + + pub fn dat_file_size(&self) -> io::Result { + self.current_dat_file_size() + } + + /// Get the modification time of the .dat file as Unix seconds. + pub fn dat_file_mod_time(&self) -> u64 { + if let Some(dat_file) = self.dat_file.as_ref() { + dat_file + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0) + } else { + self.remote_dat_file + .as_ref() + .map(|remote_dat_file| remote_dat_file.modified_time) + .unwrap_or(0) + } + } + + pub fn idx_file_size(&self) -> u64 { + self.nm.as_ref().map_or(0, |nm| nm.index_file_size()) + } + + // ---- Compaction / Vacuum ---- + + /// Compact the volume by copying only live needles to new .cpd/.cpx files. + /// This reads from the current .dat/.idx and writes to .cpd/.cpx. + /// Call `commit_compact()` after to swap the files. + pub fn compact_by_index( + &mut self, + _preallocate: u64, + _max_bytes_per_second: i64, + progress_fn: F, + ) -> Result<(), VolumeError> + where + F: Fn(i64) -> bool, + { + if self.is_compacting { + return Ok(()); // already compacting + } + self.is_compacting = true; + + let result = self.do_compact_by_index(progress_fn); + + self.is_compacting = false; + result + } + + fn do_compact_by_index(&mut self, progress_fn: F) -> Result<(), VolumeError> + where + F: Fn(i64) -> bool, + { + // Guard against nil needle map (matches Go's nil check before compaction sync) + if self.nm.is_none() { + return Err(VolumeError::Io(io::Error::other(format!( + "volume {} needle map is nil", + self.id + )))); + } + + // Record state before compaction for makeupDiff + self.last_compact_index_offset = self.nm.as_ref().map_or(0, |nm| nm.index_file_size()); + self.last_compact_revision = self.super_block.compaction_revision; + + // Sync current data + self.sync_to_disk()?; + + let cpd_path = self.file_name(".cpd"); + let cpx_path = self.file_name(".cpx"); + let version = self.version(); + + // Write new super block with incremented compaction revision + let mut new_sb = self.super_block.clone(); + new_sb.compaction_revision += 1; + let sb_bytes = new_sb.to_bytes(); + + let mut dst = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&cpd_path)?; + dst.write_all(&sb_bytes)?; + let mut new_offset = sb_bytes.len() as i64; + + // Build new index in memory + let mut new_nm = CompactNeedleMap::new(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Collect live entries from needle map (sorted ascending) + let nm = self.nm.as_ref().ok_or(VolumeError::NotInitialized)?; + let mut entries: Vec<(NeedleId, Offset, Size)> = Vec::new(); + for (id, nv) in nm.iter_entries().map_err(VolumeError::Io)? { + if nv.offset.is_zero() || nv.size.is_deleted() { + continue; + } + entries.push((id, nv.offset, nv.size)); + } + entries.sort_by_key(|(_, offset, _)| *offset); + + let mut skipped_needles: u64 = 0; + let mut skipped_data_bytes: u64 = 0; + let mut expected_live_bytes: u64 = 0; + for (id, offset, size) in entries { + // Progress callback + if !progress_fn(offset.to_actual_offset()) { + // Interrupted + let _ = fs::remove_file(&cpd_path); + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::Interrupted, + "compaction interrupted", + ))); + } + + // Read needle from source + let mut n = Needle { + id, + ..Needle::default() + }; + match self.read_needle_data_at(&mut n, offset.to_actual_offset(), size) { + Ok(()) => {} + Err(e) => { + // Record EIO for health monitoring (parity with Go's checkReadWriteError). + if let VolumeError::Io(ref io_err) = e { + self.check_read_write_error(Some(io_err)); + } + // Only drop the entry when the failure is one of the well- + // known permanent-corruption shapes. A transient disk fault, + // a tiered-read timeout, or a Windows hardware error (which + // surfaces as a generic Io rather than UnexpectedEof) must + // abort so an operator notices, rather than silently + // compacting away data that might come back on retry. + // See issue #8928. + if !is_skippable_needle_read_error(&e) { + return Err(VolumeError::Io(io::Error::other(format!( + "cannot hydrate needle from file: {}", + e + )))); + } + skipped_needles += 1; + if size.is_valid() { + skipped_data_bytes += size.0 as u64; + } + warn!( + volume_id = self.id.0, + key = id.0, + offset = offset.to_actual_offset(), + size = size.0, + error = %e, + "vacuum: dropping unreadable needle" + ); + continue; + } + } + + // Skip TTL-expired needles using the volume's TTL (matches Go's volume_vacuum.go) + if n.has_ttl() { + let ttl_minutes = self.super_block.ttl.minutes(); + if ttl_minutes > 0 && n.last_modified > 0 { + let expire_at = n.last_modified + (ttl_minutes as u64) * 60; + if now >= expire_at { + continue; + } + } + } + + // Tally the live bytes from the frozen snapshot this loop copied + // from, not the live needle map. Unreadable needles return before + // this point, so no further skipped-byte adjustment is needed. + expected_live_bytes += size.0 as u64; + + // Write needle to destination + let bytes = n.write_bytes(version); + dst.write_all(&bytes)?; + + // Update new index + new_nm.put(id, Offset::from_actual_offset(new_offset), n.size)?; + new_offset += bytes.len() as i64; + } + + if skipped_needles > 0 { + warn!( + volume_id = self.id.0, + skipped_needles, + skipped_data_bytes, + "vacuum: dropped unreadable index entries during compaction" + ); + } + + dst.sync_all()?; + + if self.super_block.ttl.is_empty() { + let dst_dat_size = dst.metadata()?.len(); + if exceeds_expected_compacted_size(expected_live_bytes, dst_dat_size) { + let _ = fs::remove_file(&cpd_path); + let _ = fs::remove_file(&cpx_path); + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!( + "volume {} unexpected new data size: {} does not match expected live content size {} from the pre-compaction snapshot", + self.id.0, dst_dat_size, expected_live_bytes + ), + ))); + } + } + + // Save new index + new_nm.save_to_idx(&cpx_path)?; + + Ok(()) + } + + /// Commit a previously completed compaction: swap .cpd/.cpx to .dat/.idx and reload. + /// Matches Go's isCompactionInProgress CompareAndSwap guard. + pub fn commit_compact(&mut self) -> Result<(), VolumeError> { + if self.is_compacting { + return Ok(()); // already compacting, silently skip (matches Go) + } + self.is_compacting = true; + + let result = self.do_commit_compact(); + + self.is_compacting = false; + result + } + + fn do_commit_compact(&mut self) -> Result<(), VolumeError> { + if let Err(e) = self.makeup_diff() { + warn!("makeup_diff failed: {}", e); + // Match Go: clean up .cpd/.cpx on makeup_diff failure + let cpd = self.file_name(".cpd"); + let cpx = self.file_name(".cpx"); + let _ = fs::remove_file(&cpd); + let _ = fs::remove_file(&cpx); + return Err(e); + } + + // Close current files + if let Some(ref mut nm) = self.nm { + nm.close(); + } + self.nm = None; + if let Some(ref dat_file) = self.dat_file { + let _ = dat_file.sync_all(); + } + self.dat_file = None; + self.remote_dat_file = None; + + // makeup_diff has fsynced the .cpd/.cpx contents. Persist a durable .cpc + // commit marker BEFORE the renames so the two-rename swap is atomic + // across a crash: a marker on disk means the swap is decided and + // reconcile rolls forward; no marker means roll back. Without it, a + // crash between the two renames leaves a stale .idx that a later vacuum + // compacts to empty. + self.write_compact_commit_marker()?; + self.apply_compact_swap()?; + + // Reload + self.load(true, false, 0, self.version())?; + + Ok(()) + } + + /// Write and fsync the .cpc marker, then fsync the directory so the marker + /// survives a crash before apply_compact_swap. + fn write_compact_commit_marker(&self) -> Result<(), VolumeError> { + let marker_path = self.file_name(".cpc"); + let f = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&marker_path)?; + f.sync_all()?; + drop(f); + fsync_dir(&marker_path)?; + Ok(()) + } + + /// Perform the durable two-rename compaction commit. Idempotent: run both at + /// the tail of commit and by reconcile rolling forward after a crash. It + /// requires BOTH .cpd/.cpx to be present, so a stale or duplicate commit + /// returns an error without deleting the live .dat/.idx. + fn apply_compact_swap(&self) -> Result<(), VolumeError> { + // Normal commit: both temp files must be present, or a stale/duplicate + // commit could clobber the live .dat/.idx. + if !Path::new(&self.file_name(".cpd")).exists() + || !Path::new(&self.file_name(".cpx")).exists() + { + return Err(VolumeError::Io(io::Error::new( + io::ErrorKind::NotFound, + "compact files (.cpd/.cpx) not found", + ))); + } + self.finish_compact_swap() + } + + /// Renames whichever compaction temp file is still present (.cpd->.dat, + /// .cpx->.idx), fsyncs, drops the stale .ldb/.rdb, then clears the .cpc + /// marker. Tolerates a partial state: a crash after the .dat rename but + /// before the .idx rename leaves only .cpx, which must still be applied -- + /// not abandoned, which would pair a fresh .dat with a stale .idx. Existence + /// is checked robustly so a transient error never silently skips the swap. + fn finish_compact_swap(&self) -> Result<(), VolumeError> { + let exists = |p: String| match fs::metadata(&p) { + Ok(_) => true, + Err(e) if e.kind() == io::ErrorKind::NotFound => false, + Err(_) => true, + }; + let dat_path = self.file_name(".dat"); + let idx_path = self.file_name(".idx"); + let cpd_exists = exists(self.file_name(".cpd")); + let cpx_exists = exists(self.file_name(".cpx")); + + if cpd_exists { + #[cfg(windows)] + { + let _ = fs::remove_file(&dat_path); + } + fs::rename(self.file_name(".cpd"), &dat_path)?; + } + if cpx_exists { + #[cfg(windows)] + { + let _ = fs::remove_file(&idx_path); + } + fs::rename(self.file_name(".cpx"), &idx_path)?; + } + if cpd_exists || cpx_exists { + fsync_dir(&dat_path)?; + if self.dir != self.dir_idx { + fsync_dir(&idx_path)?; + } + let _ = fs::remove_dir_all(self.file_name(".ldb")); + let _ = fs::remove_file(self.file_name(".rdb")); + } + + // Clear the marker last and fsync the dir so a restart does not re-run a + // completed swap. + let marker_path = self.file_name(".cpc"); + match fs::remove_file(&marker_path) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + fsync_dir(&marker_path)?; + Ok(()) + } + + /// Recover an interrupted compaction commit on load. When the .cpc marker is + /// present the swap was decided, so roll FORWARD by finishing the renames; + /// when it is absent any leftover .cpd/.cpx are an abandoned generation, so + /// roll BACK by deleting them (and any stale .ldb beside a healthy .idx). + pub fn reconcile_compact_state(&self) -> Result<(), VolumeError> { + let cpc_path = self.file_name(".cpc"); + if Path::new(&cpc_path).exists() { + // Marker present: the swap was decided. Finish whichever rename is + // still pending -- a crash may have completed only the .dat rename, + // so the lone remaining .cpx must still be applied, not abandoned. + // If neither temp file remains, finish_compact_swap clears the marker. + tracing::info!( + volume_id = self.id.0, + "rolling forward interrupted compaction commit" + ); + return self.finish_compact_swap(); + } + + // No marker: roll back any orphan compaction temp files. + let mut rolled_back = false; + for ext in &[".cpd", ".cpx"] { + let p = self.file_name(ext); + if Path::new(&p).exists() { + tracing::info!( + volume_id = self.id.0, + "rolling back orphan compaction file {}", + ext + ); + match fs::remove_file(&p) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + rolled_back = true; + } + } + if rolled_back { + let _ = fs::remove_dir_all(self.file_name(".ldb")); + let _ = fs::remove_file(self.file_name(".rdb")); + } + Ok(()) + } + + /// Clean up leftover compaction files (.cpd, .cpx). + pub fn cleanup_compact(&self) -> Result<(), VolumeError> { + // Refuse to unlink .cpd/.cpx while a .cpc marker exists: those temp files + // are the only inputs reconcile can roll forward to, so removing them + // mid-commit would strand a decided swap. + if Path::new(&self.file_name(".cpc")).exists() { + return Err(VolumeError::Io(io::Error::other(format!( + "volume {}: refusing cleanup while commit marker present", + self.id + )))); + } + + let cpd_path = self.file_name(".cpd"); + let cpx_path = self.file_name(".cpx"); + let cpldb_path = self.file_name(".cpldb"); + let cpc_path = self.file_name(".cpc"); + + let e1 = fs::remove_file(&cpd_path); + let e2 = fs::remove_file(&cpx_path); + let e3 = fs::remove_dir_all(&cpldb_path); + let e4 = fs::remove_file(&cpc_path); + + // Ignore NotFound errors + for e in [e1, e2, e3, e4] { + if let Err(e) = e + && e.kind() != io::ErrorKind::NotFound + { + return Err(e.into()); + } + } + + Ok(()) + } + + /// Read any new needles appended during compaction and append them to .cpd/.cpx + fn makeup_diff(&mut self) -> Result<(), VolumeError> { + let old_idx_size = self.nm.as_ref().map_or(0, |nm| nm.index_file_size()); + if old_idx_size == 0 || old_idx_size <= self.last_compact_index_offset { + return Ok(()); + } + + let old_super_block = &self.super_block; + if old_super_block.compaction_revision != self.last_compact_revision { + return Err(VolumeError::Io(io::Error::other(format!( + "current old dat file's compact revision {} is not the expected one {}", + old_super_block.compaction_revision, self.last_compact_revision + )))); + } + + // Read the new .cpd file's super block and verify its compaction revision is old + 1 + let cpd_path_check = self.file_name(".cpd"); + let mut cpd_file_check = File::open(&cpd_path_check)?; + let mut sb_buf = [0u8; SUPER_BLOCK_SIZE]; + cpd_file_check.read_exact(&mut sb_buf)?; + let new_super_block = SuperBlock::from_bytes(&sb_buf)?; + let old_compact_revision = old_super_block.compaction_revision; + let new_compact_revision = new_super_block.compaction_revision; + if old_compact_revision + 1 != new_compact_revision { + return Err(VolumeError::Io(io::Error::other(format!( + "old dat file's compact revision {} + 1 does not equal new dat file's compact revision {}", + old_compact_revision, new_compact_revision + )))); + } + + let old_idx_path = self.file_name(".idx"); + let mut old_idx_file = File::open(&old_idx_path)?; + + // Read new entries from .idx + let mut incremented_entries = std::collections::HashMap::new(); + let offset = self.last_compact_index_offset; + + old_idx_file.seek(SeekFrom::Start(offset))?; + let entry_count = (old_idx_size - offset) / NEEDLE_MAP_ENTRY_SIZE as u64; + for _ in 0..entry_count { + let mut buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + old_idx_file.read_exact(&mut buf)?; + let (key, needle_offset, size) = crate::storage::types::idx_entry_from_bytes(&buf); + incremented_entries.insert(key, (needle_offset, size)); + } + + if incremented_entries.is_empty() { + return Ok(()); + } + + let cpd_path = self.file_name(".cpd"); + let cpx_path = self.file_name(".cpx"); + + let mut dst_dat = OpenOptions::new().read(true).write(true).open(&cpd_path)?; + let mut dst_idx = OpenOptions::new().append(true).open(&cpx_path)?; + + let mut dat_offset = dst_dat.seek(SeekFrom::End(0))?; + let padding_rem = dat_offset % NEEDLE_PADDING_SIZE as u64; + if padding_rem != 0 { + dat_offset += NEEDLE_PADDING_SIZE as u64 - padding_rem; + dst_dat.seek(SeekFrom::Start(dat_offset))?; + } + + let version = self.version(); + let old_dat_path = self.file_name(".dat"); + let old_dat_file = File::open(&old_dat_path)?; + + for (key, (needle_offset, size)) in incremented_entries { + if !needle_offset.is_zero() && !size.is_deleted() && size.0 > 0 { + let actual_size = crate::storage::needle::needle::get_actual_size(size, version); + let mut blob = vec![0u8; actual_size as usize]; + + #[cfg(unix)] + { + use std::os::unix::fs::FileExt; + old_dat_file + .read_exact_at(&mut blob, needle_offset.to_actual_offset() as u64)?; + } + #[cfg(windows)] + { + crate::storage::volume::read_exact_at( + &old_dat_file, + &mut blob, + needle_offset.to_actual_offset() as u64, + )?; + } + + dst_dat.write_all(&blob)?; + + let mut idx_entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + crate::storage::types::idx_entry_to_bytes( + &mut idx_entry_buf, + key, + Offset::from_actual_offset(dat_offset as i64), + size, + ); + dst_idx.write_all(&idx_entry_buf)?; + + dat_offset += actual_size as u64; + } else { + let mut fake_del_needle = Needle { + id: key, + cookie: Cookie(0x12345678), + append_at_ns: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64, + ..Needle::default() + }; + let bytes = fake_del_needle.write_bytes(version); + dst_dat.write_all(&bytes)?; + + // Record the tombstone's real .dat offset, like the normal delete path, + // so a deletion left at the .dat tail stays visible to the integrity + // check on reload. Offset 0 hid the trailing tombstone and falsely + // flipped the volume read-only. + let mut idx_entry_buf = [0u8; NEEDLE_MAP_ENTRY_SIZE]; + crate::storage::types::idx_entry_to_bytes( + &mut idx_entry_buf, + key, + Offset::from_actual_offset(dat_offset as i64), + Size(crate::storage::types::TOMBSTONE_FILE_SIZE.into()), + ); + dst_idx.write_all(&idx_entry_buf)?; + + dat_offset += bytes.len() as u64; + } + } + + dst_dat.sync_all()?; + dst_idx.sync_all()?; + + Ok(()) + } + + // ---- Sync / Close ---- + + pub fn sync_to_disk(&mut self) -> io::Result<()> { + if let Some(ref dat_file) = self.dat_file { + dat_file.sync_all()?; + } + if let Some(ref nm) = self.nm { + nm.sync()?; + } + Ok(()) + } + + pub fn close(&mut self) { + let dat_synced = if let Some(ref dat_file) = self.dat_file { + dat_file.sync_all().is_ok() + } else { + true + }; + self.dat_file = None; + self.remote_dat_file = None; + // When the .dat flushed, checkpoint the index so the next load starts + // from the recorded .idx size. When it did not, skip the checkpoint: + // a durable index pointing past an unflushed .dat tail would make the + // reload's max_needle_end check mark the volume read-only. Without the + // checkpoint, META_IDX_SIZE stays at the last successful one and the + // reload replays the uncheckpointed tail (redb flushes on drop). + if let Some(ref mut nm) = self.nm { + if dat_synced { + nm.close(); + } else { + tracing::warn!( + "volume {}: .dat sync failed on close, skipping index checkpoint", + self.id.0 + ); + nm.close_without_checkpoint(); + } + } + self.nm = None; + } + + /// Move this volume's `.idx` (and `.sdx` when present) from its current + /// index directory into `new_idx_dir`, then reload in place. Mirrors Go's + /// `Volume::RelocateIndexTo`: it is a no-op when the index is already there + /// or nothing is co-located to move, and is used to pull an index a decode + /// or reconstruct left beside the data back into the configured `-dir.idx`. + pub fn relocate_index_to(&mut self, new_idx_dir: &str) -> Result<(), VolumeError> { + let _guard = self.data_file_access_control.write_lock(); + + if self.dir_idx == new_idx_dir { + return Ok(()); + } + let old_base = volume_file_name(&self.dir_idx, &self.collection, self.id); + let old_idx = format!("{old_base}.idx"); + if !Path::new(&old_idx).exists() { + return Ok(()); // nothing co-located to move + } + let new_base = volume_file_name(new_idx_dir, &self.collection, self.id); + let new_idx = format!("{new_base}.idx"); + + // Close the index and data handles before moving the index file. + let version = self.version(); + self.close(); + + if let Err(e) = rename_or_copy(&old_idx, &new_idx) { + // Reopen against the old dir so the volume is not left down; surface + // a failed reopen since it leaves the volume unusable until reload. + if let Err(reopen) = self.load(true, false, 0, version) { + error!( + "relocate volume {}: reopen after failed .idx move: {}", + self.id, reopen + ); + } + return Err(VolumeError::Io(io::Error::other(format!( + "relocate index for volume {}: move .idx: {e}", + self.id + )))); + } + + // The .sdx is a derived sorted index; move it when present, but a + // failure is not fatal — drop the stale copy so a reload rebuilds it. + let old_sdx = format!("{old_base}.sdx"); + if Path::new(&old_sdx).exists() { + let new_sdx = format!("{new_base}.sdx"); + if let Err(e) = rename_or_copy(&old_sdx, &new_sdx) { + warn!( + "relocate volume {}: move .sdx: {} (will rebuild)", + self.id, e + ); + let _ = fs::remove_file(&old_sdx); + } + } + + self.dir_idx = new_idx_dir.to_string(); + self.load(true, false, 0, version) + } + + /// Remove all volume files from disk. + /// Destroy removes everything related to this volume. When keep_remote_data + /// is true the cloud-tier object backing the volume is left intact — used + /// by moves where another server is taking over the same .vif. + pub fn destroy(&mut self, only_empty: bool, keep_remote_data: bool) -> Result<(), VolumeError> { + if only_empty && self.file_count() > 0 { + return Err(VolumeError::NotEmpty); + } + if self.is_compacting { + return Err(VolumeError::Io(io::Error::other(format!( + "volume {} is compacting", + self.id + )))); + } + + let (storage_name, storage_key) = self.remote_storage_name_key(); + if !keep_remote_data + && self.has_remote_file + && !storage_name.is_empty() + && !storage_key.is_empty() + { + let backend = crate::remote_storage::s3_tier::global_s3_tier_registry() + .read() + .unwrap() + .get(&storage_name); + if let Some(backend) = backend { + if let Err(e) = backend.delete_file_blocking(&storage_key) { + warn!( + volume_id = self.id.0, + storage_name, + storage_key, + error = %e, + "failed to delete remote tier file during destroy" + ); + } + } else { + warn!( + volume_id = self.id.0, + storage_name, storage_key, "remote tier backend not found during destroy" + ); + } + } + + // A regular volume and an EC volume for the same id share .vif. + // When EC artefacts coexist on this disk (e.g. shards distributed onto + // a source replica before it is deleted), keep the .vif so removing the + // regular volume does not strip the EC volume's info file. + let keep_vif = self.shares_vif_with_ec_volume(); + self.close(); + remove_volume_files(&self.data_file_name(), keep_vif); + remove_volume_files(&self.index_file_name(), keep_vif); + Ok(()) + } + + /// Reports whether an EC volume for this id has a sealed .ecx on the same + /// disk, in which case its .vif is the same file as the regular volume's + /// and must outlive the regular volume's deletion. Mirrors the on-disk + /// portion of Go's Volume.sharesVifWithEcVolume / HasEcxFileOnDisk. + fn shares_vif_with_ec_volume(&self) -> bool { + let has_ecx = |base: &str| -> bool { + fs::metadata(format!("{}.ecx", base)) + .map(|m| !m.is_dir() && m.len() > 0) + .unwrap_or(false) + }; + if has_ecx(&volume_file_name(&self.dir_idx, &self.collection, self.id)) { + return true; + } + self.dir != self.dir_idx && has_ecx(&volume_file_name(&self.dir, &self.collection, self.id)) + } + + /// Check if an I/O error is a storage-media failure and record it for + /// health monitoring. On success (None), clears any previously recorded + /// EIO error. Matches Go's `checkReadWriteError` in volume_write.go. + fn check_read_write_error(&self, err: Option<&io::Error>) { + use std::sync::atomic::Ordering; + if let Some(e) = err + && is_storage_io_error(e) + { + self.io_error_count.fetch_add(1, Ordering::Relaxed); + if let Ok(mut guard) = self.last_io_error.lock() { + *guard = Some(e.to_string()); + } + crate::metrics::STORAGE_IO_ERROR_COUNTER.inc(); + return; + } + self.io_error_count.store(0, Ordering::Relaxed); + if let Ok(mut guard) = self.last_io_error.lock() + && guard.is_some() + { + *guard = None; + } + } + + /// Returns the last recorded I/O error string, if any. + pub fn last_io_error(&self) -> Option { + self.last_io_error.lock().ok()?.clone() + } + + pub fn get_io_error_state(&self) -> (Option, i32, bool) { + use std::sync::atomic::Ordering; + let err = self.last_io_error.lock().ok().and_then(|g| g.clone()); + let count = self.io_error_count.load(Ordering::Relaxed); + let quarantined = self.io_error_quarantined.load(Ordering::Relaxed); + (err, count, quarantined) + } + + pub fn mark_io_quarantined(&self) { + self.io_error_quarantined + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + pub fn reset_io_error_state(&self) { + use std::sync::atomic::Ordering; + self.io_error_count.store(0, Ordering::Relaxed); + self.io_error_quarantined.store(false, Ordering::Relaxed); + if let Ok(mut guard) = self.last_io_error.lock() { + *guard = None; + } + } + + #[cfg(test)] + pub(crate) fn set_last_io_error_for_test(&self, err: Option<&str>) { + use std::sync::atomic::Ordering; + if let Ok(mut guard) = self.last_io_error.lock() { + *guard = err.map(|value| value.to_string()); + } + // Set count at/above the heartbeat tolerance (3) so the test + // helper reflects a sustained error, not a single transient one. + if err.is_some() { + self.io_error_count.store(3, Ordering::Relaxed); + } else { + self.io_error_count.store(0, Ordering::Relaxed); + } + } + + #[cfg(test)] + pub(crate) fn fail_next_fsync_for_test(&mut self, fail: bool) { + self.fail_fsync_for_test = fail; + } + + #[cfg(test)] + pub(crate) fn fail_next_idx_sync_for_test(&mut self, fail: bool) { + self.fail_idx_sync_for_test = fail; + } + + #[cfg(test)] + pub(crate) fn set_last_modified_ts_for_test(&mut self, ts_seconds: u64) { + self.last_modified_ts_seconds = ts_seconds; + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Generate volume file base name: dir/collection_id or dir/id +/// Byte offset just past the needle's on-disk record. Deletion tombstones +/// carry TombstoneFileSize (-1) in the .idx but are written with DataSize=0, +/// so their on-disk record is sized as 0. Mirrors Go's needleDiskEnd. +pub(crate) fn needle_disk_end(offset: Offset, size: Size, version: Version) -> i64 { + let on_disk_size = if size.is_deleted() { Size(0) } else { size }; + offset.to_actual_offset() + get_actual_size(on_disk_size, version) +} + +fn size_mismatch_error(offset: i64, id: NeedleId, found: Size, expected: Size) -> VolumeError { + VolumeError::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "entry not found: offset {} found id {:?} size {}, expected size {}", + offset, id, found.0, expected.0 + ), + )) +} + +/// Drop a partial row from the end of `.idx` and return the remaining length. +/// +/// `.idx` is a run of fixed-size rows, and writers append at EOF: a short write +/// leaves a partial row behind, and every row appended after it lands off +/// alignment, so the next load parses the rest of the file as garbage. The +/// partial row is unrecoverable either way — every loader already skips it — so +/// removing it before anything can append is what keeps the file parseable. +/// Go instead refuses to load a volume whose `.idx` is not a whole number of +/// rows; trimming keeps it mountable, and the rows before the tear are intact. +fn trim_torn_idx_tail(idx_file: &File, idx_path: &str) -> Result { + let size = idx_file.metadata()?.len(); + let aligned = size - size % NEEDLE_MAP_ENTRY_SIZE as u64; + if aligned != size { + warn!( + idx = %idx_path, + size, aligned, "dropping a partial row from the end of the index" + ); + idx_file.set_len(aligned)?; + idx_file.sync_all()?; + } + Ok(aligned) +} + +pub fn volume_file_name(dir: &str, collection: &str, id: VolumeId) -> String { + if collection.is_empty() { + format!("{}/{}", dir, id.0) + } else { + format!("{}/{}_{}", dir, collection, id.0) + } +} + +/// Move `src` to `dst`, falling back to copy+remove when the two live on +/// different filesystems (a real `-dir.idx` split). Mirrors Go's +/// `RenameOrCopyFile`: any rename error other than cross-device is fatal, and a +/// failed copy or a source that cannot be removed rolls the copy back so a +/// failure never leaves two divergent files. +fn rename_or_copy(src: &str, dst: &str) -> io::Result<()> { + match fs::rename(src, dst) { + Ok(()) => return Ok(()), + Err(e) if e.raw_os_error() != Some(libc::EXDEV) => return Err(e), + Err(_) => {} + } + fs::copy(src, dst)?; + // fsync the destination before dropping the source, matching Go's out.Sync(). + if let Err(e) = OpenOptions::new() + .write(true) + .open(dst) + .and_then(|f| f.sync_all()) + { + let _ = fs::remove_file(dst); + return Err(e); + } + if let Err(e) = fs::remove_file(src) { + let _ = fs::remove_file(dst); + return Err(e); + } + Ok(()) +} + +/// Generate a monotonically increasing append timestamp. +fn get_append_at_ns(last: u64) -> u64 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + if now <= last { last + 1 } else { now } +} + +/// Remove all files associated with a volume. +/// .dat/.idx removals log at info level so destructive calls are traceable. +/// fsync the parent directory of `path` so a rename/create/unlink inside it is +/// durable, propagating a sync failure so the commit path can abort rather than +/// proceed with an undurable rename or marker. A path with no openable parent is +/// tolerated; directory fsync is unsupported on Windows, so it is a no-op there +/// (matching the Go fsyncDir helper, which skips it too). +pub(crate) fn fsync_dir(path: &str) -> io::Result<()> { + #[cfg(windows)] + { + let _ = path; + Ok(()) + } + #[cfg(not(windows))] + { + if let Some(parent) = Path::new(path).parent() + && let Ok(d) = File::open(parent) + { + return d.sync_all(); + } + Ok(()) + } +} + +pub(crate) fn remove_volume_files(base: &str, keep_vif: bool) { + for ext in &[ + ".dat", ".idx", ".vif", ".sdx", ".cpd", ".cpx", ".cpc", ".note", ".rdb", + ] { + if *ext == ".vif" && keep_vif { + continue; + } + let path = format!("{}{}", base, ext); + let size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let existed = fs::remove_file(&path).is_ok(); + if existed && (*ext == ".dat" || *ext == ".idx") { + tracing::info!("removed volume file {} (size={})", path, size); + } + } + // leveldb uses a directory + let _ = fs::remove_dir_all(format!("{}.ldb", base)); +} + +// ============================================================================ +// ScanVolumeFile — iterate all needles in a .dat file +// ============================================================================ + +/// Callback for scanning needles in a volume file. +pub trait VolumeFileVisitor { + fn visit_super_block(&mut self, sb: &SuperBlock) -> Result<(), VolumeError>; + fn read_needle_body(&self) -> bool; + fn visit_needle(&mut self, n: &Needle, offset: i64) -> Result<(), VolumeError>; +} + +/// Scan all needles in a volume's .dat file. +pub fn scan_volume_file( + dat_path: &str, + visitor: &mut dyn VolumeFileVisitor, +) -> Result<(), VolumeError> { + let mut file = File::open(dat_path)?; + + // Read super block + let mut sb_buf = [0u8; SUPER_BLOCK_SIZE]; + file.read_exact(&mut sb_buf)?; + let sb = SuperBlock::from_bytes(&sb_buf)?; + visitor.visit_super_block(&sb)?; + + let version = sb.version; + let mut offset = sb.block_size() as i64; + + loop { + // Read needle header + let mut header = [0u8; NEEDLE_HEADER_SIZE]; + file.seek(SeekFrom::Start(offset as u64))?; + match file.read_exact(&mut header) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(e.into()), + } + + let (_cookie, _id, size) = Needle::parse_header(&header); + + if size.0 == 0 && _id.is_empty() { + break; // end of valid data + } + // A negative size is a corrupt header, and body_length would advance the + // walk backwards from it. Go's scanners stop here by returning io.EOF. + if size.0 < 0 { + break; + } + + let body_length = needle::needle_body_length(size, version); + let total_size = NEEDLE_HEADER_SIZE as i64 + body_length; + + // Skip full body parsing for deleted needles (tombstone or negative size) + if size.is_deleted() || size.0 <= 0 { + let mut n = Needle::default(); + n.read_header(&header); + visitor.visit_needle(&n, offset)?; + } else if visitor.read_needle_body() { + let mut buf = vec![0u8; total_size as usize]; + file.seek(SeekFrom::Start(offset as u64))?; + file.read_exact(&mut buf)?; + + let mut n = Needle::default(); + n.read_bytes(&buf, offset, size, version)?; + visitor.visit_needle(&n, offset)?; + } else { + let mut n = Needle::default(); + n.read_header(&header); + visitor.visit_needle(&n, offset)?; + } + + offset += total_size; + } + + Ok(()) +} + +/// Reserve disk blocks for a file without changing its visible size. +/// On Linux, uses `fallocate(FALLOC_FL_KEEP_SIZE)` to actually reserve blocks. +/// On other platforms, this is a no-op. +fn preallocate_file(file: &File, size: u64) { + #[cfg(target_os = "linux")] + { + use std::os::unix::io::AsRawFd; + let fd = file.as_raw_fd(); + // FALLOC_FL_KEEP_SIZE = 1: allocate blocks without changing file size + let ret = unsafe { libc::fallocate(fd, 1, 0, size as libc::off_t) }; + if ret == 0 { + tracing::info!(bytes = size, "preallocated disk space"); + } else { + tracing::warn!( + bytes = size, + error = %io::Error::last_os_error(), + "fallocate failed" + ); + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (file, size); + tracing::debug!(bytes = size, "preallocation not supported on this platform"); + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::needle::crc::CRC; + use tempfile::TempDir; + + fn spawn_fake_s3_server(body: Vec) -> (String, tokio::sync::oneshot::Sender<()>) { + use axum::Router; + use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; + use axum::routing::any; + + let body = Arc::new(body); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async move { + let app = Router::new().fallback(any(move |headers: HeaderMap| { + let body = body.clone(); + async move { + let bytes = body.as_ref(); + if let Some(range) = headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + && let Some(spec) = range.strip_prefix("bytes=") + { + let (start, end) = spec.split_once('-').unwrap(); + let start = start.parse::().unwrap(); + let end = if end.is_empty() { + bytes.len().saturating_sub(1) + } else { + end.parse::().unwrap() + } + .min(bytes.len().saturating_sub(1)); + let chunk = bytes[start..=end].to_vec(); + let mut response_headers = HeaderMap::new(); + response_headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&chunk.len().to_string()).unwrap(), + ); + response_headers.insert( + header::CONTENT_RANGE, + HeaderValue::from_str(&format!( + "bytes {}-{}/{}", + start, + end, + bytes.len() + )) + .unwrap(), + ); + return (StatusCode::PARTIAL_CONTENT, response_headers, chunk); + } + + let mut response_headers = HeaderMap::new(); + response_headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&bytes.len().to_string()).unwrap(), + ); + (StatusCode::OK, response_headers, bytes.to_vec()) + } + })); + + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + }); + + // Wait for the server thread to be ready before returning. + ready_rx.recv().unwrap(); + (format!("http://{}", addr), shutdown_tx) + } + + fn make_test_volume(dir: &str) -> Volume { + Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap() + } + + fn write_test_needle(v: &mut Volume, id: u64, data: &[u8]) -> u64 { + let mut n = Needle { + id: NeedleId(id), + cookie: Cookie(0x12345678), + data: data.to_vec(), + data_size: data.len() as u32, + flags: 0, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + n.append_at_ns + } + + /// Run a plan to completion, keeping owned copies of every record. + fn scan_all(plan: &DatScanPlan) -> Vec<(Vec, Vec, u64)> { + let mut records = Vec::new(); + plan.scan(|needle| { + records.push(( + needle.header.to_vec(), + needle.body.to_vec(), + needle.append_at_ns, + )); + ControlFlow::Continue(()) + }) + .unwrap(); + records + } + + #[test] + fn test_data_file_access_control_blocks_writer_until_reader_releases() { + let control = Arc::new(DataFileAccessControl::default()); + let read_lease = control.read_lock(); + let writer_control = control.clone(); + let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let acquired_clone = acquired.clone(); + + let writer = std::thread::spawn(move || { + let _write_lease = writer_control.write_lock(); + acquired_clone.store(true, std::sync::atomic::Ordering::Relaxed); + }); + + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!(!acquired.load(std::sync::atomic::Ordering::Relaxed)); + + drop(read_lease); + writer.join().unwrap(); + + assert!(acquired.load(std::sync::atomic::Ordering::Relaxed)); + } + + #[test] + fn test_volume_file_name() { + assert_eq!(volume_file_name("/data", "", VolumeId(1)), "/data/1"); + assert_eq!( + volume_file_name("/data", "pics", VolumeId(42)), + "/data/pics_42" + ); + } + + #[test] + fn test_volume_create_and_load() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let v = make_test_volume(dir); + assert_eq!(v.version(), VERSION_3); + assert_eq!(v.file_count(), 0); + assert_eq!(v.content_size(), 0); + + // .dat and .idx files should exist + assert!(Path::new(&v.file_name(".dat")).exists()); + assert!(Path::new(&v.file_name(".idx")).exists()); + } + + #[test] + fn binary_search_reports_is_last_when_caller_is_caught_up() { + // is_last drives the caught-up heartbeat; if it stops reporting true + // for an up-to-date caller, the tail sender re-reads the whole volume. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut newest_ns = 0u64; + for id in 1..=3u64 { + let mut n = Needle { + id: NeedleId(id), + cookie: Cookie(0x12345678), + data: vec![b'x'; 64], + data_size: 64, + flags: 0, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + newest_ns = n.append_at_ns; + } + assert!(newest_ns > 0, "needles must carry an append timestamp"); + + // Caught up: asking from the newest timestamp has nothing newer. + let (_offset, is_last) = v.binary_search_by_append_at_ns(newest_ns).unwrap(); + assert!( + is_last, + "a caller at the newest append_at_ns must be reported as caught up" + ); + + // And from beyond the newest, likewise. + let (_offset, is_last_future) = v + .binary_search_by_append_at_ns(newest_ns + 1_000_000_000) + .unwrap(); + assert!( + is_last_future, + "a caller ahead of the newest needle must be reported as caught up" + ); + } + + #[test] + fn binary_search_does_not_report_is_last_when_data_is_newer() { + // Complement: a behind caller must NOT be caught up, or tailing loses data. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut first_ns = 0u64; + for id in 1..=3u64 { + let mut n = Needle { + id: NeedleId(id), + cookie: Cookie(0x12345678), + data: vec![b'y'; 64], + data_size: 64, + flags: 0, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + if id == 1 { + first_ns = n.append_at_ns; + } + } + + let (_offset, is_last) = v.binary_search_by_append_at_ns(first_ns).unwrap(); + assert!( + !is_last, + "a caller behind the newest needle must NOT be reported as caught up" + ); + } + + #[test] + fn binary_search_does_not_report_is_last_when_only_a_delete_is_newer() { + // A delete is data the tail must ship. A caught-up caller before the + // delete must be sent to scan from the tombstone, not handed a heartbeat. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut newest_ns = 0u64; + for id in 1..=3u64 { + let mut n = Needle { + id: NeedleId(id), + cookie: Cookie(0x12345678), + data: vec![b'z'; 64], + data_size: 64, + flags: 0, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + newest_ns = n.append_at_ns; + } + let (_offset, is_last) = v.binary_search_by_append_at_ns(newest_ns).unwrap(); + assert!(is_last, "precondition: the caller starts caught up"); + + v.delete_needle(&mut Needle { + id: NeedleId(2), + cookie: Cookie(0x12345678), + ..Needle::default() + }) + .unwrap(); + + let (offset, is_last) = v.binary_search_by_append_at_ns(newest_ns).unwrap(); + assert!( + !is_last, + "a caller older than a trailing delete must NOT be reported as caught up" + ); + + // Scan and filter exactly as volume_tail_sender does: the tombstone, + // and only the tombstone, is newer than the caller. + let shipped: Vec = + scan_all(&v.dat_scan_plan(offset.to_actual_offset() as u64).unwrap()) + .into_iter() + .map(|(_, _, append_at_ns)| append_at_ns) + .filter(|&append_at_ns| append_at_ns > newest_ns) + .collect(); + assert_eq!(shipped.len(), 1, "the tail must ship the tombstone"); + } + + #[test] + fn binary_search_on_compacted_volume_still_reports_a_later_write() { + // Compaction rewrites .idx in needle-id order, so append_at_ns is no + // longer monotonic by row: an overwritten key can sit before an older + // one. The caller's since_ns is the last row's timestamp, so such rows + // were already in the files it copied. A write made afterwards is + // appended as the final row, which the search cannot step past. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let write = |v: &mut Volume, id: u64, byte: u8| { + let mut n = Needle { + id: NeedleId(id), + cookie: Cookie(0x12345678), + data: vec![byte; 64], + data_size: 64, + flags: 0, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + n.append_at_ns + }; + write(&mut v, 1, b'c'); + let key2_ns = write(&mut v, 2, b'c'); + let key1_ns = write(&mut v, 1, b'd'); // genuine overwrite: different data + assert!( + key1_ns > key2_ns, + "precondition: the overwrite must append a newer record, not dedup" + ); + v.compact_by_index(0, 0, |_| true).unwrap(); + v.commit_compact().unwrap(); + + let (_offset, is_last) = v.binary_search_by_append_at_ns(key2_ns).unwrap(); + assert!( + is_last, + "precondition: compaction ordered .idx by key, so key 2 is the final row" + ); + + let key3_ns = write(&mut v, 3, b'c'); + let (offset, is_last) = v.binary_search_by_append_at_ns(key2_ns).unwrap(); + assert!( + !is_last, + "a write after compaction is the final row and must not be hidden" + ); + let shipped: Vec = + scan_all(&v.dat_scan_plan(offset.to_actual_offset() as u64).unwrap()) + .into_iter() + .map(|(_, _, append_at_ns)| append_at_ns) + .filter(|&append_at_ns| append_at_ns > key2_ns) + .collect(); + assert!( + shipped.contains(&key3_ns), + "the tail must ship the write made after compaction" + ); + } + + #[test] + fn dat_scan_plan_visits_needles_and_tombstones_in_file_order() { + // The tail ships exactly what the scan visits, so the scan must cover + // the file record by record: the on-disk bytes, in file order, + // tombstones included. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + let written: Vec = (1..=3u64) + .map(|id| write_test_needle(&mut v, id, b"scan")) + .collect(); + v.delete_needle(&mut Needle { + id: NeedleId(2), + cookie: Cookie(0x12345678), + ..Needle::default() + }) + .unwrap(); + + let sb_size = v.super_block.block_size() as u64; + let records = scan_all(&v.dat_scan_plan(sb_size).unwrap()); + let dat = std::fs::read(v.file_name(".dat")).unwrap(); + + assert_eq!(records.len(), 4, "three writes and one tombstone"); + let mut offset = sb_size as usize; + for (header, body, _) in &records { + assert_eq!(&dat[offset..offset + header.len()], header.as_slice()); + offset += header.len(); + assert_eq!(&dat[offset..offset + body.len()], body.as_slice()); + offset += body.len(); + } + assert_eq!(offset, dat.len(), "the scan must reach the end of the file"); + + let ids: Vec = records + .iter() + .map(|(header, _, _)| Needle::parse_header(header).1) + .collect(); + assert_eq!( + ids, + vec![NeedleId(1), NeedleId(2), NeedleId(3), NeedleId(2)] + ); + let stamps: Vec = records.iter().map(|r| r.2).collect(); + assert_eq!(&stamps[..3], written.as_slice()); + assert!(stamps[3] > stamps[2], "the tombstone is the newest record"); + } + + #[cfg(unix)] + #[test] + fn dat_scan_plan_outlives_a_vacuum_commit() { + // The tail sender drops the store guard before scanning, so a vacuum + // commit can land mid-scan. The commit renames .cpd over .dat; a plan + // taken before it must keep reading the file its offset was resolved + // against, not the compacted one. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + write_test_needle(&mut v, 1, b"first"); + write_test_needle(&mut v, 2, b"second"); + write_test_needle(&mut v, 1, b"first, overwritten"); + + let sb_size = v.super_block.block_size() as u64; + let before = scan_all(&v.dat_scan_plan(sb_size).unwrap()); + let plan = v.dat_scan_plan(sb_size).unwrap(); + let old_len = std::fs::metadata(v.file_name(".dat")).unwrap().len(); + + v.compact_by_index(0, 0, |_| true).unwrap(); + v.commit_compact().unwrap(); + let new_len = std::fs::metadata(v.file_name(".dat")).unwrap().len(); + assert!( + new_len < old_len, + "precondition: the commit swapped in a smaller .dat" + ); + + assert_eq!(scan_all(&plan), before); + } + + #[cfg(unix)] + #[test] + fn dat_scan_plan_outlives_volume_destroy() { + // Unmount or delete can land mid-scan too. The plan holds a handle, + // not a path, so it finishes reading what was there. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + write_test_needle(&mut v, 1, b"going"); + write_test_needle(&mut v, 2, b"gone"); + + let sb_size = v.super_block.block_size() as u64; + let before = scan_all(&v.dat_scan_plan(sb_size).unwrap()); + let plan = v.dat_scan_plan(sb_size).unwrap(); + let dat_path = v.file_name(".dat"); + + v.destroy(false, false).unwrap(); + assert!( + !Path::new(&dat_path).exists(), + "precondition: destroy removed .dat" + ); + + assert_eq!(scan_all(&plan), before); + } + + #[test] + fn dat_scan_plan_fails_when_the_snapshot_is_truncated() { + // Unmount followed by a VolumeCopy of the same id reopens .dat with + // truncate(true) on the inode a running plan holds. Every byte below + // the captured end existed when the plan was taken, so a short read + // there is an incomplete pass, not the end of the data: the stream + // must fail rather than let volume.move take a partial tail as + // complete. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + write_test_needle(&mut v, 1, b"kept"); + write_test_needle(&mut v, 2, b"cut off"); + + let sb_size = v.super_block.block_size() as u64; + let first_record_len = { + let records = scan_all(&v.dat_scan_plan(sb_size).unwrap()); + assert_eq!(records.len(), 2, "precondition: two records on disk"); + (records[0].0.len() + records[0].1.len()) as u64 + }; + let plan = v.dat_scan_plan(sb_size).unwrap(); + let end = plan.end; + + // Truncate through the header of the second record. + let second = sb_size + first_record_len; + OpenOptions::new() + .write(true) + .open(v.file_name(".dat")) + .unwrap() + .set_len(second + NEEDLE_HEADER_SIZE as u64 / 2) + .unwrap(); + + let mut visited = Vec::new(); + let err = plan + .scan(|n| { + visited.push(n.append_at_ns); + ControlFlow::Continue(()) + }) + .unwrap_err(); + assert_eq!(visited.len(), 1, "the intact first record is still visited"); + match err { + VolumeError::Io(e) => { + assert_eq!(e.kind(), io::ErrorKind::UnexpectedEof); + let msg = e.to_string(); + assert!(msg.contains("truncated"), "{msg}"); + assert!(msg.contains(&format!("snapshot end {end}")), "{msg}"); + } + other => panic!("expected an I/O error, got {other:?}"), + } + } + + #[test] + fn dat_scan_plan_stops_at_the_snapshot_end() { + // The end bound is read under the guard, while no append can be in + // flight. A record appended after the plan belongs to the next pass. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + let first_ns = write_test_needle(&mut v, 1, b"before"); + + let plan = v.dat_scan_plan(v.super_block.block_size() as u64).unwrap(); + write_test_needle(&mut v, 2, b"after"); + + let stamps: Vec = scan_all(&plan).into_iter().map(|r| r.2).collect(); + assert_eq!(stamps, vec![first_ns]); + } + + #[test] + fn dat_scan_plan_stops_when_the_visitor_breaks() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + for id in 1..=3u64 { + write_test_needle(&mut v, id, b"brk"); + } + + let mut visited = 0; + v.dat_scan_plan(v.super_block.block_size() as u64) + .unwrap() + .scan(|_| { + visited += 1; + ControlFlow::Break(()) + }) + .unwrap(); + assert_eq!(visited, 1); + } + + #[test] + fn dat_scan_plan_ends_the_pass_at_a_corrupt_header() { + // A negative size is a corrupt header: sizing a buffer from it + // overflows, and a small one walks the scan from a wrong offset. A + // size running past the end bound cannot be a complete record either, + // and one near i32::MAX overflows the padding arithmetic. Both end + // the pass after the records before them, without reading or + // allocating the bogus body. + for bad_size in [-1000, i32::MAX] { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + write_test_needle(&mut v, 1, b"good"); + + let sb_size = v.super_block.block_size() as u64; + let dat_path = v.file_name(".dat"); + let start = sb_size as usize; + let mut bad = + std::fs::read(&dat_path).unwrap()[start..start + NEEDLE_HEADER_SIZE].to_vec(); + Size(bad_size).to_bytes(&mut bad[COOKIE_SIZE + NEEDLE_ID_SIZE..NEEDLE_HEADER_SIZE]); + // Bytes after the header, so the size check stops the walk, not EOF. + bad.extend_from_slice(&[0u8; 64]); + OpenOptions::new() + .append(true) + .open(&dat_path) + .unwrap() + .write_all(&bad) + .unwrap(); + + let records = scan_all(&v.dat_scan_plan(sb_size).unwrap()); + assert_eq!(records.len(), 1, "size {}: only the good record", bad_size); + } + } + + #[test] + fn dat_scan_plan_fails_when_the_record_body_is_corrupt() { + // A record can fit inside the captured snapshot and still fail needle + // parsing. The tail must surface that failure instead of emitting a + // raw record with a zero append timestamp and reporting a clean pass. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + write_test_needle(&mut v, 1, b"good"); + + let sb_size = v.super_block.block_size() as u64; + let plan = v.dat_scan_plan(sb_size).unwrap(); + + let mut dat = OpenOptions::new() + .write(true) + .open(v.file_name(".dat")) + .unwrap(); + dat.seek(SeekFrom::Start(sb_size + NEEDLE_HEADER_SIZE as u64)) + .unwrap(); + dat.write_all(&u32::MAX.to_be_bytes()).unwrap(); + drop(dat); + + let mut visited = 0; + let err = plan + .scan(|_| { + visited += 1; + ControlFlow::Continue(()) + }) + .unwrap_err(); + + assert_eq!(visited, 0, "the corrupt record must not be emitted"); + assert!( + matches!(err, VolumeError::Needle(_)), + "expected a needle parse error, got {err:?}" + ); + } + + #[test] + fn test_volume_write_read() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + // Write a needle + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0x12345678), + data: b"hello world".to_vec(), + data_size: 11, + flags: 0, + ..Needle::default() + }; + let (offset, size, unchanged) = v.write_needle(&mut n, true, false).unwrap(); + assert!(!unchanged); + assert!(offset > 0); // after superblock + assert!(size.0 > 0); + assert_eq!(v.file_count(), 1); + + // Read it back + let mut read_n = Needle { + id: NeedleId(1), + ..Needle::default() + }; + let count = v.read_needle(&mut read_n).unwrap(); + assert_eq!(count, 11); + assert_eq!(read_n.data, b"hello world"); + assert_eq!(read_n.cookie, Cookie(0x12345678)); + } + + /// A durable write goes down the same path and lands the same data; the + /// flush is extra work, not different work. + #[test] + fn test_volume_write_needle_fsync() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0x12345678), + data: b"durable payload".to_vec(), + data_size: 15, + flags: 0, + ..Needle::default() + }; + let (offset, _, unchanged) = v.write_needle(&mut n, true, true).unwrap(); + assert!(!unchanged); + assert!(offset > 0); + + let mut read_n = Needle { + id: NeedleId(1), + ..Needle::default() + }; + assert_eq!(v.read_needle(&mut read_n).unwrap(), 15); + assert_eq!(read_n.data, b"durable payload"); + + // rewriting the same content still dedups, so there is nothing to flush + let mut same = Needle { + id: NeedleId(1), + cookie: Cookie(0x12345678), + data: b"durable payload".to_vec(), + data_size: 15, + flags: 0, + ..Needle::default() + }; + let (_, _, unchanged) = v.write_needle(&mut same, true, true).unwrap(); + assert!(unchanged); + } + + /// A durable write whose flush fails must leave nothing behind: the append + /// comes off the .dat and the mapping it would have replaced still stands, + /// with the volume's own accounting untouched. + #[test] + fn test_write_needle_failed_fsync_keeps_prior_mapping() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut kept = Needle { + id: NeedleId(1), + cookie: Cookie(0xaa), + data: b"first-copy".to_vec(), + data_size: 10, + ..Needle::default() + }; + v.write_needle(&mut kept, true, true).unwrap(); + let prior = v.nm.as_ref().unwrap().get(NeedleId(1)).unwrap().unwrap(); + let dat_len_before = std::fs::metadata(v.file_name(".dat")).unwrap().len(); + let file_count_before = v.file_count(); + let content_size_before = v.content_size(); + + v.fail_next_fsync_for_test(true); + let mut replacement = Needle { + id: NeedleId(1), + cookie: Cookie(0xaa), + data: b"second-copy".to_vec(), + data_size: 11, + ..Needle::default() + }; + v.write_needle(&mut replacement, true, true).unwrap_err(); + v.fail_next_fsync_for_test(false); + + assert_eq!( + std::fs::metadata(v.file_name(".dat")).unwrap().len(), + dat_len_before, + "the unflushed append should be off the .dat" + ); + let now = v.nm.as_ref().unwrap().get(NeedleId(1)).unwrap().unwrap(); + assert_eq!( + now.offset, prior.offset, + "the mapping should never have moved" + ); + assert_eq!(now.size, prior.size); + assert_eq!( + v.file_count(), + file_count_before, + "a rejected write must not count towards the volume" + ); + assert_eq!(v.content_size(), content_size_before); + + let mut read_n = Needle { + id: NeedleId(1), + ..Needle::default() + }; + assert_eq!(v.read_needle(&mut read_n).unwrap(), 10); + assert_eq!(read_n.data, b"first-copy"); + } + + /// A durable write that dedups against an earlier non-durable one still has + /// to flush the index. The row that earlier write left behind can be sitting + /// in the page cache, so acking without flushing is the same false promise + /// as skipping the flush on a fresh append. + #[test] + fn test_dedup_durable_write_flushes_the_index() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut first = Needle { + id: NeedleId(1), + cookie: Cookie(0xaa), + data: b"same-content".to_vec(), + data_size: 12, + ..Needle::default() + }; + v.write_needle(&mut first, true, false).unwrap(); + + v.fail_next_idx_sync_for_test(true); + let mut same = Needle { + id: NeedleId(1), + cookie: Cookie(0xaa), + data: b"same-content".to_vec(), + data_size: 12, + ..Needle::default() + }; + let result = v.write_needle(&mut same, true, true); + v.fail_next_idx_sync_for_test(false); + + assert!( + result.is_err(), + "a dedup hit must not be acked without flushing the index" + ); + assert!( + v.is_read_only(), + "and the failed index flush quarantines the volume, dedup or not" + ); + } + + /// A durable write whose index flush fails leaves the .dat record down and + /// its row published but not guaranteed to come back. The volume stops + /// taking writes rather than appending more past a record whose index may + /// not survive a restart. + #[test] + fn test_write_needle_failed_idx_sync_quarantines_volume() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + v.fail_next_idx_sync_for_test(true); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0xaa), + data: b"index-never-flushed".to_vec(), + data_size: 19, + ..Needle::default() + }; + v.write_needle(&mut n, true, true).unwrap_err(); + v.fail_next_idx_sync_for_test(false); + + assert!( + v.is_read_only(), + "a volume whose index flush failed must stop taking writes" + ); + let mut later = Needle { + id: NeedleId(2), + cookie: Cookie(0xbb), + data: b"should-be-refused".to_vec(), + data_size: 17, + ..Needle::default() + }; + assert!( + matches!( + v.write_needle(&mut later, true, false), + Err(VolumeError::ReadOnly) + ), + "later writes must not append past the record whose index is in doubt" + ); + } + + /// Same for a needle the failed write introduced: it never becomes visible, + /// rather than being published and then tombstoned back out. + #[test] + fn test_write_needle_failed_fsync_publishes_nothing() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let dat_len_before = std::fs::metadata(v.file_name(".dat")).unwrap().len(); + let file_count_before = v.file_count(); + + v.fail_next_fsync_for_test(true); + let mut fresh = Needle { + id: NeedleId(7), + cookie: Cookie(0x77), + data: b"never-landed".to_vec(), + data_size: 12, + ..Needle::default() + }; + v.write_needle(&mut fresh, true, true).unwrap_err(); + v.fail_next_fsync_for_test(false); + + assert_eq!( + std::fs::metadata(v.file_name(".dat")).unwrap().len(), + dat_len_before + ); + assert!( + v.nm.as_ref().unwrap().get(NeedleId(7)).unwrap().is_none(), + "a needle that never reached the disk must not be indexed at all" + ); + assert_eq!(v.file_count(), file_count_before); + assert_eq!( + v.deleted_count(), + 0, + "nothing was written, so nothing was deleted" + ); + + let mut read_n = Needle { + id: NeedleId(7), + ..Needle::default() + }; + assert!(v.read_needle(&mut read_n).is_err()); + } + + #[test] + fn test_volume_write_dedup() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0xaa), + data: b"same data".to_vec(), + data_size: 9, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + + // Write same needle again — should be unchanged + let mut n2 = Needle { + id: NeedleId(1), + cookie: Cookie(0xaa), + data: b"same data".to_vec(), + data_size: 9, + ..Needle::default() + }; + n2.checksum = CRC::new(&n2.data); + let (_, _, unchanged) = v.write_needle(&mut n2, true, false).unwrap(); + assert!(unchanged); + } + + #[test] + fn test_volume_delete() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0xbb), + data: b"delete me".to_vec(), + data_size: 9, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + assert_eq!(v.file_count(), 1); + + let deleted_size = v + .delete_needle(&mut Needle { + id: NeedleId(1), + cookie: Cookie(0xbb), + ..Needle::default() + }) + .unwrap(); + assert!(deleted_size.0 > 0); + // Additive-only: file_count stays at 1 after delete + assert_eq!(v.file_count(), 1); + assert_eq!(v.deleted_count(), 1); + + // Read should fail with Deleted + let mut read_n = Needle { + id: NeedleId(1), + ..Needle::default() + }; + let err = v.read_needle(&mut read_n).unwrap_err(); + assert!(matches!(err, VolumeError::Deleted)); + } + + // Guard the Rust integrity-check tombstone path against the Go regression + // where verifyDeletedNeedleIntegrity forwarded TombstoneFileSize into the + // needle-size check, mismatched against the on-disk Size=0 header, and + // sent every volume with a trailing deletion read-only on load. The Rust + // check guards its size comparison with !size.is_deleted(); this test + // keeps that guarantee from silently regressing. + #[test] + fn test_check_volume_data_integrity_with_deletion_tombstone() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + { + let mut v = make_test_volume(dir); + for i in 1..=3 { + let data = format!("data {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.delete_needle(&mut Needle { + id: NeedleId(2), + cookie: Cookie(2), + ..Needle::default() + }) + .unwrap(); + v.sync_to_disk().unwrap(); + } + + let v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + assert!( + !v.is_no_write_or_delete(), + "volume should not be read-only after reload with trailing deletion tombstone" + ); + } + + // Reproduce the delete-traffic TTL bug: deletes append a tombstone to the + // .dat, which moves the file's mtime, and the loader read the TTL clock + // back from that mtime. A volume taking delete traffic therefore had + // is_expired() re-armed for another full TTL on every restart and was + // never reclaimed. + #[test] + fn test_ttl_clock_survives_deletes() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let ttl = crate::storage::needle::ttl::TTL::read("5m").unwrap(); + let last_write_ns = (SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + - 2 * 60 * 60) + * 1_000_000_000; + + let content_size = { + let mut v = make_ttl_volume(dir, ttl); + let mut written = Vec::new(); + for i in 1..=3u64 { + let data = format!("data {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + let (offset, _, _) = v.write_needle(&mut n, true, false).unwrap(); + written.push((offset, n.size)); + } + // More than one tombstone: the scan has to walk back over the whole + // run of them to reach a write. + for i in [2u64, 3] { + v.delete_needle(&mut Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + ..Needle::default() + }) + .unwrap(); + } + v.sync_to_disk().unwrap(); + // Backdate the writes on disk so the last one sits well outside the + // TTL, while the tombstones leave the .dat mtime at now. + for (offset, size) in written { + backdate_append_at_ns(&v.dat_path(), offset, size, last_write_ns); + } + v.content_size() + }; + + let v = make_ttl_volume(dir, ttl); + assert_eq!(v.last_modified_ts(), last_write_ns / 1_000_000_000); + assert!( + v.is_expired(content_size, 1024 * 1024), + "a TTL volume whose last write is 2h old must be expired after a reload" + ); + } + + // The one layout where a .dat's order does not track its write order: + // vacuum rewrites it by key, and an overwrite keeps its original, lower key. + // Reading the position rather than the timestamps would recover the + // highest-key needle's older write time and expire the volume before the + // overwrite has lived out its TTL. + #[test] + fn test_ttl_clock_after_vacuum_takes_newest_write() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let ttl = crate::storage::needle::ttl::TTL::read("5m").unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let old_write_ns = (now - 2 * 60 * 60) * 1_000_000_000; + let new_write_ns = (now - 60) * 1_000_000_000; + + let content_size = { + let mut v = make_ttl_volume(dir, ttl); + // Needle 1 is overwritten last but sorts first, so vacuum leaves it + // at the head of the .dat with the newest timestamp of the three. + for (id, append_at_ns) in [(2u64, old_write_ns), (3, old_write_ns), (1, new_write_ns)] { + let data = format!("data {}", id); + let mut n = Needle { + id: NeedleId(id), + cookie: Cookie(id as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + let (offset, _, _) = v.write_needle(&mut n, true, false).unwrap(); + v.sync_to_disk().unwrap(); + backdate_append_at_ns(&v.dat_path(), offset, n.size, append_at_ns); + } + v.compact_by_index(0, 0, |_| true).unwrap(); + v.commit_compact().unwrap(); + v.content_size() + }; + + let v = make_ttl_volume(dir, ttl); + assert_eq!(v.last_modified_ts(), new_write_ns / 1_000_000_000); + assert!( + !v.is_expired(content_size, 1024 * 1024), + "a volume overwritten a minute ago must not be expired after a vacuum and reload" + ); + } + + // Guard the destroy time an EC volume is reclaimed on: it was recomputed as + // now+TTL every time the .vif was written, so a read-only mark, a tier + // upload or an EC encode handed an already expiring volume another full TTL. + #[test] + fn test_expire_at_sec_counts_from_last_write() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let ttl = crate::storage::needle::ttl::TTL::read("5m").unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut v = make_ttl_volume(dir, ttl); + // A volume with nothing written yet has no last write to count from, and + // must not land in 1970 with its data due for destruction on sight. + assert!(v.expire_at_sec() >= now); + + v.set_last_modified_ts_for_test(now - 3600); + let want = now - 3600 + ttl.to_seconds(); + for pass in 0..2 { + v.save_volume_info().unwrap(); + assert_eq!( + v.volume_info.expire_at_sec, want, + ".vif save {} moved the destroy time off the last write", + pass + ); + } + } + + fn make_ttl_volume(dir: &str, ttl: crate::storage::needle::ttl::TTL) -> Volume { + Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec { + ttl: Some(ttl), + ..Default::default() + }, + ) + .unwrap() + } + + fn backdate_append_at_ns(dat_path: &str, offset: u64, size: Size, append_at_ns: u64) { + let ts_offset = + offset + NEEDLE_HEADER_SIZE as u64 + size.0 as u64 + NEEDLE_CHECKSUM_SIZE as u64; + let mut f = OpenOptions::new().write(true).open(dat_path).unwrap(); + f.seek(SeekFrom::Start(ts_offset)).unwrap(); + f.write_all(&append_at_ns.to_be_bytes()).unwrap(); + } + + fn write_three_needles(dir: &str) { + let mut v = make_test_volume(dir); + for i in 1..=3u64 { + let data = format!("data {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + } + + fn reload_volume(dir: &str) -> Volume { + Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap() + } + + // A torn shutdown can leave bytes in the .dat past the last indexed + // needle. Appending after such a tail would place the next needle at an + // offset the 8-byte-unit .idx encoding may not represent, so the load + // check must quarantine the volume read-only, matching Go's + // verifyNeedleIntegrity "actual N bytes expected M bytes" behavior. + #[test] + fn test_integrity_detects_unindexed_dat_tail() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_three_needles(dir); + + let dat_path = volume_file_name(dir, "", VolumeId(1)) + ".dat"; + let mut f = OpenOptions::new().append(true).open(&dat_path).unwrap(); + f.write_all(&[0xFFu8; 100]).unwrap(); + f.sync_all().unwrap(); + drop(f); + + let v = reload_volume(dir); + assert!( + v.is_no_write_or_delete(), + "volume with unindexed .dat tail must load read-only" + ); + } + + // Go's tail check is v3-only, so it serves a v1/v2 volume with an + // unindexed tail read-write. Checking it here flipped whole legacy disks + // read-only on their first boot under this server. + #[test] + fn test_integrity_skips_dat_tail_check_before_v3() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + { + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec { + version: VERSION_2, + ..Default::default() + }, + ) + .unwrap(); + for i in 1..=3u64 { + let data = format!("data {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + } + + let dat_path = volume_file_name(dir, "", VolumeId(1)) + ".dat"; + let mut f = OpenOptions::new().append(true).open(&dat_path).unwrap(); + f.write_all(&[0xFFu8; 96]).unwrap(); + f.sync_all().unwrap(); + drop(f); + + let v = reload_volume(dir); + assert_eq!(v.version(), VERSION_2, "volume should reload as v2"); + assert!( + !v.is_no_write_or_delete(), + "v2 volume with unindexed .dat tail must stay writable, as in Go" + ); + } + + // Same torn tail, but with a deletion tombstone as the last indexed + // record — the tombstone path must also verify the .dat ends at it. + #[test] + fn test_integrity_detects_tail_after_tombstone() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + { + let mut v = make_test_volume(dir); + let data = b"data 1".to_vec(); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: data.clone(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.delete_needle(&mut Needle { + id: NeedleId(1), + cookie: Cookie(1), + ..Needle::default() + }) + .unwrap(); + v.sync_to_disk().unwrap(); + } + + let dat_path = volume_file_name(dir, "", VolumeId(1)) + ".dat"; + let mut f = OpenOptions::new().append(true).open(&dat_path).unwrap(); + f.write_all(&[0xFFu8; 64]).unwrap(); + f.sync_all().unwrap(); + drop(f); + + let v = reload_volume(dir); + assert!( + v.is_no_write_or_delete(), + "volume with unindexed tail after tombstone must load read-only" + ); + } + + // A key-sorted .idx (legacy weed fix output) puts the highest-key needle + // last by file position, not the .dat-tail needle. The check must find + // the true tail entry via the max-offset scan instead of falsely flipping + // a healthy volume read-only. + #[test] + fn test_integrity_key_sorted_idx_no_false_readonly() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_three_needles(dir); + + // Swap the last two .idx entries so the file-position-last entry + // references a mid-file needle. + let idx_path = volume_file_name(dir, "", VolumeId(1)) + ".idx"; + let content = fs::read(&idx_path).unwrap(); + assert_eq!(content.len(), 3 * NEEDLE_MAP_ENTRY_SIZE); + let mut swapped = content.clone(); + swapped[NEEDLE_MAP_ENTRY_SIZE..2 * NEEDLE_MAP_ENTRY_SIZE] + .copy_from_slice(&content[2 * NEEDLE_MAP_ENTRY_SIZE..]); + swapped[2 * NEEDLE_MAP_ENTRY_SIZE..] + .copy_from_slice(&content[NEEDLE_MAP_ENTRY_SIZE..2 * NEEDLE_MAP_ENTRY_SIZE]); + fs::write(&idx_path, swapped).unwrap(); + + let v = reload_volume(dir); + assert!( + !v.is_no_write_or_delete(), + "healthy volume with key-sorted .idx must not load read-only" + ); + } + + // A .dat truncated mid-needle (crash during append) must quarantine. + #[test] + fn test_integrity_detects_truncated_dat_tail() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + write_three_needles(dir); + + let dat_path = volume_file_name(dir, "", VolumeId(1)) + ".dat"; + let len = fs::metadata(&dat_path).unwrap().len(); + let f = OpenOptions::new().write(true).open(&dat_path).unwrap(); + f.set_len(len - 5).unwrap(); + f.sync_all().unwrap(); + drop(f); + + let v = reload_volume(dir); + assert!( + v.is_no_write_or_delete(), + "volume with truncated .dat tail must load read-only" + ); + } + + #[test] + fn test_scrub_empty_volume() { + // Mirror of Go's TestScrubVolumeData "zero-size volume without index" + // case (weed/storage/volume_checking_test.go): a freshly created / + // pre-allocated volume has a superblock-only .dat and a zero-size .idx, + // and must scrub clean instead of being flagged as corrupt. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let v = make_test_volume(dir); + + // .dat holds only the superblock; .idx is empty. + assert_eq!(v.dat_file_size().unwrap(), SUPER_BLOCK_SIZE as u64); + + let (files_checked, broken) = v.scrub().unwrap(); + assert_eq!(files_checked, 0); + assert!( + broken.is_empty(), + "empty volume should scrub clean, got {:?}", + broken + ); + + // The index-only mode must agree. + let (idx_checked, idx_broken) = v.scrub_index().unwrap(); + assert_eq!(idx_checked, 0); + assert!( + idx_broken.is_empty(), + "empty volume should scrub_index clean, got {:?}", + idx_broken + ); + } + + #[test] + fn test_scrub_overwritten_volume_scrubs_clean() { + // An overwrite/delete appends a second .dat record + .idx row. Walking + // the on-disk .idx (not the deduped map) makes total_read match the + // physical .dat, so a healthy overwritten volume is NOT flagged broken. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + for (id, body) in [(1u64, "first version"), (1, "second version"), (2, "other")] { + let mut n = Needle { + id: NeedleId(id), + cookie: Cookie(id as u32), + data: body.as_bytes().to_vec(), + data_size: body.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + + let (count, broken) = v.scrub().unwrap(); + assert!( + broken.is_empty(), + "overwritten volume should scrub clean, got {:?}", + broken + ); + assert_eq!(count, 3, "scrub must count every .idx row"); + } + + #[test] + fn test_scrub_ignores_offset0_tombstone() { + // A remote-tier delete appends an offset-0 tombstone to the .idx with no + // physical .dat bytes; it must not flag the volume (neither the size + // reconcile nor the structural overlap check). + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let data = b"needle data".to_vec(); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: data.clone(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.sync_to_disk().unwrap(); + + // Append an offset-0 logical tombstone to the on-disk .idx. + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(v.file_name(".idx")) + .unwrap(); + crate::storage::idx::write_index_entry( + &mut f, + NeedleId(2), + Offset::from_actual_offset(0), + Size(-1), + ) + .unwrap(); + f.sync_all().unwrap(); + drop(f); + + let (count, broken) = v.scrub().unwrap(); + assert!( + broken.is_empty(), + "offset-0 tombstone must not flag the volume, got {:?}", + broken + ); + assert_eq!(count, 2, "both .idx rows are walked"); + } + + #[test] + fn test_scrub_index_flags_zero_size_idx_with_data() { + // A populated .dat with an empty .idx is corruption — the pre-allocated + // exception only covers a superblock-only .dat. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let data = b"needle data".to_vec(); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: data.clone(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.sync_to_disk().unwrap(); + assert!(v.dat_file_size().unwrap() > SUPER_BLOCK_SIZE as u64); + + // Truncate the .idx to zero while the .dat keeps its needle. + std::fs::File::create(v.file_name(".idx")).unwrap(); + + let (count, broken) = v.scrub_index().unwrap(); + assert_eq!(count, 0); + assert!( + broken.iter().any(|e| e.contains("zero-size IDX file")), + "expected zero-size IDX error, got {:?}", + broken + ); + } + + #[test] + fn test_scrub_healthy_volume() { + // Mirror of Go's TestScrubVolumeData "healthy volume" case: a volume + // with live needles scrubs clean and the .dat size accounting matches. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + for i in 1..=5 { + let data = format!("needle data {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + + let (files_checked, broken) = v.scrub().unwrap(); + assert_eq!(files_checked, 5); + assert!( + broken.is_empty(), + "healthy volume should scrub clean, got {:?}", + broken + ); + } + + #[test] + fn test_volume_multiple_needles() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + for i in 1..=10 { + let data = format!("needle data {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + + assert_eq!(v.file_count(), 10); + assert_eq!(v.max_file_key(), NeedleId(10)); + + // Read back needle 5 + let mut n = Needle { + id: NeedleId(5), + ..Needle::default() + }; + v.read_needle(&mut n).unwrap(); + assert_eq!(n.data, b"needle data 5"); + } + + #[test] + fn test_volume_reload_from_disk() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + // Write some needles + { + let mut v = make_test_volume(dir); + for i in 1..=3 { + let data = format!("data {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + } + + // Reload and verify + let v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + assert_eq!(v.file_count(), 3); + + let mut n = Needle { + id: NeedleId(2), + ..Needle::default() + }; + v.read_needle(&mut n).unwrap(); + assert_eq!(std::str::from_utf8(&n.data).unwrap(), "data 2"); + } + + #[test] + fn test_redb_volume_close_then_reload_keeps_counters_exact() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let open = || { + Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::Redb, + &VolumeSpec::default(), + ) + .unwrap() + }; + + { + let mut v = open(); + for i in 1..=3 { + let data = format!("data {}", i); + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: data.as_bytes().to_vec(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + // Volume::close is the shutdown path (store -> disk location -> + // volume). It must checkpoint the redb index, or the reload + // replays rows the table already holds and inflates the counters. + v.close(); + } + + let v = open(); + assert_eq!(v.file_count(), 3); + assert_eq!(v.deleted_count(), 0); + let mut n = Needle { + id: NeedleId(2), + ..Needle::default() + }; + v.read_needle(&mut n).unwrap(); + assert_eq!(std::str::from_utf8(&n.data).unwrap(), "data 2"); + } + + #[test] + fn test_redb_volume_checkpoint_flushes_dat_before_index() { + use crate::storage::needle_map::test_support::durable_idx_size; + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::Redb, + &VolumeSpec::default(), + ) + .unwrap(); + let rdb_path = std::path::PathBuf::from(v.file_name(".rdb")); + let write = |v: &mut Volume, i: u64| { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(1), + data: b"x".to_vec(), + data_size: 1, + ..Needle::default() + }; + // fsync=false: the .dat append is not flushed by the write itself. + v.write_needle(&mut n, true, false).unwrap(); + }; + for i in 1..1000 { + write(&mut v, i); + } + assert_eq!(durable_idx_size(&rdb_path), None); + + // The 1000th write makes an index checkpoint due. A checkpoint makes + // the index durable, so the volume has to flush the .dat first, and + // when that flush fails nothing may be checkpointed: a durable index + // row pointing past the end of an unflushed .dat loads read-only. + v.fail_next_fsync_for_test(true); + write(&mut v, 1000); + assert_eq!(durable_idx_size(&rdb_path), None); + + v.fail_next_fsync_for_test(false); + write(&mut v, 1001); + assert_eq!( + durable_idx_size(&rdb_path), + Some(1001 * NEEDLE_MAP_ENTRY_SIZE as u64) + ); + } + + #[test] + fn test_relocate_index_to_moves_index_and_serves_reads() { + let root = TempDir::new().unwrap(); + let data_dir = root.path().join("data"); + let idx_dir = root.path().join("idx"); + fs::create_dir_all(&data_dir).unwrap(); + fs::create_dir_all(&idx_dir).unwrap(); + let data = data_dir.to_str().unwrap(); + let idx = idx_dir.to_str().unwrap(); + + // Create the volume with its index co-located in the data dir (the state + // an EC reconstruct leaves), write a needle, and flush. + let mut v = Volume::new( + data, + data, + VolumeId(7), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + let payload = b"payload-across-relocate".to_vec(); + let mut n = Needle { + id: NeedleId(42), + cookie: Cookie(0x55), + data: payload.clone(), + data_size: payload.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.sync_to_disk().unwrap(); + + let data_idx = format!("{data}/7.idx"); + let idx_dir_idx = format!("{idx}/7.idx"); + assert!( + Path::new(&data_idx).exists(), + "precondition: index co-located with the data" + ); + + v.relocate_index_to(idx).unwrap(); + + assert!( + Path::new(&idx_dir_idx).exists(), + "index moved to the idx dir" + ); + assert!( + !Path::new(&data_idx).exists(), + "index gone from the data dir" + ); + assert_eq!(v.file_count(), 1); + + // The volume reloaded in place, so the needle still reads back. + let mut got = Needle { + id: NeedleId(42), + ..Needle::default() + }; + v.read_needle(&mut got).unwrap(); + assert_eq!(got.data, payload, "needle content survives the relocate"); + + // Relocating again is a no-op: the index is already where asked. + v.relocate_index_to(idx).unwrap(); + assert!(Path::new(&idx_dir_idx).exists()); + } + + #[test] + fn test_load_keeps_index_co_located_with_the_data() { + let root = TempDir::new().unwrap(); + let data_dir = root.path().join("data"); + let idx_dir = root.path().join("idx"); + fs::create_dir_all(&data_dir).unwrap(); + fs::create_dir_all(&idx_dir).unwrap(); + let data = data_dir.to_str().unwrap(); + let idx = idx_dir.to_str().unwrap(); + + let mut v = Volume::new( + data, + data, + VolumeId(7), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + let payload = b"payload-beside-the-data".to_vec(); + let mut n = Needle { + id: NeedleId(42), + cookie: Cookie(0x55), + data: payload.clone(), + data_size: payload.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.sync_to_disk().unwrap(); + drop(v); + + // --dir.idx now names an empty directory: the index already beside the + // data keeps serving, and nothing lands in the new directory. + let reopened = Volume::new( + data, + idx, + VolumeId(7), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + assert!( + Path::new(&format!("{data}/7.idx")).exists(), + "index stays with the data" + ); + assert!( + !Path::new(&format!("{idx}/7.idx")).exists(), + "nothing written to the new idx dir" + ); + + let mut got = Needle { + id: NeedleId(42), + ..Needle::default() + }; + reopened.read_needle(&mut got).unwrap(); + assert_eq!(got.data, payload); + } + + #[test] + fn test_relocate_index_to_noop_when_already_in_place() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: b"x".to_vec(), + data_size: 1, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.sync_to_disk().unwrap(); + + // Data and index share a directory, so there is nothing to move. + v.relocate_index_to(dir).unwrap(); + assert!(Path::new(&format!("{dir}/1.idx")).exists()); + let mut got = Needle { + id: NeedleId(1), + ..Needle::default() + }; + v.read_needle(&mut got).unwrap(); + assert_eq!(got.data, b"x"); + } + + #[test] + fn test_volume_cookie_mismatch() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0xaa), + data: b"original".to_vec(), + data_size: 8, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + + // Write with wrong cookie + let mut n2 = Needle { + id: NeedleId(1), + cookie: Cookie(0xbb), + data: b"overwrite".to_vec(), + data_size: 9, + ..Needle::default() + }; + let err = v.write_needle(&mut n2, true, false).unwrap_err(); + assert!(matches!(err, VolumeError::CookieMismatch(_))); + } + + // A size disagreeing with the blob's own header indexes the needle at the wrong + // length and, on v3, stamps the append timestamp into the middle of the needle. + #[test] + fn test_write_needle_blob_rejects_size_mismatch() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0x12345678), + data: b"the merged payload".to_vec(), + data_size: 18, + ..Needle::default() + }; + n.checksum = CRC::new(&n.data); + let (offset, _, _) = v.write_needle(&mut n, true, false).unwrap(); + let blob = v.read_needle_blob(offset as i64, n.size).unwrap(); + + let dat_size_before = v.dat_file_size().unwrap(); + + // Size(n.data_size) is what append reports, and what a caller following the + // payload-size convention would send. + let err = v + .write_needle_blob_and_index(NeedleId(2), &blob, Size(n.data_size as i32)) + .unwrap_err(); + assert!(matches!(err, VolumeError::Io(_)), "got {err:?}"); + assert_eq!(v.dat_file_size().unwrap(), dat_size_before); + + v.write_needle_blob_and_index(NeedleId(2), &blob, n.size) + .unwrap(); + } + + #[test] + fn test_volume_destroy() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let dat_path; + let idx_path; + + { + let mut v = make_test_volume(dir); + dat_path = v.file_name(".dat"); + idx_path = v.file_name(".idx"); + assert!(Path::new(&dat_path).exists()); + v.destroy(false, false).unwrap(); + } + + assert!(!Path::new(&dat_path).exists()); + assert!(!Path::new(&idx_path).exists()); + } + + #[test] + fn test_read_all_needles_uses_dat_order_for_live_offsets() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + let mut first = Needle { + id: NeedleId(10), + cookie: Cookie(0x11223344), + data: b"first".to_vec(), + data_size: 5, + ..Needle::default() + }; + v.write_needle(&mut first, true, false).unwrap(); + + let mut second = Needle { + id: NeedleId(20), + cookie: Cookie(0x55667788), + data: b"second".to_vec(), + data_size: 6, + ..Needle::default() + }; + v.write_needle(&mut second, true, false).unwrap(); + + let mut first_overwrite = Needle { + id: NeedleId(10), + cookie: Cookie(0x11223344), + data: b"first-overwrite".to_vec(), + data_size: 15, + ..Needle::default() + }; + v.write_needle(&mut first_overwrite, true, false).unwrap(); + + let needles = v.read_all_needles().unwrap(); + let ids: Vec = needles.iter().map(|n| u64::from(n.id)).collect(); + let bodies: Vec<&[u8]> = needles.iter().map(|n| n.data.as_slice()).collect(); + + assert_eq!(ids, vec![20, 10]); + assert_eq!( + bodies, + vec![b"second".as_slice(), b"first-overwrite".as_slice()] + ); + } + + #[test] + fn test_get_append_at_ns() { + let t1 = get_append_at_ns(0); + assert!(t1 > 0); + let t2 = get_append_at_ns(t1); + assert!(t2 > t1); + // If we pass a future timestamp, should return last+1 + let future = u64::MAX - 1; + let t3 = get_append_at_ns(future); + assert_eq!(t3, future + 1); + } + + #[test] + fn test_volume_compact() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + // Write 3 needles + for i in 1..=3u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: format!("data-{}", i).into_bytes(), + data_size: format!("data-{}", i).len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + assert_eq!(v.file_count(), 3); + + // Delete needle 2 + let mut del = Needle { + id: NeedleId(2), + cookie: Cookie(2), + ..Needle::default() + }; + v.delete_needle(&mut del).unwrap(); + // Additive-only: file_count stays at 3 after delete + assert_eq!(v.file_count(), 3); + assert_eq!(v.deleted_count(), 1); + + let dat_size_before = v.dat_file_size().unwrap(); + + // Compact + v.compact_by_index(0, 0, |_| true).unwrap(); + + // Verify compact files exist + assert!(Path::new(&v.file_name(".cpd")).exists()); + assert!(Path::new(&v.file_name(".cpx")).exists()); + + // Commit: swap files and reload + v.commit_compact().unwrap(); + + // After compaction: 2 live needles, 0 deleted + assert_eq!(v.file_count(), 2); + assert_eq!(v.deleted_count(), 0); + + // Dat should be smaller (deleted needle removed) + let dat_size_after = v.dat_file_size().unwrap(); + assert!( + dat_size_after < dat_size_before, + "dat should shrink after compact" + ); + + // Read back live needles + let mut n1 = Needle { + id: NeedleId(1), + ..Needle::default() + }; + v.read_needle(&mut n1).unwrap(); + assert_eq!(n1.data, b"data-1"); + + let mut n3 = Needle { + id: NeedleId(3), + ..Needle::default() + }; + v.read_needle(&mut n3).unwrap(); + assert_eq!(n3.data, b"data-3"); + + // Needle 2 should not exist + let mut n2 = Needle { + id: NeedleId(2), + ..Needle::default() + }; + assert!(v.read_needle(&mut n2).is_err()); + + // Compact files should not exist after commit + assert!(!Path::new(&v.file_name(".cpd")).exists()); + assert!(!Path::new(&v.file_name(".cpx")).exists()); + + // Cleanup should be a no-op + v.cleanup_compact().unwrap(); + } + + /// Guards the copy-phase integrity check against regressing into + /// double-subtracting skipped bytes: expected_live_bytes already excludes + /// needles dropped as unreadable (they continue before the tally), so the + /// check must compare it directly against the compacted .dat size. + #[test] + fn test_exceeds_expected_compacted_size() { + assert!(!exceeds_expected_compacted_size(100, 100)); + assert!(!exceeds_expected_compacted_size(100, 150)); + assert!(exceeds_expected_compacted_size(100, 90)); + } + + /// A write that lands on the live volume mid-copy must not trip the + /// post-copy integrity check. The Rust port snapshots the index entries + /// before the copy loop (equivalent to Go's frozen oldNm), so a concurrent + /// write is invisible to the tally and the check stays quiet. The write + /// is then replayed by makeup_diff during commit_compact. + #[test] + fn test_compact_by_index_tolerates_concurrent_write() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + for i in 1..=8u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: format!("data-{}", i).into_bytes(), + data_size: format!("data-{}", i).len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + + v.compact_by_index(0, 0, |_| true).unwrap(); + assert!(Path::new(&v.file_name(".cpd")).exists()); + + // A write arriving after the snapshot but before commit must survive + // via makeup_diff, exactly like a concurrent write in the Go server. + let mut late = Needle { + id: NeedleId(99), + cookie: Cookie(99), + data: b"late-write".to_vec(), + data_size: 10, + ..Needle::default() + }; + v.write_needle(&mut late, true, false).unwrap(); + + v.commit_compact().unwrap(); + + let mut got = Needle { + id: NeedleId(99), + cookie: Cookie(99), + ..Needle::default() + }; + v.read_needle(&mut got).unwrap(); + assert_eq!(got.data, b"late-write"); + } + + /// Vacuum compaction must tolerate an .idx entry whose offset points past + /// the end of the .dat file (the failure mode in issue #8928). The bad + /// entry is silently dropped from the resulting .cpx; healthy needles + /// survive untouched. + #[test] + fn test_compact_by_index_drops_dangling_needle() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + // Write a handful of healthy needles to establish a baseline .dat/.idx. + for i in 1..=5u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: format!("payload-{}", i).into_bytes(), + data_size: format!("payload-{}", i).len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + + let dat_size = v.dat_file_size().unwrap(); + let bad_key = NeedleId(9999); + let bad_offset = Offset::from_actual_offset((dat_size + 1024 * 1024) as i64); + let bad_size = Size(2048); + v.nm.as_mut() + .expect("needle map present") + .put(bad_key, bad_offset, bad_size) + .unwrap(); + v.nm.as_ref().unwrap().sync().unwrap(); + + // Vacuum must succeed in spite of the dangling entry. + v.compact_by_index(0, 0, |_| true) + .expect("compact_by_index should tolerate dangling entries"); + + // Walk the resulting .cpx and confirm the bad key was dropped while + // every healthy key made it through. + let cpx_path = v.file_name(".cpx"); + let mut cpx = File::open(&cpx_path).unwrap(); + let mut kept: Vec = Vec::new(); + idx::walk_index_file(&mut cpx, 0, |key, _, size| { + if !size.is_deleted() { + kept.push(key.0); + } + Ok(()) + }) + .unwrap(); + + assert!( + !kept.contains(&bad_key.0), + "dangling key {} should have been dropped from .cpx, got {:?}", + bad_key.0, + kept + ); + for i in 1..=5u64 { + assert!( + kept.contains(&i), + "healthy key {} missing from compacted .cpx, got {:?}", + i, + kept + ); + } + } + + /// The needle map's max_needle_end accumulator must let volume.load + /// detect an .idx whose entries point past the end of the .dat — the + /// deeper-than-tail corruption shape from issue #8928 that the existing + /// last-10-entries scan cannot see. The check is populated by the load + /// walk and read in volume.load() to flip the volume read-only. + #[test] + fn test_max_needle_end_detects_dangling_entry() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + for i in 1..=4u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: format!("data-{}", i).into_bytes(), + data_size: format!("data-{}", i).len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + + let dat_size = v.dat_file_size().unwrap() as i64; + let idx_path = v.file_name(".idx"); + let version = v.version(); + + // Sanity: a fresh load walk over the healthy .idx puts max_needle_end + // somewhere inside the .dat. + let mut idx_reader = File::open(&idx_path).unwrap(); + let healthy_nm = CompactNeedleMap::load_from_idx(&mut idx_reader, version).unwrap(); + let healthy_end = healthy_nm.max_needle_end(); + assert!( + healthy_end > 0 && healthy_end <= dat_size, + "healthy volume should have max_needle_end ({}) in [1, dat_size={}]", + healthy_end, + dat_size + ); + + // Inject a dangling entry by appending a bogus 16/17-byte record + // directly to the .idx, then reload. The load walk should observe + // max_needle_end past dat_size — which is exactly the signal + // volume.load uses to mark the volume read-only. + let bad_offset = Offset::from_actual_offset(dat_size + 4 * 1024 * 1024); + let mut idx_append = OpenOptions::new().append(true).open(&idx_path).unwrap(); + idx::write_index_entry(&mut idx_append, NeedleId(9999), bad_offset, Size(1024)).unwrap(); + idx_append.sync_all().unwrap(); + + let mut idx_reread = File::open(&idx_path).unwrap(); + let bad_nm = CompactNeedleMap::load_from_idx(&mut idx_reread, version).unwrap(); + let bad_end = bad_nm.max_needle_end(); + assert!( + bad_end > dat_size, + "after dangling-entry inject max_needle_end ({}) should exceed dat_size ({})", + bad_end, + dat_size + ); + } + + #[test] + fn test_compaction_revision_relookup() { + // Verifies that re_lookup_needle_data_offset returns the correct data offset + // and compaction revision, and that after compaction the offset changes. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + // Write two needles + let mut n1 = Needle { + id: NeedleId(1), + cookie: Cookie(0xAABBCCDD), + data: b"first-needle-data".to_vec(), + data_size: 17, + ..Needle::default() + }; + v.write_needle(&mut n1, true, false).unwrap(); + + let mut n2 = Needle { + id: NeedleId(2), + cookie: Cookie(0x11223344), + data: b"second-needle-data".to_vec(), + data_size: 18, + ..Needle::default() + }; + v.write_needle(&mut n2, true, false).unwrap(); + + // Get initial revision and offset for needle 1 + let initial_rev = v.super_block.compaction_revision; + let (initial_offset, rev) = v.re_lookup_needle_data_offset(NeedleId(1)).unwrap(); + assert_eq!(rev, initial_rev); + assert!(initial_offset > 0, "data offset should be positive"); + + // Delete needle 2 so compaction removes it + let mut del_n2 = Needle { + id: NeedleId(2), + cookie: Cookie(0x11223344), + ..Needle::default() + }; + v.delete_needle(&mut del_n2).unwrap(); + + // Compact the volume — this increments compaction_revision and may move needles + v.compact_by_index(0, 0, |_| true).unwrap(); + v.commit_compact().unwrap(); + + // After compaction, the revision should have changed + let new_rev = v.super_block.compaction_revision; + assert_eq!( + new_rev, + initial_rev + 1, + "compaction should increment revision" + ); + + // Re-lookup needle 1 — should still be found with the new revision + let (new_offset, relookup_rev) = v.re_lookup_needle_data_offset(NeedleId(1)).unwrap(); + assert_eq!(relookup_rev, new_rev); + assert!(new_offset > 0, "data offset should still be positive"); + + // The data should still be readable correctly after compaction + let mut read_n1 = Needle { + id: NeedleId(1), + ..Needle::default() + }; + v.read_needle(&mut read_n1).unwrap(); + assert_eq!(read_n1.data, b"first-needle-data"); + + // Deleted needle should not be found + let result = v.re_lookup_needle_data_offset(NeedleId(2)); + assert!( + result.is_err(), + "deleted needle should not be found after compaction" + ); + } + + #[test] + fn test_stream_info_includes_compaction_revision() { + // Verifies that NeedleStreamInfo carries the volume's compaction revision + // so that StreamingBody can detect when compaction has occurred. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + // Write a needle large enough to have meaningful data + let data = vec![0xABu8; 2048]; + let mut n = Needle { + id: NeedleId(42), + cookie: Cookie(0xDEADBEEF), + data: data.clone(), + data_size: data.len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + + // Read stream info + let mut read_n = Needle { + id: NeedleId(42), + cookie: Cookie(0xDEADBEEF), + ..Needle::default() + }; + let info = v.read_needle_stream_info(&mut read_n, false).unwrap(); + + assert_eq!(info.volume_id, VolumeId(1)); + assert_eq!(info.needle_id, NeedleId(42)); + assert_eq!(info.compaction_revision, v.super_block.compaction_revision); + assert_eq!(info.data_size, data.len() as u32); + assert!(info.data_file_offset > 0); + } + + #[test] + fn test_remote_vif_load_blocks_writes_but_allows_delete() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let dat_size_before_reload = { + let mut v = make_test_volume(dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0x1234), + data: b"remote".to_vec(), + data_size: 6, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + + let vif = VifVolumeInfo { + files: vec![VifRemoteFile { + backend_type: "s3".to_string(), + backend_id: "vif_rw_test".to_string(), + key: "remote-key".to_string(), + offset: 0, + file_size: v.dat_file_size().unwrap(), + modified_time: 123, + extension: ".dat".to_string(), + }], + version: v.version().0 as u32, + ..VifVolumeInfo::default() + }; + std::fs::write( + format!("{}/1.vif", dir), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + // Register a dummy S3 backend so load_remote_dat_file can look it + // up. This test never reads remote data, so a dead endpoint is fine. + // Use a test-specific backend_id to avoid racing with other tests + // that share the global registry. + let tier_config = crate::remote_storage::s3_tier::S3TierConfig { + access_key: "access".to_string(), + secret_key: "secret".to_string(), + region: "us-east-1".to_string(), + bucket: "bucket-a".to_string(), + endpoint: "http://127.0.0.1:1".to_string(), + storage_class: "STANDARD".to_string(), + force_path_style: true, + }; + crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap() + .register( + "s3.vif_rw_test".to_string(), + crate::remote_storage::s3_tier::S3TierBackend::new(&tier_config), + ); + + v.dat_file_size().unwrap() + }; + + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + + assert!(v.is_read_only()); + assert!(!v.no_write_or_delete); + assert!(v.no_write_can_delete); + + let err = v + .write_needle( + &mut Needle { + id: NeedleId(2), + cookie: Cookie(0x5678), + data: b"blocked".to_vec(), + data_size: 7, + ..Needle::default() + }, + true, + false, + ) + .unwrap_err(); + assert!(matches!(err, VolumeError::ReadOnly)); + + let deleted_size = v + .delete_needle(&mut Needle { + id: NeedleId(1), + cookie: Cookie(0x1234), + ..Needle::default() + }) + .unwrap(); + assert!(deleted_size.0 > 0); + assert_eq!(v.dat_file_size().unwrap(), dat_size_before_reload); + + crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.vif_rw_test"); + } + + /// Build a volume with `needles` needles, tier it to a fake remote backend, + /// and reload it the way a restart would — so it comes back read-only with + /// its index on disk. The local .dat is left in place, matching the state a + /// completed tier-down download leaves behind. + fn reload_as_tiered(dir: &str, backend_id: &str, needles: u64) -> Volume { + { + let mut v = make_test_volume(dir); + for i in 1..=needles { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(0x1234), + data: format!("needle-{i}").into_bytes(), + data_size: format!("needle-{i}").len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + + let vif = VifVolumeInfo { + files: vec![VifRemoteFile { + backend_type: "s3".to_string(), + backend_id: backend_id.to_string(), + key: "remote-key".to_string(), + offset: 0, + file_size: v.dat_file_size().unwrap(), + modified_time: 123, + extension: ".dat".to_string(), + }], + version: v.version().0 as u32, + ..VifVolumeInfo::default() + }; + std::fs::write( + format!("{}/1.vif", dir), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + let tier_config = crate::remote_storage::s3_tier::S3TierConfig { + access_key: "access".to_string(), + secret_key: "secret".to_string(), + region: "us-east-1".to_string(), + bucket: "bucket-a".to_string(), + endpoint: "http://127.0.0.1:1".to_string(), + storage_class: "STANDARD".to_string(), + force_path_style: true, + }; + crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap() + .register( + format!("s3.{backend_id}"), + crate::remote_storage::s3_tier::S3TierBackend::new(&tier_config), + ); + } + + Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap() + } + + // Tier-down clears the remote mode and publishes the volume as writable. The + // map it booted with is the read-only sorted one, whose put always fails, so + // without a rebuild the first write appends to .dat and then cannot be + // indexed — leaving bytes nothing references. + /// A volume tiered while the server runs must install the sorted map right + /// then. Reconciling only on the way down left it holding the whole index + /// in RAM and its .idx descriptor pinned until the next restart — the cost + /// the sorted map exists to avoid. + #[test] + fn test_tier_up_installs_the_sorted_needle_map() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + for i in 1..=2u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(0x1234), + data: format!("needle-{i}").into_bytes(), + data_size: format!("needle-{i}").len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + assert!( + !matches!(v.nm, Some(NeedleMap::SortedFile(_))), + "a local volume starts with a writable in-memory map" + ); + + // What the tier-up handler does once the .dat is uploaded: record the + // remote reference and reconcile the mode. + v.volume_info.files.push(PbRemoteFile { + backend_type: "s3".to_string(), + backend_id: "vif_tierup_test".to_string(), + key: "remote-key".to_string(), + offset: 0, + file_size: v.dat_file_size().unwrap(), + modified_time: 123, + extension: ".dat".to_string(), + }); + v.refresh_remote_write_mode().unwrap(); + + assert!(v.has_remote_file); + assert!( + matches!(v.nm, Some(NeedleMap::SortedFile(_))), + "tier-up must install the sorted map without waiting for a restart" + ); + // The index still answers, now off .sdx. + let nv = + v.nm.as_ref() + .unwrap() + .get(NeedleId(1)) + .unwrap() + .expect("needle 1 still resolves through the sorted index"); + assert!(!nv.size.is_deleted()); + } + + #[test] + fn test_tier_down_swaps_in_a_writable_needle_map() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = reload_as_tiered(dir, "vif_tierdown_test", 2); + assert!(matches!(v.nm, Some(NeedleMap::SortedFile(_)))); + + // The tail of the tier-down handler, once the .dat is back on disk. + v.volume_info.files.remove(0); + v.refresh_remote_write_mode().unwrap(); + v.save_volume_info().unwrap(); + v.open_local_dat_backend().unwrap(); + + assert!(!v.has_remote_file); + assert!( + !v.is_read_only(), + "tier-down should publish a writable volume" + ); + assert!( + !matches!(v.nm, Some(NeedleMap::SortedFile(_))), + "tier-down must swap the read-only map out before writes are allowed" + ); + + let mut n = Needle { + id: NeedleId(7), + cookie: Cookie(0x5678), + data: b"after-tier-down".to_vec(), + data_size: 15, + ..Needle::default() + }; + v.write_needle(&mut n, true, true).unwrap(); + v.sync_to_disk().unwrap(); + drop(v); + + // Reload: the needle must come back from .idx, not just have landed in + // .dat as unreferenced bytes. + let v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + let mut probe = Needle { + id: NeedleId(7), + ..Needle::default() + }; + v.read_needle(&mut probe).unwrap(); + assert_eq!(probe.data, b"after-tier-down"); + + crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.vif_tierdown_test"); + } + + // A .sdx that cannot be read end to end must abort compaction. Treating the + // short scan as the complete live set would commit a volume missing every + // needle past the truncation. + #[test] + fn test_compaction_aborts_on_unreadable_sorted_index() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = reload_as_tiered(dir, "vif_compact_test", 4); + let Some(NeedleMap::SortedFile(ref nm)) = v.nm else { + panic!("tiered volume should search the on-disk .sdx"); + }; + let sdx_path = nm.db_file_name().to_string(); + + let sdx = OpenOptions::new().write(true).open(&sdx_path).unwrap(); + sdx.set_len(NEEDLE_MAP_ENTRY_SIZE as u64).unwrap(); + drop(sdx); + crate::storage::needle_map::file_pool::pooled_index_files().discard(&sdx_path); + + let err = v + .compact_by_index(0, 0, |_| true) + .expect_err("compaction must not commit a partially scanned index"); + assert!( + matches!(err, VolumeError::Io(ref e) if e.kind() == io::ErrorKind::UnexpectedEof), + "unexpected error: {err:?}" + ); + + crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.vif_compact_test"); + } + + // Building .sdx writes to the index directory, which a read-only volume's + // may not allow. Before the sorted map that volume mounted and served reads + // off an in-memory index; it must still do so rather than going offline. + #[test] + #[cfg(unix)] + fn test_read_only_volume_mounts_when_the_index_dir_is_not_writable() { + use std::os::unix::fs::PermissionsExt; + + // root ignores the directory mode, so there is nothing to simulate. + if unsafe { libc::geteuid() } == 0 { + return; + } + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap().to_string(); + { + let mut v = make_test_volume(&dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0x1234), + data: b"still-readable".to_vec(), + data_size: 14, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.set_read_only_persist(false, true).unwrap(); + v.sync_to_disk().unwrap(); + } + + let set_mode = |mode: u32| { + let mut perms = std::fs::metadata(tmp.path()).unwrap().permissions(); + perms.set_mode(mode); + std::fs::set_permissions(tmp.path(), perms).unwrap(); + }; + set_mode(0o555); + + let loaded = Volume::new( + &dir, + &dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ); + set_mode(0o755); // restore before any assertion, so cleanup works + + let v = loaded.expect("a read-only volume must mount without writing its index dir"); + assert!( + !matches!(v.nm, Some(NeedleMap::SortedFile(_))), + "the .sdx could not be built, so the index should have fallen back to memory" + ); + let mut probe = Needle { + id: NeedleId(1), + ..Needle::default() + }; + v.read_needle(&mut probe).unwrap(); + assert_eq!(probe.data, b"still-readable"); + } + + // set_writable clears the read-only flags before it can know the .idx writer + // will attach. If attaching fails the flags have to go back: a volume that + // advertises writable while its map has no writer takes puts into memory and + // loses them at the next restart. + #[test] + #[cfg(unix)] + fn test_set_writable_rolls_back_when_the_idx_writer_cannot_attach() { + use std::os::unix::fs::PermissionsExt; + + // root ignores the directory mode, so there is nothing to simulate. + if unsafe { libc::geteuid() } == 0 { + return; + } + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap().to_string(); + { + let mut v = make_test_volume(&dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0x1234), + data: b"before".to_vec(), + data_size: 6, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.set_read_only_persist(false, true).unwrap(); + v.sync_to_disk().unwrap(); + } + + // A read-only mount, both halves of it: nothing new can be created in the + // directory, so .sdx generation fails and the index falls back to memory, + // and the .idx itself cannot be opened for write, so no writer attaches. + let set_mode = |path: &std::path::Path, mode: u32| { + let mut perms = std::fs::metadata(path).unwrap().permissions(); + perms.set_mode(mode); + std::fs::set_permissions(path, perms).unwrap(); + }; + let idx_path = tmp.path().join("1.idx"); + set_mode(&idx_path, 0o444); + set_mode(tmp.path(), 0o555); + + let loaded = Volume::new( + &dir, + &dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ); + let marked = loaded.map(|mut v| { + let r = v.set_writable(); + ( + v.no_write_or_delete, + v.is_read_only(), + v.nm.as_ref().unwrap().has_idx_writer(), + r, + ) + }); + // Restore before any assertion, so cleanup works. + set_mode(tmp.path(), 0o755); + set_mode(&idx_path, 0o644); + + let (no_write_or_delete, read_only, has_writer, result) = marked.unwrap(); + assert!( + result.is_err(), + "set_writable cannot succeed without an .idx writer" + ); + assert!(!has_writer, "the writer is what failed to attach"); + assert!( + no_write_or_delete && read_only, + "a failed mark must leave the volume read-only, not writable with an index it cannot append to" + ); + } + + // .idx is a run of fixed-size rows and writers append at EOF, so a partial + // row left by a short write puts every row appended after it off alignment + // and the next load parses the rest as garbage. The tail has to go before + // anything writable attaches. + #[test] + fn test_writable_load_trims_a_torn_idx_tail() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap().to_string(); + { + let mut v = make_test_volume(&dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0x1234), + data: b"first".to_vec(), + data_size: 5, + ..Needle::default() + }; + v.write_needle(&mut n, true, true).unwrap(); + v.sync_to_disk().unwrap(); + } + + let idx_path = tmp.path().join("1.idx"); + let whole = std::fs::metadata(&idx_path).unwrap().len(); + { + use std::io::Write as _; + let mut f = OpenOptions::new().append(true).open(&idx_path).unwrap(); + f.write_all(&[0xcd; 5]).unwrap(); + } + + let mut v = Volume::new( + &dir, + &dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + assert_eq!( + std::fs::metadata(&idx_path).unwrap().len(), + whole, + "the partial row should be gone before any writer attaches" + ); + + let mut n = Needle { + id: NeedleId(2), + cookie: Cookie(0x5678), + data: b"second".to_vec(), + data_size: 6, + ..Needle::default() + }; + v.write_needle(&mut n, true, true).unwrap(); + v.sync_to_disk().unwrap(); + drop(v); + + // Both rows have to survive the round trip; a misaligned append loses + // the second one and garbles whatever follows it. + let v = Volume::new( + &dir, + &dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + for (id, want) in [(1u64, &b"first"[..]), (2u64, &b"second"[..])] { + let mut probe = Needle { + id: NeedleId(id), + ..Needle::default() + }; + v.read_needle(&mut probe).unwrap(); + assert_eq!(probe.data, want); + } + } + + // A delete-capable read-only volume — every tiered one — takes the in-memory + // loader's read-write branch, which fails on the same unwritable directory + // that refused the .sdx. It has to end up read-only rather than offline. + #[test] + #[cfg(unix)] + fn test_delete_capable_volume_falls_all_the_way_back_to_read_only() { + use std::os::unix::fs::PermissionsExt; + + // root ignores the directory mode, so there is nothing to simulate. + if unsafe { libc::geteuid() } == 0 { + return; + } + + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap().to_string(); + { + let mut v = make_test_volume(&dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(0x1234), + data: b"still-readable".to_vec(), + data_size: 14, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.set_read_only_persist(true, true).unwrap(); + v.sync_to_disk().unwrap(); + } + + let set_mode = |path: &std::path::Path, mode: u32| { + let mut perms = std::fs::metadata(path).unwrap().permissions(); + perms.set_mode(mode); + std::fs::set_permissions(path, perms).unwrap(); + }; + let idx_path = tmp.path().join("1.idx"); + set_mode(&idx_path, 0o444); + set_mode(tmp.path(), 0o555); + + let loaded = Volume::new( + &dir, + &dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ); + set_mode(tmp.path(), 0o755); + set_mode(&idx_path, 0o644); + + let v = loaded.expect("a delete-capable volume must mount without a writable index dir"); + assert!( + v.no_write_or_delete, + "with no writer for tombstones, deletes have to be refused outright" + ); + let mut probe = Needle { + id: NeedleId(1), + ..Needle::default() + }; + v.read_needle(&mut probe).unwrap(); + assert_eq!(probe.data, b"still-readable"); + } + + // Issue #10937: a volume server holding hundreds of thousands of cloud-tiered + // volumes ran out of descriptors because every one of them pinned its index + // for the life of the process. A tiered volume must search the on-disk .sdx + // and hold nothing open while it sits idle. + #[test] + fn test_remote_volume_index_is_on_disk_and_holds_no_descriptors() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + { + let mut v = make_test_volume(dir); + for i in 1..=4u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(0x1234), + data: format!("needle-{i}").into_bytes(), + data_size: format!("needle-{i}").len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + + // A writable volume does pin its .idx, which is also what proves the + // descriptor probe below is looking at something real. + if let Some(count) = crate::storage::needle_map::file_pool::open_index_fds(tmp.path()) { + assert!(count > 0, "a writable volume should hold its .idx open"); + } + + let vif = VifVolumeInfo { + files: vec![VifRemoteFile { + backend_type: "s3".to_string(), + backend_id: "vif_fd_test".to_string(), + key: "remote-key".to_string(), + offset: 0, + file_size: v.dat_file_size().unwrap(), + modified_time: 123, + extension: ".dat".to_string(), + }], + version: v.version().0 as u32, + ..VifVolumeInfo::default() + }; + std::fs::write( + format!("{}/1.vif", dir), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + let tier_config = crate::remote_storage::s3_tier::S3TierConfig { + access_key: "access".to_string(), + secret_key: "secret".to_string(), + region: "us-east-1".to_string(), + bucket: "bucket-a".to_string(), + endpoint: "http://127.0.0.1:1".to_string(), + storage_class: "STANDARD".to_string(), + force_path_style: true, + }; + crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap() + .register( + "s3.vif_fd_test".to_string(), + crate::remote_storage::s3_tier::S3TierBackend::new(&tier_config), + ); + } + + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + + assert!(v.has_remote_file); + let Some(NeedleMap::SortedFile(ref nm)) = v.nm else { + panic!( + "tiered volume should search the on-disk .sdx, got {:?}", + v.nm.is_some() + ); + }; + let idx_path = nm.index_file_name().to_string(); + let sdx_path = nm.db_file_name().to_string(); + assert!(Path::new(&sdx_path).exists(), "load should build the .sdx"); + + let drop_pooled = || { + crate::storage::needle_map::file_pool::pooled_index_files().discard(&idx_path); + crate::storage::needle_map::file_pool::pooled_index_files().discard(&sdx_path); + }; + + if crate::storage::needle_map::file_pool::open_index_fds(tmp.path()).is_some() { + drop_pooled(); + assert_eq!( + crate::storage::needle_map::file_pool::open_index_fds(tmp.path()), + Some(0), + "a loaded tiered volume must hold no .idx/.sdx descriptor" + ); + } + + // Lookups still resolve, straight off .sdx. + assert!( + !v.nm + .as_ref() + .unwrap() + .get(NeedleId(3)) + .unwrap() + .unwrap() + .size + .is_deleted() + ); + assert!(v.nm.as_ref().unwrap().get(NeedleId(9)).unwrap().is_none()); + + // Deletes are still allowed on a tiered volume and land in .idx. + let idx_before = std::fs::metadata(&idx_path).unwrap().len(); + let deleted = v + .delete_needle(&mut Needle { + id: NeedleId(3), + cookie: Cookie(0x1234), + ..Needle::default() + }) + .unwrap(); + assert!(deleted.0 > 0); + assert_eq!( + std::fs::metadata(&idx_path).unwrap().len(), + idx_before + NEEDLE_MAP_ENTRY_SIZE as u64, + "the tombstone must be appended to the .idx tail" + ); + assert!( + v.nm.as_ref() + .unwrap() + .get(NeedleId(3)) + .unwrap() + .unwrap() + .size + .is_deleted() + ); + + if crate::storage::needle_map::file_pool::open_index_fds(tmp.path()).is_some() { + drop_pooled(); + assert_eq!( + crate::storage::needle_map::file_pool::open_index_fds(tmp.path()), + Some(0), + "reads and deletes must give their borrowed handles back" + ); + } + + crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.vif_fd_test"); + } + + #[test] + fn test_set_writable_keeps_remote_delete_only_mode() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + v.volume_info.files.push(PbRemoteFile { + backend_type: "s3".to_string(), + backend_id: "default".to_string(), + key: "remote-key".to_string(), + offset: 0, + file_size: v.dat_file_size().unwrap(), + modified_time: 123, + extension: ".dat".to_string(), + }); + v.refresh_remote_write_mode().unwrap(); + v.set_writable().unwrap(); + + assert!(v.is_read_only()); + assert!(!v.no_write_or_delete); + assert!(v.no_write_can_delete); + } + + // A volume booted with .vif ReadOnly=true used to come back stuck — + // load_index_inmemory built the CompactNeedleMap without an .idx writer + // attached, and set_writable only flipped the flag and rewrote .vif. + // The next put silently skipped the .idx append, so the write landed in + // memory only and was lost on the next restart. + #[test] + fn test_set_writable_reattaches_idx_writer_after_persisted_readonly() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + { + let mut v = make_test_volume(dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: b"initial".to_vec(), + data_size: 7, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.set_read_only_persist(false, true).unwrap(); + v.sync_to_disk().unwrap(); + } + + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + assert!( + v.no_write_or_delete, + "reloaded volume should be read-only from .vif" + ); + assert!( + matches!(v.nm, Some(NeedleMap::SortedFile(_))), + "read-only load should search the on-disk .sdx" + ); + + v.set_writable().unwrap(); + assert!(!v.is_read_only()); + assert!( + !matches!(v.nm, Some(NeedleMap::SortedFile(_))), + "set_writable must swap out the read-only map, whose put always fails" + ); + assert!( + v.nm.as_ref().unwrap().has_idx_writer(), + "set_writable must reattach the .idx writer or post-restart writes vanish" + ); + + let mut n = Needle { + id: NeedleId(2), + cookie: Cookie(2), + data: b"after-mark-writable".to_vec(), + data_size: 19, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.sync_to_disk().unwrap(); + + // Reload one more time — the .idx must contain the post-mark-writable + // entry, not just have it in memory. + drop(v); + let v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + let mut probe = Needle { + id: NeedleId(2), + ..Needle::default() + }; + v.read_needle(&mut probe).unwrap(); + assert_eq!( + std::str::from_utf8(&probe.data).unwrap(), + "after-mark-writable" + ); + } + + // readOnlyCanDelete rejects writes, keeps accepting deletes, and survives + // a restart. + #[test] + fn test_read_only_can_delete_persists_and_keeps_deletes_working() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + { + let mut v = make_test_volume(dir); + for id in 1..=2u64 { + let mut n = Needle { + id: NeedleId(id), + cookie: Cookie(id as u32), + data: b"payload".to_vec(), + data_size: 7, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.set_read_only_persist(true, true).unwrap(); + assert!(v.no_write_can_delete); + assert!(!v.no_write_or_delete); + assert!(v.is_read_only()); + + let err = v + .write_needle( + &mut Needle { + id: NeedleId(3), + cookie: Cookie(3), + data: b"blocked".to_vec(), + data_size: 7, + ..Needle::default() + }, + true, + false, + ) + .unwrap_err(); + assert!(matches!(err, VolumeError::ReadOnly)); + + let deleted = v + .delete_needle(&mut Needle { + id: NeedleId(1), + cookie: Cookie(1), + ..Needle::default() + }) + .unwrap(); + assert!(deleted.0 > 0, "deletes must still land"); + v.sync_to_disk().unwrap(); + } + + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + assert!(v.no_write_can_delete, "state must survive a restart"); + assert!(!v.no_write_or_delete); + assert!( + v.nm.as_ref().unwrap().has_idx_writer(), + "canDelete boot must attach an .idx writer for delete tombstones" + ); + + let err = v + .write_needle( + &mut Needle { + id: NeedleId(4), + cookie: Cookie(4), + data: b"blocked".to_vec(), + data_size: 7, + ..Needle::default() + }, + true, + false, + ) + .unwrap_err(); + assert!(matches!(err, VolumeError::ReadOnly)); + + let deleted = v + .delete_needle(&mut Needle { + id: NeedleId(2), + cookie: Cookie(2), + ..Needle::default() + }) + .unwrap(); + assert!(deleted.0 > 0, "deletes must still land after restart"); + + // downgrading to plain readonly clears the canDelete flag + v.set_read_only_persist(false, true).unwrap(); + assert!(v.no_write_or_delete); + assert!(!v.no_write_can_delete); + + // -writable clears the state again. + v.set_writable().unwrap(); + assert!(!v.is_read_only()); + let mut n = Needle { + id: NeedleId(5), + cookie: Cookie(5), + data: b"writable-again".to_vec(), + data_size: 14, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + + // Upgrading a volume that booted plain persisted-readonly to canDelete + // must attach the missing .idx writer, or delete tombstones cannot append. + #[test] + fn test_read_only_can_delete_upgrade_after_readonly_boot() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + { + let mut v = make_test_volume(dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: b"payload".to_vec(), + data_size: 7, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.set_read_only_persist(false, true).unwrap(); + v.sync_to_disk().unwrap(); + } + + let mut v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + assert!(v.no_write_or_delete); + let err = v + .delete_needle(&mut Needle { + id: NeedleId(1), + cookie: Cookie(1), + ..Needle::default() + }) + .unwrap_err(); + assert!( + matches!(err, VolumeError::ReadOnly), + "plain readonly must reject deletes" + ); + + v.set_read_only_persist(true, true).unwrap(); + assert!(!v.no_write_or_delete); + assert!(v.no_write_can_delete); + assert!( + v.nm.as_ref().unwrap().has_idx_writer(), + "upgrade to canDelete must attach the .idx writer" + ); + let deleted = v + .delete_needle(&mut Needle { + id: NeedleId(1), + cookie: Cookie(1), + ..Needle::default() + }) + .unwrap(); + assert!(deleted.0 > 0, "deletes must land once canDelete is set"); + } + + #[test] + fn test_load_vif_defaults_local_version_and_bytes_offset() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + { + let _v = make_test_volume(dir); + let vif = VifVolumeInfo::default(); + std::fs::write( + format!("{}/1.vif", dir), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + } + + let v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + + assert_eq!(v.volume_info.version, Version::current().0 as u32); + assert_eq!(v.volume_info.bytes_offset, OFFSET_SIZE as u32); + assert_eq!(v.version(), Version::current()); + } + + #[test] + fn test_version_superblock_overrides_vif_version() { + // Go behavior: after reading the superblock, volumeInfo.Version is set + // to SuperBlock.Version, overriding whatever was in the .vif file. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + { + let _v = make_test_volume(dir); + // Write a .vif with version=2, but the .dat superblock is version=3 + let vif = VifVolumeInfo { + version: VERSION_2.0 as u32, + bytes_offset: OFFSET_SIZE as u32, + ..VifVolumeInfo::default() + }; + std::fs::write( + format!("{}/1.vif", dir), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + } + + let v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + + // Superblock version (3) overrides the .vif version (2) + assert_eq!(v.volume_info.version, VERSION_3.0 as u32); + assert_eq!(v.version(), VERSION_3); + } + + #[test] + fn test_load_vif_rejects_bytes_offset_mismatch() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + { + let _v = make_test_volume(dir); + let vif = VifVolumeInfo { + version: Version::current().0 as u32, + bytes_offset: (OFFSET_SIZE as u32) + 1, + ..VifVolumeInfo::default() + }; + std::fs::write( + format!("{}/1.vif", dir), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + } + + let result = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ); + + match result { + Ok(_) => panic!("expected bytes_offset mismatch to fail"), + Err(VolumeError::Io(io_err)) => { + assert_eq!(io_err.kind(), io::ErrorKind::InvalidData); + assert!(io_err.to_string().contains("bytes_offset mismatch")); + } + Err(other) => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn test_remote_only_volume_load_reads_from_tier_backend() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let dat_bytes = { + let mut v = make_test_volume(dir); + let mut n = Needle { + id: NeedleId(7), + cookie: Cookie(0x7788), + data: b"remote-only".to_vec(), + data_size: 11, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + v.sync_to_disk().unwrap(); + std::fs::read(v.file_name(".dat")).unwrap() + }; + + let dat_path = format!("{}/1.dat", dir); + std::fs::remove_file(&dat_path).unwrap(); + + let (endpoint, shutdown_tx) = spawn_fake_s3_server(dat_bytes.clone()); + // Use a test-specific backend_id to avoid racing with other tests + // that share the global registry. Never call clear() — only + // register/remove our own entries. + let tier_config = crate::remote_storage::s3_tier::S3TierConfig { + access_key: "access".to_string(), + secret_key: "secret".to_string(), + region: "us-east-1".to_string(), + bucket: "bucket-a".to_string(), + endpoint, + storage_class: "STANDARD".to_string(), + force_path_style: true, + }; + { + let mut registry = crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap(); + registry.register( + "s3.remote_only_rw".to_string(), + crate::remote_storage::s3_tier::S3TierBackend::new(&tier_config), + ); + } + + let vif = VifVolumeInfo { + files: vec![VifRemoteFile { + backend_type: "s3".to_string(), + backend_id: "remote_only_rw".to_string(), + key: "remote-key".to_string(), + offset: 0, + file_size: dat_bytes.len() as u64, + modified_time: 123, + extension: ".dat".to_string(), + }], + version: Version::current().0 as u32, + bytes_offset: OFFSET_SIZE as u32, + dat_file_size: dat_bytes.len() as i64, + ..VifVolumeInfo::default() + }; + std::fs::write( + format!("{}/1.vif", dir), + serde_json::to_string_pretty(&vif).unwrap(), + ) + .unwrap(); + + let v = Volume::new( + dir, + dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + + assert!(v.has_remote_file); + assert!(v.dat_file.is_none()); + assert!(v.remote_dat_file.is_some()); + + let mut n = Needle { + id: NeedleId(7), + ..Needle::default() + }; + let size = v.read_needle(&mut n).unwrap(); + assert_eq!(size, 11); + assert_eq!(n.data, b"remote-only"); + + let mut meta = Needle { + id: NeedleId(7), + ..Needle::default() + }; + let info = v.read_needle_stream_info(&mut meta, false).unwrap(); + assert!(matches!(info.source, NeedleStreamSource::Remote(_))); + let mut streamed = vec![0u8; info.data_size as usize]; + info.source + .read_exact_at(&mut streamed, info.data_file_offset) + .unwrap(); + assert_eq!(streamed, b"remote-only"); + assert_eq!(meta.data_size, 11); + + let _ = shutdown_tx.send(()); + crate::remote_storage::s3_tier::global_s3_tier_registry() + .write() + .unwrap() + .remove("s3.remote_only_rw"); + } + + /// Volume destroy removes .vif alongside the primary data files. + #[test] + fn test_destroy_removes_vif() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let mut v = make_test_volume(dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: b"test".to_vec(), + data_size: 4, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + + // Write a .vif file (as EC encode would) + let vif_path = format!("{}/1.vif", dir); + std::fs::write(&vif_path, r#"{"version":3}"#).unwrap(); + assert!(std::path::Path::new(&vif_path).exists()); + + // .dat and .idx should exist + let dat_path = format!("{}/1.dat", dir); + let idx_path = format!("{}/1.idx", dir); + assert!(std::path::Path::new(&dat_path).exists()); + assert!(std::path::Path::new(&idx_path).exists()); + + // Destroy the volume + v.destroy(false, false).unwrap(); + + // .dat and .idx should be gone + assert!( + !std::path::Path::new(&dat_path).exists(), + ".dat should be removed" + ); + assert!( + !std::path::Path::new(&idx_path).exists(), + ".idx should be removed" + ); + + assert!( + !std::path::Path::new(&vif_path).exists(), + ".vif should be removed" + ); + } + + /// Volume destroy with separate idx directory must clean up both dirs and .vif. + #[test] + fn test_destroy_with_separate_idx_dir() { + let dat_tmp = TempDir::new().unwrap(); + let idx_tmp = TempDir::new().unwrap(); + let dat_dir = dat_tmp.path().to_str().unwrap(); + let idx_dir = idx_tmp.path().to_str().unwrap(); + + let mut v = Volume::new( + dat_dir, + idx_dir, + VolumeId(1), + NeedleMapKind::InMemory, + &VolumeSpec::default(), + ) + .unwrap(); + + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: b"hello".to_vec(), + data_size: 5, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + + // Write .vif in data dir (as EC encode would) + let vif_path = format!("{}/1.vif", dat_dir); + std::fs::write(&vif_path, r#"{"version":3}"#).unwrap(); + + let dat_path = format!("{}/1.dat", dat_dir); + let idx_path = format!("{}/1.idx", idx_dir); + assert!(std::path::Path::new(&dat_path).exists()); + assert!(std::path::Path::new(&idx_path).exists()); + + v.destroy(false, false).unwrap(); + + assert!( + !std::path::Path::new(&dat_path).exists(), + ".dat removed from data dir" + ); + assert!( + !std::path::Path::new(&idx_path).exists(), + ".idx removed from idx dir" + ); + assert!( + !std::path::Path::new(&vif_path).exists(), + ".vif removed from data dir" + ); + } + + /// When an EC volume for the same id has a sealed .ecx on the same disk, the + /// .vif is shared with it and must survive the regular volume's deletion. + #[test] + fn test_destroy_keeps_vif_when_ec_coexists() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + + let mut v = make_test_volume(dir); + let mut n = Needle { + id: NeedleId(1), + cookie: Cookie(1), + data: b"test".to_vec(), + data_size: 4, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + + let vif_path = format!("{}/1.vif", dir); + std::fs::write(&vif_path, r#"{"version":3}"#).unwrap(); + // A sealed .ecx marks a coexisting EC volume for the same id. + let ecx_path = format!("{}/1.ecx", dir); + std::fs::write(&ecx_path, b"ec-index").unwrap(); + + v.destroy(false, false).unwrap(); + + let dat_path = format!("{}/1.dat", dir); + let idx_path = format!("{}/1.idx", dir); + assert!(!std::path::Path::new(&dat_path).exists(), ".dat removed"); + assert!(!std::path::Path::new(&idx_path).exists(), ".idx removed"); + assert!( + std::path::Path::new(&vif_path).exists(), + ".vif kept: shared with the coexisting EC volume" + ); + assert!( + std::path::Path::new(&ecx_path).exists(), + ".ecx is an EC sidecar, never touched here" + ); + } + + /// A crash after the .idx/.dat were consumed but before the .cpx->.idx + /// rename committed leaves only .cpd + .cpx + .cpc. reconcile_compact_state + /// must roll the swap FORWARD and produce a loadable, writable volume + /// holding the compacted needle count. + #[test] + fn test_reconcile_roll_forward_marker_only() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + for i in 1..=6u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: format!("data-{}", i).into_bytes(), + data_size: format!("data-{}", i).len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + for id in [2u64, 5u64] { + let mut del = Needle { + id: NeedleId(id), + cookie: Cookie(id as u32), + ..Needle::default() + }; + v.delete_needle(&mut del).unwrap(); + } + let expected_live = v.file_count() - v.deleted_count(); + + v.compact_by_index(0, 0, |_| true).unwrap(); + v.close(); + + let cpd = v.file_name(".cpd"); + let cpx = v.file_name(".cpx"); + let cpc = v.file_name(".cpc"); + let dat = v.file_name(".dat"); + let idx = v.file_name(".idx"); + assert!(Path::new(&cpd).exists()); + assert!(Path::new(&cpx).exists()); + + // Simulate the mid-commit crash: original .dat/.idx are gone and only + // the compacted temp files plus the commit marker survive. + fs::remove_file(&dat).unwrap(); + fs::remove_file(&idx).unwrap(); + let _ = fs::remove_dir_all(v.file_name(".ldb")); + fs::write(&cpc, b"").unwrap(); + + v.reconcile_compact_state().unwrap(); + + assert!(!Path::new(&cpd).exists(), ".cpd consumed by roll-forward"); + assert!(!Path::new(&cpx).exists(), ".cpx consumed by roll-forward"); + assert!(!Path::new(&cpc).exists(), ".cpc cleared after swap"); + assert!(Path::new(&dat).exists(), ".dat restored by roll-forward"); + assert!(Path::new(&idx).exists(), ".idx restored by roll-forward"); + + // The rolled-forward files must load as a writable volume with the + // compacted needle count. + let reloaded = make_test_volume(dir); + assert!(!reloaded.is_read_only(), "rolled-forward volume read-only"); + assert_eq!( + reloaded.file_count(), + expected_live, + "rolled-forward volume needle count" + ); + } + + /// With no .cpc marker, leftover .cpd/.cpx are an abandoned generation; + /// reconcile must roll BACK by deleting them and leave the live .dat/.idx. + #[test] + fn test_reconcile_roll_back_no_marker() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + for i in 1..=4u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: format!("data-{}", i).into_bytes(), + data_size: format!("data-{}", i).len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.compact_by_index(0, 0, |_| true).unwrap(); + + let cpd = v.file_name(".cpd"); + let cpx = v.file_name(".cpx"); + let dat = v.file_name(".dat"); + let idx = v.file_name(".idx"); + assert!(Path::new(&cpd).exists()); + assert!(Path::new(&cpx).exists()); + // No .cpc marker exists. + + v.reconcile_compact_state().unwrap(); + + assert!(!Path::new(&cpd).exists(), ".cpd rolled back"); + assert!(!Path::new(&cpx).exists(), ".cpx rolled back"); + assert!(Path::new(&dat).exists(), "live .dat kept"); + assert!(Path::new(&idx).exists(), "live .idx kept"); + } + + /// apply_compact_swap must abort without touching the live .dat/.idx when + /// the .cpd/.cpx temp files are missing (a stale or duplicate commit). + #[test] + fn test_apply_compact_swap_missing_temp_files_preserves_live() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().to_str().unwrap(); + let mut v = make_test_volume(dir); + + for i in 1..=3u64 { + let mut n = Needle { + id: NeedleId(i), + cookie: Cookie(i as u32), + data: format!("data-{}", i).into_bytes(), + data_size: format!("data-{}", i).len() as u32, + ..Needle::default() + }; + v.write_needle(&mut n, true, false).unwrap(); + } + v.sync_to_disk().unwrap(); + + let dat = v.file_name(".dat"); + let idx = v.file_name(".idx"); + let dat_len_before = fs::metadata(&dat).unwrap().len(); + + // No .cpd/.cpx present: the swap must refuse. + assert!( + v.apply_compact_swap().is_err(), + "swap should error when .cpd/.cpx are missing" + ); + assert!(Path::new(&dat).exists(), "live .dat preserved"); + assert!(Path::new(&idx).exists(), "live .idx preserved"); + assert_eq!( + fs::metadata(&dat).unwrap().len(), + dat_len_before, + "live .dat unchanged" + ); + } +} diff --git a/weed/admin/view/app/plugin_templ.go b/weed/admin/view/app/plugin_templ.go new file mode 100644 index 0000000..735da58 --- /dev/null +++ b/weed/admin/view/app/plugin_templ.go @@ -0,0 +1,115 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package app + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Plugin(page string, initialJob string, lane string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + currentPage := page + if currentPage == "" { + currentPage = "overview" + } + currentLane := lane + if currentLane == "" { + currentLane = "default" + } + laneTitle := pluginLaneTitle(currentLane) + laneDescription := pluginLaneDescription(currentLane) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(laneTitle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 21, Col: 84} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(laneDescription) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 22, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Workers

0

Active Jobs

0

Activities (recent)

0

Next Run

-

Per Job Type Summary
Job TypeActive JobsRecent Activities
Loading...
Scheduler State
Sequential scheduler with per-job runtime limits
Job TypeEnabledDetectorIn FlightMax RuntimeExec GlobalExec/WorkerExecutor WorkersEffective ExecLast Run
Loading...
Workers
WorkerAddressCapabilitiesLoad
Loading...
Job Type Configuration
Not loaded
Selected Job Type
-
Descriptor
Select a job type to load schema and config.
Admin Config Form
No admin form loaded.
Worker Config Form
No worker form loaded.
Job Scheduling Settings
How often to check for new work.
Per-attempt deadline for one job. Size-aware tasks may extend this automatically.
Next Run
Scheduler
-
Not scheduled
Run History
Keep last 10 success + last 10 errors
Successful Runs
TimeJob IDWorkerDuration
No data
Error Runs
TimeJob IDWorkerError
No data
Detection Results
Run detection to see proposals.
Job Queue
States: pending/assigned/running
Job IDTypeStateProgressWorkerUpdatedMessage
Loading...
Detection Jobs
Detection activities for selected job type
TimeJob TypeRequest IDWorkerStageSourceMessage
Loading...
Execution Jobs
Job IDTypeStateProgressWorkerUpdatedMessage
Loading...
Execution Activities
Non-detection events only
TimeJob TypeJob IDSourceStageMessage
Loading...
Job Detail
Select a job to view details.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/admin/view/app/s3_buckets_templ.go b/weed/admin/view/app/s3_buckets_templ.go new file mode 100644 index 0000000..cfae7e1 --- /dev/null +++ b/weed/admin/view/app/s3_buckets_templ.go @@ -0,0 +1,899 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package app + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/seaweedfs/seaweedfs/weed/admin/dash" +) + +func S3Buckets(data dash.S3BucketsData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Object Store Buckets

Total Buckets
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalBuckets)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 42, Col: 73} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
Total Storage
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(data.TotalSize)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 62, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
Last Updated
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("15:04:05")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 83, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Object Store Buckets
Actions:
Export List
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, bucket := range data.Buckets { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(data.Buckets) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
Name") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = getSortIcon("name", data.SortBy, data.SortOrder).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Owner") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = getSortIcon("owner", data.SortBy, data.SortOrder).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "Created") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = getSortIcon("created", data.SortBy, data.SortOrder).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Logical Size") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = getSortIcon("logical_size", data.SortBy, data.SortOrder).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Physical Size") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = getSortIcon("physical_size", data.SortBy, data.SortOrder).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "QuotaVersioningObject LockLifecyclePolicyActions
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 165, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.ReadOnly { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Read-only") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.Owner != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.Owner) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 176, Col: 101} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "No owner") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.CreatedAt.Format("2006-01-02 15:04")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 182, Col: 92} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.LogicalSize)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 184, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.PhysicalSize > 0 && bucket.LogicalSize > 0 && bucket.PhysicalSize > bucket.LogicalSize { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1fx overhead", float64(bucket.PhysicalSize)/float64(bucket.LogicalSize))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 187, Col: 144} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.PhysicalSize)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 191, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.Quota > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 = []any{fmt.Sprintf("badge bg-%s", getQuotaStatusColor(bucket.LogicalSize, bucket.Quota, bucket.QuotaEnabled))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(bucket.Quota)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 196, Col: 86} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.QuotaEnabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f%% used", float64(bucket.LogicalSize)/float64(bucket.Quota)*100)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 200, Col: 146} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
Disabled
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "No quota") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.VersioningStatus == "Enabled" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "Enabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if bucket.VersioningStatus == "Suspended" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Suspended") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "Not configured") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.ObjectLockEnabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
Enabled
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.ObjectLockMode) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 230, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d days", bucket.ObjectLockDuration)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 230, Col: 138} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Not configured") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.LifecycleRuleCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.LifecycleEnabledCount < bucket.LifecycleRuleCount { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d enabled", bucket.LifecycleEnabledCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 251, Col: 120} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "Not configured") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if bucket.PolicyStatementCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "Not configured") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
No Object Store buckets found

Create your first bucket to get started with S3 storage.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.TotalPages > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
Showing ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", (data.CurrentPage-1)*data.PageSize+1)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 348, Col: 101} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " to ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", minInt(data.CurrentPage*data.PageSize, data.TotalBuckets))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 348, Col: 185} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " of ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalBuckets)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 348, Col: 229} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " buckets
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
Last updated: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/s3_buckets.templ`, Line: 408, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Lifecycle
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = PolicyDatalists().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
Bucket Policy
Loading...
Loading bucket policy...

Every statement must specify a Principal, and every Resource must refer to this bucket. Leave the document empty and click Delete to remove the policy.

Enter the bucket policy document as JSON
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// Helper functions for template +func getQuotaStatusColor(used, quota int64, enabled bool) string { + if !enabled || quota <= 0 { + return "secondary" + } + + percentage := float64(used) / float64(quota) * 100 + if percentage >= 90 { + return "danger" + } else if percentage >= 75 { + return "warning" + } else { + return "success" + } +} + +func getQuotaInMB(quotaBytes int64) int64 { + if quotaBytes < 0 { + quotaBytes = -quotaBytes // Handle disabled quotas (negative values) + } + return quotaBytes / (1024 * 1024) +} + +var _ = templruntime.GeneratedTemplate diff --git a/weed/mq/kafka/protocol/handler.go b/weed/mq/kafka/protocol/handler.go new file mode 100644 index 0000000..c1225c5 --- /dev/null +++ b/weed/mq/kafka/protocol/handler.go @@ -0,0 +1,4265 @@ +package protocol + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "hash/fnv" + "io" + "net" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/mq/kafka/consumer" + "github.com/seaweedfs/seaweedfs/weed/mq/kafka/consumer_offset" + "github.com/seaweedfs/seaweedfs/weed/mq/kafka/integration" + "github.com/seaweedfs/seaweedfs/weed/mq/kafka/schema" + mqschema "github.com/seaweedfs/seaweedfs/weed/mq/schema" + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/pb/mq_pb" + "github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" + "github.com/seaweedfs/seaweedfs/weed/security" + "github.com/seaweedfs/seaweedfs/weed/util" + "github.com/seaweedfs/seaweedfs/weed/util/mem" +) + +// GetAdvertisedAddress returns the host:port that should be advertised to clients +// This handles the Docker networking issue where internal IPs aren't reachable by external clients +func (h *Handler) GetAdvertisedAddress(gatewayAddr string) (string, int) { + host, port := "localhost", 9093 + + // First, check for environment variable override + if advertisedHost := os.Getenv("KAFKA_ADVERTISED_HOST"); advertisedHost != "" { + host = advertisedHost + glog.V(2).Infof("Using KAFKA_ADVERTISED_HOST: %s", advertisedHost) + } else if gatewayAddr != "" { + // Try to parse the gateway address to extract hostname and port + parsedHost, gatewayPort, err := net.SplitHostPort(gatewayAddr) + if err == nil { + // Successfully parsed host:port + if gatewayPortInt, err := strconv.Atoi(gatewayPort); err == nil { + port = gatewayPortInt + } + // Use the parsed host if it's not 0.0.0.0 or empty + if parsedHost != "" && parsedHost != "0.0.0.0" { + host = parsedHost + glog.V(2).Infof("Using host from gatewayAddr: %s", host) + } else { + // Fall back to localhost for 0.0.0.0 or ambiguous addresses + host = "localhost" + glog.V(2).Infof("gatewayAddr is 0.0.0.0, using localhost for client advertising") + } + } else { + // Could not parse, use as-is if it looks like a hostname + if gatewayAddr != "" && gatewayAddr != "0.0.0.0" { + host = gatewayAddr + glog.V(2).Infof("Using gatewayAddr directly as host (unparseable): %s", host) + } + } + } else { + // No gateway address and no environment variable + host = "localhost" + glog.V(2).Infof("No gatewayAddr provided, using localhost") + } + + return host, port +} + +// generateNodeID generates a deterministic node ID from a gateway address. +// This must match the logic in gateway/coordinator_registry.go to ensure consistency +// between Metadata and FindCoordinator responses. +func generateNodeID(gatewayAddress string) int32 { + if gatewayAddress == "" { + return 1 // Default fallback + } + h := fnv.New32a() + _, _ = h.Write([]byte(gatewayAddress)) + // Use only positive values and avoid 0 + return int32(h.Sum32()&0x7fffffff) + 1 +} + +// GetNodeID returns the consistent node ID for this gateway. +// This is used by both Metadata and FindCoordinator handlers to ensure +// clients see the same broker/coordinator node ID across all APIs. +func (h *Handler) GetNodeID() int32 { + gatewayAddr := h.GetGatewayAddress() + return generateNodeID(gatewayAddr) +} + +// TopicInfo holds basic information about a topic +type TopicInfo struct { + Name string + Partitions int32 + CreatedAt int64 +} + +// TopicPartitionKey uniquely identifies a topic partition +type TopicPartitionKey struct { + Topic string + Partition int32 +} + +// contextKey is a type for context keys to avoid collisions +type contextKey string + +const ( + // connContextKey is the context key for storing ConnectionContext + connContextKey contextKey = "connectionContext" +) + +// kafkaRequest represents a Kafka API request to be processed +type kafkaRequest struct { + correlationID uint32 + apiKey uint16 + apiVersion uint16 + requestBody []byte + ctx context.Context + connContext *ConnectionContext // Per-connection context to avoid race conditions +} + +// kafkaResponse represents a Kafka API response +type kafkaResponse struct { + correlationID uint32 + apiKey uint16 + apiVersion uint16 + response []byte + err error +} + +const ( + // DefaultKafkaNamespace is the default namespace for Kafka topics in SeaweedMQ + DefaultKafkaNamespace = "kafka" +) + +// APIKey represents a Kafka API key type for better type safety +type APIKey uint16 + +// Kafka API Keys +const ( + APIKeyProduce APIKey = 0 + APIKeyFetch APIKey = 1 + APIKeyListOffsets APIKey = 2 + APIKeyMetadata APIKey = 3 + APIKeyOffsetCommit APIKey = 8 + APIKeyOffsetFetch APIKey = 9 + APIKeyFindCoordinator APIKey = 10 + APIKeyJoinGroup APIKey = 11 + APIKeyHeartbeat APIKey = 12 + APIKeyLeaveGroup APIKey = 13 + APIKeySyncGroup APIKey = 14 + APIKeyDescribeGroups APIKey = 15 + APIKeyListGroups APIKey = 16 + APIKeyApiVersions APIKey = 18 + APIKeyCreateTopics APIKey = 19 + APIKeyDeleteTopics APIKey = 20 + APIKeyInitProducerId APIKey = 22 + APIKeyDescribeConfigs APIKey = 32 + APIKeyDescribeCluster APIKey = 60 +) + +// SeaweedMQHandlerInterface defines the interface for SeaweedMQ integration +type SeaweedMQHandlerInterface interface { + TopicExists(topic string) bool + ListTopics() []string + CreateTopic(topic string, partitions int32) error + CreateTopicWithSchemas(name string, partitions int32, keyRecordType *schema_pb.RecordType, valueRecordType *schema_pb.RecordType) error + DeleteTopic(topic string) error + GetTopicInfo(topic string) (*integration.KafkaTopicInfo, bool) + InvalidateTopicExistsCache(topic string) + // Ledger methods REMOVED - SMQ handles Kafka offsets natively + ProduceRecord(ctx context.Context, topicName string, partitionID int32, key, value []byte) (int64, error) + ProduceRecordValue(ctx context.Context, topicName string, partitionID int32, key []byte, recordValueBytes []byte) (int64, error) + // GetStoredRecords retrieves records from SMQ storage (optional - for advanced implementations) + // ctx is used to control the fetch timeout (should match Kafka fetch request's MaxWaitTime) + GetStoredRecords(ctx context.Context, topic string, partition int32, fromOffset int64, maxRecords int) ([]integration.SMQRecord, error) + // GetEarliestOffset returns the earliest available offset for a topic partition + GetEarliestOffset(topic string, partition int32) (int64, error) + // GetLatestOffset returns the latest available offset for a topic partition + GetLatestOffset(topic string, partition int32) (int64, error) + // WithFilerClient executes a function with a filer client for accessing SeaweedMQ metadata + WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error + // GetBrokerAddresses returns the discovered SMQ broker addresses for Metadata responses + GetBrokerAddresses() []string + // CreatePerConnectionBrokerClient creates an isolated BrokerClient for each TCP connection + CreatePerConnectionBrokerClient() (*integration.BrokerClient, error) + // SetProtocolHandler sets the protocol handler reference for connection context access + SetProtocolHandler(handler integration.ProtocolHandler) + Close() error +} + +// ConsumerOffsetStorage defines the interface for storing consumer offsets +// This is used by OffsetCommit and OffsetFetch protocol handlers +type ConsumerOffsetStorage interface { + CommitOffset(group, topic string, partition int32, offset int64, metadata string) error + FetchOffset(group, topic string, partition int32) (int64, string, error) + FetchAllOffsets(group string) (map[TopicPartition]OffsetMetadata, error) + DeleteGroup(group string) error + Close() error +} + +// TopicPartition uniquely identifies a topic partition for offset storage +type TopicPartition struct { + Topic string + Partition int32 +} + +// OffsetMetadata contains offset and associated metadata +type OffsetMetadata struct { + Offset int64 + Metadata string +} + +// TopicSchemaConfig holds schema configuration for a topic +type TopicSchemaConfig struct { + // Value schema configuration + ValueSchemaID uint32 + ValueSchemaFormat schema.Format + + // Key schema configuration (optional) + KeySchemaID uint32 + KeySchemaFormat schema.Format + HasKeySchema bool // indicates if key schema is configured +} + +// Legacy accessors for backward compatibility +func (c *TopicSchemaConfig) SchemaID() uint32 { + return c.ValueSchemaID +} + +func (c *TopicSchemaConfig) SchemaFormat() schema.Format { + return c.ValueSchemaFormat +} + +// getTopicSchemaFormat returns the schema format string for a topic +func (h *Handler) getTopicSchemaFormat(topic string) string { + h.topicSchemaConfigMu.RLock() + defer h.topicSchemaConfigMu.RUnlock() + + if config, exists := h.topicSchemaConfigs[topic]; exists { + return config.ValueSchemaFormat.String() + } + return "" // Empty string means schemaless or format unknown +} + +// Handler processes Kafka protocol requests from clients using SeaweedMQ +type Handler struct { + // SeaweedMQ integration + seaweedMQHandler SeaweedMQHandlerInterface + + // SMQ offset storage removed - using ConsumerOffsetStorage instead + + // Consumer offset storage for Kafka protocol OffsetCommit/OffsetFetch + consumerOffsetStorage ConsumerOffsetStorage + + // Consumer group coordination + groupCoordinator *consumer.GroupCoordinator + + // Response caching to reduce CPU usage for repeated requests + metadataCache *ResponseCache + coordinatorCache *ResponseCache + + // Coordinator registry for distributed coordinator assignment + coordinatorRegistry CoordinatorRegistryInterface + + // Schema management (optional, for schematized topics) + schemaManager *schema.Manager + useSchema bool + brokerClient *schema.BrokerClient + + // Topic schema configuration cache + topicSchemaConfigs map[string]*TopicSchemaConfig + topicSchemaConfigMu sync.RWMutex + + // Track registered schemas to prevent duplicate registrations + registeredSchemas map[string]bool // key: "topic:schemaID" or "topic-key:schemaID" + registeredSchemasMu sync.RWMutex + + // RecordType inference cache to avoid recreating Avro codecs (37% CPU overhead!) + // Key: schema content hash or schema string + inferredRecordTypes map[string]*schema_pb.RecordType + inferredRecordTypesMu sync.RWMutex + + // SMQ broker addresses discovered from masters for Metadata responses + smqBrokerAddresses []string + + // Gateway address for coordinator registry + gatewayAddress string + + // Connection contexts stored per connection ID (thread-safe) + // Replaces the race-prone shared connContext field + connContexts sync.Map // map[string]*ConnectionContext + + // Schema Registry URL for delayed initialization + schemaRegistryURL string + + // Default partition count for auto-created topics + defaultPartitions int32 +} + +// NewHandler creates a basic Kafka handler with in-memory storage +// WARNING: This is for testing ONLY - never use in production! +// For production use with persistent storage, use NewSeaweedMQBrokerHandler instead +func NewHandler() *Handler { + // Production safety check - prevent accidental production use + // Comment out for testing: os.Getenv can be used for runtime checks + panic("NewHandler() with in-memory storage should NEVER be used in production! Use NewSeaweedMQBrokerHandler() with SeaweedMQ masters for production, or NewTestHandler() for tests.") +} + +// NewTestHandler and NewSimpleTestHandler moved to handler_test.go (test-only file) + +// All test-related types and implementations moved to handler_test.go (test-only file) + +// NewTestHandlerWithMock creates a test handler with a custom SeaweedMQHandlerInterface +// This is useful for unit tests that need a handler but don't want to connect to real SeaweedMQ +func NewTestHandlerWithMock(mockHandler SeaweedMQHandlerInterface) *Handler { + return &Handler{ + seaweedMQHandler: mockHandler, + consumerOffsetStorage: nil, // Unit tests don't need offset storage + groupCoordinator: consumer.NewGroupCoordinator(), + registeredSchemas: make(map[string]bool), + topicSchemaConfigs: make(map[string]*TopicSchemaConfig), + inferredRecordTypes: make(map[string]*schema_pb.RecordType), + defaultPartitions: 1, + } +} + +// NewSeaweedMQBrokerHandler creates a new handler with SeaweedMQ broker integration +func NewSeaweedMQBrokerHandler(masters string, filerGroup string, clientHost string) (*Handler, error) { + return NewSeaweedMQBrokerHandlerWithDefaults(masters, filerGroup, clientHost, 4) // Default to 4 partitions +} + +// NewSeaweedMQBrokerHandlerWithDefaults creates a new handler with SeaweedMQ broker integration and custom defaults +func NewSeaweedMQBrokerHandlerWithDefaults(masters string, filerGroup string, clientHost string, defaultPartitions int32) (*Handler, error) { + // Set up SeaweedMQ integration + smqHandler, err := integration.NewSeaweedMQBrokerHandler(masters, filerGroup, clientHost) + if err != nil { + return nil, err + } + + // Use the shared filer client accessor from SeaweedMQHandler + sharedFilerAccessor := smqHandler.GetFilerClientAccessor() + if sharedFilerAccessor == nil { + return nil, fmt.Errorf("no shared filer client accessor available from SMQ handler") + } + + // Create consumer offset storage (for OffsetCommit/OffsetFetch protocol) + // Use filer-based storage for persistence across restarts + consumerOffsetStorage := newOffsetStorageAdapter( + consumer_offset.NewFilerStorage(sharedFilerAccessor), + ) + + // Create response caches to reduce CPU usage + // Metadata cache: 5 second TTL (Schema Registry polls frequently) + // Coordinator cache: 10 second TTL (less frequent, more stable) + metadataCache := NewResponseCache(5 * time.Second) + coordinatorCache := NewResponseCache(10 * time.Second) + + // Start cleanup loops + metadataCache.StartCleanupLoop(30 * time.Second) + coordinatorCache.StartCleanupLoop(60 * time.Second) + + handler := &Handler{ + seaweedMQHandler: smqHandler, + consumerOffsetStorage: consumerOffsetStorage, + groupCoordinator: consumer.NewGroupCoordinator(), + smqBrokerAddresses: nil, // Will be set by SetSMQBrokerAddresses() when server starts + registeredSchemas: make(map[string]bool), + topicSchemaConfigs: make(map[string]*TopicSchemaConfig), + inferredRecordTypes: make(map[string]*schema_pb.RecordType), + defaultPartitions: defaultPartitions, + metadataCache: metadataCache, + coordinatorCache: coordinatorCache, + } + + // Set protocol handler reference in SMQ handler for connection context access + smqHandler.SetProtocolHandler(handler) + + return handler, nil +} + +// AddTopicForTesting creates a topic for testing purposes +// This delegates to the underlying SeaweedMQ handler +func (h *Handler) AddTopicForTesting(topicName string, partitions int32) { + if h.seaweedMQHandler != nil { + h.seaweedMQHandler.CreateTopic(topicName, partitions) + } +} + +// Delegate methods to SeaweedMQ handler + +// GetOrCreateLedger method REMOVED - SMQ handles Kafka offsets natively + +// GetLedger method REMOVED - SMQ handles Kafka offsets natively + +// Close shuts down the handler and all connections +func (h *Handler) Close() error { + // Close group coordinator + if h.groupCoordinator != nil { + h.groupCoordinator.Close() + } + + // Close broker client if present + if h.brokerClient != nil { + if err := h.brokerClient.Close(); err != nil { + glog.Warningf("Failed to close broker client: %v", err) + } + } + + // Close SeaweedMQ handler if present + if h.seaweedMQHandler != nil { + return h.seaweedMQHandler.Close() + } + return nil +} + +// SetSMQBrokerAddresses updates the SMQ broker addresses used in Metadata responses +func (h *Handler) SetSMQBrokerAddresses(brokerAddresses []string) { + h.smqBrokerAddresses = brokerAddresses +} + +// GetSMQBrokerAddresses returns the SMQ broker addresses +func (h *Handler) GetSMQBrokerAddresses() []string { + // First try to get from the SeaweedMQ handler (preferred) + if h.seaweedMQHandler != nil { + if brokerAddresses := h.seaweedMQHandler.GetBrokerAddresses(); len(brokerAddresses) > 0 { + return brokerAddresses + } + } + + // Fallback to manually set addresses + if len(h.smqBrokerAddresses) > 0 { + return h.smqBrokerAddresses + } + + // No brokers configured - return empty slice + // This will cause proper error handling in callers + return []string{} +} + +// GetGatewayAddress returns the current gateway address as a string (for coordinator registry) +func (h *Handler) GetGatewayAddress() string { + if h.gatewayAddress != "" { + return h.gatewayAddress + } + // No gateway address configured - return empty string + // Callers should handle this as a configuration error + return "" +} + +// SetGatewayAddress sets the gateway address for coordinator registry +func (h *Handler) SetGatewayAddress(address string) { + h.gatewayAddress = address +} + +// SetCoordinatorRegistry sets the coordinator registry for this handler +func (h *Handler) SetCoordinatorRegistry(registry CoordinatorRegistryInterface) { + h.coordinatorRegistry = registry +} + +// GetCoordinatorRegistry returns the coordinator registry +func (h *Handler) GetCoordinatorRegistry() CoordinatorRegistryInterface { + return h.coordinatorRegistry +} + +// isDataPlaneAPI returns true if the API key is a data plane operation (Fetch, Produce) +// Data plane operations can be slow and may block on I/O +func isDataPlaneAPI(apiKey uint16) bool { + switch APIKey(apiKey) { + case APIKeyProduce: + return true + case APIKeyFetch: + return true + default: + return false + } +} + +// GetConnectionContext returns the current connection context converted to integration.ConnectionContext +// This implements the integration.ProtocolHandler interface +// +// NOTE: Since this method doesn't receive a context parameter, it returns a "best guess" connection context. +// In single-connection scenarios (like tests), this works correctly. In high-concurrency scenarios with many +// simultaneous connections, this may return a connection context from a different connection. +// For a proper fix, the integration.ProtocolHandler interface would need to be updated to pass context.Context. +func (h *Handler) GetConnectionContext() *integration.ConnectionContext { + // Try to find any active connection context + // In most cases (single connection, or low concurrency), this will return the correct context + var connCtx *ConnectionContext + h.connContexts.Range(func(key, value interface{}) bool { + if ctx, ok := value.(*ConnectionContext); ok { + connCtx = ctx + return false // Stop iteration after finding first context + } + return true + }) + + if connCtx == nil { + return nil + } + + // Convert protocol.ConnectionContext to integration.ConnectionContext + return &integration.ConnectionContext{ + ClientID: connCtx.ClientID, + ConsumerGroup: connCtx.ConsumerGroup, + MemberID: connCtx.MemberID, + BrokerClient: connCtx.BrokerClient, + } +} + +// HandleConn processes a single client connection +func (h *Handler) HandleConn(ctx context.Context, conn net.Conn) error { + connectionID := fmt.Sprintf("%s->%s", conn.RemoteAddr(), conn.LocalAddr()) + + // Record connection metrics + RecordConnectionMetrics() + + // Create cancellable context for this connection + // This ensures all requests are cancelled when the connection closes + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Create per-connection BrokerClient for isolated gRPC streams + // This prevents different connections from interfering with each other's Fetch requests + // In mock/unit test mode, this may not be available, so we continue without it + var connBrokerClient *integration.BrokerClient + connBrokerClient, err := h.seaweedMQHandler.CreatePerConnectionBrokerClient() + if err != nil { + // Continue without broker client for unit test/mock mode + connBrokerClient = nil + } + + // RACE CONDITION FIX: Create connection-local context and pass through request pipeline + // Store in thread-safe map to enable lookup from methods that don't have direct access + connContext := &ConnectionContext{ + RemoteAddr: conn.RemoteAddr(), + LocalAddr: conn.LocalAddr(), + ConnectionID: connectionID, + BrokerClient: connBrokerClient, + } + + // Store in thread-safe map for later retrieval + h.connContexts.Store(connectionID, connContext) + + defer func() { + // Close all partition readers first + cleanupPartitionReaders(connContext) + // Close the per-connection broker client + if connBrokerClient != nil { + if closeErr := connBrokerClient.Close(); closeErr != nil { + glog.Errorf("[%s] Error closing BrokerClient: %v", connectionID, closeErr) + } + } + // Remove connection context from map + h.connContexts.Delete(connectionID) + RecordDisconnectionMetrics() + conn.Close() + }() + + r := bufio.NewReader(conn) + w := bufio.NewWriter(conn) + defer w.Flush() + + // Use default timeout config + timeoutConfig := DefaultTimeoutConfig() + + // Track consecutive read timeouts to detect stale/CLOSE_WAIT connections + consecutiveTimeouts := 0 + const maxConsecutiveTimeouts = 3 // Give up after 3 timeouts in a row + + // Separate control plane from data plane + // Control plane: Metadata, Heartbeat, JoinGroup, etc. (must be fast, never block) + // Data plane: Fetch, Produce (can be slow, may block on I/O) + // + // Architecture: + // - Main loop routes requests to appropriate channel based on API key + // - Control goroutine processes control messages (fast, sequential) + // - Data goroutine processes data messages (can be slow) + // - Response writer handles responses in order using correlation IDs + controlChan := make(chan *kafkaRequest, 10) + dataChan := make(chan *kafkaRequest, 10) + responseChan := make(chan *kafkaResponse, 100) + var wg sync.WaitGroup + + // Response writer - maintains request/response order per connection + // While we process requests concurrently (control/data plane), + // we MUST track the order requests arrive and send responses in that same order. + // Solution: Track received correlation IDs in a queue, send responses in that queue order. + correlationQueue := make([]uint32, 0, 100) + correlationQueueMu := &sync.Mutex{} + + wg.Add(1) + go func() { + defer wg.Done() + glog.V(2).Infof("[%s] Response writer started", connectionID) + defer glog.V(2).Infof("[%s] Response writer exiting", connectionID) + pendingResponses := make(map[uint32]*kafkaResponse) + nextToSend := 0 // Index in correlationQueue + + for { + select { + case resp, ok := <-responseChan: + if !ok { + // responseChan closed, exit + return + } + // Only log at V(3) for debugging, not V(4) in hot path + glog.V(3).Infof("[%s] Response writer received correlation=%d", connectionID, resp.correlationID) + correlationQueueMu.Lock() + pendingResponses[resp.correlationID] = resp + + // Send all responses we can in queue order + for nextToSend < len(correlationQueue) { + expectedID := correlationQueue[nextToSend] + readyResp, exists := pendingResponses[expectedID] + if !exists { + // Response not ready yet, stop sending + break + } + + // Send this response + var respBody []byte + if readyResp.err != nil { + glog.Errorf("[%s] Error processing correlation=%d: %v", connectionID, readyResp.correlationID, readyResp.err) + // Build an API-versioned error response so the body matches + // the schema the client expects for this API key/version. + // A generic 2-byte error code would corrupt the protocol + // stream for APIs that start with throttle_time or arrays. + respBody = BuildAPIErrorResponse(readyResp.apiKey, readyResp.apiVersion, ErrorCodeUnknownServerError) + } else { + respBody = readyResp.response + } + if writeErr := h.writeResponseWithHeader(w, readyResp.correlationID, readyResp.apiKey, readyResp.apiVersion, respBody, timeoutConfig.WriteTimeout); writeErr != nil { + glog.Errorf("[%s] Response writer WRITE ERROR correlation=%d: %v - EXITING", connectionID, readyResp.correlationID, writeErr) + correlationQueueMu.Unlock() + return + } + + // Remove from pending and advance + delete(pendingResponses, expectedID) + nextToSend++ + } + correlationQueueMu.Unlock() + case <-ctx.Done(): + // Context cancelled, exit immediately to prevent deadlock + glog.V(2).Infof("[%s] Response writer: context cancelled, exiting", connectionID) + return + } + } + }() + + // Control plane processor - fast operations, never blocks + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case req, ok := <-controlChan: + if !ok { + // Channel closed, exit + return + } + // Removed V(4) logging from hot path - only log errors and important events + + // Wrap request processing with panic recovery to prevent deadlocks + // If processRequestSync panics, we MUST still send a response to avoid blocking the response writer + var response []byte + var err error + func() { + defer func() { + if r := recover(); r != nil { + glog.Errorf("[%s] PANIC in control plane correlation=%d: %v", connectionID, req.correlationID, r) + err = fmt.Errorf("internal server error: panic in request handler: %v", r) + } + }() + response, err = h.processRequestSync(req) + }() + + select { + case responseChan <- &kafkaResponse{ + correlationID: req.correlationID, + apiKey: req.apiKey, + apiVersion: req.apiVersion, + response: response, + err: err, + }: + // Response sent successfully - no logging here + case <-ctx.Done(): + // Connection closed, stop processing + return + case <-time.After(5 * time.Second): + // responseChan stuck — cancel context to tear down connection. + // The orphaned correlationID would stall the ordered writer permanently. + glog.Warningf("[%s] Control plane: timeout sending response correlation=%d, tearing down connection", connectionID, req.correlationID) + cancel() + } + case <-ctx.Done(): + // Context cancelled, drain remaining requests before exiting + glog.V(2).Infof("[%s] Control plane: context cancelled, draining remaining requests", connectionID) + for { + select { + case req, ok := <-controlChan: + if !ok { + return + } + // Process remaining requests with a short timeout + glog.V(3).Infof("[%s] Control plane: processing drained request correlation=%d", connectionID, req.correlationID) + response, err := h.processRequestSync(req) + select { + case responseChan <- &kafkaResponse{ + correlationID: req.correlationID, + apiKey: req.apiKey, + apiVersion: req.apiVersion, + response: response, + err: err, + }: + glog.V(3).Infof("[%s] Control plane: sent drained response correlation=%d", connectionID, req.correlationID) + case <-time.After(1 * time.Second): + glog.Warningf("[%s] Control plane: timeout sending drained response correlation=%d, discarding", connectionID, req.correlationID) + return + } + default: + // Channel empty, safe to exit + glog.V(4).Infof("[%s] Control plane: drain complete, exiting", connectionID) + return + } + } + } + } + }() + + // Data plane processor - can block on I/O + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case req, ok := <-dataChan: + if !ok { + // Channel closed, exit + return + } + // Removed V(4) logging from hot path - only log errors and important events + + // Wrap request processing with panic recovery to prevent deadlocks + // If processRequestSync panics, we MUST still send a response to avoid blocking the response writer + var response []byte + var err error + func() { + defer func() { + if r := recover(); r != nil { + glog.Errorf("[%s] PANIC in data plane correlation=%d: %v", connectionID, req.correlationID, r) + err = fmt.Errorf("internal server error: panic in request handler: %v", r) + } + }() + response, err = h.processRequestSync(req) + }() + + // Use select with context to avoid sending on closed channel + select { + case responseChan <- &kafkaResponse{ + correlationID: req.correlationID, + apiKey: req.apiKey, + apiVersion: req.apiVersion, + response: response, + err: err, + }: + // Response sent successfully - no logging here + case <-ctx.Done(): + // Connection closed, stop processing + return + case <-time.After(5 * time.Second): + // responseChan stuck — cancel context to tear down connection. + // The orphaned correlationID would stall the ordered writer permanently. + glog.Warningf("[%s] Data plane: timeout sending response correlation=%d, tearing down connection", connectionID, req.correlationID) + cancel() + } + case <-ctx.Done(): + // Context cancelled, drain remaining requests before exiting + glog.V(2).Infof("[%s] Data plane: context cancelled, draining remaining requests", connectionID) + for { + select { + case req, ok := <-dataChan: + if !ok { + return + } + // Process remaining requests with a short timeout + response, err := h.processRequestSync(req) + select { + case responseChan <- &kafkaResponse{ + correlationID: req.correlationID, + apiKey: req.apiKey, + apiVersion: req.apiVersion, + response: response, + err: err, + }: + // Response sent - no logging + case <-time.After(1 * time.Second): + glog.Warningf("[%s] Data plane: timeout sending drained response correlation=%d, discarding", connectionID, req.correlationID) + return + } + default: + // Channel empty, safe to exit + glog.V(2).Infof("[%s] Data plane: drain complete, exiting", connectionID) + return + } + } + } + } + }() + + defer func() { + // Cancel context FIRST so the response writer (and any worker stuck + // in an inner select) sees ctx.Done() and can exit. Previously + // cancel() ran in a separate defer registered earlier (LIFO: later + // defer runs first), so wg.Wait() below would deadlock waiting for + // the response writer which was itself waiting for ctx.Done(). + cancel() + // Close input channels to stop accepting new requests + close(controlChan) + close(dataChan) + // Wait for worker goroutines to finish processing and sending responses + wg.Wait() + // NOW close responseChan (safe — all senders have exited) + close(responseChan) + }() + + for { + // OPTIMIZATION: Consolidated context/deadline check - avoid redundant select statements + // Check context once at the beginning of the loop + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Set read deadline based on context or default timeout + // OPTIMIZATION: Calculate deadline once per iteration, not multiple times + var readDeadline time.Time + if deadline, ok := ctx.Deadline(); ok { + readDeadline = deadline + } else { + readDeadline = time.Now().Add(timeoutConfig.ReadTimeout) + } + + if err := conn.SetReadDeadline(readDeadline); err != nil { + return fmt.Errorf("set read deadline: %w", err) + } + + // Read message size (4 bytes) + var sizeBytes [4]byte + if _, err := io.ReadFull(r, sizeBytes[:]); err != nil { + if err == io.EOF { + return nil + } + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + // Track consecutive timeouts to detect stale connections + consecutiveTimeouts++ + if consecutiveTimeouts >= maxConsecutiveTimeouts { + return nil + } + // Idle timeout while waiting for next request; keep connection open + continue + } + return fmt.Errorf("read message size: %w", err) + } + + // Successfully read data, reset timeout counter + consecutiveTimeouts = 0 + + // Successfully read the message size + size := binary.BigEndian.Uint32(sizeBytes[:]) + if size == 0 || size > 1024*1024 { // 1MB limit + // Use standardized error for message size limit + // Send error response for message too large + errorResponse := BuildErrorResponse(0, ErrorCodeMessageTooLarge) // correlation ID 0 since we can't parse it yet + if writeErr := h.writeResponseWithCorrelationID(w, 0, errorResponse, timeoutConfig.WriteTimeout); writeErr != nil { + } + return fmt.Errorf("message size %d exceeds limit", size) + } + + // Set read deadline for message body + if err := conn.SetReadDeadline(time.Now().Add(timeoutConfig.ReadTimeout)); err != nil { + } + + // Read the message into a pooled buffer, then copy to owned memory + // and return the pool buffer immediately. The previous code used + // defer mem.Free which accumulated one deferred free per iteration, + // leaking pool buffers for the connection lifetime and risking + // use-after-free when the defers ran before processing goroutines + // finished draining. + poolBuf := mem.Allocate(int(size)) + if _, err := io.ReadFull(r, poolBuf); err != nil { + mem.Free(poolBuf) + _ = HandleTimeoutError(err, "read") // errorCode + return fmt.Errorf("read message: %w", err) + } + messageBuf := make([]byte, int(size)) + copy(messageBuf, poolBuf) + mem.Free(poolBuf) + + // Parse at least the basic header to get API key and correlation ID + if len(messageBuf) < 8 { + return fmt.Errorf("message too short") + } + + apiKey := binary.BigEndian.Uint16(messageBuf[0:2]) + apiVersion := binary.BigEndian.Uint16(messageBuf[2:4]) + correlationID := binary.BigEndian.Uint32(messageBuf[4:8]) + + // Validate API version against what we support + if err := h.validateAPIVersion(apiKey, apiVersion); err != nil { + glog.Errorf("API VERSION VALIDATION FAILED: Key=%d (%s), Version=%d, error=%v", apiKey, getAPIName(APIKey(apiKey)), apiVersion, err) + // Return proper Kafka error response for unsupported version + response, writeErr := h.buildUnsupportedVersionResponse(correlationID, apiKey, apiVersion) + if writeErr != nil { + return fmt.Errorf("build error response: %w", writeErr) + } + // Add to correlation queue BEFORE sending to responseChan so the + // response writer can match and send it. Without this the response + // sits in pendingResponses forever and the client hangs. + correlationQueueMu.Lock() + correlationQueue = append(correlationQueue, correlationID) + correlationQueueMu.Unlock() + + // Send error response through response queue to maintain sequential ordering + select { + case responseChan <- &kafkaResponse{ + correlationID: correlationID, + apiKey: apiKey, + apiVersion: apiVersion, + response: response, + err: nil, + }: + // Error response queued successfully, continue reading next request + continue + case <-ctx.Done(): + return ctx.Err() + } + } + + // Extract request body - special handling for ApiVersions requests + var requestBody []byte + if apiKey == uint16(APIKeyApiVersions) && apiVersion >= 3 { + // ApiVersions v3+ uses client_software_name + client_software_version, not client_id + bodyOffset := 8 // Skip api_key(2) + api_version(2) + correlation_id(4) + + // Skip client_software_name (compact string) + if len(messageBuf) > bodyOffset { + clientNameLen := int(messageBuf[bodyOffset]) // compact string length + if clientNameLen > 0 { + clientNameLen-- // compact strings encode length+1 + bodyOffset += 1 + clientNameLen + } else { + bodyOffset += 1 // just the length byte for null/empty + } + } + + // Skip client_software_version (compact string) + if len(messageBuf) > bodyOffset { + clientVersionLen := int(messageBuf[bodyOffset]) // compact string length + if clientVersionLen > 0 { + clientVersionLen-- // compact strings encode length+1 + bodyOffset += 1 + clientVersionLen + } else { + bodyOffset += 1 // just the length byte for null/empty + } + } + + // Skip tagged fields (should be 0x00 for ApiVersions) + if len(messageBuf) > bodyOffset { + bodyOffset += 1 // tagged fields byte + } + + requestBody = messageBuf[bodyOffset:] + } else { + // Parse header using flexible version utilities for other APIs + header, parsedRequestBody, parseErr := ParseRequestHeader(messageBuf) + if parseErr != nil { + glog.Errorf("Request header parsing failed: API=%d (%s) v%d, correlation=%d, error=%v", + apiKey, getAPIName(APIKey(apiKey)), apiVersion, correlationID, parseErr) + + // Fall back to basic header parsing if flexible version parsing fails + + // Basic header parsing fallback (original logic) + bodyOffset := 8 + if len(messageBuf) < bodyOffset+2 { + return fmt.Errorf("invalid header: missing client_id length") + } + clientIDLen := int16(binary.BigEndian.Uint16(messageBuf[bodyOffset : bodyOffset+2])) + bodyOffset += 2 + if clientIDLen >= 0 { + if len(messageBuf) < bodyOffset+int(clientIDLen) { + return fmt.Errorf("invalid header: client_id truncated") + } + bodyOffset += int(clientIDLen) + } + requestBody = messageBuf[bodyOffset:] + } else { + // Use the successfully parsed request body + requestBody = parsedRequestBody + + // Validate parsed header matches what we already extracted + if header.APIKey != apiKey || header.APIVersion != apiVersion || header.CorrelationID != correlationID { + // Fall back to basic parsing rather than failing + bodyOffset := 8 + if len(messageBuf) < bodyOffset+2 { + return fmt.Errorf("invalid header: missing client_id length") + } + clientIDLen := int16(binary.BigEndian.Uint16(messageBuf[bodyOffset : bodyOffset+2])) + bodyOffset += 2 + if clientIDLen >= 0 { + if len(messageBuf) < bodyOffset+int(clientIDLen) { + return fmt.Errorf("invalid header: client_id truncated") + } + bodyOffset += int(clientIDLen) + } + requestBody = messageBuf[bodyOffset:] + } else if header.ClientID != nil { + // Store client ID in connection context for use in fetch requests + connContext.ClientID = *header.ClientID + } + } + } + + // Route request to appropriate processor + // Control plane: Fast, never blocks (Metadata, Heartbeat, etc.) + // Data plane: Can be slow (Fetch, Produce) + + // Attach connection context to the Go context for retrieval in nested calls + ctxWithConn := context.WithValue(ctx, connContextKey, connContext) + + req := &kafkaRequest{ + correlationID: correlationID, + apiKey: apiKey, + apiVersion: apiVersion, + requestBody: requestBody, + ctx: ctxWithConn, + connContext: connContext, // Pass per-connection context to avoid race conditions + } + + // Route to appropriate channel based on API key + var targetChan chan *kafkaRequest + if isDataPlaneAPI(apiKey) { + targetChan = dataChan + } else { + targetChan = controlChan + } + + // Add correlation ID BEFORE channel send to prevent race condition: + // If we add after, the processor can finish and send the response before the + // ID is in the queue. The response writer then can't match the response to a + // queue entry, causing a deadlock for sequential clients (e.g., Sarama). + // If the channel send fails, we send an error response so the response writer + // can advance past this entry. + correlationQueueMu.Lock() + correlationQueue = append(correlationQueue, correlationID) + correlationQueueMu.Unlock() + + select { + case targetChan <- req: + // Request queued successfully + case <-ctx.Done(): + // Context cancelled - send error response so response writer can advance + select { + case responseChan <- &kafkaResponse{ + correlationID: correlationID, + apiKey: apiKey, + apiVersion: apiVersion, + err: ctx.Err(), + }: + default: + } + return ctx.Err() + case <-time.After(10 * time.Second): + // Channel full for too long - send error response so response writer can advance + glog.Errorf("[%s] Failed to queue correlation=%d - channel full (10s timeout)", connectionID, correlationID) + select { + case responseChan <- &kafkaResponse{ + correlationID: correlationID, + apiKey: apiKey, + apiVersion: apiVersion, + err: fmt.Errorf("request queue full"), + }: + default: + } + return fmt.Errorf("request queue full: correlation=%d", correlationID) + } + } +} + +// processRequestSync processes a single Kafka API request synchronously and returns the response +func (h *Handler) processRequestSync(req *kafkaRequest) ([]byte, error) { + // Record request start time for latency tracking + requestStart := time.Now() + apiName := getAPIName(APIKey(req.apiKey)) + + // Only log high-volume requests at V(2), not V(4) + if glog.V(2) { + glog.V(2).Infof("[API] %s (key=%d, ver=%d, corr=%d)", + apiName, req.apiKey, req.apiVersion, req.correlationID) + } + + var response []byte + var err error + + switch APIKey(req.apiKey) { + case APIKeyApiVersions: + response, err = h.handleApiVersions(req.correlationID, req.apiVersion) + + case APIKeyMetadata: + response, err = h.handleMetadata(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyListOffsets: + response, err = h.handleListOffsets(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyCreateTopics: + response, err = h.handleCreateTopics(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyDeleteTopics: + response, err = h.handleDeleteTopics(req.correlationID, req.requestBody) + + case APIKeyProduce: + response, err = h.handleProduce(req.ctx, req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyFetch: + response, err = h.handleFetch(req.ctx, req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyJoinGroup: + response, err = h.handleJoinGroup(req.connContext, req.correlationID, req.apiVersion, req.requestBody) + + case APIKeySyncGroup: + response, err = h.handleSyncGroup(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyOffsetCommit: + response, err = h.handleOffsetCommit(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyOffsetFetch: + response, err = h.handleOffsetFetch(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyFindCoordinator: + response, err = h.handleFindCoordinator(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyHeartbeat: + response, err = h.handleHeartbeat(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyLeaveGroup: + response, err = h.handleLeaveGroup(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyDescribeGroups: + response, err = h.handleDescribeGroups(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyListGroups: + response, err = h.handleListGroups(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyDescribeConfigs: + response, err = h.handleDescribeConfigs(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyDescribeCluster: + response, err = h.handleDescribeCluster(req.correlationID, req.apiVersion, req.requestBody) + + case APIKeyInitProducerId: + response, err = h.handleInitProducerId(req.correlationID, req.apiVersion, req.requestBody) + + default: + glog.Warningf("Unsupported API key: %d (%s) v%d - Correlation: %d", req.apiKey, apiName, req.apiVersion, req.correlationID) + err = fmt.Errorf("unsupported API key: %d (version %d)", req.apiKey, req.apiVersion) + } + + glog.V(2).Infof("processRequestSync: Switch completed for correlation=%d, about to record metrics", req.correlationID) + // Record metrics + requestLatency := time.Since(requestStart) + if err != nil { + RecordErrorMetrics(req.apiKey, requestLatency) + } else { + RecordRequestMetrics(req.apiKey, requestLatency) + } + glog.V(2).Infof("processRequestSync: Metrics recorded for correlation=%d, about to return", req.correlationID) + + return response, err +} + +// ApiKeyInfo represents supported API key information +type ApiKeyInfo struct { + ApiKey APIKey + MinVersion uint16 + MaxVersion uint16 +} + +// SupportedApiKeys defines all supported API keys and their version ranges +var SupportedApiKeys = []ApiKeyInfo{ + {APIKeyApiVersions, 0, 4}, // ApiVersions - support up to v4 for Kafka 8.0.0 compatibility + {APIKeyMetadata, 0, 7}, // Metadata - support up to v7 + {APIKeyProduce, 0, 7}, // Produce + {APIKeyFetch, 0, 7}, // Fetch + {APIKeyListOffsets, 0, 2}, // ListOffsets + {APIKeyCreateTopics, 0, 5}, // CreateTopics + {APIKeyDeleteTopics, 0, 4}, // DeleteTopics + {APIKeyFindCoordinator, 0, 3}, // FindCoordinator - v3+ supports flexible responses + {APIKeyJoinGroup, 0, 6}, // JoinGroup + {APIKeySyncGroup, 0, 5}, // SyncGroup + {APIKeyOffsetCommit, 0, 2}, // OffsetCommit + {APIKeyOffsetFetch, 0, 5}, // OffsetFetch + {APIKeyHeartbeat, 0, 4}, // Heartbeat + {APIKeyLeaveGroup, 0, 4}, // LeaveGroup + {APIKeyDescribeGroups, 0, 5}, // DescribeGroups + {APIKeyListGroups, 0, 4}, // ListGroups + {APIKeyDescribeConfigs, 0, 4}, // DescribeConfigs + {APIKeyInitProducerId, 0, 4}, // InitProducerId - support up to v4 for transactional producers + {APIKeyDescribeCluster, 0, 1}, // DescribeCluster - for AdminClient compatibility (KIP-919) +} + +func (h *Handler) handleApiVersions(correlationID uint32, apiVersion uint16) ([]byte, error) { + // Send correct flexible or non-flexible response based on API version + // This fixes the AdminClient "collection size 2184558" error by using proper varint encoding + response := make([]byte, 0, 512) + + // NOTE: Correlation ID is handled by writeResponseWithCorrelationID + // Do NOT include it in the response body + + // === RESPONSE BODY === + // Error code (2 bytes) - always fixed-length + response = append(response, 0, 0) // No error + + // API Keys Array - use correct encoding based on version + if apiVersion >= 3 { + // FLEXIBLE FORMAT: Compact array with varint length - THIS FIXES THE ADMINCLIENT BUG! + response = append(response, CompactArrayLength(uint32(len(SupportedApiKeys)))...) + + // Add API key entries with per-element tagged fields + for _, api := range SupportedApiKeys { + response = append(response, byte(api.ApiKey>>8), byte(api.ApiKey)) // api_key (2 bytes) + response = append(response, byte(api.MinVersion>>8), byte(api.MinVersion)) // min_version (2 bytes) + response = append(response, byte(api.MaxVersion>>8), byte(api.MaxVersion)) // max_version (2 bytes) + response = append(response, 0x00) // Per-element tagged fields (varint: empty) + } + + } else { + // NON-FLEXIBLE FORMAT: Regular array with fixed 4-byte length + response = append(response, 0, 0, 0, byte(len(SupportedApiKeys))) // Array length (4 bytes) + + // Add API key entries without tagged fields + for _, api := range SupportedApiKeys { + response = append(response, byte(api.ApiKey>>8), byte(api.ApiKey)) // api_key (2 bytes) + response = append(response, byte(api.MinVersion>>8), byte(api.MinVersion)) // min_version (2 bytes) + response = append(response, byte(api.MaxVersion>>8), byte(api.MaxVersion)) // max_version (2 bytes) + } + } + + // Throttle time (for v1+) - always fixed-length + if apiVersion >= 1 { + response = append(response, 0, 0, 0, 0) // throttle_time_ms = 0 (4 bytes) + } + + // Response-level tagged fields (for v3+ flexible versions) + if apiVersion >= 3 { + response = append(response, 0x00) // Empty response-level tagged fields (varint: single byte 0) + } + + return response, nil +} + +// handleMetadataV0 implements the Metadata API response in version 0 format. +// v0 response layout: +// correlation_id(4) + brokers(ARRAY) + topics(ARRAY) +// broker: node_id(4) + host(STRING) + port(4) +// topic: error_code(2) + name(STRING) + partitions(ARRAY) +// partition: error_code(2) + partition_id(4) + leader(4) + replicas(ARRAY) + isr(ARRAY) +func (h *Handler) HandleMetadataV0(correlationID uint32, requestBody []byte) ([]byte, error) { + response := make([]byte, 0, 256) + + // NOTE: Correlation ID is handled by writeResponseWithCorrelationID + // Do NOT include it in the response body + + // Get consistent node ID for this gateway + nodeID := h.GetNodeID() + nodeIDBytes := make([]byte, 4) + binary.BigEndian.PutUint32(nodeIDBytes, uint32(nodeID)) + + // Brokers array length (4 bytes) - 1 broker (this gateway) + response = append(response, 0, 0, 0, 1) + + // Broker 0: node_id(4) + host(STRING) + port(4) + response = append(response, nodeIDBytes...) // Use consistent node ID + + // Get advertised address for client connections + host, port := h.GetAdvertisedAddress(h.GetGatewayAddress()) + + // Host (STRING: 2 bytes length + bytes) - validate length fits in uint16 + if len(host) > 65535 { + return nil, fmt.Errorf("host name too long: %d bytes", len(host)) + } + hostLen := uint16(len(host)) + response = append(response, byte(hostLen>>8), byte(hostLen)) + response = append(response, []byte(host)...) + + // Port (4 bytes) - validate port range + if port < 0 || port > 65535 { + return nil, fmt.Errorf("invalid port number: %d", port) + } + portBytes := make([]byte, 4) + binary.BigEndian.PutUint32(portBytes, uint32(port)) + response = append(response, portBytes...) + + // Parse requested topics (empty means all) + requestedTopics := h.parseMetadataTopics(requestBody) + glog.V(3).Infof("[METADATA v0] Requested topics: %v (empty=all)", requestedTopics) + + // Determine topics to return using SeaweedMQ handler + var topicsToReturn []string + if len(requestedTopics) == 0 { + topicsToReturn = h.seaweedMQHandler.ListTopics() + } else { + for _, name := range requestedTopics { + if h.ensureTopicExists(name, h.GetDefaultPartitions()) { + topicsToReturn = append(topicsToReturn, name) + } + } + } + + // Topics array length (4 bytes) + topicsCountBytes := make([]byte, 4) + binary.BigEndian.PutUint32(topicsCountBytes, uint32(len(topicsToReturn))) + response = append(response, topicsCountBytes...) + + // Topic entries + for _, topicName := range topicsToReturn { + // error_code(2) = 0 + response = append(response, 0, 0) + + // name (STRING) + nameBytes := []byte(topicName) + nameLen := uint16(len(nameBytes)) + response = append(response, byte(nameLen>>8), byte(nameLen)) + response = append(response, nameBytes...) + + // Get actual partition count from topic info + topicInfo, exists := h.seaweedMQHandler.GetTopicInfo(topicName) + partitionCount := h.GetDefaultPartitions() // Use configurable default + if exists && topicInfo != nil { + partitionCount = topicInfo.Partitions + } + + // partitions array length (4 bytes) + partitionsBytes := make([]byte, 4) + binary.BigEndian.PutUint32(partitionsBytes, uint32(partitionCount)) + response = append(response, partitionsBytes...) + + // Create partition entries for each partition + for partitionID := int32(0); partitionID < partitionCount; partitionID++ { + // partition: error_code(2) + partition_id(4) + leader(4) + response = append(response, 0, 0) // error_code + + // partition_id (4 bytes) + partitionIDBytes := make([]byte, 4) + binary.BigEndian.PutUint32(partitionIDBytes, uint32(partitionID)) + response = append(response, partitionIDBytes...) + + response = append(response, nodeIDBytes...) // leader = this broker + + // replicas: array length(4) + one broker id (this broker) + response = append(response, 0, 0, 0, 1) + response = append(response, nodeIDBytes...) + + // isr: array length(4) + one broker id (this broker) + response = append(response, 0, 0, 0, 1) + response = append(response, nodeIDBytes...) + } + } + + for range topicsToReturn { + } + return response, nil +} + +func (h *Handler) HandleMetadataV1(correlationID uint32, requestBody []byte) ([]byte, error) { + // Simplified Metadata v1 implementation - based on working v0 + v1 additions + // v1 adds: ControllerID (after brokers), Rack (for brokers), IsInternal (for topics) + + // Parse requested topics (empty means all) + requestedTopics := h.parseMetadataTopics(requestBody) + glog.V(3).Infof("[METADATA v1] Requested topics: %v (empty=all)", requestedTopics) + + // Determine topics to return using SeaweedMQ handler + var topicsToReturn []string + if len(requestedTopics) == 0 { + topicsToReturn = h.seaweedMQHandler.ListTopics() + } else { + for _, name := range requestedTopics { + if h.ensureTopicExists(name, h.GetDefaultPartitions()) { + topicsToReturn = append(topicsToReturn, name) + } + } + } + + // Build response using same approach as v0 but with v1 additions + response := make([]byte, 0, 256) + + // NOTE: Correlation ID is handled by writeResponseWithHeader + // Do NOT include it in the response body + + // Get consistent node ID for this gateway + nodeID := h.GetNodeID() + nodeIDBytes := make([]byte, 4) + binary.BigEndian.PutUint32(nodeIDBytes, uint32(nodeID)) + + // Brokers array length (4 bytes) - 1 broker (this gateway) + response = append(response, 0, 0, 0, 1) + + // Broker 0: node_id(4) + host(STRING) + port(4) + rack(STRING) + response = append(response, nodeIDBytes...) // Use consistent node ID + + // Get advertised address for client connections + host, port := h.GetAdvertisedAddress(h.GetGatewayAddress()) + + // Host (STRING: 2 bytes length + bytes) - validate length fits in uint16 + if len(host) > 65535 { + return nil, fmt.Errorf("host name too long: %d bytes", len(host)) + } + hostLen := uint16(len(host)) + response = append(response, byte(hostLen>>8), byte(hostLen)) + response = append(response, []byte(host)...) + + // Port (4 bytes) - validate port range + if port < 0 || port > 65535 { + return nil, fmt.Errorf("invalid port number: %d", port) + } + portBytes := make([]byte, 4) + binary.BigEndian.PutUint32(portBytes, uint32(port)) + response = append(response, portBytes...) + + // Rack (STRING: 2 bytes length + bytes) - v1 addition, non-nullable empty string + response = append(response, 0, 0) // empty string + + // ControllerID (4 bytes) - v1 addition + response = append(response, nodeIDBytes...) // controller_id = this broker + + // Topics array length (4 bytes) + topicsCountBytes := make([]byte, 4) + binary.BigEndian.PutUint32(topicsCountBytes, uint32(len(topicsToReturn))) + response = append(response, topicsCountBytes...) + + // Topics + for _, topicName := range topicsToReturn { + // error_code (2 bytes) + response = append(response, 0, 0) + + // topic name (STRING: 2 bytes length + bytes) + topicLen := uint16(len(topicName)) + response = append(response, byte(topicLen>>8), byte(topicLen)) + response = append(response, []byte(topicName)...) + + // is_internal (1 byte) - v1 addition + response = append(response, 0) // false + + // Get actual partition count from topic info + topicInfo, exists := h.seaweedMQHandler.GetTopicInfo(topicName) + partitionCount := h.GetDefaultPartitions() // Use configurable default + if exists && topicInfo != nil { + partitionCount = topicInfo.Partitions + } + + // partitions array length (4 bytes) + partitionsBytes := make([]byte, 4) + binary.BigEndian.PutUint32(partitionsBytes, uint32(partitionCount)) + response = append(response, partitionsBytes...) + + // Create partition entries for each partition + for partitionID := int32(0); partitionID < partitionCount; partitionID++ { + // partition: error_code(2) + partition_id(4) + leader_id(4) + replicas(ARRAY) + isr(ARRAY) + response = append(response, 0, 0) // error_code + + // partition_id (4 bytes) + partitionIDBytes := make([]byte, 4) + binary.BigEndian.PutUint32(partitionIDBytes, uint32(partitionID)) + response = append(response, partitionIDBytes...) + + response = append(response, nodeIDBytes...) // leader_id = this broker + + // replicas: array length(4) + one broker id (this broker) + response = append(response, 0, 0, 0, 1) + response = append(response, nodeIDBytes...) + + // isr: array length(4) + one broker id (this broker) + response = append(response, 0, 0, 0, 1) + response = append(response, nodeIDBytes...) + } + } + + return response, nil +} + +// HandleMetadataV2 implements Metadata API v2 with ClusterID field +func (h *Handler) HandleMetadataV2(correlationID uint32, requestBody []byte) ([]byte, error) { + // Metadata v2 adds ClusterID field (nullable string) + // v2 response layout: correlation_id(4) + brokers(ARRAY) + cluster_id(NULLABLE_STRING) + controller_id(4) + topics(ARRAY) + + // Parse requested topics (empty means all) + requestedTopics := h.parseMetadataTopics(requestBody) + glog.V(3).Infof("[METADATA v2] Requested topics: %v (empty=all)", requestedTopics) + + // Determine topics to return using SeaweedMQ handler + var topicsToReturn []string + if len(requestedTopics) == 0 { + topicsToReturn = h.seaweedMQHandler.ListTopics() + } else { + for _, name := range requestedTopics { + if h.ensureTopicExists(name, h.GetDefaultPartitions()) { + topicsToReturn = append(topicsToReturn, name) + } + } + } + + var buf bytes.Buffer + + // Correlation ID (4 bytes) + // NOTE: Correlation ID is handled by writeResponseWithCorrelationID + // Do NOT include it in the response body + + // Brokers array (4 bytes length + brokers) - 1 broker (this gateway) + binary.Write(&buf, binary.BigEndian, int32(1)) + + // Get advertised address for client connections + host, port := h.GetAdvertisedAddress(h.GetGatewayAddress()) + + nodeID := h.GetNodeID() // Get consistent node ID for this gateway + + // Broker: node_id(4) + host(STRING) + port(4) + rack(STRING) + cluster_id(NULLABLE_STRING) + binary.Write(&buf, binary.BigEndian, nodeID) + + // Host (STRING: 2 bytes length + data) - validate length fits in int16 + if len(host) > 32767 { + return nil, fmt.Errorf("host name too long: %d bytes", len(host)) + } + binary.Write(&buf, binary.BigEndian, int16(len(host))) + buf.WriteString(host) + + // Port (4 bytes) - validate port range + if port < 0 || port > 65535 { + return nil, fmt.Errorf("invalid port number: %d", port) + } + binary.Write(&buf, binary.BigEndian, int32(port)) + + // Rack (STRING: 2 bytes length + data) - v1+ addition, non-nullable + binary.Write(&buf, binary.BigEndian, int16(0)) // Empty string + + // ClusterID (NULLABLE_STRING: 2 bytes length + data) - v2 addition + // Schema Registry requires a non-null cluster ID + clusterID := "seaweedfs-kafka-gateway" + binary.Write(&buf, binary.BigEndian, int16(len(clusterID))) + buf.WriteString(clusterID) + + // ControllerID (4 bytes) - v1+ addition + binary.Write(&buf, binary.BigEndian, nodeID) + + // Topics array (4 bytes length + topics) + binary.Write(&buf, binary.BigEndian, int32(len(topicsToReturn))) + + for _, topicName := range topicsToReturn { + // ErrorCode (2 bytes) + binary.Write(&buf, binary.BigEndian, int16(0)) + + // Name (STRING: 2 bytes length + data) + binary.Write(&buf, binary.BigEndian, int16(len(topicName))) + buf.WriteString(topicName) + + // IsInternal (1 byte) - v1+ addition + buf.WriteByte(0) // false + + // Get actual partition count from topic info + topicInfo, exists := h.seaweedMQHandler.GetTopicInfo(topicName) + partitionCount := h.GetDefaultPartitions() // Use configurable default + if exists && topicInfo != nil { + partitionCount = topicInfo.Partitions + } + + // Partitions array (4 bytes length + partitions) + binary.Write(&buf, binary.BigEndian, partitionCount) + + // Create partition entries for each partition + for partitionID := int32(0); partitionID < partitionCount; partitionID++ { + binary.Write(&buf, binary.BigEndian, int16(0)) // ErrorCode + binary.Write(&buf, binary.BigEndian, partitionID) // PartitionIndex + binary.Write(&buf, binary.BigEndian, nodeID) // LeaderID + + // ReplicaNodes array (4 bytes length + nodes) + binary.Write(&buf, binary.BigEndian, int32(1)) // 1 replica + binary.Write(&buf, binary.BigEndian, nodeID) // NodeID 1 + + // IsrNodes array (4 bytes length + nodes) + binary.Write(&buf, binary.BigEndian, int32(1)) // 1 ISR node + binary.Write(&buf, binary.BigEndian, nodeID) // NodeID 1 + } + } + + response := buf.Bytes() + + return response, nil +} + +// HandleMetadataV3V4 implements Metadata API v3/v4 with ThrottleTimeMs field +func (h *Handler) HandleMetadataV3V4(correlationID uint32, requestBody []byte) ([]byte, error) { + // Metadata v3/v4 adds ThrottleTimeMs field at the beginning + // v3/v4 response layout: correlation_id(4) + throttle_time_ms(4) + brokers(ARRAY) + cluster_id(NULLABLE_STRING) + controller_id(4) + topics(ARRAY) + + // Parse requested topics (empty means all) + requestedTopics := h.parseMetadataTopics(requestBody) + glog.V(3).Infof("[METADATA v3/v4] Requested topics: %v (empty=all)", requestedTopics) + + // Determine topics to return using SeaweedMQ handler + var topicsToReturn []string + if len(requestedTopics) == 0 { + topicsToReturn = h.seaweedMQHandler.ListTopics() + } else { + for _, name := range requestedTopics { + if h.ensureTopicExists(name, h.GetDefaultPartitions()) { + topicsToReturn = append(topicsToReturn, name) + } + } + } + + var buf bytes.Buffer + + // Correlation ID (4 bytes) + // NOTE: Correlation ID is handled by writeResponseWithCorrelationID + // Do NOT include it in the response body + + // ThrottleTimeMs (4 bytes) - v3+ addition + binary.Write(&buf, binary.BigEndian, int32(0)) // No throttling + + // Brokers array (4 bytes length + brokers) - 1 broker (this gateway) + binary.Write(&buf, binary.BigEndian, int32(1)) + + // Get advertised address for client connections + host, port := h.GetAdvertisedAddress(h.GetGatewayAddress()) + + nodeID := h.GetNodeID() // Get consistent node ID for this gateway + + // Broker: node_id(4) + host(STRING) + port(4) + rack(STRING) + cluster_id(NULLABLE_STRING) + binary.Write(&buf, binary.BigEndian, nodeID) + + // Host (STRING: 2 bytes length + data) - validate length fits in int16 + if len(host) > 32767 { + return nil, fmt.Errorf("host name too long: %d bytes", len(host)) + } + binary.Write(&buf, binary.BigEndian, int16(len(host))) + buf.WriteString(host) + + // Port (4 bytes) - validate port range + if port < 0 || port > 65535 { + return nil, fmt.Errorf("invalid port number: %d", port) + } + binary.Write(&buf, binary.BigEndian, int32(port)) + + // Rack (STRING: 2 bytes length + data) - v1+ addition, non-nullable + binary.Write(&buf, binary.BigEndian, int16(0)) // Empty string + + // ClusterID (NULLABLE_STRING: 2 bytes length + data) - v2+ addition + // Schema Registry requires a non-null cluster ID + clusterID := "seaweedfs-kafka-gateway" + binary.Write(&buf, binary.BigEndian, int16(len(clusterID))) + buf.WriteString(clusterID) + + // ControllerID (4 bytes) - v1+ addition + binary.Write(&buf, binary.BigEndian, nodeID) + + // Topics array (4 bytes length + topics) + binary.Write(&buf, binary.BigEndian, int32(len(topicsToReturn))) + + for _, topicName := range topicsToReturn { + // ErrorCode (2 bytes) + binary.Write(&buf, binary.BigEndian, int16(0)) + + // Name (STRING: 2 bytes length + data) + binary.Write(&buf, binary.BigEndian, int16(len(topicName))) + buf.WriteString(topicName) + + // IsInternal (1 byte) - v1+ addition + buf.WriteByte(0) // false + + // Get actual partition count from topic info + topicInfo, exists := h.seaweedMQHandler.GetTopicInfo(topicName) + partitionCount := h.GetDefaultPartitions() // Use configurable default + if exists && topicInfo != nil { + partitionCount = topicInfo.Partitions + } + + // Partitions array (4 bytes length + partitions) + binary.Write(&buf, binary.BigEndian, partitionCount) + + // Create partition entries for each partition + for partitionID := int32(0); partitionID < partitionCount; partitionID++ { + binary.Write(&buf, binary.BigEndian, int16(0)) // ErrorCode + binary.Write(&buf, binary.BigEndian, partitionID) // PartitionIndex + binary.Write(&buf, binary.BigEndian, nodeID) // LeaderID + + // ReplicaNodes array (4 bytes length + nodes) + binary.Write(&buf, binary.BigEndian, int32(1)) // 1 replica + binary.Write(&buf, binary.BigEndian, nodeID) // NodeID 1 + + // IsrNodes array (4 bytes length + nodes) + binary.Write(&buf, binary.BigEndian, int32(1)) // 1 ISR node + binary.Write(&buf, binary.BigEndian, nodeID) // NodeID 1 + } + } + + response := buf.Bytes() + + // Detailed logging for Metadata response + maxDisplay := len(response) + if maxDisplay > 50 { + maxDisplay = 50 + } + if len(response) > 100 { + } + + return response, nil +} + +// HandleMetadataV5V6 implements Metadata API v5/v6 with OfflineReplicas field +func (h *Handler) HandleMetadataV5V6(correlationID uint32, requestBody []byte) ([]byte, error) { + return h.handleMetadataV5ToV8(correlationID, requestBody, 5) +} + +// HandleMetadataV7 implements Metadata API v7 with LeaderEpoch field (REGULAR FORMAT, NOT FLEXIBLE) +func (h *Handler) HandleMetadataV7(correlationID uint32, requestBody []byte) ([]byte, error) { + // Metadata v7 uses REGULAR arrays/strings (like v5/v6), NOT compact format + // Only v9+ uses compact format (flexible responses) + return h.handleMetadataV5ToV8(correlationID, requestBody, 7) +} + +// handleMetadataV5ToV8 handles Metadata v5-v8 with regular (non-compact) encoding +// v5/v6: adds OfflineReplicas field to partitions +// v7: adds LeaderEpoch field to partitions +// v8: adds ClusterAuthorizedOperations field +// All use REGULAR arrays/strings (NOT compact) - only v9+ uses compact format +func (h *Handler) handleMetadataV5ToV8(correlationID uint32, requestBody []byte, apiVersion int) ([]byte, error) { + // v5-v8 response layout: throttle_time_ms(4) + brokers(ARRAY) + cluster_id(NULLABLE_STRING) + controller_id(4) + topics(ARRAY) [+ cluster_authorized_operations(4) for v8] + // Each partition includes: error_code(2) + partition_index(4) + leader_id(4) [+ leader_epoch(4) for v7+] + replica_nodes(ARRAY) + isr_nodes(ARRAY) + offline_replicas(ARRAY) + + // Parse requested topics (empty means all) + requestedTopics := h.parseMetadataTopics(requestBody) + glog.V(3).Infof("[METADATA v%d] Requested topics: %v (empty=all)", apiVersion, requestedTopics) + + // Determine topics to return using SeaweedMQ handler + var topicsToReturn []string + if len(requestedTopics) == 0 { + topicsToReturn = h.seaweedMQHandler.ListTopics() + } else { + // FIXED: Proper topic existence checking (removed the hack) + // Now that CreateTopics v5 works, we use proper Kafka workflow: + // 1. Check which requested topics actually exist + // 2. Auto-create system topics if they don't exist + // 3. Only return existing topics in metadata + // 4. Client will call CreateTopics for non-existent topics + // 5. Then request metadata again to see the created topics + for _, topic := range requestedTopics { + partitions := h.GetDefaultPartitions() + if isSystemTopic(topic) { + // System topics use a single partition. ensureTopicExists tolerates + // the auto-create race, so a false result means the topic genuinely + // does not exist - don't advertise it with error_code=0. + partitions = 1 + } + if h.ensureTopicExists(topic, partitions) { + topicsToReturn = append(topicsToReturn, topic) + } + } + glog.V(3).Infof("[METADATA v%d] Returning topics: %v (requested: %v)", apiVersion, topicsToReturn, requestedTopics) + } + + var buf bytes.Buffer + + // Correlation ID (4 bytes) + // NOTE: Correlation ID is handled by writeResponseWithCorrelationID + // Do NOT include it in the response body + + // ThrottleTimeMs (4 bytes) - v3+ addition + binary.Write(&buf, binary.BigEndian, int32(0)) // No throttling + + // Brokers array (4 bytes length + brokers) - 1 broker (this gateway) + binary.Write(&buf, binary.BigEndian, int32(1)) + + // Get advertised address for client connections + host, port := h.GetAdvertisedAddress(h.GetGatewayAddress()) + + nodeID := h.GetNodeID() // Get consistent node ID for this gateway + + // Broker: node_id(4) + host(STRING) + port(4) + rack(STRING) + cluster_id(NULLABLE_STRING) + binary.Write(&buf, binary.BigEndian, nodeID) + + // Host (STRING: 2 bytes length + data) - validate length fits in int16 + if len(host) > 32767 { + return nil, fmt.Errorf("host name too long: %d bytes", len(host)) + } + binary.Write(&buf, binary.BigEndian, int16(len(host))) + buf.WriteString(host) + + // Port (4 bytes) - validate port range + if port < 0 || port > 65535 { + return nil, fmt.Errorf("invalid port number: %d", port) + } + binary.Write(&buf, binary.BigEndian, int32(port)) + + // Rack (STRING: 2 bytes length + data) - v1+ addition, non-nullable + binary.Write(&buf, binary.BigEndian, int16(0)) // Empty string + + // ClusterID (NULLABLE_STRING: 2 bytes length + data) - v2+ addition + // Schema Registry requires a non-null cluster ID + clusterID := "seaweedfs-kafka-gateway" + binary.Write(&buf, binary.BigEndian, int16(len(clusterID))) + buf.WriteString(clusterID) + + // ControllerID (4 bytes) - v1+ addition + binary.Write(&buf, binary.BigEndian, nodeID) + + // Topics array (4 bytes length + topics) + binary.Write(&buf, binary.BigEndian, int32(len(topicsToReturn))) + + for _, topicName := range topicsToReturn { + // ErrorCode (2 bytes) + binary.Write(&buf, binary.BigEndian, int16(0)) + + // Name (STRING: 2 bytes length + data) + binary.Write(&buf, binary.BigEndian, int16(len(topicName))) + buf.WriteString(topicName) + + // IsInternal (1 byte) - v1+ addition + buf.WriteByte(0) // false + + // Get actual partition count from topic info + topicInfo, exists := h.seaweedMQHandler.GetTopicInfo(topicName) + partitionCount := h.GetDefaultPartitions() // Use configurable default + if exists && topicInfo != nil { + partitionCount = topicInfo.Partitions + } + + // Partitions array (4 bytes length + partitions) + binary.Write(&buf, binary.BigEndian, partitionCount) + + // Create partition entries for each partition + for partitionID := int32(0); partitionID < partitionCount; partitionID++ { + binary.Write(&buf, binary.BigEndian, int16(0)) // ErrorCode + binary.Write(&buf, binary.BigEndian, partitionID) // PartitionIndex + binary.Write(&buf, binary.BigEndian, nodeID) // LeaderID + + // LeaderEpoch (4 bytes) - v7+ addition + if apiVersion >= 7 { + binary.Write(&buf, binary.BigEndian, int32(0)) // Leader epoch 0 + } + + // ReplicaNodes array (4 bytes length + nodes) + binary.Write(&buf, binary.BigEndian, int32(1)) // 1 replica + binary.Write(&buf, binary.BigEndian, nodeID) // NodeID 1 + + // IsrNodes array (4 bytes length + nodes) + binary.Write(&buf, binary.BigEndian, int32(1)) // 1 ISR node + binary.Write(&buf, binary.BigEndian, nodeID) // NodeID 1 + + // OfflineReplicas array (4 bytes length + nodes) - v5+ addition + binary.Write(&buf, binary.BigEndian, int32(0)) // No offline replicas + } + } + + // ClusterAuthorizedOperations (4 bytes) - v8+ addition + if apiVersion >= 8 { + binary.Write(&buf, binary.BigEndian, int32(-2147483648)) // All operations allowed (bit mask) + } + + response := buf.Bytes() + + // Detailed logging for Metadata response + maxDisplay := len(response) + if maxDisplay > 50 { + maxDisplay = 50 + } + if len(response) > 100 { + } + + return response, nil +} + +func (h *Handler) parseMetadataTopics(requestBody []byte) []string { + // Support both v0/v1 parsing: v1 payload starts directly with topics array length (int32), + // while older assumptions may have included a client_id string first. + if len(requestBody) < 4 { + return []string{} + } + + // Try path A: interpret first 4 bytes as topics_count + offset := 0 + topicsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + if topicsCount == 0xFFFFFFFF { // -1 means all topics + return []string{} + } + if topicsCount <= 1000000 { // sane bound + offset += 4 + topics := make([]string, 0, topicsCount) + for i := uint32(0); i < topicsCount && offset+2 <= len(requestBody); i++ { + nameLen := int(binary.BigEndian.Uint16(requestBody[offset : offset+2])) + offset += 2 + if offset+nameLen > len(requestBody) { + break + } + topics = append(topics, string(requestBody[offset:offset+nameLen])) + offset += nameLen + } + return topics + } + + // Path B: assume leading client_id string then topics_count + if len(requestBody) < 6 { + return []string{} + } + clientIDLen := int(binary.BigEndian.Uint16(requestBody[0:2])) + offset = 2 + clientIDLen + if len(requestBody) < offset+4 { + return []string{} + } + topicsCount = binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + if topicsCount == 0xFFFFFFFF { + return []string{} + } + topics := make([]string, 0, topicsCount) + for i := uint32(0); i < topicsCount && offset+2 <= len(requestBody); i++ { + nameLen := int(binary.BigEndian.Uint16(requestBody[offset : offset+2])) + offset += 2 + if offset+nameLen > len(requestBody) { + break + } + topics = append(topics, string(requestBody[offset:offset+nameLen])) + offset += nameLen + } + return topics +} + +func (h *Handler) handleListOffsets(correlationID uint32, apiVersion uint16, requestBody []byte) ([]byte, error) { + + // Parse minimal request to understand what's being asked (header already stripped) + offset := 0 + + maxBytes := len(requestBody) + if maxBytes > 64 { + maxBytes = 64 + } + + // v1+ has replica_id(4) + if apiVersion >= 1 { + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("ListOffsets v%d request missing replica_id", apiVersion) + } + _ = int32(binary.BigEndian.Uint32(requestBody[offset : offset+4])) // replicaID + offset += 4 + } + + // v2+ adds isolation_level(1) + if apiVersion >= 2 { + if len(requestBody) < offset+1 { + return nil, fmt.Errorf("ListOffsets v%d request missing isolation_level", apiVersion) + } + _ = requestBody[offset] // isolationLevel + offset += 1 + } + + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("ListOffsets request missing topics count") + } + + topicsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + response := make([]byte, 0, 256) + + // NOTE: Correlation ID is handled by writeResponseWithHeader + // Do NOT include it in the response body + + // Throttle time (4 bytes, 0 = no throttling) - v2+ only + if apiVersion >= 2 { + response = append(response, 0, 0, 0, 0) + } + + // Topics count (will be updated later with actual count) + topicsCountBytes := make([]byte, 4) + topicsCountOffset := len(response) // Remember where to update the count + binary.BigEndian.PutUint32(topicsCountBytes, topicsCount) + response = append(response, topicsCountBytes...) + + // Track how many topics we actually process + actualTopicsCount := uint32(0) + + // Process each requested topic + for i := uint32(0); i < topicsCount && offset < len(requestBody); i++ { + if len(requestBody) < offset+2 { + break + } + + // Parse topic name + topicNameSize := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + + if len(requestBody) < offset+int(topicNameSize)+4 { + break + } + + topicName := requestBody[offset : offset+int(topicNameSize)] + offset += int(topicNameSize) + + // Parse partitions count for this topic + partitionsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + // Response: topic_name_size(2) + topic_name + partitions_array + response = append(response, byte(topicNameSize>>8), byte(topicNameSize)) + response = append(response, topicName...) + + partitionsCountBytes := make([]byte, 4) + binary.BigEndian.PutUint32(partitionsCountBytes, partitionsCount) + response = append(response, partitionsCountBytes...) + + // Process each partition + for j := uint32(0); j < partitionsCount && offset+12 <= len(requestBody); j++ { + // Parse partition request: partition_id(4) + timestamp(8) + partitionID := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + timestamp := int64(binary.BigEndian.Uint64(requestBody[offset+4 : offset+12])) + offset += 12 + + // Response: partition_id(4) + error_code(2) + timestamp(8) + offset(8) + partitionIDBytes := make([]byte, 4) + binary.BigEndian.PutUint32(partitionIDBytes, partitionID) + response = append(response, partitionIDBytes...) + + // Error code (0 = no error) + response = append(response, 0, 0) + + // Use direct SMQ reading - no ledgers needed + // SMQ handles offset management internally + var responseTimestamp int64 + var responseOffset int64 + + switch timestamp { + case -2: // earliest offset + // Get the actual earliest offset from SMQ + earliestOffset, err := h.seaweedMQHandler.GetEarliestOffset(string(topicName), int32(partitionID)) + if err != nil { + responseOffset = 0 // fallback to 0 + } else { + responseOffset = earliestOffset + } + responseTimestamp = 0 // No specific timestamp for earliest + + case -1: // latest offset + // Get the actual latest offset from SMQ + if h.seaweedMQHandler == nil { + responseOffset = 0 + } else { + latestOffset, err := h.seaweedMQHandler.GetLatestOffset(string(topicName), int32(partitionID)) + if err != nil { + responseOffset = 0 // fallback to 0 + } else { + responseOffset = latestOffset + } + } + responseTimestamp = 0 // No specific timestamp for latest + default: // specific timestamp - find offset by timestamp + // For timestamp-based lookup, we need to implement this properly + // For now, return 0 as fallback + responseOffset = 0 + responseTimestamp = timestamp + } + + // Ensure we never return a timestamp as offset - this was the bug! + if responseOffset > 1000000000 { // If offset looks like a timestamp + responseOffset = 0 + } + + timestampBytes := make([]byte, 8) + binary.BigEndian.PutUint64(timestampBytes, uint64(responseTimestamp)) + response = append(response, timestampBytes...) + + offsetBytes := make([]byte, 8) + binary.BigEndian.PutUint64(offsetBytes, uint64(responseOffset)) + response = append(response, offsetBytes...) + } + + // Successfully processed this topic + actualTopicsCount++ + } + + // Update the topics count in the response header with the actual count + // This prevents ErrIncompleteResponse when request parsing fails mid-way + if actualTopicsCount != topicsCount { + binary.BigEndian.PutUint32(response[topicsCountOffset:topicsCountOffset+4], actualTopicsCount) + } else { + } + + if len(response) > 0 { + respPreview := len(response) + if respPreview > 32 { + respPreview = 32 + } + } + return response, nil + +} + +func (h *Handler) handleCreateTopics(correlationID uint32, apiVersion uint16, requestBody []byte) ([]byte, error) { + + if len(requestBody) < 2 { + return nil, fmt.Errorf("CreateTopics request too short") + } + + // Parse based on API version + switch apiVersion { + case 0, 1: + response, err := h.handleCreateTopicsV0V1(correlationID, requestBody) + return response, err + case 2, 3, 4: + // kafka-go sends v2-4 in regular format, not compact + response, err := h.handleCreateTopicsV2To4(correlationID, requestBody) + return response, err + case 5: + // v5+ uses flexible format with compact arrays + response, err := h.handleCreateTopicsV2Plus(correlationID, apiVersion, requestBody) + return response, err + default: + return nil, fmt.Errorf("unsupported CreateTopics API version: %d", apiVersion) + } +} + +// handleCreateTopicsV2To4 handles CreateTopics API versions 2-4 (auto-detect regular vs compact format) +func (h *Handler) handleCreateTopicsV2To4(correlationID uint32, requestBody []byte) ([]byte, error) { + // Auto-detect format: kafka-go sends regular format, tests send compact format + if len(requestBody) < 1 { + return nil, fmt.Errorf("CreateTopics v2-4 request too short") + } + + // Detect format by checking first byte + // Compact format: first byte is compact array length (usually 0x02 for 1 topic) + // Regular format: first 4 bytes are regular array count (usually 0x00000001 for 1 topic) + isCompactFormat := false + if len(requestBody) >= 4 { + // Check if this looks like a regular 4-byte array count + regularCount := binary.BigEndian.Uint32(requestBody[0:4]) + // If the "regular count" is very large (> 1000), it's probably compact format + // Also check if first byte is small (typical compact array length) + if regularCount > 1000 || (requestBody[0] <= 10 && requestBody[0] > 0) { + isCompactFormat = true + } + } else if requestBody[0] <= 10 && requestBody[0] > 0 { + isCompactFormat = true + } + + if isCompactFormat { + // Delegate to the compact format handler + response, err := h.handleCreateTopicsV2Plus(correlationID, 2, requestBody) + return response, err + } + + // Handle regular format + offset := 0 + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("CreateTopics v2-4 request too short for topics array") + } + + topicsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + // Parse topics + topics := make([]struct { + name string + partitions uint32 + replication uint16 + }, 0, topicsCount) + for i := uint32(0); i < topicsCount; i++ { + if len(requestBody) < offset+2 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated topic name length") + } + nameLen := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + if len(requestBody) < offset+int(nameLen) { + return nil, fmt.Errorf("CreateTopics v2-4: truncated topic name") + } + topicName := string(requestBody[offset : offset+int(nameLen)]) + offset += int(nameLen) + + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated num_partitions") + } + numPartitions := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + if len(requestBody) < offset+2 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated replication_factor") + } + replication := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + + // Assignments array (array of partition assignments) - skip contents + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated assignments count") + } + assignments := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + for j := uint32(0); j < assignments; j++ { + // partition_id (int32) + replicas (array int32) + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated assignment partition id") + } + offset += 4 + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated replicas count") + } + replicasCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + // skip replica ids + offset += int(replicasCount) * 4 + } + + // Configs array (array of (name,value) strings) - skip contents + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated configs count") + } + configs := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + for j := uint32(0); j < configs; j++ { + // name (string) + if len(requestBody) < offset+2 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated config name length") + } + nameLen := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + int(nameLen) + // value (nullable string) + if len(requestBody) < offset+2 { + return nil, fmt.Errorf("CreateTopics v2-4: truncated config value length") + } + valueLen := int16(binary.BigEndian.Uint16(requestBody[offset : offset+2])) + offset += 2 + if valueLen >= 0 { + offset += int(valueLen) + } + } + + topics = append(topics, struct { + name string + partitions uint32 + replication uint16 + }{topicName, numPartitions, replication}) + } + + // timeout_ms + if len(requestBody) >= offset+4 { + _ = binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + } + // validate_only (boolean) + if len(requestBody) >= offset+1 { + _ = requestBody[offset] + offset += 1 + } + + // Build response + response := make([]byte, 0, 128) + // NOTE: Correlation ID is handled by writeResponseWithHeader + // Do NOT include it in the response body + // throttle_time_ms (4 bytes) + response = append(response, 0, 0, 0, 0) + // topics array count (int32) + countBytes := make([]byte, 4) + binary.BigEndian.PutUint32(countBytes, uint32(len(topics))) + response = append(response, countBytes...) + // per-topic responses + for _, t := range topics { + // topic name (string) + nameLen := make([]byte, 2) + binary.BigEndian.PutUint16(nameLen, uint16(len(t.name))) + response = append(response, nameLen...) + response = append(response, []byte(t.name)...) + // error_code (int16) + var errCode uint16 = 0 + if h.seaweedMQHandler.TopicExists(t.name) { + errCode = 36 // TOPIC_ALREADY_EXISTS + } else if t.partitions == 0 { + errCode = 37 // INVALID_PARTITIONS + } else if t.replication == 0 { + errCode = 38 // INVALID_REPLICATION_FACTOR + } else { + // Use schema-aware topic creation + if err := h.createTopicWithSchemaSupport(t.name, int32(t.partitions)); err != nil { + errCode = 0xFFFF // UNKNOWN_SERVER_ERROR (-1 as uint16) + } + } + eb := make([]byte, 2) + binary.BigEndian.PutUint16(eb, errCode) + response = append(response, eb...) + // error_message (nullable string) -> null + response = append(response, 0xFF, 0xFF) + } + + return response, nil +} + +func (h *Handler) handleCreateTopicsV0V1(correlationID uint32, requestBody []byte) ([]byte, error) { + + if len(requestBody) < 4 { + return nil, fmt.Errorf("CreateTopics v0/v1 request too short") + } + + offset := 0 + + // Parse topics array (regular array format: count + topics) + topicsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + // Build response + response := make([]byte, 0, 256) + + // NOTE: Correlation ID is handled by writeResponseWithHeader + // Do NOT include it in the response body + + // Topics array count (4 bytes in v0/v1) + topicsCountBytes := make([]byte, 4) + binary.BigEndian.PutUint32(topicsCountBytes, topicsCount) + response = append(response, topicsCountBytes...) + + // Process each topic + for i := uint32(0); i < topicsCount && offset < len(requestBody); i++ { + // Parse topic name (regular string: length + bytes) + if len(requestBody) < offset+2 { + break + } + topicNameLength := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + + if len(requestBody) < offset+int(topicNameLength) { + break + } + topicName := string(requestBody[offset : offset+int(topicNameLength)]) + offset += int(topicNameLength) + + // Parse num_partitions (4 bytes) + if len(requestBody) < offset+4 { + break + } + numPartitions := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + // Parse replication_factor (2 bytes) + if len(requestBody) < offset+2 { + break + } + replicationFactor := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + + // Parse assignments array (4 bytes count, then assignments) + if len(requestBody) < offset+4 { + break + } + assignmentsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + // Skip assignments for now (simplified) + for j := uint32(0); j < assignmentsCount && offset < len(requestBody); j++ { + // Skip partition_id (4 bytes) + if len(requestBody) >= offset+4 { + offset += 4 + } + // Skip replicas array (4 bytes count + replica_ids) + if len(requestBody) >= offset+4 { + replicasCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + offset += int(replicasCount) * 4 // Skip replica IDs + } + } + + // Parse configs array (4 bytes count, then configs) + if len(requestBody) >= offset+4 { + configsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + // Skip configs (simplified) + for j := uint32(0); j < configsCount && offset < len(requestBody); j++ { + // Skip config name (string: 2 bytes length + bytes) + if len(requestBody) >= offset+2 { + configNameLength := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + int(configNameLength) + } + // Skip config value (string: 2 bytes length + bytes) + if len(requestBody) >= offset+2 { + configValueLength := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + int(configValueLength) + } + } + } + + // Build response for this topic + // Topic name (string: length + bytes) + topicNameLengthBytes := make([]byte, 2) + binary.BigEndian.PutUint16(topicNameLengthBytes, uint16(len(topicName))) + response = append(response, topicNameLengthBytes...) + response = append(response, []byte(topicName)...) + + // Determine error code and message + var errorCode uint16 = 0 + + // Apply defaults for invalid values + if numPartitions <= 0 { + numPartitions = uint32(h.GetDefaultPartitions()) // Use configurable default + } + if replicationFactor <= 0 { + replicationFactor = 1 // Default to 1 replica + } + + // Use SeaweedMQ integration + if h.seaweedMQHandler.TopicExists(topicName) { + errorCode = 36 // TOPIC_ALREADY_EXISTS + } else { + // Create the topic in SeaweedMQ with schema support + if err := h.createTopicWithSchemaSupport(topicName, int32(numPartitions)); err != nil { + errorCode = 0xFFFF // UNKNOWN_SERVER_ERROR (-1 as uint16) + } + } + + // Error code (2 bytes) + errorCodeBytes := make([]byte, 2) + binary.BigEndian.PutUint16(errorCodeBytes, errorCode) + response = append(response, errorCodeBytes...) + } + + // Parse timeout_ms (4 bytes) - at the end of request + if len(requestBody) >= offset+4 { + _ = binary.BigEndian.Uint32(requestBody[offset : offset+4]) // timeoutMs + offset += 4 + } + + // Parse validate_only (1 byte) - only in v1 + if len(requestBody) >= offset+1 { + _ = requestBody[offset] != 0 // validateOnly + } + + return response, nil +} + +// handleCreateTopicsV2Plus handles CreateTopics API versions 2+ (flexible versions with compact arrays/strings) +// For simplicity and consistency with existing response builder, this parses the flexible request, +// converts it into the non-flexible v2-v4 body format, and reuses handleCreateTopicsV2To4 to build the response. +func (h *Handler) handleCreateTopicsV2Plus(correlationID uint32, apiVersion uint16, requestBody []byte) ([]byte, error) { + offset := 0 + + // ADMIN CLIENT COMPATIBILITY FIX: + // AdminClient's CreateTopics v5 request DOES start with top-level tagged fields (usually empty) + // Parse them first, then the topics compact array + + // Parse top-level tagged fields first (usually 0x00 for empty) + _, consumed, err := DecodeTaggedFields(requestBody[offset:]) + if err != nil { + // Don't fail - AdminClient might not always include tagged fields properly + // Just log and continue with topics parsing + } else { + offset += consumed + } + + // Topics (compact array) - Now correctly positioned after tagged fields + topicsCount, consumed, err := DecodeCompactArrayLength(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode topics compact array: %w", apiVersion, err) + } + offset += consumed + + type topicSpec struct { + name string + partitions uint32 + replication uint16 + } + topics := make([]topicSpec, 0, topicsCount) + + for i := uint32(0); i < topicsCount; i++ { + // Topic name (compact string) + name, consumed, err := DecodeFlexibleString(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode topic[%d] name: %w", apiVersion, i, err) + } + offset += consumed + + if len(requestBody) < offset+6 { + return nil, fmt.Errorf("CreateTopics v%d: truncated partitions/replication for topic[%d]", apiVersion, i) + } + + partitions := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + replication := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + + // ADMIN CLIENT COMPATIBILITY: AdminClient uses little-endian for replication factor + // This violates Kafka protocol spec but we need to handle it for compatibility + if replication == 256 { + replication = 1 // AdminClient sent 0x01 0x00, intended as little-endian 1 + } + + // Apply defaults for invalid values + if partitions <= 0 { + partitions = uint32(h.GetDefaultPartitions()) // Use configurable default + } + if replication <= 0 { + replication = 1 // Default to 1 replica + } + + // FIX 2: Assignments (compact array) - this was missing! + assignCount, consumed, err := DecodeCompactArrayLength(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode topic[%d] assignments array: %w", apiVersion, i, err) + } + offset += consumed + + // Skip assignment entries (partition_id + replicas array) + for j := uint32(0); j < assignCount; j++ { + // partition_id (int32) + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("CreateTopics v%d: truncated assignment[%d] partition_id", apiVersion, j) + } + offset += 4 + + // replicas (compact array of int32) + replicasCount, consumed, err := DecodeCompactArrayLength(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode assignment[%d] replicas: %w", apiVersion, j, err) + } + offset += consumed + + // Skip replica broker IDs (int32 each) + if len(requestBody) < offset+int(replicasCount)*4 { + return nil, fmt.Errorf("CreateTopics v%d: truncated assignment[%d] replicas", apiVersion, j) + } + offset += int(replicasCount) * 4 + + // Assignment tagged fields + _, consumed, err = DecodeTaggedFields(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode assignment[%d] tagged fields: %w", apiVersion, j, err) + } + offset += consumed + } + + // Configs (compact array) - skip entries + cfgCount, consumed, err := DecodeCompactArrayLength(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode topic[%d] configs array: %w", apiVersion, i, err) + } + offset += consumed + + for j := uint32(0); j < cfgCount; j++ { + // name (compact string) + _, consumed, err := DecodeFlexibleString(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode topic[%d] config[%d] name: %w", apiVersion, i, j, err) + } + offset += consumed + + // value (nullable compact string) + _, consumed, err = DecodeFlexibleString(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode topic[%d] config[%d] value: %w", apiVersion, i, j, err) + } + offset += consumed + + // tagged fields for each config + _, consumed, err = DecodeTaggedFields(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode topic[%d] config[%d] tagged fields: %w", apiVersion, i, j, err) + } + offset += consumed + } + + // Tagged fields for topic + _, consumed, err = DecodeTaggedFields(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("CreateTopics v%d: decode topic[%d] tagged fields: %w", apiVersion, i, err) + } + offset += consumed + + topics = append(topics, topicSpec{name: name, partitions: partitions, replication: replication}) + } + + for range topics { + } + + // timeout_ms (int32) + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("CreateTopics v%d: missing timeout_ms", apiVersion) + } + timeoutMs := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + // validate_only (boolean) + if len(requestBody) < offset+1 { + return nil, fmt.Errorf("CreateTopics v%d: missing validate_only flag", apiVersion) + } + validateOnly := requestBody[offset] != 0 + offset += 1 + + // Remaining bytes after parsing - could be additional fields + if offset < len(requestBody) { + } + + // Reconstruct a non-flexible v2-like request body and reuse existing handler + // Format: topics(ARRAY) + timeout_ms(INT32) + validate_only(BOOLEAN) + var legacyBody []byte + + // topics count (int32) + legacyBody = append(legacyBody, 0, 0, 0, byte(len(topics))) + if len(topics) > 0 { + legacyBody[len(legacyBody)-1] = byte(len(topics)) + } + + for _, t := range topics { + // topic name (STRING) + nameLen := uint16(len(t.name)) + legacyBody = append(legacyBody, byte(nameLen>>8), byte(nameLen)) + legacyBody = append(legacyBody, []byte(t.name)...) + + // num_partitions (INT32) + legacyBody = append(legacyBody, byte(t.partitions>>24), byte(t.partitions>>16), byte(t.partitions>>8), byte(t.partitions)) + + // replication_factor (INT16) + legacyBody = append(legacyBody, byte(t.replication>>8), byte(t.replication)) + + // assignments array (INT32 count = 0) + legacyBody = append(legacyBody, 0, 0, 0, 0) + + // configs array (INT32 count = 0) + legacyBody = append(legacyBody, 0, 0, 0, 0) + } + + // timeout_ms + legacyBody = append(legacyBody, byte(timeoutMs>>24), byte(timeoutMs>>16), byte(timeoutMs>>8), byte(timeoutMs)) + + // validate_only + if validateOnly { + legacyBody = append(legacyBody, 1) + } else { + legacyBody = append(legacyBody, 0) + } + + // Build response directly instead of delegating to avoid circular dependency + response := make([]byte, 0, 128) + + // NOTE: Correlation ID and header tagged fields are handled by writeResponseWithHeader + // Do NOT include them in the response body + + // throttle_time_ms (4 bytes) - first field in CreateTopics response body + response = append(response, 0, 0, 0, 0) + + // topics (compact array) - V5 FLEXIBLE FORMAT + topicCount := len(topics) + + // Debug: log response size at each step + debugResponseSize := func(step string) { + } + debugResponseSize("After correlation ID and throttle_time_ms") + + // Compact array: length is encoded as UNSIGNED_VARINT(actualLength + 1) + response = append(response, EncodeUvarint(uint32(topicCount+1))...) + debugResponseSize("After topics array length") + + // For each topic + for _, t := range topics { + // name (compact string): length is encoded as UNSIGNED_VARINT(actualLength + 1) + nameBytes := []byte(t.name) + response = append(response, EncodeUvarint(uint32(len(nameBytes)+1))...) + response = append(response, nameBytes...) + + // TopicId - Not present in v5, only added in v7+ + // v5 CreateTopics response does not include TopicId field + + // error_code (int16) + var errCode uint16 = 0 + + // ADMIN CLIENT COMPATIBILITY: Apply defaults before error checking + actualPartitions := t.partitions + if actualPartitions == 0 { + actualPartitions = 1 // Default to 1 partition if 0 requested + } + actualReplication := t.replication + if actualReplication == 0 { + actualReplication = 1 // Default to 1 replication if 0 requested + } + + // ADMIN CLIENT COMPATIBILITY: Always return success for existing topics + // AdminClient expects topic creation to succeed, even if topic already exists + if h.seaweedMQHandler.TopicExists(t.name) { + errCode = 0 // SUCCESS - AdminClient can handle this gracefully + } else { + // Use corrected values for error checking and topic creation with schema support + if err := h.createTopicWithSchemaSupport(t.name, int32(actualPartitions)); err != nil { + errCode = 0xFFFF // UNKNOWN_SERVER_ERROR (-1 as uint16) + } + } + eb := make([]byte, 2) + binary.BigEndian.PutUint16(eb, errCode) + response = append(response, eb...) + + // error_message (compact nullable string) - ADMINCLIENT 7.4.0-CE COMPATIBILITY FIX + // For "_schemas" topic, send null for byte-level compatibility with Java reference + // For other topics, send empty string to avoid NPE in AdminClient response handling + if t.name == "_schemas" { + response = append(response, 0) // Null = 0 + } else { + response = append(response, 1) // Empty string = 1 (0 chars + 1) + } + + // ADDED FOR V5: num_partitions (int32) + // ADMIN CLIENT COMPATIBILITY: Use corrected values from error checking logic + partBytes := make([]byte, 4) + binary.BigEndian.PutUint32(partBytes, actualPartitions) + response = append(response, partBytes...) + + // ADDED FOR V5: replication_factor (int16) + replBytes := make([]byte, 2) + binary.BigEndian.PutUint16(replBytes, actualReplication) + response = append(response, replBytes...) + + // configs (compact nullable array) - ADDED FOR V5 + // ADMINCLIENT 7.4.0-CE NPE FIX: Send empty configs array instead of null + // AdminClient 7.4.0-ce has NPE when configs=null but were requested + // Empty array = 1 (0 configs + 1), still achieves ~30-byte response + response = append(response, 1) // Empty configs array = 1 (0 configs + 1) + + // Tagged fields for each topic - V5 format per Kafka source + // Count tagged fields (topicConfigErrorCode only if != 0) + topicConfigErrorCode := uint16(0) // No error + numTaggedFields := 0 + if topicConfigErrorCode != 0 { + numTaggedFields = 1 + } + + // Write tagged fields count + response = append(response, EncodeUvarint(uint32(numTaggedFields))...) + + // Write tagged fields (only if topicConfigErrorCode != 0) + if topicConfigErrorCode != 0 { + // Tag 0: TopicConfigErrorCode + response = append(response, EncodeUvarint(0)...) // Tag number 0 + response = append(response, EncodeUvarint(2)...) // Length (int16 = 2 bytes) + topicConfigErrBytes := make([]byte, 2) + binary.BigEndian.PutUint16(topicConfigErrBytes, topicConfigErrorCode) + response = append(response, topicConfigErrBytes...) + } + + debugResponseSize(fmt.Sprintf("After topic '%s'", t.name)) + } + + // Top-level tagged fields for v5 flexible response (empty) + response = append(response, 0) // Empty tagged fields = 0 + debugResponseSize("Final response") + + return response, nil +} + +func (h *Handler) handleDeleteTopics(correlationID uint32, requestBody []byte) ([]byte, error) { + // Parse minimal DeleteTopics request + // Request format: client_id + timeout(4) + topics_array + + if len(requestBody) < 6 { // client_id_size(2) + timeout(4) + return nil, fmt.Errorf("DeleteTopics request too short") + } + + // Skip client_id + clientIDSize := binary.BigEndian.Uint16(requestBody[0:2]) + offset := 2 + int(clientIDSize) + + if len(requestBody) < offset+8 { // timeout(4) + topics_count(4) + return nil, fmt.Errorf("DeleteTopics request missing data") + } + + // Skip timeout + offset += 4 + + topicsCount := binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + + response := make([]byte, 0, 256) + + // NOTE: Correlation ID is handled by writeResponseWithHeader + // Do NOT include it in the response body + + // Throttle time (4 bytes, 0 = no throttling) + response = append(response, 0, 0, 0, 0) + + // Topics count (same as request) + topicsCountBytes := make([]byte, 4) + binary.BigEndian.PutUint32(topicsCountBytes, topicsCount) + response = append(response, topicsCountBytes...) + + // Process each topic (using SeaweedMQ handler) + + for i := uint32(0); i < topicsCount && offset < len(requestBody); i++ { + if len(requestBody) < offset+2 { + break + } + + // Parse topic name + topicNameSize := binary.BigEndian.Uint16(requestBody[offset : offset+2]) + offset += 2 + + if len(requestBody) < offset+int(topicNameSize) { + break + } + + topicName := string(requestBody[offset : offset+int(topicNameSize)]) + offset += int(topicNameSize) + + // Response: topic_name + error_code(2) + error_message + response = append(response, byte(topicNameSize>>8), byte(topicNameSize)) + response = append(response, []byte(topicName)...) + + // Check if topic exists and delete it + var errorCode uint16 = 0 + var errorMessage string = "" + + // Use SeaweedMQ integration + if !h.seaweedMQHandler.TopicExists(topicName) { + errorCode = 3 // UNKNOWN_TOPIC_OR_PARTITION + errorMessage = "Unknown topic" + } else { + // Delete the topic from SeaweedMQ + if err := h.seaweedMQHandler.DeleteTopic(topicName); err != nil { + errorCode = 0xFFFF // UNKNOWN_SERVER_ERROR (-1 as uint16) + errorMessage = err.Error() + } + } + + // Error code + response = append(response, byte(errorCode>>8), byte(errorCode)) + + // Error message (nullable string) + if errorMessage == "" { + response = append(response, 0xFF, 0xFF) // null string + } else { + errorMsgLen := uint16(len(errorMessage)) + response = append(response, byte(errorMsgLen>>8), byte(errorMsgLen)) + response = append(response, []byte(errorMessage)...) + } + } + + return response, nil +} + +// validateAPIVersion checks if we support the requested API version +func (h *Handler) validateAPIVersion(apiKey, apiVersion uint16) error { + supportedVersions := map[APIKey][2]uint16{ + APIKeyApiVersions: {0, 4}, // ApiVersions: v0-v4 (Kafka 8.0.0 compatibility) + APIKeyMetadata: {0, 7}, // Metadata: v0-v7 + APIKeyProduce: {0, 7}, // Produce: v0-v7 + APIKeyFetch: {0, 7}, // Fetch: v0-v7 + APIKeyListOffsets: {0, 2}, // ListOffsets: v0-v2 + APIKeyCreateTopics: {0, 5}, // CreateTopics: v0-v5 (updated to match implementation) + APIKeyDeleteTopics: {0, 4}, // DeleteTopics: v0-v4 + APIKeyFindCoordinator: {0, 3}, // FindCoordinator: v0-v3 (v3+ uses flexible format) + APIKeyJoinGroup: {0, 6}, // JoinGroup: cap to v6 (first flexible version) + APIKeySyncGroup: {0, 5}, // SyncGroup: v0-v5 + APIKeyOffsetCommit: {0, 2}, // OffsetCommit: v0-v2 + APIKeyOffsetFetch: {0, 5}, // OffsetFetch: v0-v5 (updated to match implementation) + APIKeyHeartbeat: {0, 4}, // Heartbeat: v0-v4 + APIKeyLeaveGroup: {0, 4}, // LeaveGroup: v0-v4 + APIKeyDescribeGroups: {0, 5}, // DescribeGroups: v0-v5 + APIKeyListGroups: {0, 4}, // ListGroups: v0-v4 + APIKeyDescribeConfigs: {0, 4}, // DescribeConfigs: v0-v4 + APIKeyInitProducerId: {0, 4}, // InitProducerId: v0-v4 + APIKeyDescribeCluster: {0, 1}, // DescribeCluster: v0-v1 (KIP-919, AdminClient compatibility) + } + + if versionRange, exists := supportedVersions[APIKey(apiKey)]; exists { + minVer, maxVer := versionRange[0], versionRange[1] + if apiVersion < minVer || apiVersion > maxVer { + return fmt.Errorf("unsupported API version %d for API key %d (supported: %d-%d)", + apiVersion, apiKey, minVer, maxVer) + } + return nil + } + + return fmt.Errorf("unsupported API key: %d", apiKey) +} + +// buildUnsupportedVersionResponse creates a proper Kafka error response +func (h *Handler) buildUnsupportedVersionResponse(correlationID uint32, apiKey, apiVersion uint16) ([]byte, error) { + errorMsg := fmt.Sprintf("Unsupported version %d for API key", apiVersion) + return BuildErrorResponseWithMessage(correlationID, ErrorCodeUnsupportedVersion, errorMsg), nil +} + +// handleMetadata routes to the appropriate version-specific handler +func (h *Handler) handleMetadata(correlationID uint32, apiVersion uint16, requestBody []byte) ([]byte, error) { + + var response []byte + var err error + + switch apiVersion { + case 0: + response, err = h.HandleMetadataV0(correlationID, requestBody) + case 1: + response, err = h.HandleMetadataV1(correlationID, requestBody) + case 2: + response, err = h.HandleMetadataV2(correlationID, requestBody) + case 3, 4: + response, err = h.HandleMetadataV3V4(correlationID, requestBody) + case 5, 6: + response, err = h.HandleMetadataV5V6(correlationID, requestBody) + case 7: + response, err = h.HandleMetadataV7(correlationID, requestBody) + default: + // For versions > 7, use the V7 handler (flexible format) + if apiVersion > 7 { + response, err = h.HandleMetadataV7(correlationID, requestBody) + } else { + err = fmt.Errorf("metadata version %d not implemented yet", apiVersion) + } + } + + if err != nil { + } else { + } + return response, err +} + +// getAPIName returns a human-readable name for Kafka API keys (for debugging) +func getAPIName(apiKey APIKey) string { + switch apiKey { + case APIKeyProduce: + return "Produce" + case APIKeyFetch: + return "Fetch" + case APIKeyListOffsets: + return "ListOffsets" + case APIKeyMetadata: + return "Metadata" + case APIKeyOffsetCommit: + return "OffsetCommit" + case APIKeyOffsetFetch: + return "OffsetFetch" + case APIKeyFindCoordinator: + return "FindCoordinator" + case APIKeyJoinGroup: + return "JoinGroup" + case APIKeyHeartbeat: + return "Heartbeat" + case APIKeyLeaveGroup: + return "LeaveGroup" + case APIKeySyncGroup: + return "SyncGroup" + case APIKeyDescribeGroups: + return "DescribeGroups" + case APIKeyListGroups: + return "ListGroups" + case APIKeyApiVersions: + return "ApiVersions" + case APIKeyCreateTopics: + return "CreateTopics" + case APIKeyDeleteTopics: + return "DeleteTopics" + case APIKeyDescribeConfigs: + return "DescribeConfigs" + case APIKeyInitProducerId: + return "InitProducerId" + case APIKeyDescribeCluster: + return "DescribeCluster" + default: + return "Unknown" + } +} + +// handleDescribeConfigs handles DescribeConfigs API requests (API key 32) +func (h *Handler) handleDescribeConfigs(correlationID uint32, apiVersion uint16, requestBody []byte) ([]byte, error) { + + // Parse request to extract resources + resources, err := h.parseDescribeConfigsRequest(requestBody, apiVersion) + if err != nil { + glog.Errorf("DescribeConfigs parsing error: %v", err) + return nil, fmt.Errorf("failed to parse DescribeConfigs request: %w", err) + } + + isFlexible := apiVersion >= 4 + if !isFlexible { + // Legacy (non-flexible) response for v0-3 + response := make([]byte, 0, 2048) + + // NOTE: Correlation ID is handled by writeResponseWithHeader + // Do NOT include it in the response body + + // Throttle time (0ms) + throttleBytes := make([]byte, 4) + binary.BigEndian.PutUint32(throttleBytes, 0) + response = append(response, throttleBytes...) + + // Resources array length + resourcesBytes := make([]byte, 4) + binary.BigEndian.PutUint32(resourcesBytes, uint32(len(resources))) + response = append(response, resourcesBytes...) + + // For each resource, return appropriate configs + for _, resource := range resources { + resourceResponse := h.buildDescribeConfigsResourceResponse(resource, apiVersion) + response = append(response, resourceResponse...) + } + + return response, nil + } + + // Flexible response for v4+ + response := make([]byte, 0, 2048) + + // NOTE: Correlation ID is handled by writeResponseWithHeader + // Do NOT include it in the response body + + // throttle_time_ms (4 bytes) + response = append(response, 0, 0, 0, 0) + + // Results (compact array) + response = append(response, EncodeUvarint(uint32(len(resources)+1))...) + + for _, res := range resources { + // ErrorCode (int16) = 0 + response = append(response, 0, 0) + // ErrorMessage (compact nullable string) = null (0) + response = append(response, 0) + // ResourceType (int8) + response = append(response, byte(res.ResourceType)) + // ResourceName (compact string) + nameBytes := []byte(res.ResourceName) + response = append(response, EncodeUvarint(uint32(len(nameBytes)+1))...) + response = append(response, nameBytes...) + + // Build configs for this resource + var cfgs []ConfigEntry + if res.ResourceType == 2 { // Topic + cfgs = h.getTopicConfigs(res.ResourceName, res.ConfigNames) + // Ensure cleanup.policy is compact for _schemas + if res.ResourceName == "_schemas" { + replaced := false + for i := range cfgs { + if cfgs[i].Name == "cleanup.policy" { + cfgs[i].Value = "compact" + replaced = true + break + } + } + if !replaced { + cfgs = append(cfgs, ConfigEntry{Name: "cleanup.policy", Value: "compact"}) + } + } + } else if res.ResourceType == 4 { // Broker + cfgs = h.getBrokerConfigs(res.ConfigNames) + } else { + cfgs = []ConfigEntry{} + } + + // Configs (compact array) + response = append(response, EncodeUvarint(uint32(len(cfgs)+1))...) + + for _, cfg := range cfgs { + // name (compact string) + cb := []byte(cfg.Name) + response = append(response, EncodeUvarint(uint32(len(cb)+1))...) + response = append(response, cb...) + + // value (compact nullable string) + vb := []byte(cfg.Value) + if len(vb) == 0 { + response = append(response, 0) // null + } else { + response = append(response, EncodeUvarint(uint32(len(vb)+1))...) + response = append(response, vb...) + } + + // readOnly (bool) + if cfg.ReadOnly { + response = append(response, 1) + } else { + response = append(response, 0) + } + + // configSource (int8): DEFAULT_CONFIG = 5 + response = append(response, byte(5)) + + // isSensitive (bool) + if cfg.Sensitive { + response = append(response, 1) + } else { + response = append(response, 0) + } + + // synonyms (compact array) - empty + response = append(response, 1) + + // config_type (int8) - STRING = 1 + response = append(response, byte(1)) + + // documentation (compact nullable string) - null + response = append(response, 0) + + // per-config tagged fields (empty) + response = append(response, 0) + } + + // Per-result tagged fields (empty) + response = append(response, 0) + } + + // Top-level tagged fields (empty) + response = append(response, 0) + + return response, nil +} + +// isFlexibleResponse determines if an API response should use flexible format (with header tagged fields) +// Based on Kafka protocol specifications: most APIs become flexible at v3+, but some differ +func isFlexibleResponse(apiKey uint16, apiVersion uint16) bool { + // Reference: kafka-go/protocol/response.go:119 and sarama/response_header.go:21 + // Flexible responses have headerVersion >= 1, which adds tagged fields after correlation ID + + switch APIKey(apiKey) { + case APIKeyProduce: + return apiVersion >= 9 + case APIKeyFetch: + return apiVersion >= 12 + case APIKeyMetadata: + // Metadata v9+ uses flexible responses (v7-8 use compact arrays/strings but NOT flexible headers) + return apiVersion >= 9 + case APIKeyOffsetCommit: + return apiVersion >= 8 + case APIKeyOffsetFetch: + return apiVersion >= 6 + case APIKeyFindCoordinator: + return apiVersion >= 3 + case APIKeyJoinGroup: + return apiVersion >= 6 + case APIKeyHeartbeat: + return apiVersion >= 4 + case APIKeyLeaveGroup: + return apiVersion >= 4 + case APIKeySyncGroup: + return apiVersion >= 4 + case APIKeyApiVersions: + // AdminClient compatibility requires header version 0 (no tagged fields) + // Even though ApiVersions v3+ technically supports flexible responses, AdminClient + // expects the header to NOT include tagged fields. This is a known quirk. + return false // Always use non-flexible header for ApiVersions + case APIKeyCreateTopics: + return apiVersion >= 5 + case APIKeyDeleteTopics: + return apiVersion >= 4 + case APIKeyInitProducerId: + return apiVersion >= 2 // Flexible from v2+ (KIP-360) + case APIKeyDescribeConfigs: + return apiVersion >= 4 + case APIKeyDescribeCluster: + return true // All versions (0+) are flexible + default: + // For unknown APIs, assume non-flexible (safer default) + return false + } +} + +// writeResponseWithHeader writes a Kafka response following the wire protocol: +// [Size: 4 bytes][Correlation ID: 4 bytes][Tagged Fields (if flexible)][Body] +func (h *Handler) writeResponseWithHeader(w *bufio.Writer, correlationID uint32, apiKey uint16, apiVersion uint16, responseBody []byte, timeout time.Duration) error { + // Kafka wire protocol format (from kafka-go/protocol/response.go:116-138 and sarama/response_header.go:10-27): + // [4 bytes: size = len(everything after this)] + // [4 bytes: correlation ID] + // [varint: header tagged fields (0x00 for empty) - ONLY for flexible responses with headerVersion >= 1] + // [N bytes: response body] + + // Determine if this response should be flexible + isFlexible := isFlexibleResponse(apiKey, apiVersion) + + // Calculate total size: correlation ID (4) + tagged fields (1 if flexible) + body + totalSize := 4 + len(responseBody) + if isFlexible { + totalSize += 1 // Add 1 byte for empty tagged fields (0x00) + } + + // Build complete response in memory for hex dump logging + fullResponse := make([]byte, 0, 4+totalSize) + + // Write size + sizeBuf := make([]byte, 4) + binary.BigEndian.PutUint32(sizeBuf, uint32(totalSize)) + fullResponse = append(fullResponse, sizeBuf...) + + // Write correlation ID + correlationBuf := make([]byte, 4) + binary.BigEndian.PutUint32(correlationBuf, correlationID) + fullResponse = append(fullResponse, correlationBuf...) + + // Write header-level tagged fields for flexible responses + if isFlexible { + // Empty tagged fields = 0x00 (varint 0) + fullResponse = append(fullResponse, 0x00) + } + + // Write response body + fullResponse = append(fullResponse, responseBody...) + + // Write to connection + if _, err := w.Write(fullResponse); err != nil { + return fmt.Errorf("write response: %w", err) + } + + // Flush + if err := w.Flush(); err != nil { + return fmt.Errorf("flush response: %w", err) + } + + return nil +} + +// writeResponseWithCorrelationID is deprecated - use writeResponseWithHeader instead +// Kept for compatibility with direct callers that don't have API info +func (h *Handler) writeResponseWithCorrelationID(w *bufio.Writer, correlationID uint32, responseBody []byte, timeout time.Duration) error { + // Assume non-flexible for backward compatibility + return h.writeResponseWithHeader(w, correlationID, 0, 0, responseBody, timeout) +} + +// EnableSchemaManagement enables schema management with the given configuration +func (h *Handler) EnableSchemaManagement(config schema.ManagerConfig) error { + manager, err := schema.NewManagerWithHealthCheck(config) + if err != nil { + return fmt.Errorf("failed to create schema manager: %w", err) + } + + h.schemaManager = manager + h.useSchema = true + + return nil +} + +// EnableBrokerIntegration enables mq.broker integration for schematized messages +func (h *Handler) EnableBrokerIntegration(brokers []string) error { + if !h.IsSchemaEnabled() { + return fmt.Errorf("schema management must be enabled before broker integration") + } + + brokerClient := schema.NewBrokerClient(schema.BrokerClientConfig{ + Brokers: brokers, + SchemaManager: h.schemaManager, + }) + + h.brokerClient = brokerClient + return nil +} + +// DisableSchemaManagement disables schema management and broker integration +func (h *Handler) DisableSchemaManagement() { + if h.brokerClient != nil { + h.brokerClient.Close() + h.brokerClient = nil + } + h.schemaManager = nil + h.useSchema = false +} + +// SetSchemaRegistryURL sets the Schema Registry URL for delayed initialization +func (h *Handler) SetSchemaRegistryURL(url string) { + h.schemaRegistryURL = url +} + +// SetDefaultPartitions sets the default partition count for auto-created topics +func (h *Handler) SetDefaultPartitions(partitions int32) { + h.defaultPartitions = partitions +} + +// GetDefaultPartitions returns the default partition count for auto-created topics +func (h *Handler) GetDefaultPartitions() int32 { + if h.defaultPartitions <= 0 { + return 4 // Fallback default + } + return h.defaultPartitions +} + +// IsSchemaEnabled returns whether schema management is enabled +func (h *Handler) IsSchemaEnabled() bool { + // Try to initialize schema management if not already done + if !h.useSchema && h.schemaRegistryURL != "" { + h.tryInitializeSchemaManagement() + } + return h.useSchema && h.schemaManager != nil +} + +// tryInitializeSchemaManagement attempts to initialize schema management +// This is called lazily when schema functionality is first needed +func (h *Handler) tryInitializeSchemaManagement() { + if h.useSchema || h.schemaRegistryURL == "" { + return // Already initialized or no URL provided + } + + schemaConfig := schema.ManagerConfig{ + RegistryURL: h.schemaRegistryURL, + } + + if err := h.EnableSchemaManagement(schemaConfig); err != nil { + return + } + +} + +// IsBrokerIntegrationEnabled returns true if broker integration is enabled +func (h *Handler) IsBrokerIntegrationEnabled() bool { + return h.IsSchemaEnabled() && h.brokerClient != nil +} + +// commitOffsetToSMQ commits offset using SMQ storage +func (h *Handler) commitOffsetToSMQ(key ConsumerOffsetKey, offsetValue int64, metadata string) error { + // Use new consumer offset storage if available, fall back to SMQ storage + if h.consumerOffsetStorage != nil { + return h.consumerOffsetStorage.CommitOffset(key.ConsumerGroup, key.Topic, key.Partition, offsetValue, metadata) + } + + // No SMQ offset storage - only use consumer offset storage + return fmt.Errorf("offset storage not initialized") +} + +// fetchOffsetFromSMQ fetches offset using SMQ storage +func (h *Handler) fetchOffsetFromSMQ(key ConsumerOffsetKey) (int64, string, error) { + // Use new consumer offset storage if available, fall back to SMQ storage + if h.consumerOffsetStorage != nil { + return h.consumerOffsetStorage.FetchOffset(key.ConsumerGroup, key.Topic, key.Partition) + } + + // SMQ offset storage removed - no fallback + return -1, "", fmt.Errorf("offset storage not initialized") +} + +// DescribeConfigsResource represents a resource in a DescribeConfigs request +type DescribeConfigsResource struct { + ResourceType int8 // 2 = Topic, 4 = Broker + ResourceName string + ConfigNames []string // Empty means return all configs +} + +// parseDescribeConfigsRequest parses a DescribeConfigs request body +func (h *Handler) parseDescribeConfigsRequest(requestBody []byte, apiVersion uint16) ([]DescribeConfigsResource, error) { + if len(requestBody) < 1 { + return nil, fmt.Errorf("request too short") + } + + offset := 0 + + // DescribeConfigs v4+ uses flexible protocol (compact arrays with varint) + isFlexible := apiVersion >= 4 + + var resourcesLength uint32 + if isFlexible { + // FIX: Skip top-level tagged fields for DescribeConfigs v4+ flexible protocol + // The request body starts with tagged fields count (usually 0x00 = empty) + _, consumed, err := DecodeTaggedFields(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("DescribeConfigs v%d: decode top-level tagged fields: %w", apiVersion, err) + } + offset += consumed + + // Resources (compact array) - Now correctly positioned after tagged fields + resourcesLength, consumed, err = DecodeCompactArrayLength(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("decode resources compact array: %w", err) + } + offset += consumed + } else { + // Regular array: length is int32 + if len(requestBody) < 4 { + return nil, fmt.Errorf("request too short for regular array") + } + resourcesLength = binary.BigEndian.Uint32(requestBody[offset : offset+4]) + offset += 4 + } + + // Validate resources length to prevent panic + if resourcesLength > 100 { // Reasonable limit + return nil, fmt.Errorf("invalid resources length: %d", resourcesLength) + } + + resources := make([]DescribeConfigsResource, 0, resourcesLength) + + for i := uint32(0); i < resourcesLength; i++ { + if offset+1 > len(requestBody) { + return nil, fmt.Errorf("insufficient data for resource type") + } + + // Resource type (1 byte) + resourceType := int8(requestBody[offset]) + offset++ + + // Resource name (string - compact for v4+, regular for v0-3) + var resourceName string + if isFlexible { + // Compact string: length is encoded as UNSIGNED_VARINT(actualLength + 1) + name, consumed, err := DecodeFlexibleString(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("decode resource name compact string: %w", err) + } + resourceName = name + offset += consumed + } else { + // Regular string: length is int16 + if offset+2 > len(requestBody) { + return nil, fmt.Errorf("insufficient data for resource name length") + } + nameLength := int(binary.BigEndian.Uint16(requestBody[offset : offset+2])) + offset += 2 + + // Validate name length to prevent panic + if nameLength < 0 || nameLength > 1000 { // Reasonable limit + return nil, fmt.Errorf("invalid resource name length: %d", nameLength) + } + + if offset+nameLength > len(requestBody) { + return nil, fmt.Errorf("insufficient data for resource name") + } + resourceName = string(requestBody[offset : offset+nameLength]) + offset += nameLength + } + + // Config names array (compact for v4+, regular for v0-3) + var configNames []string + if isFlexible { + // Compact array: length is encoded as UNSIGNED_VARINT(actualLength + 1) + // For nullable arrays, 0 means null, 1 means empty + configNamesCount, consumed, err := DecodeCompactArrayLength(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("decode config names compact array: %w", err) + } + offset += consumed + + // Parse each config name as compact string (if not null) + if configNamesCount > 0 { + for j := uint32(0); j < configNamesCount; j++ { + configName, consumed, err := DecodeFlexibleString(requestBody[offset:]) + if err != nil { + return nil, fmt.Errorf("decode config name[%d] compact string: %w", j, err) + } + offset += consumed + configNames = append(configNames, configName) + } + } + } else { + // Regular array: length is int32 + if offset+4 > len(requestBody) { + return nil, fmt.Errorf("insufficient data for config names length") + } + configNamesLength := int32(binary.BigEndian.Uint32(requestBody[offset : offset+4])) + offset += 4 + + // Validate config names length to prevent panic + // Note: -1 means null/empty array in Kafka protocol + if configNamesLength < -1 || configNamesLength > 1000 { // Reasonable limit + return nil, fmt.Errorf("invalid config names length: %d", configNamesLength) + } + + // Handle null array case + if configNamesLength == -1 { + configNamesLength = 0 + } + + configNames = make([]string, 0, configNamesLength) + for j := int32(0); j < configNamesLength; j++ { + if offset+2 > len(requestBody) { + return nil, fmt.Errorf("insufficient data for config name length") + } + configNameLength := int(binary.BigEndian.Uint16(requestBody[offset : offset+2])) + offset += 2 + + // Validate config name length to prevent panic + if configNameLength < 0 || configNameLength > 500 { // Reasonable limit + return nil, fmt.Errorf("invalid config name length: %d", configNameLength) + } + + if offset+configNameLength > len(requestBody) { + return nil, fmt.Errorf("insufficient data for config name") + } + configName := string(requestBody[offset : offset+configNameLength]) + offset += configNameLength + + configNames = append(configNames, configName) + } + } + + resources = append(resources, DescribeConfigsResource{ + ResourceType: resourceType, + ResourceName: resourceName, + ConfigNames: configNames, + }) + } + + return resources, nil +} + +// buildDescribeConfigsResourceResponse builds the response for a single resource +func (h *Handler) buildDescribeConfigsResourceResponse(resource DescribeConfigsResource, apiVersion uint16) []byte { + response := make([]byte, 0, 512) + + // Error code (0 = no error) + errorCodeBytes := make([]byte, 2) + binary.BigEndian.PutUint16(errorCodeBytes, 0) + response = append(response, errorCodeBytes...) + + // Error message (null string = -1 length) + errorMsgBytes := make([]byte, 2) + binary.BigEndian.PutUint16(errorMsgBytes, 0xFFFF) // -1 as uint16 + response = append(response, errorMsgBytes...) + + // Resource type + response = append(response, byte(resource.ResourceType)) + + // Resource name + nameBytes := make([]byte, 2+len(resource.ResourceName)) + binary.BigEndian.PutUint16(nameBytes[0:2], uint16(len(resource.ResourceName))) + copy(nameBytes[2:], []byte(resource.ResourceName)) + response = append(response, nameBytes...) + + // Get configs for this resource + configs := h.getConfigsForResource(resource) + + // Config entries array length + configCountBytes := make([]byte, 4) + binary.BigEndian.PutUint32(configCountBytes, uint32(len(configs))) + response = append(response, configCountBytes...) + + // Add each config entry + for _, config := range configs { + configBytes := h.buildConfigEntry(config, apiVersion) + response = append(response, configBytes...) + } + + return response +} + +// ConfigEntry represents a single configuration entry +type ConfigEntry struct { + Name string + Value string + ReadOnly bool + IsDefault bool + Sensitive bool +} + +// getConfigsForResource returns appropriate configs for a resource +func (h *Handler) getConfigsForResource(resource DescribeConfigsResource) []ConfigEntry { + switch resource.ResourceType { + case 2: // Topic + return h.getTopicConfigs(resource.ResourceName, resource.ConfigNames) + case 4: // Broker + return h.getBrokerConfigs(resource.ConfigNames) + default: + return []ConfigEntry{} + } +} + +// getTopicConfigs returns topic-level configurations +func (h *Handler) getTopicConfigs(topicName string, requestedConfigs []string) []ConfigEntry { + // Default topic configs that admin clients commonly request + allConfigs := map[string]ConfigEntry{ + "cleanup.policy": { + Name: "cleanup.policy", + Value: "delete", + ReadOnly: false, + IsDefault: true, + Sensitive: false, + }, + "retention.ms": { + Name: "retention.ms", + Value: "604800000", // 7 days in milliseconds + ReadOnly: false, + IsDefault: true, + Sensitive: false, + }, + "retention.bytes": { + Name: "retention.bytes", + Value: "-1", // Unlimited + ReadOnly: false, + IsDefault: true, + Sensitive: false, + }, + "segment.ms": { + Name: "segment.ms", + Value: "86400000", // 1 day in milliseconds + ReadOnly: false, + IsDefault: true, + Sensitive: false, + }, + "max.message.bytes": { + Name: "max.message.bytes", + Value: "1048588", // ~1MB + ReadOnly: false, + IsDefault: true, + Sensitive: false, + }, + "min.insync.replicas": { + Name: "min.insync.replicas", + Value: "1", + ReadOnly: false, + IsDefault: true, + Sensitive: false, + }, + } + + // If specific configs requested, filter to those + if len(requestedConfigs) > 0 { + filteredConfigs := make([]ConfigEntry, 0, len(requestedConfigs)) + for _, configName := range requestedConfigs { + if config, exists := allConfigs[configName]; exists { + filteredConfigs = append(filteredConfigs, config) + } + } + return filteredConfigs + } + + // Return all configs + configs := make([]ConfigEntry, 0, len(allConfigs)) + for _, config := range allConfigs { + configs = append(configs, config) + } + return configs +} + +// getBrokerConfigs returns broker-level configurations +func (h *Handler) getBrokerConfigs(requestedConfigs []string) []ConfigEntry { + // Default broker configs that admin clients commonly request + allConfigs := map[string]ConfigEntry{ + "log.retention.hours": { + Name: "log.retention.hours", + Value: "168", // 7 days + ReadOnly: false, + IsDefault: true, + Sensitive: false, + }, + "log.segment.bytes": { + Name: "log.segment.bytes", + Value: "1073741824", // 1GB + ReadOnly: false, + IsDefault: true, + Sensitive: false, + }, + "num.network.threads": { + Name: "num.network.threads", + Value: "3", + ReadOnly: true, + IsDefault: true, + Sensitive: false, + }, + "num.io.threads": { + Name: "num.io.threads", + Value: "8", + ReadOnly: true, + IsDefault: true, + Sensitive: false, + }, + } + + // If specific configs requested, filter to those + if len(requestedConfigs) > 0 { + filteredConfigs := make([]ConfigEntry, 0, len(requestedConfigs)) + for _, configName := range requestedConfigs { + if config, exists := allConfigs[configName]; exists { + filteredConfigs = append(filteredConfigs, config) + } + } + return filteredConfigs + } + + // Return all configs + configs := make([]ConfigEntry, 0, len(allConfigs)) + for _, config := range allConfigs { + configs = append(configs, config) + } + return configs +} + +// buildConfigEntry builds the wire format for a single config entry +func (h *Handler) buildConfigEntry(config ConfigEntry, apiVersion uint16) []byte { + entry := make([]byte, 0, 256) + + // Config name + nameBytes := make([]byte, 2+len(config.Name)) + binary.BigEndian.PutUint16(nameBytes[0:2], uint16(len(config.Name))) + copy(nameBytes[2:], []byte(config.Name)) + entry = append(entry, nameBytes...) + + // Config value + valueBytes := make([]byte, 2+len(config.Value)) + binary.BigEndian.PutUint16(valueBytes[0:2], uint16(len(config.Value))) + copy(valueBytes[2:], []byte(config.Value)) + entry = append(entry, valueBytes...) + + // Read only flag + if config.ReadOnly { + entry = append(entry, 1) + } else { + entry = append(entry, 0) + } + + // Is default flag (only for version 0) + if apiVersion == 0 { + if config.IsDefault { + entry = append(entry, 1) + } else { + entry = append(entry, 0) + } + } + + // Config source (for versions 1-3) + if apiVersion >= 1 && apiVersion <= 3 { + // ConfigSource: 1 = DYNAMIC_TOPIC_CONFIG, 2 = DYNAMIC_BROKER_CONFIG, 4 = STATIC_BROKER_CONFIG, 5 = DEFAULT_CONFIG + configSource := int8(5) // DEFAULT_CONFIG for all our configs since they're defaults + entry = append(entry, byte(configSource)) + } + + // Sensitive flag + if config.Sensitive { + entry = append(entry, 1) + } else { + entry = append(entry, 0) + } + + // Config synonyms (for versions 1-3) + if apiVersion >= 1 && apiVersion <= 3 { + // Empty synonyms array (4 bytes for array length = 0) + synonymsLength := make([]byte, 4) + binary.BigEndian.PutUint32(synonymsLength, 0) + entry = append(entry, synonymsLength...) + } + + // Config type (for version 3 only) + if apiVersion == 3 { + configType := int8(1) // STRING type for all our configs + entry = append(entry, byte(configType)) + } + + // Config documentation (for version 3 only) + if apiVersion == 3 { + // Null documentation (length = -1) + docLength := make([]byte, 2) + binary.BigEndian.PutUint16(docLength, 0xFFFF) // -1 as uint16 + entry = append(entry, docLength...) + } + + return entry +} + +// registerSchemasViaBrokerAPI registers both key and value schemas via the broker's ConfigureTopic API +// Only the gateway leader performs the registration to avoid concurrent updates. +func (h *Handler) registerSchemasViaBrokerAPI(topicName string, valueRecordType *schema_pb.RecordType, keyRecordType *schema_pb.RecordType) error { + if valueRecordType == nil && keyRecordType == nil { + return nil + } + + // Check coordinator registry for multi-gateway deployments + // In single-gateway mode, coordinator registry may not be initialized - that's OK + if reg := h.GetCoordinatorRegistry(); reg != nil { + // Multi-gateway mode - check if we're the leader + isLeader := reg.IsLeader() + + if !isLeader { + // Not leader - in production multi-gateway setups, skip to avoid conflicts + // In single-gateway setups where leader election fails, log warning but proceed + // This ensures schema registration works even if distributed locking has issues + // Note: Schema registration is idempotent, so duplicate registrations are safe + } else { + } + } else { + // No coordinator registry - definitely single-gateway mode + } + + // Require SeaweedMQ integration to access broker + if h.seaweedMQHandler == nil { + return fmt.Errorf("no SeaweedMQ handler available for broker access") + } + + // Get broker addresses + brokerAddresses := h.seaweedMQHandler.GetBrokerAddresses() + if len(brokerAddresses) == 0 { + return fmt.Errorf("no broker addresses available") + } + + // Use the first available broker + brokerAddress := brokerAddresses[0] + + // Load security configuration + util.LoadSecurityConfiguration() + grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.mq") + + // Get current topic configuration to preserve partition count + seaweedTopic := &schema_pb.Topic{ + Namespace: DefaultKafkaNamespace, + Name: topicName, + } + + return pb.WithBrokerGrpcClient(false, brokerAddress, grpcDialOption, func(client mq_pb.SeaweedMessagingClient) error { + // First get current configuration + getResp, err := client.GetTopicConfiguration(context.Background(), &mq_pb.GetTopicConfigurationRequest{ + Topic: seaweedTopic, + }) + if err != nil { + // Convert dual schemas to flat schema format + var flatSchema *schema_pb.RecordType + var keyColumns []string + if keyRecordType != nil || valueRecordType != nil { + flatSchema, keyColumns = mqschema.CombineFlatSchemaFromKeyValue(keyRecordType, valueRecordType) + } + + // If topic doesn't exist, create it with configurable default partition count + // Get schema format from topic config if available + schemaFormat := h.getTopicSchemaFormat(topicName) + _, err := client.ConfigureTopic(context.Background(), &mq_pb.ConfigureTopicRequest{ + Topic: seaweedTopic, + PartitionCount: h.GetDefaultPartitions(), // Use configurable default + MessageRecordType: flatSchema, + KeyColumns: keyColumns, + SchemaFormat: schemaFormat, + }) + return err + } + + // Convert dual schemas to flat schema format for update + var flatSchema *schema_pb.RecordType + var keyColumns []string + if keyRecordType != nil || valueRecordType != nil { + flatSchema, keyColumns = mqschema.CombineFlatSchemaFromKeyValue(keyRecordType, valueRecordType) + } + + // Update existing topic with new schema + // Get schema format from topic config if available + schemaFormat := h.getTopicSchemaFormat(topicName) + _, err = client.ConfigureTopic(context.Background(), &mq_pb.ConfigureTopicRequest{ + Topic: seaweedTopic, + PartitionCount: getResp.PartitionCount, + MessageRecordType: flatSchema, + KeyColumns: keyColumns, + Retention: getResp.Retention, + SchemaFormat: schemaFormat, + }) + return err + }) +} + +// handleInitProducerId handles InitProducerId API requests (API key 22) +// This API is used to initialize a producer for transactional or idempotent operations +func (h *Handler) handleInitProducerId(correlationID uint32, apiVersion uint16, requestBody []byte) ([]byte, error) { + + // InitProducerId Request Format (varies by version): + // v0-v1: transactional_id(NULLABLE_STRING) + transaction_timeout_ms(INT32) + // v2+: transactional_id(NULLABLE_STRING) + transaction_timeout_ms(INT32) + producer_id(INT64) + producer_epoch(INT16) + // v4+: Uses flexible format with tagged fields + + maxBytes := len(requestBody) + if maxBytes > 64 { + maxBytes = 64 + } + + offset := 0 + + // Parse transactional_id (NULLABLE_STRING or COMPACT_NULLABLE_STRING for flexible versions) + var transactionalId *string + if apiVersion >= 4 { + // Flexible version - use compact nullable string + if len(requestBody) < offset+1 { + return nil, fmt.Errorf("InitProducerId request too short for transactional_id") + } + + length := int(requestBody[offset]) + offset++ + + if length == 0 { + // Null string + transactionalId = nil + } else { + // Non-null string (length is encoded as length+1 in compact format) + actualLength := length - 1 + if len(requestBody) < offset+actualLength { + return nil, fmt.Errorf("InitProducerId request transactional_id too short") + } + if actualLength > 0 { + id := string(requestBody[offset : offset+actualLength]) + transactionalId = &id + offset += actualLength + } else { + // Empty string + id := "" + transactionalId = &id + } + } + } else { + // Non-flexible version - use regular nullable string + if len(requestBody) < offset+2 { + return nil, fmt.Errorf("InitProducerId request too short for transactional_id length") + } + + length := int(binary.BigEndian.Uint16(requestBody[offset : offset+2])) + offset += 2 + + if length == 0xFFFF { + // Null string (-1 as uint16) + transactionalId = nil + } else { + if len(requestBody) < offset+length { + return nil, fmt.Errorf("InitProducerId request transactional_id too short") + } + if length > 0 { + id := string(requestBody[offset : offset+length]) + transactionalId = &id + offset += length + } else { + // Empty string + id := "" + transactionalId = &id + } + } + } + _ = transactionalId // Used for logging/tracking, but not in core logic yet + + // Parse transaction_timeout_ms (INT32) + if len(requestBody) < offset+4 { + return nil, fmt.Errorf("InitProducerId request too short for transaction_timeout_ms") + } + _ = binary.BigEndian.Uint32(requestBody[offset : offset+4]) // transactionTimeoutMs + offset += 4 + + // For v2+, there might be additional fields, but we'll ignore them for now + // as we're providing a basic implementation + + // Build response + response := make([]byte, 0, 64) + + // NOTE: Correlation ID is handled by writeResponseWithHeader + // Do NOT include it in the response body + // Note: Header tagged fields are also handled by writeResponseWithHeader for flexible versions + + // InitProducerId Response Format: + // throttle_time_ms(INT32) + error_code(INT16) + producer_id(INT64) + producer_epoch(INT16) + // + tagged_fields (for flexible versions) + + // Throttle time (4 bytes) - v1+ + if apiVersion >= 1 { + response = append(response, 0, 0, 0, 0) // No throttling + } + + // Error code (2 bytes) - SUCCESS + response = append(response, 0, 0) // No error + + // Producer ID (8 bytes) - generate a simple producer ID + // In a real implementation, this would be managed by a transaction coordinator + producerId := int64(1000) // Simple fixed producer ID for now + producerIdBytes := make([]byte, 8) + binary.BigEndian.PutUint64(producerIdBytes, uint64(producerId)) + response = append(response, producerIdBytes...) + + // Producer epoch (2 bytes) - start with epoch 0 + response = append(response, 0, 0) // Epoch 0 + + // For flexible versions (v4+), add response body tagged fields + if apiVersion >= 4 { + response = append(response, 0x00) // Empty response body tagged fields + } + + respPreview := len(response) + if respPreview > 32 { + respPreview = 32 + } + return response, nil +} + +// ensureTopicExists reports whether the named topic exists, auto-creating it +// when missing (matching auto.create.topics.enable=true). +// +// TopicExists can return a transient false negative for a topic that is in fact +// present: a broker/filer blip, or a just-created topic whose existence cache is +// still stale. When that happens the auto-create attempt fails with +// ErrTopicAlreadyExists - which confirms the topic exists. Treating that as +// success (rather than dropping the topic) is what keeps producers from getting +// a spurious UNKNOWN_TOPIC_OR_PARTITION right after the topic was created. +func (h *Handler) ensureTopicExists(topicName string, partitions int32) bool { + if h.seaweedMQHandler.TopicExists(topicName) { + return true + } + // Existence cache may be stale right after creation; force a fresh broker check. + h.seaweedMQHandler.InvalidateTopicExistsCache(topicName) + if h.seaweedMQHandler.TopicExists(topicName) { + return true + } + if err := h.createTopicWithSchemaSupport(topicName, partitions); err != nil { + if errors.Is(err, integration.ErrTopicAlreadyExists) { + // The topic exists; the recheck above just cached a stale negative. + // Clear it so direct TopicExists callers don't see the false negative. + h.seaweedMQHandler.InvalidateTopicExistsCache(topicName) + return true + } + glog.V(2).Infof("[METADATA] Failed to auto-create topic %s: %v", topicName, err) + return false + } + // Topic just created - clear the negative cache so callers see it immediately. + h.seaweedMQHandler.InvalidateTopicExistsCache(topicName) + return true +} + +// createTopicWithSchemaSupport creates a topic with optional schema integration +// This function creates topics with schema support when schema management is enabled +func (h *Handler) createTopicWithSchemaSupport(topicName string, partitions int32) error { + + // For system topics like _schemas, __consumer_offsets, etc., use default schema + if isSystemTopic(topicName) { + return h.createTopicWithDefaultFlexibleSchema(topicName, partitions) + } + + // Check if Schema Registry URL is configured + if h.schemaRegistryURL != "" { + + // Try to initialize schema management if not already done + if h.schemaManager == nil { + h.tryInitializeSchemaManagement() + } + + // If schema manager is still nil after initialization attempt, Schema Registry is unavailable + if h.schemaManager == nil { + return fmt.Errorf("Schema Registry is configured at %s but unavailable - cannot create topic %s without schema validation", h.schemaRegistryURL, topicName) + } + + // Schema Registry is available - try to fetch existing schema + keyRecordType, valueRecordType, err := h.fetchSchemaForTopic(topicName) + if err != nil { + // Check if this is a connection error vs schema not found + if h.isSchemaRegistryConnectionError(err) { + return fmt.Errorf("Schema Registry is unavailable: %w", err) + } + // Schema not found - this is an error when schema management is enforced + return fmt.Errorf("schema is required for topic %s but no schema found in Schema Registry", topicName) + } + + if keyRecordType != nil || valueRecordType != nil { + // Create topic with schema from Schema Registry + return h.seaweedMQHandler.CreateTopicWithSchemas(topicName, partitions, keyRecordType, valueRecordType) + } + + // No schemas found - this is an error when schema management is enforced + return fmt.Errorf("schema is required for topic %s but no schema found in Schema Registry", topicName) + } + + // Schema Registry URL not configured - create topic without schema (backward compatibility) + return h.seaweedMQHandler.CreateTopic(topicName, partitions) +} + +// createTopicWithDefaultFlexibleSchema creates a topic with a flexible default schema +// that can handle both Avro and JSON messages when schema management is enabled +func (h *Handler) createTopicWithDefaultFlexibleSchema(topicName string, partitions int32) error { + // System topics like _schemas should be PLAIN Kafka topics without schema management + // Schema Registry uses _schemas to STORE schemas, so it can't have schema management itself + + glog.V(1).Infof("Creating system topic %s as PLAIN topic (no schema management)", topicName) + return h.seaweedMQHandler.CreateTopic(topicName, partitions) +} + +// fetchSchemaForTopic attempts to fetch schema information for a topic from Schema Registry +// Returns key and value RecordTypes if schemas are found +func (h *Handler) fetchSchemaForTopic(topicName string) (*schema_pb.RecordType, *schema_pb.RecordType, error) { + if h.schemaManager == nil { + return nil, nil, fmt.Errorf("schema manager not available") + } + + var keyRecordType *schema_pb.RecordType + var valueRecordType *schema_pb.RecordType + var lastConnectionError error + + // Try to fetch value schema using standard Kafka naming convention: -value + valueSubject := topicName + "-value" + cachedSchema, err := h.schemaManager.GetLatestSchema(valueSubject) + if err != nil { + // Check if this is a connection error (Schema Registry unavailable) + if h.isSchemaRegistryConnectionError(err) { + lastConnectionError = err + } + // Not found or connection error - continue to check key schema + } else if cachedSchema != nil { + + // Convert schema to RecordType + recordType, err := h.convertSchemaToRecordType(cachedSchema.Schema, cachedSchema.LatestID) + if err == nil { + valueRecordType = recordType + // Store schema configuration for later use + h.storeTopicSchemaConfig(topicName, cachedSchema.LatestID, schema.FormatAvro) + } else { + } + } + + // Try to fetch key schema (optional) + keySubject := topicName + "-key" + cachedKeySchema, keyErr := h.schemaManager.GetLatestSchema(keySubject) + if keyErr != nil { + if h.isSchemaRegistryConnectionError(keyErr) { + lastConnectionError = keyErr + } + // Not found or connection error - key schema is optional + } else if cachedKeySchema != nil { + + // Convert schema to RecordType + recordType, err := h.convertSchemaToRecordType(cachedKeySchema.Schema, cachedKeySchema.LatestID) + if err == nil { + keyRecordType = recordType + // Store key schema configuration for later use + h.storeTopicKeySchemaConfig(topicName, cachedKeySchema.LatestID, schema.FormatAvro) + } else { + } + } + + // If we encountered connection errors, fail fast + if lastConnectionError != nil && keyRecordType == nil && valueRecordType == nil { + return nil, nil, fmt.Errorf("Schema Registry is unavailable: %w", lastConnectionError) + } + + // Return error if no schemas found (but Schema Registry was reachable) + if keyRecordType == nil && valueRecordType == nil { + return nil, nil, fmt.Errorf("no schemas found for topic %s", topicName) + } + + return keyRecordType, valueRecordType, nil +} + +// isSchemaRegistryConnectionError determines if an error is due to Schema Registry being unavailable +// vs a schema not being found (404) +func (h *Handler) isSchemaRegistryConnectionError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + + // Connection errors (network issues, DNS resolution, etc.) + if strings.Contains(errStr, "failed to fetch") && + (strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "no such host") || + strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "network is unreachable")) { + return true + } + + // HTTP 5xx errors (server errors) + if strings.Contains(errStr, "schema registry error 5") { + return true + } + + // HTTP 404 errors are "schema not found", not connection errors + if strings.Contains(errStr, "schema registry error 404") { + return false + } + + // Other HTTP errors (401, 403, etc.) should be treated as connection/config issues + if strings.Contains(errStr, "schema registry error") { + return true + } + + return false +} + +// convertSchemaToRecordType converts a schema string to a RecordType +func (h *Handler) convertSchemaToRecordType(schemaStr string, schemaID uint32) (*schema_pb.RecordType, error) { + // Get the cached schema to determine format + cachedSchema, err := h.schemaManager.GetSchemaByID(schemaID) + if err != nil { + return nil, fmt.Errorf("failed to get cached schema: %w", err) + } + + // Create appropriate decoder and infer RecordType based on format + switch cachedSchema.Format { + case schema.FormatAvro: + // Create Avro decoder and infer RecordType + decoder, err := schema.NewAvroDecoder(schemaStr) + if err != nil { + return nil, fmt.Errorf("failed to create Avro decoder: %w", err) + } + return decoder.InferRecordType() + + case schema.FormatJSONSchema: + // Create JSON Schema decoder and infer RecordType + decoder, err := schema.NewJSONSchemaDecoder(schemaStr) + if err != nil { + return nil, fmt.Errorf("failed to create JSON Schema decoder: %w", err) + } + return decoder.InferRecordType() + + case schema.FormatProtobuf: + // For Protobuf, we need the binary descriptor, not string + // This is a limitation - Protobuf schemas in Schema Registry are typically stored as binary descriptors + return nil, fmt.Errorf("Protobuf schema conversion from string not supported - requires binary descriptor") + + default: + return nil, fmt.Errorf("unsupported schema format: %v", cachedSchema.Format) + } +} + +// isSystemTopic checks if a topic is a Kafka system topic +func isSystemTopic(topicName string) bool { + systemTopics := []string{ + "_schemas", + "__consumer_offsets", + "__transaction_state", + "_confluent-ksql-default__command_topic", + "_confluent-metrics", + } + + for _, systemTopic := range systemTopics { + if topicName == systemTopic { + return true + } + } + + // Check for topics starting with underscore (common system topic pattern) + return len(topicName) > 0 && topicName[0] == '_' +} + +// getConnectionContextFromRequest extracts the connection context from the request context +func (h *Handler) getConnectionContextFromRequest(ctx context.Context) *ConnectionContext { + if connCtx, ok := ctx.Value(connContextKey).(*ConnectionContext); ok { + return connCtx + } + return nil +} + +// getOrCreatePartitionReader gets an existing partition reader or creates a new one +// This maintains persistent readers per connection that stream forward, eliminating +// repeated offset lookups and reducing broker CPU load +func (h *Handler) getOrCreatePartitionReader(ctx context.Context, connCtx *ConnectionContext, key TopicPartitionKey, startOffset int64) *partitionReader { + // Try to get existing reader + if val, ok := connCtx.partitionReaders.Load(key); ok { + return val.(*partitionReader) + } + + // Create new reader + reader := newPartitionReader(ctx, h, connCtx, key.Topic, key.Partition, startOffset) + + // Store it (handle race condition where another goroutine created one) + if actual, loaded := connCtx.partitionReaders.LoadOrStore(key, reader); loaded { + // Another goroutine created it first, close ours and use theirs + reader.close() + return actual.(*partitionReader) + } + + return reader +} + +// cleanupPartitionReaders closes all partition readers for a connection +// Called when connection is closing +func cleanupPartitionReaders(connCtx *ConnectionContext) { + if connCtx == nil { + return + } + + connCtx.partitionReaders.Range(func(key, value interface{}) bool { + if reader, ok := value.(*partitionReader); ok { + reader.close() + } + return true // Continue iteration + }) + + glog.V(4).Infof("[%s] Cleaned up partition readers", connCtx.ConnectionID) +} diff --git a/weed/query/engine/engine.go b/weed/query/engine/engine.go new file mode 100644 index 0000000..7a1c783 --- /dev/null +++ b/weed/query/engine/engine.go @@ -0,0 +1,5599 @@ +package engine + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math" + "math/big" + "regexp" + "strconv" + "strings" + "time" + + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/mq/schema" + "github.com/seaweedfs/seaweedfs/weed/mq/topic" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/pb/mq_pb" + "github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" + "github.com/seaweedfs/seaweedfs/weed/query/sqltypes" + "github.com/seaweedfs/seaweedfs/weed/util" + util_http "github.com/seaweedfs/seaweedfs/weed/util/http" + "google.golang.org/protobuf/proto" +) + +// SQL Function Name Constants +const ( + // Aggregation Functions + FuncCOUNT = "COUNT" + FuncSUM = "SUM" + FuncAVG = "AVG" + FuncMIN = "MIN" + FuncMAX = "MAX" + + // String Functions + FuncUPPER = "UPPER" + FuncLOWER = "LOWER" + FuncLENGTH = "LENGTH" + FuncTRIM = "TRIM" + FuncBTRIM = "BTRIM" // CockroachDB's internal name for TRIM + FuncLTRIM = "LTRIM" + FuncRTRIM = "RTRIM" + FuncSUBSTRING = "SUBSTRING" + FuncLEFT = "LEFT" + FuncRIGHT = "RIGHT" + FuncCONCAT = "CONCAT" + + // DateTime Functions + FuncCURRENT_DATE = "CURRENT_DATE" + FuncCURRENT_TIME = "CURRENT_TIME" + FuncCURRENT_TIMESTAMP = "CURRENT_TIMESTAMP" + FuncNOW = "NOW" + FuncEXTRACT = "EXTRACT" + FuncDATE_TRUNC = "DATE_TRUNC" + + // PostgreSQL uses EXTRACT(part FROM date) instead of convenience functions like YEAR(), MONTH(), etc. +) + +// PostgreSQL-compatible SQL AST types +type Statement interface { + isStatement() +} + +type ShowStatement struct { + Type string // "databases", "tables", "columns" + Table string // for SHOW COLUMNS FROM table + Schema string // for database context + OnTable NameRef // for compatibility with existing code that checks OnTable +} + +func (s *ShowStatement) isStatement() {} + +type UseStatement struct { + Database string // database name to switch to +} + +func (u *UseStatement) isStatement() {} + +type DDLStatement struct { + Action string // "create", "alter", "drop" + NewName NameRef + TableSpec *TableSpec +} + +type NameRef struct { + Name StringGetter + Qualifier StringGetter +} + +type StringGetter interface { + String() string +} + +type stringValue string + +func (s stringValue) String() string { return string(s) } + +type TableSpec struct { + Columns []ColumnDef +} + +type ColumnDef struct { + Name StringGetter + Type TypeRef +} + +type TypeRef struct { + Type string +} + +func (d *DDLStatement) isStatement() {} + +type SelectStatement struct { + SelectExprs []SelectExpr + From []TableExpr + Where *WhereClause + Limit *LimitClause + WindowFunctions []*WindowFunction +} + +type WhereClause struct { + Expr ExprNode +} + +type LimitClause struct { + Rowcount ExprNode + Offset ExprNode +} + +func (s *SelectStatement) isStatement() {} + +// Window function types for time-series analytics +type WindowSpec struct { + PartitionBy []ExprNode + OrderBy []*OrderByClause +} + +type WindowFunction struct { + Function string // ROW_NUMBER, RANK, LAG, LEAD + Args []ExprNode // Function arguments + Over *WindowSpec + Alias string // Column alias for the result +} + +type OrderByClause struct { + Column string + Order string // ASC or DESC +} + +type SelectExpr interface { + isSelectExpr() +} + +type StarExpr struct{} + +func (s *StarExpr) isSelectExpr() {} + +type AliasedExpr struct { + Expr ExprNode + As AliasRef +} + +type AliasRef interface { + IsEmpty() bool + String() string +} + +type aliasValue string + +func (a aliasValue) IsEmpty() bool { return string(a) == "" } +func (a aliasValue) String() string { return string(a) } +func (a *AliasedExpr) isSelectExpr() {} + +type TableExpr interface { + isTableExpr() +} + +type AliasedTableExpr struct { + Expr interface{} +} + +func (a *AliasedTableExpr) isTableExpr() {} + +type TableName struct { + Name StringGetter + Qualifier StringGetter +} + +type ExprNode interface { + isExprNode() +} + +type FuncExpr struct { + Name StringGetter + Exprs []SelectExpr +} + +func (f *FuncExpr) isExprNode() {} + +type ColName struct { + Name StringGetter +} + +func (c *ColName) isExprNode() {} + +// ArithmeticExpr represents arithmetic operations like id+user_id and string concatenation like name||suffix +type ArithmeticExpr struct { + Left ExprNode + Right ExprNode + Operator string // +, -, *, /, %, || +} + +func (a *ArithmeticExpr) isExprNode() {} + +type ComparisonExpr struct { + Left ExprNode + Right ExprNode + Operator string +} + +func (c *ComparisonExpr) isExprNode() {} + +type AndExpr struct { + Left ExprNode + Right ExprNode +} + +func (a *AndExpr) isExprNode() {} + +type OrExpr struct { + Left ExprNode + Right ExprNode +} + +func (o *OrExpr) isExprNode() {} + +type ParenExpr struct { + Expr ExprNode +} + +func (p *ParenExpr) isExprNode() {} + +type SQLVal struct { + Type int + Val []byte +} + +func (s *SQLVal) isExprNode() {} + +type ValTuple []ExprNode + +func (v ValTuple) isExprNode() {} + +type IntervalExpr struct { + Value string // The interval value (e.g., "1 hour", "30 minutes") + Unit string // The unit (parsed from value) +} + +func (i *IntervalExpr) isExprNode() {} + +type BetweenExpr struct { + Left ExprNode // The expression to test + From ExprNode // Lower bound (inclusive) + To ExprNode // Upper bound (inclusive) + Not bool // true for NOT BETWEEN +} + +func (b *BetweenExpr) isExprNode() {} + +type IsNullExpr struct { + Expr ExprNode // The expression to test for null +} + +func (i *IsNullExpr) isExprNode() {} + +type IsNotNullExpr struct { + Expr ExprNode // The expression to test for not null +} + +func (i *IsNotNullExpr) isExprNode() {} + +// SQLVal types +const ( + IntVal = iota + StrVal + FloatVal +) + +// Operator constants +const ( + CreateStr = "create" + AlterStr = "alter" + DropStr = "drop" + EqualStr = "=" + LessThanStr = "<" + GreaterThanStr = ">" + LessEqualStr = "<=" + GreaterEqualStr = ">=" + NotEqualStr = "!=" +) + +// parseIdentifier properly parses a potentially quoted identifier (database/table name) +func parseIdentifier(identifier string) string { + identifier = strings.TrimSpace(identifier) + identifier = strings.TrimSuffix(identifier, ";") // Remove trailing semicolon + + // Handle double quotes (PostgreSQL standard) + if len(identifier) >= 2 && identifier[0] == '"' && identifier[len(identifier)-1] == '"' { + return identifier[1 : len(identifier)-1] + } + + // Handle backticks (MySQL compatibility) + if len(identifier) >= 2 && identifier[0] == '`' && identifier[len(identifier)-1] == '`' { + return identifier[1 : len(identifier)-1] + } + + return identifier +} + +// ParseSQL parses PostgreSQL-compatible SQL statements using CockroachDB parser for SELECT queries +func ParseSQL(sql string) (Statement, error) { + sql = strings.TrimSpace(sql) + sqlUpper := strings.ToUpper(sql) + + // Handle USE statement + if strings.HasPrefix(sqlUpper, "USE ") { + parts := strings.Fields(sql) + if len(parts) < 2 { + return nil, fmt.Errorf("USE statement requires a database name") + } + // Parse the database name properly, handling quoted identifiers + dbName := parseIdentifier(strings.Join(parts[1:], " ")) + return &UseStatement{Database: dbName}, nil + } + + // Handle DESCRIBE/DESC statements as aliases for SHOW COLUMNS FROM + if strings.HasPrefix(sqlUpper, "DESCRIBE ") || strings.HasPrefix(sqlUpper, "DESC ") { + parts := strings.Fields(sql) + if len(parts) < 2 { + return nil, fmt.Errorf("DESCRIBE/DESC statement requires a table name") + } + + var tableName string + var database string + + // Get the raw table name (before parsing identifiers) + var rawTableName string + if len(parts) >= 3 && strings.ToUpper(parts[1]) == "TABLE" { + rawTableName = parts[2] + } else { + rawTableName = parts[1] + } + + // Parse database.table format first, then apply parseIdentifier to each part + if strings.Contains(rawTableName, ".") { + // Handle quoted database.table like "db"."table" + if strings.HasPrefix(rawTableName, "\"") || strings.HasPrefix(rawTableName, "`") { + // Find the closing quote and the dot + var quoteChar byte = '"' + if rawTableName[0] == '`' { + quoteChar = '`' + } + + // Find the matching closing quote + closingIndex := -1 + for i := 1; i < len(rawTableName); i++ { + if rawTableName[i] == quoteChar { + closingIndex = i + break + } + } + + if closingIndex != -1 && closingIndex+1 < len(rawTableName) && rawTableName[closingIndex+1] == '.' { + // Valid quoted database name + database = parseIdentifier(rawTableName[:closingIndex+1]) + tableName = parseIdentifier(rawTableName[closingIndex+2:]) + } else { + // Fall back to simple split then parse + dbTableParts := strings.SplitN(rawTableName, ".", 2) + database = parseIdentifier(dbTableParts[0]) + tableName = parseIdentifier(dbTableParts[1]) + } + } else { + // Simple case: no quotes, just split then parse + dbTableParts := strings.SplitN(rawTableName, ".", 2) + database = parseIdentifier(dbTableParts[0]) + tableName = parseIdentifier(dbTableParts[1]) + } + } else { + // No database.table format, just parse the table name + tableName = parseIdentifier(rawTableName) + } + + stmt := &ShowStatement{Type: "columns"} + stmt.OnTable.Name = stringValue(tableName) + if database != "" { + stmt.OnTable.Qualifier = stringValue(database) + } + return stmt, nil + } + + // Handle SHOW statements (keep custom parsing for these simple cases) + if strings.HasPrefix(sqlUpper, "SHOW DATABASES") || strings.HasPrefix(sqlUpper, "SHOW SCHEMAS") { + return &ShowStatement{Type: "databases"}, nil + } + if strings.HasPrefix(sqlUpper, "SHOW TABLES") { + stmt := &ShowStatement{Type: "tables"} + // Handle "SHOW TABLES FROM database" syntax + if strings.Contains(sqlUpper, "FROM") { + partsUpper := strings.Fields(sqlUpper) + partsOriginal := strings.Fields(sql) // Use original casing + for i, part := range partsUpper { + if part == "FROM" && i+1 < len(partsOriginal) { + // Parse the database name properly + dbName := parseIdentifier(partsOriginal[i+1]) + stmt.Schema = dbName // Set the Schema field for the test + stmt.OnTable.Name = stringValue(dbName) // Keep for compatibility + break + } + } + } + return stmt, nil + } + if strings.HasPrefix(sqlUpper, "SHOW COLUMNS FROM") { + // Parse "SHOW COLUMNS FROM table" or "SHOW COLUMNS FROM database.table" + parts := strings.Fields(sql) + if len(parts) < 4 { + return nil, fmt.Errorf("SHOW COLUMNS FROM statement requires a table name") + } + + // Get the raw table name (before parsing identifiers) + rawTableName := parts[3] + var tableName string + var database string + + // Parse database.table format first, then apply parseIdentifier to each part + if strings.Contains(rawTableName, ".") { + // Handle quoted database.table like "db"."table" + if strings.HasPrefix(rawTableName, "\"") || strings.HasPrefix(rawTableName, "`") { + // Find the closing quote and the dot + var quoteChar byte = '"' + if rawTableName[0] == '`' { + quoteChar = '`' + } + + // Find the matching closing quote + closingIndex := -1 + for i := 1; i < len(rawTableName); i++ { + if rawTableName[i] == quoteChar { + closingIndex = i + break + } + } + + if closingIndex != -1 && closingIndex+1 < len(rawTableName) && rawTableName[closingIndex+1] == '.' { + // Valid quoted database name + database = parseIdentifier(rawTableName[:closingIndex+1]) + tableName = parseIdentifier(rawTableName[closingIndex+2:]) + } else { + // Fall back to simple split then parse + dbTableParts := strings.SplitN(rawTableName, ".", 2) + database = parseIdentifier(dbTableParts[0]) + tableName = parseIdentifier(dbTableParts[1]) + } + } else { + // Simple case: no quotes, just split then parse + dbTableParts := strings.SplitN(rawTableName, ".", 2) + database = parseIdentifier(dbTableParts[0]) + tableName = parseIdentifier(dbTableParts[1]) + } + } else { + // No database.table format, just parse the table name + tableName = parseIdentifier(rawTableName) + } + + stmt := &ShowStatement{Type: "columns"} + stmt.OnTable.Name = stringValue(tableName) + if database != "" { + stmt.OnTable.Qualifier = stringValue(database) + } + return stmt, nil + } + + // Use CockroachDB parser for SELECT statements + if strings.HasPrefix(sqlUpper, "SELECT") { + parser := NewCockroachSQLParser() + return parser.ParseSQL(sql) + } + + return nil, UnsupportedFeatureError{ + Feature: fmt.Sprintf("statement type: %s", strings.Fields(sqlUpper)[0]), + Reason: "statement parsing not implemented", + } +} + +// debugModeKey is used to store debug mode flag in context +type debugModeKey struct{} + +// isDebugMode checks if we're in debug/explain mode +func isDebugMode(ctx context.Context) bool { + debug, ok := ctx.Value(debugModeKey{}).(bool) + return ok && debug +} + +// withDebugMode returns a context with debug mode enabled +func withDebugMode(ctx context.Context) context.Context { + return context.WithValue(ctx, debugModeKey{}, true) +} + +// LogBufferStart tracks the starting buffer index for a file +// Buffer indexes are monotonically increasing, count = len(chunks) +type LogBufferStart struct { + StartIndex int64 `json:"start_index"` // Starting buffer index (count = len(chunks)) +} + +// SQLEngine provides SQL query execution capabilities for SeaweedFS +// Assumptions: +// 1. MQ namespaces map directly to SQL databases +// 2. MQ topics map directly to SQL tables +// 3. Schema evolution is handled transparently with backward compatibility +// 4. Queries run against Parquet-stored MQ messages +type SQLEngine struct { + catalog *SchemaCatalog +} + +// NewSQLEngine creates a new SQL execution engine +// Uses master address for service discovery and initialization +func NewSQLEngine(masterAddress string) *SQLEngine { + // Initialize global HTTP client if not already done + // This is needed for reading partition data from the filer + if util_http.GetGlobalHttpClient() == nil { + util_http.InitGlobalHttpClient() + } + + return &SQLEngine{ + catalog: NewSchemaCatalog(masterAddress), + } +} + +// GetCatalog returns the schema catalog for external access +func (e *SQLEngine) GetCatalog() *SchemaCatalog { + return e.catalog +} + +// ExecuteSQL parses and executes a SQL statement +// Assumptions: +// 1. All SQL statements are PostgreSQL-compatible via pg_query_go +// 2. DDL operations (CREATE/ALTER/DROP) modify underlying MQ topics +// 3. DML operations (SELECT) query Parquet files directly +// 4. Error handling follows PostgreSQL conventions +func (e *SQLEngine) ExecuteSQL(ctx context.Context, sql string) (*QueryResult, error) { + startTime := time.Now() + + // Handle EXPLAIN as a special case + sqlTrimmed := strings.TrimSpace(sql) + sqlUpper := strings.ToUpper(sqlTrimmed) + if strings.HasPrefix(sqlUpper, "EXPLAIN") { + // Extract the actual query after EXPLAIN + actualSQL := strings.TrimSpace(sqlTrimmed[7:]) // Remove "EXPLAIN" + return e.executeExplain(ctx, actualSQL, startTime) + } + + // Parse the SQL statement using PostgreSQL parser + stmt, err := ParseSQL(sql) + if err != nil { + return &QueryResult{ + Error: fmt.Errorf("SQL parse error: %v", err), + }, err + } + + // Route to appropriate handler based on statement type + switch stmt := stmt.(type) { + case *ShowStatement: + return e.executeShowStatementWithDescribe(ctx, stmt) + case *UseStatement: + return e.executeUseStatement(ctx, stmt) + case *DDLStatement: + return e.executeDDLStatement(ctx, stmt) + case *SelectStatement: + return e.executeSelectStatement(ctx, stmt) + default: + err := fmt.Errorf("unsupported SQL statement type: %T", stmt) + return &QueryResult{Error: err}, err + } +} + +// executeExplain handles EXPLAIN statements by executing the query with plan tracking +func (e *SQLEngine) executeExplain(ctx context.Context, actualSQL string, startTime time.Time) (*QueryResult, error) { + // Enable debug mode for EXPLAIN queries + ctx = withDebugMode(ctx) + + // Parse the actual SQL statement using PostgreSQL parser + stmt, err := ParseSQL(actualSQL) + if err != nil { + return &QueryResult{ + Error: fmt.Errorf("SQL parse error in EXPLAIN query: %v", err), + }, err + } + + // Create execution plan + plan := &QueryExecutionPlan{ + QueryType: strings.ToUpper(strings.Fields(actualSQL)[0]), + DataSources: []string{}, + OptimizationsUsed: []string{}, + Details: make(map[string]interface{}), + } + + var result *QueryResult + + // Route to appropriate handler based on statement type (with plan tracking) + switch stmt := stmt.(type) { + case *SelectStatement: + result, err = e.executeSelectStatementWithPlan(ctx, stmt, plan) + if err != nil { + plan.Details["error"] = err.Error() + } + case *ShowStatement: + plan.QueryType = "SHOW" + plan.ExecutionStrategy = "metadata_only" + result, err = e.executeShowStatementWithDescribe(ctx, stmt) + default: + err := fmt.Errorf("EXPLAIN not supported for statement type: %T", stmt) + return &QueryResult{Error: err}, err + } + + // Calculate execution time + plan.ExecutionTimeMs = float64(time.Since(startTime).Nanoseconds()) / 1e6 + + // Format execution plan as result + return e.formatExecutionPlan(plan, result, err) +} + +// formatExecutionPlan converts execution plan to a hierarchical tree format for display +func (e *SQLEngine) formatExecutionPlan(plan *QueryExecutionPlan, originalResult *QueryResult, originalErr error) (*QueryResult, error) { + columns := []string{"Query Execution Plan"} + rows := [][]sqltypes.Value{} + + var planLines []string + + // Use new tree structure if available, otherwise fallback to legacy format + if plan.RootNode != nil { + planLines = e.buildTreePlan(plan, originalErr) + } else { + // Build legacy hierarchical plan display + planLines = e.buildHierarchicalPlan(plan, originalErr) + } + + for _, line := range planLines { + rows = append(rows, []sqltypes.Value{ + sqltypes.NewVarChar(line), + }) + } + + if originalErr != nil { + return &QueryResult{ + Columns: columns, + Rows: rows, + ExecutionPlan: plan, + Error: originalErr, + }, originalErr + } + + return &QueryResult{ + Columns: columns, + Rows: rows, + ExecutionPlan: plan, + }, nil +} + +// buildTreePlan creates the new tree-based execution plan display +func (e *SQLEngine) buildTreePlan(plan *QueryExecutionPlan, err error) []string { + var lines []string + + // Root header + lines = append(lines, fmt.Sprintf("%s Query (%s)", plan.QueryType, plan.ExecutionStrategy)) + + // Build the execution tree + if plan.RootNode != nil { + // Root execution node is always the last (and only) child of SELECT Query + treeLines := e.formatExecutionNode(plan.RootNode, "└── ", " ", true) + lines = append(lines, treeLines...) + } + + // Add error information if present + if err != nil { + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("Error: %v", err)) + } + + return lines +} + +// formatExecutionNode recursively formats execution tree nodes +func (e *SQLEngine) formatExecutionNode(node ExecutionNode, prefix, childPrefix string, isRoot bool) []string { + var lines []string + + description := node.GetDescription() + + // Format the current node + if isRoot { + lines = append(lines, fmt.Sprintf("%s%s", prefix, description)) + } else { + lines = append(lines, fmt.Sprintf("%s%s", prefix, description)) + } + + // Add node-specific details + switch n := node.(type) { + case *FileSourceNode: + lines = e.formatFileSourceDetails(lines, n, childPrefix, isRoot) + case *ScanOperationNode: + lines = e.formatScanOperationDetails(lines, n, childPrefix, isRoot) + case *MergeOperationNode: + lines = e.formatMergeOperationDetails(lines, n, childPrefix, isRoot) + } + + // Format children + children := node.GetChildren() + if len(children) > 0 { + for i, child := range children { + isLastChild := i == len(children)-1 + + var nextPrefix, nextChildPrefix string + if isLastChild { + nextPrefix = childPrefix + "└── " + nextChildPrefix = childPrefix + " " + } else { + nextPrefix = childPrefix + "├── " + nextChildPrefix = childPrefix + "│ " + } + + childLines := e.formatExecutionNode(child, nextPrefix, nextChildPrefix, false) + lines = append(lines, childLines...) + } + } + + return lines +} + +// formatFileSourceDetails adds details for file source nodes +func (e *SQLEngine) formatFileSourceDetails(lines []string, node *FileSourceNode, childPrefix string, isRoot bool) []string { + prefix := childPrefix + if isRoot { + prefix = "│ " + } + + // Add predicates + if len(node.Predicates) > 0 { + lines = append(lines, fmt.Sprintf("%s├── Predicates: %s", prefix, strings.Join(node.Predicates, " AND "))) + } + + // Add operations + if len(node.Operations) > 0 { + lines = append(lines, fmt.Sprintf("%s└── Operations: %s", prefix, strings.Join(node.Operations, " + "))) + } else if len(node.Predicates) == 0 { + lines = append(lines, fmt.Sprintf("%s└── Operation: full_scan", prefix)) + } + + return lines +} + +// formatScanOperationDetails adds details for scan operation nodes +func (e *SQLEngine) formatScanOperationDetails(lines []string, node *ScanOperationNode, childPrefix string, isRoot bool) []string { + prefix := childPrefix + if isRoot { + prefix = "│ " + } + + hasChildren := len(node.Children) > 0 + + // Add predicates if present + if len(node.Predicates) > 0 { + if hasChildren { + lines = append(lines, fmt.Sprintf("%s├── Predicates: %s", prefix, strings.Join(node.Predicates, " AND "))) + } else { + lines = append(lines, fmt.Sprintf("%s└── Predicates: %s", prefix, strings.Join(node.Predicates, " AND "))) + } + } + + return lines +} + +// formatMergeOperationDetails adds details for merge operation nodes +func (e *SQLEngine) formatMergeOperationDetails(lines []string, node *MergeOperationNode, childPrefix string, isRoot bool) []string { + hasChildren := len(node.Children) > 0 + + // Add merge strategy info only if we have children, with proper indentation + if strategy, exists := node.Details["merge_strategy"]; exists && hasChildren { + // Strategy should be indented as a detail of this node, before its children + lines = append(lines, fmt.Sprintf("%s├── Strategy: %v", childPrefix, strategy)) + } + + return lines +} + +// buildHierarchicalPlan creates a tree-like structure for the execution plan +func (e *SQLEngine) buildHierarchicalPlan(plan *QueryExecutionPlan, err error) []string { + var lines []string + + // Root node - Query type and strategy + lines = append(lines, fmt.Sprintf("%s Query (%s)", plan.QueryType, plan.ExecutionStrategy)) + + // Aggregations section (if present) + if len(plan.Aggregations) > 0 { + lines = append(lines, "├── Aggregations") + for i, agg := range plan.Aggregations { + if i == len(plan.Aggregations)-1 { + lines = append(lines, fmt.Sprintf("│ └── %s", agg)) + } else { + lines = append(lines, fmt.Sprintf("│ ├── %s", agg)) + } + } + } + + // Data Sources section + if len(plan.DataSources) > 0 { + hasMore := len(plan.OptimizationsUsed) > 0 || plan.TotalRowsProcessed > 0 || len(plan.Details) > 0 || err != nil + if hasMore { + lines = append(lines, "├── Data Sources") + } else { + lines = append(lines, "└── Data Sources") + } + + for i, source := range plan.DataSources { + prefix := "│ " + if !hasMore && i == len(plan.DataSources)-1 { + prefix = " " + } + + if i == len(plan.DataSources)-1 { + lines = append(lines, fmt.Sprintf("%s└── %s", prefix, e.formatDataSource(source))) + } else { + lines = append(lines, fmt.Sprintf("%s├── %s", prefix, e.formatDataSource(source))) + } + } + } + + // Optimizations section + if len(plan.OptimizationsUsed) > 0 { + hasMore := plan.TotalRowsProcessed > 0 || len(plan.Details) > 0 || err != nil + if hasMore { + lines = append(lines, "├── Optimizations") + } else { + lines = append(lines, "└── Optimizations") + } + + for i, opt := range plan.OptimizationsUsed { + prefix := "│ " + if !hasMore && i == len(plan.OptimizationsUsed)-1 { + prefix = " " + } + + if i == len(plan.OptimizationsUsed)-1 { + lines = append(lines, fmt.Sprintf("%s└── %s", prefix, e.formatOptimization(opt))) + } else { + lines = append(lines, fmt.Sprintf("%s├── %s", prefix, e.formatOptimization(opt))) + } + } + } + + // Check for data sources tree availability + partitionPaths, hasPartitions := plan.Details["partition_paths"].([]string) + parquetFiles, _ := plan.Details["parquet_files"].([]string) + liveLogFiles, _ := plan.Details["live_log_files"].([]string) + + // Statistics section + statisticsPresent := plan.PartitionsScanned > 0 || plan.ParquetFilesScanned > 0 || + plan.LiveLogFilesScanned > 0 || plan.TotalRowsProcessed > 0 + + if statisticsPresent { + // Check if there are sections after Statistics (Data Sources Tree, Details, Performance) + hasDataSourcesTree := hasPartitions && len(partitionPaths) > 0 + hasMoreAfterStats := hasDataSourcesTree || len(plan.Details) > 0 || err != nil || true // Performance is always present + if hasMoreAfterStats { + lines = append(lines, "├── Statistics") + } else { + lines = append(lines, "└── Statistics") + } + + stats := []string{} + if plan.PartitionsScanned > 0 { + stats = append(stats, fmt.Sprintf("Partitions Scanned: %d", plan.PartitionsScanned)) + } + if plan.ParquetFilesScanned > 0 { + stats = append(stats, fmt.Sprintf("Parquet Files: %d", plan.ParquetFilesScanned)) + } + if plan.LiveLogFilesScanned > 0 { + stats = append(stats, fmt.Sprintf("Live Log Files: %d", plan.LiveLogFilesScanned)) + } + // Always show row statistics for aggregations, even if 0 (to show fast path efficiency) + if resultsReturned, hasResults := plan.Details["results_returned"]; hasResults { + stats = append(stats, fmt.Sprintf("Rows Scanned: %d", plan.TotalRowsProcessed)) + stats = append(stats, fmt.Sprintf("Results Returned: %v", resultsReturned)) + + // Add fast path explanation when no rows were scanned + if plan.TotalRowsProcessed == 0 { + // Use the actual scan method from Details instead of hardcoding + if scanMethod, exists := plan.Details["scan_method"].(string); exists { + stats = append(stats, fmt.Sprintf("Scan Method: %s", scanMethod)) + } else { + stats = append(stats, "Scan Method: Metadata Only") + } + } + } else if plan.TotalRowsProcessed > 0 { + stats = append(stats, fmt.Sprintf("Rows Processed: %d", plan.TotalRowsProcessed)) + } + + // Broker buffer information + if plan.BrokerBufferQueried { + stats = append(stats, fmt.Sprintf("Broker Buffer Queried: Yes (%d messages)", plan.BrokerBufferMessages)) + if plan.BufferStartIndex > 0 { + stats = append(stats, fmt.Sprintf("Buffer Start Index: %d (deduplication enabled)", plan.BufferStartIndex)) + } + } + + for i, stat := range stats { + if hasMoreAfterStats { + // More sections after Statistics, so use │ prefix + if i == len(stats)-1 { + lines = append(lines, fmt.Sprintf("│ └── %s", stat)) + } else { + lines = append(lines, fmt.Sprintf("│ ├── %s", stat)) + } + } else { + // This is the last main section, so use space prefix for final item + if i == len(stats)-1 { + lines = append(lines, fmt.Sprintf(" └── %s", stat)) + } else { + lines = append(lines, fmt.Sprintf(" ├── %s", stat)) + } + } + } + } + + // Data Sources Tree section (if file paths are available) + if hasPartitions && len(partitionPaths) > 0 { + // Check if there are more sections after this + hasMore := len(plan.Details) > 0 || err != nil + if hasMore { + lines = append(lines, "├── Data Sources Tree") + } else { + lines = append(lines, "├── Data Sources Tree") // Performance always comes after + } + + // Build a tree structure for each partition + for i, partition := range partitionPaths { + isLastPartition := i == len(partitionPaths)-1 + + // Show partition directory + partitionPrefix := "├── " + if isLastPartition { + partitionPrefix = "└── " + } + lines = append(lines, fmt.Sprintf("│ %s%s/", partitionPrefix, partition)) + + // Show parquet files in this partition + partitionParquetFiles := make([]string, 0) + for _, file := range parquetFiles { + if strings.HasPrefix(file, partition+"/") { + fileName := file[len(partition)+1:] + partitionParquetFiles = append(partitionParquetFiles, fileName) + } + } + + // Show live log files in this partition + partitionLiveLogFiles := make([]string, 0) + for _, file := range liveLogFiles { + if strings.HasPrefix(file, partition+"/") { + fileName := file[len(partition)+1:] + partitionLiveLogFiles = append(partitionLiveLogFiles, fileName) + } + } + + // Display files with proper tree formatting + totalFiles := len(partitionParquetFiles) + len(partitionLiveLogFiles) + fileIndex := 0 + + // Display parquet files + for _, fileName := range partitionParquetFiles { + fileIndex++ + isLastFile := fileIndex == totalFiles && isLastPartition + + var filePrefix string + if isLastPartition { + if isLastFile { + filePrefix = " └── " + } else { + filePrefix = " ├── " + } + } else { + if isLastFile { + filePrefix = "│ └── " + } else { + filePrefix = "│ ├── " + } + } + lines = append(lines, fmt.Sprintf("│ %s%s (parquet)", filePrefix, fileName)) + } + + // Display live log files + for _, fileName := range partitionLiveLogFiles { + fileIndex++ + isLastFile := fileIndex == totalFiles && isLastPartition + + var filePrefix string + if isLastPartition { + if isLastFile { + filePrefix = " └── " + } else { + filePrefix = " ├── " + } + } else { + if isLastFile { + filePrefix = "│ └── " + } else { + filePrefix = "│ ├── " + } + } + lines = append(lines, fmt.Sprintf("│ %s%s (live log)", filePrefix, fileName)) + } + } + } + + // Details section + // Filter out details that are shown elsewhere + filteredDetails := make([]string, 0) + for key, value := range plan.Details { + // Skip keys that are already formatted and displayed in the Statistics section + if key != "results_returned" && key != "partition_paths" && key != "parquet_files" && key != "live_log_files" { + filteredDetails = append(filteredDetails, fmt.Sprintf("%s: %v", key, value)) + } + } + + if len(filteredDetails) > 0 { + // Performance is always present, so check if there are errors after Details + hasMore := err != nil + if hasMore { + lines = append(lines, "├── Details") + } else { + lines = append(lines, "├── Details") // Performance always comes after + } + + for i, detail := range filteredDetails { + if i == len(filteredDetails)-1 { + lines = append(lines, fmt.Sprintf("│ └── %s", detail)) + } else { + lines = append(lines, fmt.Sprintf("│ ├── %s", detail)) + } + } + } + + // Performance section (always present) + if err != nil { + lines = append(lines, "├── Performance") + lines = append(lines, fmt.Sprintf("│ └── Execution Time: %.3fms", plan.ExecutionTimeMs)) + lines = append(lines, "└── Error") + lines = append(lines, fmt.Sprintf(" └── %s", err.Error())) + } else { + lines = append(lines, "└── Performance") + lines = append(lines, fmt.Sprintf(" └── Execution Time: %.3fms", plan.ExecutionTimeMs)) + } + + return lines +} + +// formatDataSource provides user-friendly names for data sources +func (e *SQLEngine) formatDataSource(source string) string { + switch source { + case "parquet_stats": + return "Parquet Statistics (fast path)" + case "parquet_files": + return "Parquet Files (full scan)" + case "live_logs": + return "Live Log Files" + case "broker_buffer": + return "Broker Buffer (real-time)" + default: + return source + } +} + +// buildExecutionTree creates a tree representation of the query execution plan +func (e *SQLEngine) buildExecutionTree(plan *QueryExecutionPlan, stmt *SelectStatement) ExecutionNode { + // Extract WHERE clause predicates for pushdown analysis + var predicates []string + if stmt.Where != nil { + predicates = e.extractPredicateStrings(stmt.Where.Expr) + } + + // Check if we have detailed file information + partitionPaths, hasPartitions := plan.Details["partition_paths"].([]string) + parquetFiles, hasParquetFiles := plan.Details["parquet_files"].([]string) + liveLogFiles, hasLiveLogFiles := plan.Details["live_log_files"].([]string) + + if !hasPartitions || len(partitionPaths) == 0 { + // Fallback: create simple structure without file details + return &ScanOperationNode{ + ScanType: "hybrid_scan", + Description: fmt.Sprintf("Hybrid Scan (%s)", plan.ExecutionStrategy), + Predicates: predicates, + Details: map[string]interface{}{ + "note": "File details not available", + }, + } + } + + // Build file source nodes + var parquetNodes []ExecutionNode + var liveLogNodes []ExecutionNode + var brokerBufferNodes []ExecutionNode + + // Create parquet file nodes + if hasParquetFiles { + for _, filePath := range parquetFiles { + operations := e.determineParquetOperations(plan, filePath) + parquetNodes = append(parquetNodes, &FileSourceNode{ + FilePath: filePath, + SourceType: "parquet", + Predicates: predicates, + Operations: operations, + OptimizationHint: e.determineOptimizationHint(plan, "parquet"), + Details: map[string]interface{}{ + "format": "parquet", + }, + }) + } + } + + // Create live log file nodes + if hasLiveLogFiles { + for _, filePath := range liveLogFiles { + operations := e.determineLiveLogOperations(plan, filePath) + liveLogNodes = append(liveLogNodes, &FileSourceNode{ + FilePath: filePath, + SourceType: "live_log", + Predicates: predicates, + Operations: operations, + OptimizationHint: e.determineOptimizationHint(plan, "live_log"), + Details: map[string]interface{}{ + "format": "log_entry", + }, + }) + } + } + + // Create broker buffer node only if queried AND has unflushed messages + if plan.BrokerBufferQueried && plan.BrokerBufferMessages > 0 { + brokerBufferNodes = append(brokerBufferNodes, &FileSourceNode{ + FilePath: "broker_memory_buffer", + SourceType: "broker_buffer", + Predicates: predicates, + Operations: []string{"memory_scan"}, + OptimizationHint: "real_time", + Details: map[string]interface{}{ + "messages": plan.BrokerBufferMessages, + "buffer_start_idx": plan.BufferStartIndex, + }, + }) + } + + // Build the tree structure based on data sources + var scanNodes []ExecutionNode + + // Add parquet scan node ONLY if there are actual parquet files + if len(parquetNodes) > 0 { + scanNodes = append(scanNodes, &ScanOperationNode{ + ScanType: "parquet_scan", + Description: fmt.Sprintf("Parquet File Scan (%d files)", len(parquetNodes)), + Predicates: predicates, + Children: parquetNodes, + Details: map[string]interface{}{ + "files_count": len(parquetNodes), + "pushdown": "column_projection + predicate_filtering", + }, + }) + } + + // Add live log scan node ONLY if there are actual live log files + if len(liveLogNodes) > 0 { + scanNodes = append(scanNodes, &ScanOperationNode{ + ScanType: "live_log_scan", + Description: fmt.Sprintf("Live Log Scan (%d files)", len(liveLogNodes)), + Predicates: predicates, + Children: liveLogNodes, + Details: map[string]interface{}{ + "files_count": len(liveLogNodes), + "pushdown": "predicate_filtering", + }, + }) + } + + // Add broker buffer scan node ONLY if buffer was actually queried + if len(brokerBufferNodes) > 0 { + scanNodes = append(scanNodes, &ScanOperationNode{ + ScanType: "broker_buffer_scan", + Description: "Real-time Buffer Scan", + Predicates: predicates, + Children: brokerBufferNodes, + Details: map[string]interface{}{ + "real_time": true, + }, + }) + } + + // Debug: Check what we actually have + totalFileNodes := len(parquetNodes) + len(liveLogNodes) + len(brokerBufferNodes) + if totalFileNodes == 0 { + // No actual files found, return simple fallback + return &ScanOperationNode{ + ScanType: "hybrid_scan", + Description: fmt.Sprintf("Hybrid Scan (%s)", plan.ExecutionStrategy), + Predicates: predicates, + Details: map[string]interface{}{ + "note": "No source files discovered", + }, + } + } + + // If no scan nodes, return a fallback structure + if len(scanNodes) == 0 { + return &ScanOperationNode{ + ScanType: "hybrid_scan", + Description: fmt.Sprintf("Hybrid Scan (%s)", plan.ExecutionStrategy), + Predicates: predicates, + Details: map[string]interface{}{ + "note": "No file details available", + }, + } + } + + // If only one scan type, return it directly + if len(scanNodes) == 1 { + return scanNodes[0] + } + + // Multiple scan types - need merge operation + return &MergeOperationNode{ + OperationType: "chronological_merge", + Description: "Chronological Merge (time-ordered)", + Children: scanNodes, + Details: map[string]interface{}{ + "merge_strategy": "timestamp_based", + "sources_count": len(scanNodes), + }, + } +} + +// extractPredicateStrings extracts predicate descriptions from WHERE clause +func (e *SQLEngine) extractPredicateStrings(expr ExprNode) []string { + var predicates []string + e.extractPredicateStringsRecursive(expr, &predicates) + return predicates +} + +func (e *SQLEngine) extractPredicateStringsRecursive(expr ExprNode, predicates *[]string) { + switch exprType := expr.(type) { + case *ComparisonExpr: + *predicates = append(*predicates, fmt.Sprintf("%s %s %s", + e.exprToString(exprType.Left), exprType.Operator, e.exprToString(exprType.Right))) + case *IsNullExpr: + *predicates = append(*predicates, fmt.Sprintf("%s IS NULL", e.exprToString(exprType.Expr))) + case *IsNotNullExpr: + *predicates = append(*predicates, fmt.Sprintf("%s IS NOT NULL", e.exprToString(exprType.Expr))) + case *AndExpr: + e.extractPredicateStringsRecursive(exprType.Left, predicates) + e.extractPredicateStringsRecursive(exprType.Right, predicates) + case *OrExpr: + e.extractPredicateStringsRecursive(exprType.Left, predicates) + e.extractPredicateStringsRecursive(exprType.Right, predicates) + case *ParenExpr: + e.extractPredicateStringsRecursive(exprType.Expr, predicates) + } +} + +func (e *SQLEngine) exprToString(expr ExprNode) string { + switch exprType := expr.(type) { + case *ColName: + return exprType.Name.String() + default: + // For now, return a simplified representation + return fmt.Sprintf("%T", expr) + } +} + +// determineParquetOperations determines what operations will be performed on parquet files +func (e *SQLEngine) determineParquetOperations(plan *QueryExecutionPlan, filePath string) []string { + var operations []string + + // Check for column projection + if contains(plan.OptimizationsUsed, "column_projection") { + operations = append(operations, "column_projection") + } + + // Check for predicate pushdown + if contains(plan.OptimizationsUsed, "predicate_pushdown") { + operations = append(operations, "predicate_pushdown") + } + + // Check for statistics usage + if contains(plan.OptimizationsUsed, "parquet_statistics") || plan.ExecutionStrategy == "hybrid_fast_path" { + operations = append(operations, "statistics_skip") + } else { + operations = append(operations, "row_group_scan") + } + + if len(operations) == 0 { + operations = append(operations, "full_scan") + } + + return operations +} + +// determineLiveLogOperations determines what operations will be performed on live log files +func (e *SQLEngine) determineLiveLogOperations(plan *QueryExecutionPlan, filePath string) []string { + var operations []string + + // Live logs typically require sequential scan + operations = append(operations, "sequential_scan") + + // Check for predicate filtering + if contains(plan.OptimizationsUsed, "predicate_pushdown") { + operations = append(operations, "predicate_filtering") + } + + return operations +} + +// determineOptimizationHint determines the optimization hint for a data source +func (e *SQLEngine) determineOptimizationHint(plan *QueryExecutionPlan, sourceType string) string { + switch plan.ExecutionStrategy { + case "hybrid_fast_path": + if sourceType == "parquet" { + return "statistics_only" + } + return "minimal_scan" + case "full_scan": + return "full_scan" + case "column_projection": + return "column_filter" + default: + return "" + } +} + +// Helper function to check if slice contains string +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// collectLiveLogFileNames collects live log file names from a partition directory +func (e *SQLEngine) collectLiveLogFileNames(filerClient filer_pb.FilerClient, partitionPath string) ([]string, error) { + var liveLogFiles []string + + err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + // List all files in partition directory + request := &filer_pb.ListEntriesRequest{ + Directory: partitionPath, + Prefix: "", + StartFromFileName: "", + InclusiveStartFrom: false, + Limit: 10000, // reasonable limit + } + + stream, err := client.ListEntries(context.Background(), request) + if err != nil { + return err + } + + for { + resp, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + return err + } + + entry := resp.Entry + if entry != nil && !entry.IsDirectory { + // Check if this is a log file (not a parquet file) + fileName := entry.Name + if !strings.HasSuffix(fileName, ".parquet") && !strings.HasSuffix(fileName, ".metadata") { + liveLogFiles = append(liveLogFiles, fileName) + } + } + } + + return nil + }) + + if err != nil { + return nil, err + } + + return liveLogFiles, nil +} + +// formatOptimization provides user-friendly names for optimizations +func (e *SQLEngine) formatOptimization(opt string) string { + switch opt { + case "parquet_statistics": + return "Parquet Statistics Usage" + case "live_log_counting": + return "Live Log Row Counting" + case "deduplication": + return "Duplicate Data Avoidance" + case "predicate_pushdown": + return "WHERE Clause Pushdown" + case "column_statistics_pruning": + return "Column Statistics File Pruning" + case "column_projection": + return "Column Selection" + case "limit_pushdown": + return "LIMIT Optimization" + default: + return opt + } +} + +// executeUseStatement handles USE database statements to switch current database context +func (e *SQLEngine) executeUseStatement(ctx context.Context, stmt *UseStatement) (*QueryResult, error) { + // Validate database name + if stmt.Database == "" { + err := fmt.Errorf("database name cannot be empty") + return &QueryResult{Error: err}, err + } + + // Set the current database in the catalog + e.catalog.SetCurrentDatabase(stmt.Database) + + // Return success message + result := &QueryResult{ + Columns: []string{"message"}, + Rows: [][]sqltypes.Value{ + {sqltypes.MakeString([]byte(fmt.Sprintf("Database changed to: %s", stmt.Database)))}, + }, + Error: nil, + } + return result, nil +} + +// executeDDLStatement handles CREATE operations only +// Note: ALTER TABLE and DROP TABLE are not supported to protect topic data +func (e *SQLEngine) executeDDLStatement(ctx context.Context, stmt *DDLStatement) (*QueryResult, error) { + switch stmt.Action { + case CreateStr: + return e.createTable(ctx, stmt) + case AlterStr: + err := fmt.Errorf("ALTER TABLE is not supported") + return &QueryResult{Error: err}, err + case DropStr: + err := fmt.Errorf("DROP TABLE is not supported") + return &QueryResult{Error: err}, err + default: + err := fmt.Errorf("unsupported DDL action: %s", stmt.Action) + return &QueryResult{Error: err}, err + } +} + +// executeSelectStatementWithPlan handles SELECT queries with execution plan tracking +func (e *SQLEngine) executeSelectStatementWithPlan(ctx context.Context, stmt *SelectStatement, plan *QueryExecutionPlan) (*QueryResult, error) { + // Initialize plan details once + if plan != nil && plan.Details == nil { + plan.Details = make(map[string]interface{}) + } + // Parse aggregations to populate plan + var aggregations []AggregationSpec + hasAggregations := false + selectAll := false + + for _, selectExpr := range stmt.SelectExprs { + switch expr := selectExpr.(type) { + case *StarExpr: + selectAll = true + case *AliasedExpr: + switch col := expr.Expr.(type) { + case *FuncExpr: + // This is an aggregation function + aggSpec, err := e.parseAggregationFunction(col, expr) + if err != nil { + return &QueryResult{Error: err}, err + } + if aggSpec != nil { + aggregations = append(aggregations, *aggSpec) + hasAggregations = true + plan.Aggregations = append(plan.Aggregations, aggSpec.Function+"("+aggSpec.Column+")") + } + } + } + } + + // Execute the query (handle aggregations specially for plan tracking) + var result *QueryResult + var err error + + // Extract table information for execution (needed for both aggregation and regular queries) + var database, tableName string + if len(stmt.From) == 1 { + if table, ok := stmt.From[0].(*AliasedTableExpr); ok { + if tableExpr, ok := table.Expr.(TableName); ok { + tableName = tableExpr.Name.String() + if tableExpr.Qualifier != nil && tableExpr.Qualifier.String() != "" { + database = tableExpr.Qualifier.String() + } + } + } + } + + // Use current database if not specified + if database == "" { + database = e.catalog.currentDatabase + if database == "" { + database = "default" + } + } + + // CRITICAL FIX: Always use HybridMessageScanner for ALL queries to read both flushed and unflushed data + // Create hybrid scanner for both aggregation and regular SELECT queries + var filerClient filer_pb.FilerClient + if e.catalog.brokerClient != nil { + filerClient, err = e.catalog.brokerClient.GetFilerClient() + if err != nil { + return &QueryResult{Error: err}, err + } + } + + hybridScanner, err := NewHybridMessageScanner(filerClient, e.catalog.brokerClient, database, tableName, e) + if err != nil { + return &QueryResult{Error: err}, err + } + + if hasAggregations { + // Execute aggregation query with plan tracking + result, err = e.executeAggregationQueryWithPlan(ctx, hybridScanner, aggregations, stmt, plan) + } else { + // CRITICAL FIX: Use HybridMessageScanner for regular SELECT queries too + // This ensures both flushed and unflushed data are read + result, err = e.executeRegularSelectWithHybridScanner(ctx, hybridScanner, stmt, plan) + } + + if err == nil && result != nil { + // Extract table name for use in execution strategy determination + var tableName string + if len(stmt.From) == 1 { + if table, ok := stmt.From[0].(*AliasedTableExpr); ok { + if tableExpr, ok := table.Expr.(TableName); ok { + tableName = tableExpr.Name.String() + } + } + } + + // Try to get topic information for partition count and row processing stats + if tableName != "" { + // Try to discover partitions for statistics + if partitions, discoverErr := e.discoverTopicPartitions("test", tableName); discoverErr == nil { + plan.PartitionsScanned = len(partitions) + } + + // For aggregations, determine actual processing based on execution strategy + if hasAggregations { + plan.Details["results_returned"] = len(result.Rows) + + // Determine actual work done based on execution strategy + if stmt.Where == nil { + // Use the same logic as actual execution to determine if fast path was used + var filerClient filer_pb.FilerClient + if e.catalog.brokerClient != nil { + filerClient, _ = e.catalog.brokerClient.GetFilerClient() + } + + hybridScanner, scannerErr := NewHybridMessageScanner(filerClient, e.catalog.brokerClient, "test", tableName, e) + var canUseFastPath bool + if scannerErr == nil { + // Test if fast path can be used (same as actual execution) + _, canOptimize := e.tryFastParquetAggregation(ctx, hybridScanner, aggregations) + canUseFastPath = canOptimize + } else { + // Fallback to simple check + canUseFastPath = true + for _, spec := range aggregations { + if !e.canUseParquetStatsForAggregation(spec) { + canUseFastPath = false + break + } + } + } + + if canUseFastPath { + // Fast path: minimal scanning (only live logs that weren't converted) + if actualScanCount, countErr := e.getActualRowsScannedForFastPath(ctx, "test", tableName); countErr == nil { + plan.TotalRowsProcessed = actualScanCount + } else { + plan.TotalRowsProcessed = 0 // Parquet stats only, no scanning + } + } else { + // Full scan: count all rows + if actualRowCount, countErr := e.getTopicTotalRowCount(ctx, "test", tableName); countErr == nil { + plan.TotalRowsProcessed = actualRowCount + } else { + plan.TotalRowsProcessed = int64(len(result.Rows)) + plan.Details["note"] = "scan_count_unavailable" + } + } + } else { + // With WHERE clause: full scan required + if actualRowCount, countErr := e.getTopicTotalRowCount(ctx, "test", tableName); countErr == nil { + plan.TotalRowsProcessed = actualRowCount + } else { + plan.TotalRowsProcessed = int64(len(result.Rows)) + plan.Details["note"] = "scan_count_unavailable" + } + } + } else { + // For non-aggregations, result count is meaningful + plan.TotalRowsProcessed = int64(len(result.Rows)) + } + } + + // Determine execution strategy based on query type (reuse fast path detection from above) + if hasAggregations { + // Skip execution strategy determination if plan was already populated by aggregation execution + // This prevents overwriting the correctly built plan from BuildAggregationPlan + if plan.ExecutionStrategy == "" { + // For aggregations, determine if fast path conditions are met + if stmt.Where == nil { + // Reuse the same logic used above for row counting + var canUseFastPath bool + if tableName != "" { + var filerClient filer_pb.FilerClient + if e.catalog.brokerClient != nil { + filerClient, _ = e.catalog.brokerClient.GetFilerClient() + } + + if filerClient != nil { + hybridScanner, scannerErr := NewHybridMessageScanner(filerClient, e.catalog.brokerClient, "test", tableName, e) + if scannerErr == nil { + // Test if fast path can be used (same as actual execution) + _, canOptimize := e.tryFastParquetAggregation(ctx, hybridScanner, aggregations) + canUseFastPath = canOptimize + } else { + canUseFastPath = false + } + } else { + // Fallback check + canUseFastPath = true + for _, spec := range aggregations { + if !e.canUseParquetStatsForAggregation(spec) { + canUseFastPath = false + break + } + } + } + } else { + canUseFastPath = false + } + + if canUseFastPath { + plan.ExecutionStrategy = "hybrid_fast_path" + plan.OptimizationsUsed = append(plan.OptimizationsUsed, "parquet_statistics", "live_log_counting", "deduplication") + plan.DataSources = []string{"parquet_stats", "live_logs"} + } else { + plan.ExecutionStrategy = "full_scan" + plan.DataSources = []string{"live_logs", "parquet_files"} + } + } else { + plan.ExecutionStrategy = "full_scan" + plan.DataSources = []string{"live_logs", "parquet_files"} + plan.OptimizationsUsed = append(plan.OptimizationsUsed, "predicate_pushdown") + } + } + } else { + // For regular SELECT queries + if selectAll { + plan.ExecutionStrategy = "hybrid_scan" + plan.DataSources = []string{"live_logs", "parquet_files"} + } else { + plan.ExecutionStrategy = "column_projection" + plan.DataSources = []string{"live_logs", "parquet_files"} + plan.OptimizationsUsed = append(plan.OptimizationsUsed, "column_projection") + } + } + + // Add WHERE clause information + if stmt.Where != nil { + // Only add predicate_pushdown if not already added + alreadyHasPredicate := false + for _, opt := range plan.OptimizationsUsed { + if opt == "predicate_pushdown" { + alreadyHasPredicate = true + break + } + } + if !alreadyHasPredicate { + plan.OptimizationsUsed = append(plan.OptimizationsUsed, "predicate_pushdown") + } + plan.Details["where_clause"] = "present" + } + + // Add LIMIT information + if stmt.Limit != nil { + plan.OptimizationsUsed = append(plan.OptimizationsUsed, "limit_pushdown") + if stmt.Limit.Rowcount != nil { + if limitExpr, ok := stmt.Limit.Rowcount.(*SQLVal); ok && limitExpr.Type == IntVal { + plan.Details["limit"] = string(limitExpr.Val) + } + } + } + } + + // Build execution tree after all plan details are populated + if err == nil && result != nil && plan != nil { + plan.RootNode = e.buildExecutionTree(plan, stmt) + } + + return result, err +} + +// executeSelectStatement handles SELECT queries +// Assumptions: +// 1. Queries run against Parquet files in MQ topics +// 2. Predicate pushdown is used for efficiency +// 3. Cross-topic joins are supported via partition-aware execution +func (e *SQLEngine) executeSelectStatement(ctx context.Context, stmt *SelectStatement) (*QueryResult, error) { + // Parse FROM clause to get table (topic) information + if len(stmt.From) != 1 { + err := fmt.Errorf("SELECT supports single table queries only") + return &QueryResult{Error: err}, err + } + + // Extract table reference + var database, tableName string + switch table := stmt.From[0].(type) { + case *AliasedTableExpr: + switch tableExpr := table.Expr.(type) { + case TableName: + tableName = tableExpr.Name.String() + if tableExpr.Qualifier != nil && tableExpr.Qualifier.String() != "" { + database = tableExpr.Qualifier.String() + } + default: + err := fmt.Errorf("unsupported table expression: %T", tableExpr) + return &QueryResult{Error: err}, err + } + default: + err := fmt.Errorf("unsupported FROM clause: %T", table) + return &QueryResult{Error: err}, err + } + + // Use current database context if not specified + if database == "" { + database = e.catalog.GetCurrentDatabase() + if database == "" { + database = "default" + } + } + + // Auto-discover and register topic if not already in catalog + if _, err := e.catalog.GetTableInfo(database, tableName); err != nil { + // Topic not in catalog, try to discover and register it + if regErr := e.discoverAndRegisterTopic(ctx, database, tableName); regErr != nil { + // Return error immediately for non-existent topics instead of falling back to sample data + return &QueryResult{Error: regErr}, regErr + } + } + + // Create HybridMessageScanner for the topic (reads both live logs + Parquet files) + // Get filerClient from broker connection (works with both real and mock brokers) + var filerClient filer_pb.FilerClient + var filerClientErr error + filerClient, filerClientErr = e.catalog.brokerClient.GetFilerClient() + if filerClientErr != nil { + // Return error if filer client is not available for topic access + return &QueryResult{Error: filerClientErr}, filerClientErr + } + + hybridScanner, err := NewHybridMessageScanner(filerClient, e.catalog.brokerClient, database, tableName, e) + if err != nil { + // Handle quiet topics gracefully: topics exist but have no active schema/brokers + if IsNoSchemaError(err) { + // Return empty result for quiet topics (normal in production environments) + return &QueryResult{ + Columns: []string{}, + Rows: [][]sqltypes.Value{}, + Database: database, + Table: tableName, + }, nil + } + // Return error for other access issues (truly non-existent topics, etc.) + topicErr := fmt.Errorf("failed to access topic %s.%s: %v", database, tableName, err) + return &QueryResult{Error: topicErr}, topicErr + } + + // Parse SELECT columns and detect aggregation functions + var columns []string + var aggregations []AggregationSpec + selectAll := false + hasAggregations := false + _ = hasAggregations // Used later in aggregation routing + // Track required base columns for arithmetic expressions + baseColumnsSet := make(map[string]bool) + + for _, selectExpr := range stmt.SelectExprs { + switch expr := selectExpr.(type) { + case *StarExpr: + selectAll = true + case *AliasedExpr: + switch col := expr.Expr.(type) { + case *ColName: + colName := col.Name.String() + + // Check if this "column" is actually an arithmetic expression with functions + if arithmeticExpr := e.parseColumnLevelCalculation(colName); arithmeticExpr != nil { + columns = append(columns, e.getArithmeticExpressionAlias(arithmeticExpr)) + e.extractBaseColumns(arithmeticExpr, baseColumnsSet) + } else { + columns = append(columns, colName) + baseColumnsSet[colName] = true + } + case *ArithmeticExpr: + // Handle arithmetic expressions like id+user_id and string concatenation like name||suffix + columns = append(columns, e.getArithmeticExpressionAlias(col)) + // Extract base columns needed for this arithmetic expression + e.extractBaseColumns(col, baseColumnsSet) + case *SQLVal: + // Handle string/numeric literals like 'good', 123, etc. + columns = append(columns, e.getSQLValAlias(col)) + case *FuncExpr: + // Distinguish between aggregation functions and string functions + funcName := strings.ToUpper(col.Name.String()) + if e.isAggregationFunction(funcName) { + // Handle aggregation functions + aggSpec, err := e.parseAggregationFunction(col, expr) + if err != nil { + return &QueryResult{Error: err}, err + } + aggregations = append(aggregations, *aggSpec) + hasAggregations = true + } else if e.isStringFunction(funcName) { + // Handle string functions like UPPER, LENGTH, etc. + columns = append(columns, e.getStringFunctionAlias(col)) + // Extract base columns needed for this string function + e.extractBaseColumnsFromFunction(col, baseColumnsSet) + } else if e.isDateTimeFunction(funcName) { + // Handle datetime functions like CURRENT_DATE, NOW, EXTRACT, DATE_TRUNC + columns = append(columns, e.getDateTimeFunctionAlias(col)) + // Extract base columns needed for this datetime function + e.extractBaseColumnsFromFunction(col, baseColumnsSet) + } else { + return &QueryResult{Error: fmt.Errorf("unsupported function: %s", funcName)}, fmt.Errorf("unsupported function: %s", funcName) + } + default: + err := fmt.Errorf("unsupported SELECT expression: %T", col) + return &QueryResult{Error: err}, err + } + default: + err := fmt.Errorf("unsupported SELECT expression: %T", expr) + return &QueryResult{Error: err}, err + } + } + + // If we have aggregations, use aggregation query path + if hasAggregations { + return e.executeAggregationQuery(ctx, hybridScanner, aggregations, stmt) + } + + // Parse WHERE clause for predicate pushdown + var predicate func(*schema_pb.RecordValue) bool + if stmt.Where != nil { + predicate, err = e.buildPredicateWithContext(stmt.Where.Expr, stmt.SelectExprs) + if err != nil { + return &QueryResult{Error: err}, err + } + } + + // Parse LIMIT and OFFSET clauses + // Use -1 to distinguish "no LIMIT" from "LIMIT 0" + limit := -1 + offset := 0 + if stmt.Limit != nil && stmt.Limit.Rowcount != nil { + switch limitExpr := stmt.Limit.Rowcount.(type) { + case *SQLVal: + if limitExpr.Type == IntVal { + var parseErr error + limit64, parseErr := strconv.ParseInt(string(limitExpr.Val), 10, 64) + if parseErr != nil { + return &QueryResult{Error: parseErr}, parseErr + } + if limit64 > math.MaxInt32 || limit64 < 0 { + return &QueryResult{Error: fmt.Errorf("LIMIT value %d is out of valid range", limit64)}, fmt.Errorf("LIMIT value %d is out of valid range", limit64) + } + limit = int(limit64) + } + } + } + + // Parse OFFSET clause if present + if stmt.Limit != nil && stmt.Limit.Offset != nil { + switch offsetExpr := stmt.Limit.Offset.(type) { + case *SQLVal: + if offsetExpr.Type == IntVal { + var parseErr error + offset64, parseErr := strconv.ParseInt(string(offsetExpr.Val), 10, 64) + if parseErr != nil { + return &QueryResult{Error: parseErr}, parseErr + } + if offset64 > math.MaxInt32 || offset64 < 0 { + return &QueryResult{Error: fmt.Errorf("OFFSET value %d is out of valid range", offset64)}, fmt.Errorf("OFFSET value %d is out of valid range", offset64) + } + offset = int(offset64) + } + } + } + + // Build hybrid scan options + // Extract time filters from WHERE clause to optimize scanning + startTimeNs, stopTimeNs := int64(0), int64(0) + if stmt.Where != nil { + startTimeNs, stopTimeNs = e.extractTimeFilters(stmt.Where.Expr) + } + + hybridScanOptions := HybridScanOptions{ + StartTimeNs: startTimeNs, // Extracted from WHERE clause time comparisons + StopTimeNs: stopTimeNs, // Extracted from WHERE clause time comparisons + Limit: limit, + Offset: offset, + Predicate: predicate, + } + + if !selectAll { + // Convert baseColumnsSet to slice for hybrid scan options + baseColumns := make([]string, 0, len(baseColumnsSet)) + for columnName := range baseColumnsSet { + baseColumns = append(baseColumns, columnName) + } + // Use base columns (not expression aliases) for data retrieval + if len(baseColumns) > 0 { + hybridScanOptions.Columns = baseColumns + } else { + // If no base columns found (shouldn't happen), use original columns + hybridScanOptions.Columns = columns + } + } + + // Execute the hybrid scan (live logs + Parquet files) + results, err := hybridScanner.Scan(ctx, hybridScanOptions) + if err != nil { + return &QueryResult{Error: err}, err + } + + // Convert to SQL result format + if selectAll { + if len(columns) > 0 { + // SELECT *, specific_columns - include both auto-discovered and explicit columns + return hybridScanner.ConvertToSQLResultWithMixedColumns(results, columns), nil + } else { + // SELECT * only - let converter determine all columns (excludes system columns) + columns = nil + return hybridScanner.ConvertToSQLResult(results, columns), nil + } + } + + // Handle custom column expressions (including arithmetic) + return e.ConvertToSQLResultWithExpressions(hybridScanner, results, stmt.SelectExprs), nil +} + +// executeRegularSelectWithHybridScanner handles regular SELECT queries using HybridMessageScanner +// This ensures both flushed and unflushed data are read, fixing the SQL empty results issue +func (e *SQLEngine) executeRegularSelectWithHybridScanner(ctx context.Context, hybridScanner *HybridMessageScanner, stmt *SelectStatement, plan *QueryExecutionPlan) (*QueryResult, error) { + // Parse SELECT expressions to determine columns and detect aggregations + var columns []string + var aggregations []AggregationSpec + var hasAggregations bool + selectAll := false + baseColumnsSet := make(map[string]bool) // Track base columns needed for expressions + + for _, selectExpr := range stmt.SelectExprs { + switch expr := selectExpr.(type) { + case *StarExpr: + selectAll = true + case *AliasedExpr: + switch col := expr.Expr.(type) { + case *ColName: + columnName := col.Name.String() + columns = append(columns, columnName) + baseColumnsSet[columnName] = true + case *FuncExpr: + funcName := strings.ToLower(col.Name.String()) + if e.isAggregationFunction(funcName) { + // Handle aggregation functions + aggSpec, err := e.parseAggregationFunction(col, expr) + if err != nil { + return &QueryResult{Error: err}, err + } + aggregations = append(aggregations, *aggSpec) + hasAggregations = true + } else if e.isStringFunction(funcName) { + // Handle string functions like UPPER, LENGTH, etc. + columns = append(columns, e.getStringFunctionAlias(col)) + // Extract base columns needed for this string function + e.extractBaseColumnsFromFunction(col, baseColumnsSet) + } else if e.isDateTimeFunction(funcName) { + // Handle datetime functions like CURRENT_DATE, NOW, EXTRACT, DATE_TRUNC + columns = append(columns, e.getDateTimeFunctionAlias(col)) + // Extract base columns needed for this datetime function + e.extractBaseColumnsFromFunction(col, baseColumnsSet) + } else { + return &QueryResult{Error: fmt.Errorf("unsupported function: %s", funcName)}, fmt.Errorf("unsupported function: %s", funcName) + } + default: + err := fmt.Errorf("unsupported SELECT expression: %T", col) + return &QueryResult{Error: err}, err + } + default: + err := fmt.Errorf("unsupported SELECT expression: %T", expr) + return &QueryResult{Error: err}, err + } + } + + // If we have aggregations, delegate to aggregation handler + if hasAggregations { + return e.executeAggregationQuery(ctx, hybridScanner, aggregations, stmt) + } + + // Parse WHERE clause for predicate pushdown + var predicate func(*schema_pb.RecordValue) bool + var err error + if stmt.Where != nil { + predicate, err = e.buildPredicateWithContext(stmt.Where.Expr, stmt.SelectExprs) + if err != nil { + return &QueryResult{Error: err}, err + } + } + + // Parse LIMIT and OFFSET clauses + // Use -1 to distinguish "no LIMIT" from "LIMIT 0" + limit := -1 + offset := 0 + if stmt.Limit != nil && stmt.Limit.Rowcount != nil { + switch limitExpr := stmt.Limit.Rowcount.(type) { + case *SQLVal: + if limitExpr.Type == IntVal { + var parseErr error + limit64, parseErr := strconv.ParseInt(string(limitExpr.Val), 10, 64) + if parseErr != nil { + return &QueryResult{Error: parseErr}, parseErr + } + if limit64 > math.MaxInt32 || limit64 < 0 { + return &QueryResult{Error: fmt.Errorf("LIMIT value %d is out of valid range", limit64)}, fmt.Errorf("LIMIT value %d is out of valid range", limit64) + } + limit = int(limit64) + } + } + } + + // Parse OFFSET clause if present + if stmt.Limit != nil && stmt.Limit.Offset != nil { + switch offsetExpr := stmt.Limit.Offset.(type) { + case *SQLVal: + if offsetExpr.Type == IntVal { + var parseErr error + offset64, parseErr := strconv.ParseInt(string(offsetExpr.Val), 10, 64) + if parseErr != nil { + return &QueryResult{Error: parseErr}, parseErr + } + if offset64 > math.MaxInt32 || offset64 < 0 { + return &QueryResult{Error: fmt.Errorf("OFFSET value %d is out of valid range", offset64)}, fmt.Errorf("OFFSET value %d is out of valid range", offset64) + } + offset = int(offset64) + } + } + } + + // Build hybrid scan options + // Extract time filters from WHERE clause to optimize scanning + startTimeNs, stopTimeNs := int64(0), int64(0) + if stmt.Where != nil { + startTimeNs, stopTimeNs = e.extractTimeFilters(stmt.Where.Expr) + } + + hybridScanOptions := HybridScanOptions{ + StartTimeNs: startTimeNs, // Extracted from WHERE clause time comparisons + StopTimeNs: stopTimeNs, // Extracted from WHERE clause time comparisons + Limit: limit, + Offset: offset, + Predicate: predicate, + } + + if !selectAll { + // Convert baseColumnsSet to slice for hybrid scan options + baseColumns := make([]string, 0, len(baseColumnsSet)) + for columnName := range baseColumnsSet { + baseColumns = append(baseColumns, columnName) + } + // Use base columns (not expression aliases) for data retrieval + if len(baseColumns) > 0 { + hybridScanOptions.Columns = baseColumns + } else { + // If no base columns found (shouldn't happen), use original columns + hybridScanOptions.Columns = columns + } + } + + // Execute the hybrid scan (both flushed and unflushed data) + var results []HybridScanResult + if plan != nil { + // EXPLAIN mode - capture broker buffer stats + var stats *HybridScanStats + results, stats, err = hybridScanner.ScanWithStats(ctx, hybridScanOptions) + if err != nil { + return &QueryResult{Error: err}, err + } + + // Populate plan with broker buffer information + if stats != nil { + plan.BrokerBufferQueried = stats.BrokerBufferQueried + plan.BrokerBufferMessages = stats.BrokerBufferMessages + plan.BufferStartIndex = stats.BufferStartIndex + + // Add broker_buffer to data sources if buffer was queried + if stats.BrokerBufferQueried { + // Check if broker_buffer is already in data sources + hasBrokerBuffer := false + for _, source := range plan.DataSources { + if source == "broker_buffer" { + hasBrokerBuffer = true + break + } + } + if !hasBrokerBuffer { + plan.DataSources = append(plan.DataSources, "broker_buffer") + } + } + } + } else { + // Normal mode - just get results + results, err = hybridScanner.Scan(ctx, hybridScanOptions) + if err != nil { + return &QueryResult{Error: err}, err + } + } + + // Convert to SQL result format + if selectAll { + if len(columns) > 0 { + // SELECT *, specific_columns - include both auto-discovered and explicit columns + return hybridScanner.ConvertToSQLResultWithMixedColumns(results, columns), nil + } else { + // SELECT * only - let converter determine all columns (excludes system columns) + columns = nil + return hybridScanner.ConvertToSQLResult(results, columns), nil + } + } + + // Handle custom column expressions (including arithmetic) + return e.ConvertToSQLResultWithExpressions(hybridScanner, results, stmt.SelectExprs), nil +} + +// extractTimeFilters extracts time range filters from WHERE clause for optimization +// This allows push-down of time-based queries to improve scan performance +// Returns (startTimeNs, stopTimeNs) where 0 means unbounded +func (e *SQLEngine) extractTimeFilters(expr ExprNode) (int64, int64) { + startTimeNs, stopTimeNs := int64(0), int64(0) + + // Recursively extract time filters from expression tree + e.extractTimeFiltersRecursive(expr, &startTimeNs, &stopTimeNs) + + // Special case: if startTimeNs == stopTimeNs, treat it like an equality query + // to avoid premature scan termination. The predicate will handle exact matching. + if startTimeNs != 0 && startTimeNs == stopTimeNs { + stopTimeNs = 0 + } + + return startTimeNs, stopTimeNs +} + +// extractTimeFiltersWithValidation extracts time filters and validates that WHERE clause contains only time-based predicates +// Returns (startTimeNs, stopTimeNs, onlyTimePredicates) where onlyTimePredicates indicates if fast path is safe +func (e *SQLEngine) extractTimeFiltersWithValidation(expr ExprNode) (int64, int64, bool) { + startTimeNs, stopTimeNs := int64(0), int64(0) + onlyTimePredicates := true + + // Recursively extract time filters and validate predicates + e.extractTimeFiltersWithValidationRecursive(expr, &startTimeNs, &stopTimeNs, &onlyTimePredicates) + + // Special case: if startTimeNs == stopTimeNs, treat it like an equality query + if startTimeNs != 0 && startTimeNs == stopTimeNs { + stopTimeNs = 0 + } + + return startTimeNs, stopTimeNs, onlyTimePredicates +} + +// extractTimeFiltersRecursive recursively processes WHERE expressions to find time comparisons +func (e *SQLEngine) extractTimeFiltersRecursive(expr ExprNode, startTimeNs, stopTimeNs *int64) { + switch exprType := expr.(type) { + case *ComparisonExpr: + e.extractTimeFromComparison(exprType, startTimeNs, stopTimeNs) + case *AndExpr: + // For AND expressions, combine time filters (intersection) + e.extractTimeFiltersRecursive(exprType.Left, startTimeNs, stopTimeNs) + e.extractTimeFiltersRecursive(exprType.Right, startTimeNs, stopTimeNs) + case *OrExpr: + // For OR expressions, we can't easily optimize time ranges + // Skip time filter extraction for OR clauses to avoid incorrect results + return + case *ParenExpr: + // Unwrap parentheses and continue + e.extractTimeFiltersRecursive(exprType.Expr, startTimeNs, stopTimeNs) + } +} + +// extractTimeFiltersWithValidationRecursive recursively processes WHERE expressions to find time comparisons and validate predicates +func (e *SQLEngine) extractTimeFiltersWithValidationRecursive(expr ExprNode, startTimeNs, stopTimeNs *int64, onlyTimePredicates *bool) { + switch exprType := expr.(type) { + case *ComparisonExpr: + // Check if this is a time-based comparison + leftCol := e.getColumnName(exprType.Left) + rightCol := e.getColumnName(exprType.Right) + + isTimeComparison := e.isTimestampColumn(leftCol) || e.isTimestampColumn(rightCol) + if isTimeComparison { + // Extract time filter from this comparison + e.extractTimeFromComparison(exprType, startTimeNs, stopTimeNs) + } else { + // Non-time predicate found - fast path is not safe + *onlyTimePredicates = false + } + case *AndExpr: + // For AND expressions, both sides must be time-only for fast path to be safe + e.extractTimeFiltersWithValidationRecursive(exprType.Left, startTimeNs, stopTimeNs, onlyTimePredicates) + e.extractTimeFiltersWithValidationRecursive(exprType.Right, startTimeNs, stopTimeNs, onlyTimePredicates) + case *OrExpr: + // OR expressions are complex and not supported in fast path + *onlyTimePredicates = false + return + case *ParenExpr: + // Unwrap parentheses and continue + e.extractTimeFiltersWithValidationRecursive(exprType.Expr, startTimeNs, stopTimeNs, onlyTimePredicates) + default: + // Unknown expression type - not safe for fast path + *onlyTimePredicates = false + } +} + +// extractTimeFromComparison extracts time bounds from comparison expressions +// Handles comparisons against timestamp columns (system columns and schema-defined timestamp types) +func (e *SQLEngine) extractTimeFromComparison(comp *ComparisonExpr, startTimeNs, stopTimeNs *int64) { + // Check if this is a time-related column comparison + leftCol := e.getColumnName(comp.Left) + rightCol := e.getColumnName(comp.Right) + + var valueExpr ExprNode + var reversed bool + + // Determine which side is the time column (using schema types) + if e.isTimestampColumn(leftCol) { + valueExpr = comp.Right + reversed = false + } else if e.isTimestampColumn(rightCol) { + valueExpr = comp.Left + reversed = true + } else { + // Not a time comparison + return + } + + // Extract the time value + timeValue := e.extractTimeValue(valueExpr) + if timeValue == 0 { + // Couldn't parse time value + return + } + + // Apply the comparison operator to determine time bounds + operator := comp.Operator + if reversed { + // Reverse the operator if column and value are swapped + operator = e.reverseOperator(operator) + } + + switch operator { + case GreaterThanStr: // timestamp > value + if *startTimeNs == 0 || timeValue > *startTimeNs { + *startTimeNs = timeValue + } + case GreaterEqualStr: // timestamp >= value + if *startTimeNs == 0 || timeValue >= *startTimeNs { + *startTimeNs = timeValue + } + case LessThanStr: // timestamp < value + if *stopTimeNs == 0 || timeValue < *stopTimeNs { + *stopTimeNs = timeValue + } + case LessEqualStr: // timestamp <= value + if *stopTimeNs == 0 || timeValue <= *stopTimeNs { + *stopTimeNs = timeValue + } + case EqualStr: // timestamp = value (point query) + // For exact matches, we set startTimeNs slightly before the target + // This works around a scan boundary bug where >= X starts after X instead of at X + // The predicate function will handle exact matching + *startTimeNs = timeValue - 1 + // Do NOT set stopTimeNs - let the predicate handle exact matching + } +} + +// isTimestampColumn checks if a column is a timestamp using schema type information +func (e *SQLEngine) isTimestampColumn(columnName string) bool { + if columnName == "" { + return false + } + + // System timestamp columns are always time columns + if columnName == SW_COLUMN_NAME_TIMESTAMP || columnName == SW_DISPLAY_NAME_TIMESTAMP { + return true + } + + // For user-defined columns, check actual schema type information + if e.catalog != nil { + currentDB := e.catalog.GetCurrentDatabase() + if currentDB == "" { + currentDB = "default" + } + + // Get current table context from query execution + // Note: This is a limitation - we need table context here + // In a full implementation, this would be passed from the query context + tableInfo, err := e.getCurrentTableInfo(currentDB) + if err == nil && tableInfo != nil { + for _, col := range tableInfo.Columns { + if strings.EqualFold(col.Name, columnName) { + // Use actual SQL type to determine if this is a timestamp + return e.isSQLTypeTimestamp(col.Type) + } + } + } + } + + // Only return true if we have explicit type information + // No guessing based on column names + return false +} + +// getTimeFiltersFromPlan extracts time filter values from execution plan details +func getTimeFiltersFromPlan(plan *QueryExecutionPlan) (startTimeNs, stopTimeNs int64) { + if plan == nil || plan.Details == nil { + return 0, 0 + } + if startNsVal, ok := plan.Details[PlanDetailStartTimeNs]; ok { + if startNs, ok2 := startNsVal.(int64); ok2 { + startTimeNs = startNs + } + } + if stopNsVal, ok := plan.Details[PlanDetailStopTimeNs]; ok { + if stopNs, ok2 := stopNsVal.(int64); ok2 { + stopTimeNs = stopNs + } + } + return +} + +// pruneParquetFilesByTime filters parquet files based on timestamp ranges, with optional debug logging +func pruneParquetFilesByTime(ctx context.Context, parquetStats []*ParquetFileStats, hybridScanner *HybridMessageScanner, startTimeNs, stopTimeNs int64) []*ParquetFileStats { + if startTimeNs == 0 && stopTimeNs == 0 { + return parquetStats + } + + qStart := startTimeNs + qStop := stopTimeNs + if qStop == 0 { + qStop = math.MaxInt64 + } + + n := 0 + for _, fs := range parquetStats { + if minNs, maxNs, ok := hybridScanner.getTimestampRangeFromStats(fs); ok { + if qStop < minNs || (qStart != 0 && qStart > maxNs) { + continue + } + } + parquetStats[n] = fs + n++ + } + return parquetStats[:n] +} + +// pruneParquetFilesByColumnStats filters parquet files based on column statistics and WHERE predicates +func (e *SQLEngine) pruneParquetFilesByColumnStats(ctx context.Context, parquetStats []*ParquetFileStats, whereExpr ExprNode) []*ParquetFileStats { + if whereExpr == nil { + return parquetStats + } + + n := 0 + for _, fs := range parquetStats { + if e.canSkipParquetFile(ctx, fs, whereExpr) { + continue + } + parquetStats[n] = fs + n++ + } + return parquetStats[:n] +} + +// canSkipParquetFile determines if a parquet file can be skipped based on column statistics +func (e *SQLEngine) canSkipParquetFile(ctx context.Context, fileStats *ParquetFileStats, whereExpr ExprNode) bool { + switch expr := whereExpr.(type) { + case *ComparisonExpr: + return e.canSkipFileByComparison(ctx, fileStats, expr) + case *AndExpr: + // For AND: skip if ANY condition allows skipping (more aggressive pruning) + return e.canSkipParquetFile(ctx, fileStats, expr.Left) || e.canSkipParquetFile(ctx, fileStats, expr.Right) + case *OrExpr: + // For OR: skip only if ALL conditions allow skipping (conservative) + return e.canSkipParquetFile(ctx, fileStats, expr.Left) && e.canSkipParquetFile(ctx, fileStats, expr.Right) + default: + // Unknown expression type - don't skip + return false + } +} + +// canSkipFileByComparison checks if a file can be skipped based on a comparison predicate +func (e *SQLEngine) canSkipFileByComparison(ctx context.Context, fileStats *ParquetFileStats, expr *ComparisonExpr) bool { + // Extract column name and comparison value + var columnName string + var compareSchemaValue *schema_pb.Value + var operator string = expr.Operator + + // Determine which side is the column and which is the value + if colRef, ok := expr.Left.(*ColName); ok { + columnName = colRef.Name.String() + if sqlVal, ok := expr.Right.(*SQLVal); ok { + compareSchemaValue = e.convertSQLValToSchemaValue(sqlVal) + } else { + return false // Can't optimize complex expressions + } + } else if colRef, ok := expr.Right.(*ColName); ok { + columnName = colRef.Name.String() + if sqlVal, ok := expr.Left.(*SQLVal); ok { + compareSchemaValue = e.convertSQLValToSchemaValue(sqlVal) + // Flip operator for reversed comparison + operator = e.flipOperator(operator) + } else { + return false + } + } else { + return false // No column reference found + } + + // Validate comparison value + if compareSchemaValue == nil { + return false + } + + // Get column statistics + colStats, exists := fileStats.ColumnStats[columnName] + if !exists || colStats == nil { + // Try case-insensitive lookup + for colName, stats := range fileStats.ColumnStats { + if strings.EqualFold(colName, columnName) { + colStats = stats + exists = true + break + } + } + } + + if !exists || colStats == nil || colStats.MinValue == nil || colStats.MaxValue == nil { + return false // No statistics available + } + + // Apply pruning logic based on operator + switch operator { + case ">": + // Skip if max(column) <= compareValue + return e.compareValues(colStats.MaxValue, compareSchemaValue) <= 0 + case ">=": + // Skip if max(column) < compareValue + return e.compareValues(colStats.MaxValue, compareSchemaValue) < 0 + case "<": + // Skip if min(column) >= compareValue + return e.compareValues(colStats.MinValue, compareSchemaValue) >= 0 + case "<=": + // Skip if min(column) > compareValue + return e.compareValues(colStats.MinValue, compareSchemaValue) > 0 + case "=": + // Skip if compareValue is outside [min, max] range + return e.compareValues(compareSchemaValue, colStats.MinValue) < 0 || + e.compareValues(compareSchemaValue, colStats.MaxValue) > 0 + case "!=", "<>": + // Skip if min == max == compareValue (all values are the same and equal to compareValue) + return e.compareValues(colStats.MinValue, colStats.MaxValue) == 0 && + e.compareValues(colStats.MinValue, compareSchemaValue) == 0 + default: + return false // Unknown operator + } +} + +// flipOperator flips comparison operators when operands are swapped +func (e *SQLEngine) flipOperator(op string) string { + switch op { + case ">": + return "<" + case ">=": + return "<=" + case "<": + return ">" + case "<=": + return ">=" + case "=", "!=", "<>": + return op // These are symmetric + default: + return op + } +} + +// populatePlanFileDetails populates execution plan with detailed file information for partitions +// Includes column statistics pruning optimization when WHERE clause is provided +func (e *SQLEngine) populatePlanFileDetails(ctx context.Context, plan *QueryExecutionPlan, hybridScanner *HybridMessageScanner, partitions []string, stmt *SelectStatement) { + // Collect actual file information for each partition + var parquetFiles []string + var liveLogFiles []string + parquetSources := make(map[string]bool) + var parquetReadErrors []string + var liveLogListErrors []string + + // Extract time filters from plan details + startTimeNs, stopTimeNs := getTimeFiltersFromPlan(plan) + + for _, partitionPath := range partitions { + // Get parquet files for this partition + if parquetStats, err := hybridScanner.ReadParquetStatistics(partitionPath); err == nil { + // Prune files by time range + filteredStats := pruneParquetFilesByTime(ctx, parquetStats, hybridScanner, startTimeNs, stopTimeNs) + + // Further prune by column statistics from WHERE clause + if stmt != nil && stmt.Where != nil { + beforeColumnPrune := len(filteredStats) + filteredStats = e.pruneParquetFilesByColumnStats(ctx, filteredStats, stmt.Where.Expr) + columnPrunedCount := beforeColumnPrune - len(filteredStats) + + if columnPrunedCount > 0 { + // Track column statistics optimization + if !contains(plan.OptimizationsUsed, "column_statistics_pruning") { + plan.OptimizationsUsed = append(plan.OptimizationsUsed, "column_statistics_pruning") + } + } + } + + for _, stats := range filteredStats { + parquetFiles = append(parquetFiles, fmt.Sprintf("%s/%s", partitionPath, stats.FileName)) + } + } else { + parquetReadErrors = append(parquetReadErrors, fmt.Sprintf("%s: %v", partitionPath, err)) + } + + // Merge accurate parquet sources from metadata + if sources, err := e.getParquetSourceFilesFromMetadata(partitionPath); err == nil { + for src := range sources { + parquetSources[src] = true + } + } + + // Get live log files for this partition + if liveFiles, err := e.collectLiveLogFileNames(hybridScanner.filerClient, partitionPath); err == nil { + for _, fileName := range liveFiles { + // Exclude live log files that have been converted to parquet (deduplicated) + if parquetSources[fileName] { + continue + } + liveLogFiles = append(liveLogFiles, fmt.Sprintf("%s/%s", partitionPath, fileName)) + } + } else { + liveLogListErrors = append(liveLogListErrors, fmt.Sprintf("%s: %v", partitionPath, err)) + } + } + + // Add file lists to plan details + if len(parquetFiles) > 0 { + plan.Details["parquet_files"] = parquetFiles + } + if len(liveLogFiles) > 0 { + plan.Details["live_log_files"] = liveLogFiles + } + if len(parquetReadErrors) > 0 { + plan.Details["error_parquet_statistics"] = parquetReadErrors + } + if len(liveLogListErrors) > 0 { + plan.Details["error_live_log_listing"] = liveLogListErrors + } +} + +// isSQLTypeTimestamp checks if a SQL type string represents a timestamp type +func (e *SQLEngine) isSQLTypeTimestamp(sqlType string) bool { + upperType := strings.ToUpper(strings.TrimSpace(sqlType)) + + // Handle type with precision/length specifications + if idx := strings.Index(upperType, "("); idx != -1 { + upperType = upperType[:idx] + } + + switch upperType { + case "TIMESTAMP", "DATETIME": + return true + case "BIGINT": + // BIGINT could be a timestamp if it follows the pattern for timestamp storage + // This is a heuristic - in a better system, we'd have semantic type information + return false // Conservative approach - require explicit TIMESTAMP type + default: + return false + } +} + +// getCurrentTableInfo attempts to get table info for the current query context +// This is a simplified implementation - ideally table context would be passed explicitly +func (e *SQLEngine) getCurrentTableInfo(database string) (*TableInfo, error) { + // This is a limitation of the current architecture + // In practice, we'd need the table context from the current query + // For now, return nil to fallback to naming conventions + // TODO: Enhance architecture to pass table context through query execution + return nil, fmt.Errorf("table context not available in current architecture") +} + +// getColumnName extracts column name from expression (handles ColName types) +func (e *SQLEngine) getColumnName(expr ExprNode) string { + switch exprType := expr.(type) { + case *ColName: + return exprType.Name.String() + } + return "" +} + +// resolveColumnAlias tries to resolve a column name that might be an alias +func (e *SQLEngine) resolveColumnAlias(columnName string, selectExprs []SelectExpr) string { + if selectExprs == nil { + return columnName + } + + // Check if this column name is actually an alias in the SELECT list + for _, selectExpr := range selectExprs { + if aliasedExpr, ok := selectExpr.(*AliasedExpr); ok && aliasedExpr != nil { + // Check if the alias matches our column name + if aliasedExpr.As != nil && !aliasedExpr.As.IsEmpty() && aliasedExpr.As.String() == columnName { + // If the aliased expression is a column, return the actual column name + if colExpr, ok := aliasedExpr.Expr.(*ColName); ok && colExpr != nil { + return colExpr.Name.String() + } + } + } + } + + // If no alias found, return the original column name + return columnName +} + +// extractTimeValue parses time values from SQL expressions +// Supports nanosecond timestamps, ISO dates, and relative times +func (e *SQLEngine) extractTimeValue(expr ExprNode) int64 { + switch exprType := expr.(type) { + case *SQLVal: + switch exprType.Type { + case IntVal: + // Parse as nanosecond timestamp + if val, err := strconv.ParseInt(string(exprType.Val), 10, 64); err == nil { + return val + } + case StrVal: + // Parse as ISO date or other string formats + timeStr := string(exprType.Val) + + // Try parsing as RFC3339 (ISO 8601) + if t, err := time.Parse(time.RFC3339, timeStr); err == nil { + return t.UnixNano() + } + + // Try parsing as RFC3339 with nanoseconds + if t, err := time.Parse(time.RFC3339Nano, timeStr); err == nil { + return t.UnixNano() + } + + // Try parsing as date only (YYYY-MM-DD) + if t, err := time.Parse("2006-01-02", timeStr); err == nil { + return t.UnixNano() + } + + // Try parsing as datetime (YYYY-MM-DD HH:MM:SS) + if t, err := time.Parse("2006-01-02 15:04:05", timeStr); err == nil { + return t.UnixNano() + } + } + } + + return 0 // Couldn't parse +} + +// reverseOperator reverses comparison operators when column and value are swapped +func (e *SQLEngine) reverseOperator(op string) string { + switch op { + case GreaterThanStr: + return LessThanStr + case GreaterEqualStr: + return LessEqualStr + case LessThanStr: + return GreaterThanStr + case LessEqualStr: + return GreaterEqualStr + case EqualStr: + return EqualStr + case NotEqualStr: + return NotEqualStr + default: + return op + } +} + +// buildPredicate creates a predicate function from a WHERE clause expression +// This is a simplified implementation - a full implementation would be much more complex +func (e *SQLEngine) buildPredicate(expr ExprNode) (func(*schema_pb.RecordValue) bool, error) { + return e.buildPredicateWithContext(expr, nil) +} + +// buildPredicateWithContext creates a predicate function with SELECT context for alias resolution +func (e *SQLEngine) buildPredicateWithContext(expr ExprNode, selectExprs []SelectExpr) (func(*schema_pb.RecordValue) bool, error) { + switch exprType := expr.(type) { + case *ComparisonExpr: + return e.buildComparisonPredicateWithContext(exprType, selectExprs) + case *BetweenExpr: + return e.buildBetweenPredicateWithContext(exprType, selectExprs) + case *IsNullExpr: + return e.buildIsNullPredicateWithContext(exprType, selectExprs) + case *IsNotNullExpr: + return e.buildIsNotNullPredicateWithContext(exprType, selectExprs) + case *AndExpr: + leftPred, err := e.buildPredicateWithContext(exprType.Left, selectExprs) + if err != nil { + return nil, err + } + rightPred, err := e.buildPredicateWithContext(exprType.Right, selectExprs) + if err != nil { + return nil, err + } + return func(record *schema_pb.RecordValue) bool { + return leftPred(record) && rightPred(record) + }, nil + case *OrExpr: + leftPred, err := e.buildPredicateWithContext(exprType.Left, selectExprs) + if err != nil { + return nil, err + } + rightPred, err := e.buildPredicateWithContext(exprType.Right, selectExprs) + if err != nil { + return nil, err + } + return func(record *schema_pb.RecordValue) bool { + return leftPred(record) || rightPred(record) + }, nil + default: + return nil, fmt.Errorf("unsupported WHERE expression: %T", expr) + } +} + +// buildComparisonPredicateWithContext creates a predicate for comparison operations with alias support +func (e *SQLEngine) buildComparisonPredicateWithContext(expr *ComparisonExpr, selectExprs []SelectExpr) (func(*schema_pb.RecordValue) bool, error) { + var columnName string + var compareValue interface{} + var operator string + + // Check if column is on the left side (normal case: column > value) + if colName, ok := expr.Left.(*ColName); ok { + rawColumnName := colName.Name.String() + // Resolve potential alias to actual column name + columnName = e.resolveColumnAlias(rawColumnName, selectExprs) + // Map display names to internal names for system columns + columnName = e.getSystemColumnInternalName(columnName) + operator = expr.Operator + + // Extract comparison value from right side + val, err := e.extractComparisonValue(expr.Right) + if err != nil { + return nil, fmt.Errorf("failed to extract right-side value: %v", err) + } + compareValue = e.convertValueForTimestampColumn(columnName, val, expr.Right) + + } else if colName, ok := expr.Right.(*ColName); ok { + // Column is on the right side (reversed case: value < column) + rawColumnName := colName.Name.String() + // Resolve potential alias to actual column name + columnName = e.resolveColumnAlias(rawColumnName, selectExprs) + // Map display names to internal names for system columns + columnName = e.getSystemColumnInternalName(columnName) + + // Reverse the operator when column is on right side + operator = e.reverseOperator(expr.Operator) + + // Extract comparison value from left side + val, err := e.extractComparisonValue(expr.Left) + if err != nil { + return nil, fmt.Errorf("failed to extract left-side value: %v", err) + } + compareValue = e.convertValueForTimestampColumn(columnName, val, expr.Left) + + } else { + // Handle literal-only comparisons like 1 = 0, 'a' = 'b', etc. + leftVal, leftErr := e.extractComparisonValue(expr.Left) + rightVal, rightErr := e.extractComparisonValue(expr.Right) + + if leftErr != nil || rightErr != nil { + return nil, fmt.Errorf("no column name found in comparison expression, left: %T, right: %T", expr.Left, expr.Right) + } + + // Evaluate the literal comparison once + result := e.compareLiteralValues(leftVal, rightVal, expr.Operator) + + // Return a constant predicate + return func(record *schema_pb.RecordValue) bool { + return result + }, nil + } + + // Return the predicate function + return func(record *schema_pb.RecordValue) bool { + fieldValue, exists := record.Fields[columnName] + if !exists { + return false // Column doesn't exist in record + } + + // Use the comparison evaluation function + return e.evaluateComparison(fieldValue, operator, compareValue) + }, nil +} + +// buildBetweenPredicateWithContext creates a predicate for BETWEEN operations +func (e *SQLEngine) buildBetweenPredicateWithContext(expr *BetweenExpr, selectExprs []SelectExpr) (func(*schema_pb.RecordValue) bool, error) { + var columnName string + var fromValue, toValue interface{} + + // Check if left side is a column name + if colName, ok := expr.Left.(*ColName); ok { + rawColumnName := colName.Name.String() + // Resolve potential alias to actual column name + columnName = e.resolveColumnAlias(rawColumnName, selectExprs) + // Map display names to internal names for system columns + columnName = e.getSystemColumnInternalName(columnName) + + // Extract FROM value + fromVal, err := e.extractComparisonValue(expr.From) + if err != nil { + return nil, fmt.Errorf("failed to extract BETWEEN from value: %v", err) + } + fromValue = e.convertValueForTimestampColumn(columnName, fromVal, expr.From) + + // Extract TO value + toVal, err := e.extractComparisonValue(expr.To) + if err != nil { + return nil, fmt.Errorf("failed to extract BETWEEN to value: %v", err) + } + toValue = e.convertValueForTimestampColumn(columnName, toVal, expr.To) + } else { + return nil, fmt.Errorf("BETWEEN left operand must be a column name, got: %T", expr.Left) + } + + // Return the predicate function + return func(record *schema_pb.RecordValue) bool { + fieldValue, exists := record.Fields[columnName] + if !exists { + return false + } + + // Evaluate: fieldValue >= fromValue AND fieldValue <= toValue + greaterThanOrEqualFrom := e.evaluateComparison(fieldValue, ">=", fromValue) + lessThanOrEqualTo := e.evaluateComparison(fieldValue, "<=", toValue) + + result := greaterThanOrEqualFrom && lessThanOrEqualTo + + // Handle NOT BETWEEN + if expr.Not { + result = !result + } + + return result + }, nil +} + +// buildIsNullPredicateWithContext creates a predicate for IS NULL operations +func (e *SQLEngine) buildIsNullPredicateWithContext(expr *IsNullExpr, selectExprs []SelectExpr) (func(*schema_pb.RecordValue) bool, error) { + // Check if the expression is a column name + if colName, ok := expr.Expr.(*ColName); ok { + rawColumnName := colName.Name.String() + // Resolve potential alias to actual column name + columnName := e.resolveColumnAlias(rawColumnName, selectExprs) + // Map display names to internal names for system columns + columnName = e.getSystemColumnInternalName(columnName) + + // Return the predicate function + return func(record *schema_pb.RecordValue) bool { + // Check if field exists and if it's null or missing + fieldValue, exists := record.Fields[columnName] + if !exists { + return true // Field doesn't exist = NULL + } + + // Check if the field value itself is null/empty + return e.isValueNull(fieldValue) + }, nil + } else { + return nil, fmt.Errorf("IS NULL left operand must be a column name, got: %T", expr.Expr) + } +} + +// buildIsNotNullPredicateWithContext creates a predicate for IS NOT NULL operations +func (e *SQLEngine) buildIsNotNullPredicateWithContext(expr *IsNotNullExpr, selectExprs []SelectExpr) (func(*schema_pb.RecordValue) bool, error) { + // Check if the expression is a column name + if colName, ok := expr.Expr.(*ColName); ok { + rawColumnName := colName.Name.String() + // Resolve potential alias to actual column name + columnName := e.resolveColumnAlias(rawColumnName, selectExprs) + // Map display names to internal names for system columns + columnName = e.getSystemColumnInternalName(columnName) + + // Return the predicate function + return func(record *schema_pb.RecordValue) bool { + // Check if field exists and if it's not null + fieldValue, exists := record.Fields[columnName] + if !exists { + return false // Field doesn't exist = NULL, so NOT NULL is false + } + + // Check if the field value itself is not null/empty + return !e.isValueNull(fieldValue) + }, nil + } else { + return nil, fmt.Errorf("IS NOT NULL left operand must be a column name, got: %T", expr.Expr) + } +} + +// isValueNull checks if a schema_pb.Value is null or represents a null value +func (e *SQLEngine) isValueNull(value *schema_pb.Value) bool { + if value == nil { + return true + } + + // Check the Kind field to see if it represents a null value + if value.Kind == nil { + return true + } + + // For different value types, check if they represent null/empty values + switch kind := value.Kind.(type) { + case *schema_pb.Value_StringValue: + // Empty string could be considered null depending on semantics + // For now, treat empty string as not null (SQL standard behavior) + return false + case *schema_pb.Value_BoolValue: + return false // Boolean values are never null + case *schema_pb.Value_Int32Value, *schema_pb.Value_Int64Value: + return false // Integer values are never null + case *schema_pb.Value_FloatValue, *schema_pb.Value_DoubleValue: + return false // Numeric values are never null + case *schema_pb.Value_BytesValue: + // Bytes could be null if empty, but for now treat as not null + return false + case *schema_pb.Value_TimestampValue: + // Check if timestamp is zero/uninitialized + return kind.TimestampValue == nil + case *schema_pb.Value_DateValue: + return kind.DateValue == nil + case *schema_pb.Value_TimeValue: + return kind.TimeValue == nil + default: + // Unknown type, consider it null to be safe + return true + } +} + +// extractComparisonValue extracts the comparison value from a SQL expression +func (e *SQLEngine) extractComparisonValue(expr ExprNode) (interface{}, error) { + switch val := expr.(type) { + case *SQLVal: + switch val.Type { + case IntVal: + intVal, err := strconv.ParseInt(string(val.Val), 10, 64) + if err != nil { + return nil, err + } + return intVal, nil + case StrVal: + return string(val.Val), nil + case FloatVal: + floatVal, err := strconv.ParseFloat(string(val.Val), 64) + if err != nil { + return nil, err + } + return floatVal, nil + default: + return nil, fmt.Errorf("unsupported SQL value type: %v", val.Type) + } + case *ArithmeticExpr: + // Handle arithmetic expressions like CURRENT_TIMESTAMP - INTERVAL '1 hour' + return e.evaluateArithmeticExpressionForComparison(val) + case *FuncExpr: + // Handle function calls like NOW(), CURRENT_TIMESTAMP + return e.evaluateFunctionExpressionForComparison(val) + case *IntervalExpr: + // Handle standalone INTERVAL expressions + nanos, err := e.evaluateInterval(val.Value) + if err != nil { + return nil, err + } + return nanos, nil + case ValTuple: + // Handle IN expressions with multiple values: column IN (value1, value2, value3) + var inValues []interface{} + for _, tupleVal := range val { + switch v := tupleVal.(type) { + case *SQLVal: + switch v.Type { + case IntVal: + intVal, err := strconv.ParseInt(string(v.Val), 10, 64) + if err != nil { + return nil, err + } + inValues = append(inValues, intVal) + case StrVal: + inValues = append(inValues, string(v.Val)) + case FloatVal: + floatVal, err := strconv.ParseFloat(string(v.Val), 64) + if err != nil { + return nil, err + } + inValues = append(inValues, floatVal) + } + } + } + return inValues, nil + default: + return nil, fmt.Errorf("unsupported comparison value type: %T", expr) + } +} + +// evaluateArithmeticExpressionForComparison evaluates an arithmetic expression for WHERE clause comparisons +func (e *SQLEngine) evaluateArithmeticExpressionForComparison(expr *ArithmeticExpr) (interface{}, error) { + // Check if this is timestamp arithmetic with intervals + if e.isTimestampArithmetic(expr.Left, expr.Right) && (expr.Operator == "+" || expr.Operator == "-") { + // Evaluate timestamp arithmetic and return the result as nanoseconds + result, err := e.evaluateTimestampArithmetic(expr.Left, expr.Right, expr.Operator) + if err != nil { + return nil, err + } + + // Extract the timestamp value as nanoseconds for comparison + if result.Kind != nil { + switch resultKind := result.Kind.(type) { + case *schema_pb.Value_Int64Value: + return resultKind.Int64Value, nil + case *schema_pb.Value_StringValue: + // If it's a formatted timestamp string, parse it back to nanoseconds + if timestamp, err := time.Parse("2006-01-02T15:04:05.000000000Z", resultKind.StringValue); err == nil { + return timestamp.UnixNano(), nil + } + return nil, fmt.Errorf("could not parse timestamp string: %s", resultKind.StringValue) + } + } + return nil, fmt.Errorf("invalid timestamp arithmetic result") + } + + // For other arithmetic operations, we'd need to evaluate them differently + // For now, return an error for unsupported arithmetic + return nil, fmt.Errorf("unsupported arithmetic expression in WHERE clause: %s", expr.Operator) +} + +// evaluateFunctionExpressionForComparison evaluates a function expression for WHERE clause comparisons +func (e *SQLEngine) evaluateFunctionExpressionForComparison(expr *FuncExpr) (interface{}, error) { + funcName := strings.ToUpper(expr.Name.String()) + + switch funcName { + case "NOW", "CURRENT_TIMESTAMP": + result, err := e.Now() + if err != nil { + return nil, err + } + // Return as nanoseconds for comparison + if result.Kind != nil { + if resultKind, ok := result.Kind.(*schema_pb.Value_TimestampValue); ok { + // Convert microseconds to nanoseconds + return resultKind.TimestampValue.TimestampMicros * 1000, nil + } + } + return nil, fmt.Errorf("invalid NOW() result: expected TimestampValue, got %T", result.Kind) + + case "CURRENT_DATE": + result, err := e.CurrentDate() + if err != nil { + return nil, err + } + // Convert date to nanoseconds (start of day) + if result.Kind != nil { + if resultKind, ok := result.Kind.(*schema_pb.Value_StringValue); ok { + if date, err := time.Parse("2006-01-02", resultKind.StringValue); err == nil { + return date.UnixNano(), nil + } + } + } + return nil, fmt.Errorf("invalid CURRENT_DATE result") + + case "CURRENT_TIME": + result, err := e.CurrentTime() + if err != nil { + return nil, err + } + // For time comparison, we might need special handling + // For now, just return the string value + if result.Kind != nil { + if resultKind, ok := result.Kind.(*schema_pb.Value_StringValue); ok { + return resultKind.StringValue, nil + } + } + return nil, fmt.Errorf("invalid CURRENT_TIME result") + + default: + return nil, fmt.Errorf("unsupported function in WHERE clause: %s", funcName) + } +} + +// evaluateComparison performs the actual comparison +func (e *SQLEngine) evaluateComparison(fieldValue *schema_pb.Value, operator string, compareValue interface{}) bool { + // This is a simplified implementation + // A full implementation would handle type coercion and all comparison operators + + switch operator { + case "=": + return e.valuesEqual(fieldValue, compareValue) + case "<": + return e.valueLessThan(fieldValue, compareValue) + case ">": + return e.valueGreaterThan(fieldValue, compareValue) + case "<=": + return e.valuesEqual(fieldValue, compareValue) || e.valueLessThan(fieldValue, compareValue) + case ">=": + return e.valuesEqual(fieldValue, compareValue) || e.valueGreaterThan(fieldValue, compareValue) + case "!=", "<>": + return !e.valuesEqual(fieldValue, compareValue) + case "LIKE", "like": + return e.valueLike(fieldValue, compareValue) + case "IN", "in": + return e.valueIn(fieldValue, compareValue) + default: + return false + } +} + +// Helper functions for value comparison with proper type coercion +func (e *SQLEngine) valuesEqual(fieldValue *schema_pb.Value, compareValue interface{}) bool { + // Handle string comparisons first + if strField, ok := fieldValue.Kind.(*schema_pb.Value_StringValue); ok { + if strVal, ok := compareValue.(string); ok { + return strField.StringValue == strVal + } + return false + } + + // Handle boolean comparisons + if boolField, ok := fieldValue.Kind.(*schema_pb.Value_BoolValue); ok { + if boolVal, ok := compareValue.(bool); ok { + return boolField.BoolValue == boolVal + } + return false + } + + // Handle logical type comparisons + if timestampField, ok := fieldValue.Kind.(*schema_pb.Value_TimestampValue); ok { + if timestampVal, ok := compareValue.(int64); ok { + return timestampField.TimestampValue.TimestampMicros == timestampVal + } + return false + } + + if dateField, ok := fieldValue.Kind.(*schema_pb.Value_DateValue); ok { + if dateVal, ok := compareValue.(int32); ok { + return dateField.DateValue.DaysSinceEpoch == dateVal + } + return false + } + + // Handle DecimalValue comparison (convert to string for comparison) + if decimalField, ok := fieldValue.Kind.(*schema_pb.Value_DecimalValue); ok { + if decimalStr, ok := compareValue.(string); ok { + // Convert decimal bytes back to string for comparison + decimalValue := e.decimalToString(decimalField.DecimalValue) + return decimalValue == decimalStr + } + return false + } + + if timeField, ok := fieldValue.Kind.(*schema_pb.Value_TimeValue); ok { + if timeVal, ok := compareValue.(int64); ok { + return timeField.TimeValue.TimeMicros == timeVal + } + return false + } + + // Handle direct int64 comparisons for timestamp precision (before float64 conversion) + if int64Field, ok := fieldValue.Kind.(*schema_pb.Value_Int64Value); ok { + if int64Val, ok := compareValue.(int64); ok { + return int64Field.Int64Value == int64Val + } + if intVal, ok := compareValue.(int); ok { + return int64Field.Int64Value == int64(intVal) + } + } + + // Handle direct int32 comparisons + if int32Field, ok := fieldValue.Kind.(*schema_pb.Value_Int32Value); ok { + if int32Val, ok := compareValue.(int32); ok { + return int32Field.Int32Value == int32Val + } + if intVal, ok := compareValue.(int); ok { + return int32Field.Int32Value == int32(intVal) + } + if int64Val, ok := compareValue.(int64); ok && int64Val >= math.MinInt32 && int64Val <= math.MaxInt32 { + return int32Field.Int32Value == int32(int64Val) + } + } + + // Handle numeric comparisons with type coercion (fallback for other numeric types) + fieldNum := e.convertToNumber(fieldValue) + compareNum := e.convertCompareValueToNumber(compareValue) + + if fieldNum != nil && compareNum != nil { + return *fieldNum == *compareNum + } + + return false +} + +// convertCompareValueToNumber converts compare values from SQL queries to float64 +func (e *SQLEngine) convertCompareValueToNumber(compareValue interface{}) *float64 { + switch v := compareValue.(type) { + case int: + result := float64(v) + return &result + case int32: + result := float64(v) + return &result + case int64: + result := float64(v) + return &result + case float32: + result := float64(v) + return &result + case float64: + return &v + case string: + // Try to parse string as number for flexible comparisons + if parsed, err := strconv.ParseFloat(v, 64); err == nil { + return &parsed + } + } + return nil +} + +// decimalToString converts a DecimalValue back to string representation +func (e *SQLEngine) decimalToString(decimalValue *schema_pb.DecimalValue) string { + if decimalValue == nil || decimalValue.Value == nil { + return "0" + } + + // Convert bytes back to big.Int + intValue := new(big.Int).SetBytes(decimalValue.Value) + + // Convert to string with proper decimal placement + str := intValue.String() + + // Handle decimal placement based on scale + scale := int(decimalValue.Scale) + if scale > 0 && len(str) > scale { + // Insert decimal point + decimalPos := len(str) - scale + return str[:decimalPos] + "." + str[decimalPos:] + } + + return str +} + +func (e *SQLEngine) valueLessThan(fieldValue *schema_pb.Value, compareValue interface{}) bool { + // Handle string comparisons lexicographically + if strField, ok := fieldValue.Kind.(*schema_pb.Value_StringValue); ok { + if strVal, ok := compareValue.(string); ok { + return strField.StringValue < strVal + } + return false + } + + // Handle logical type comparisons + if timestampField, ok := fieldValue.Kind.(*schema_pb.Value_TimestampValue); ok { + if timestampVal, ok := compareValue.(int64); ok { + return timestampField.TimestampValue.TimestampMicros < timestampVal + } + return false + } + + if dateField, ok := fieldValue.Kind.(*schema_pb.Value_DateValue); ok { + if dateVal, ok := compareValue.(int32); ok { + return dateField.DateValue.DaysSinceEpoch < dateVal + } + return false + } + + if timeField, ok := fieldValue.Kind.(*schema_pb.Value_TimeValue); ok { + if timeVal, ok := compareValue.(int64); ok { + return timeField.TimeValue.TimeMicros < timeVal + } + return false + } + + // Handle direct int64 comparisons for timestamp precision (before float64 conversion) + if int64Field, ok := fieldValue.Kind.(*schema_pb.Value_Int64Value); ok { + if int64Val, ok := compareValue.(int64); ok { + return int64Field.Int64Value < int64Val + } + if intVal, ok := compareValue.(int); ok { + return int64Field.Int64Value < int64(intVal) + } + } + + // Handle direct int32 comparisons + if int32Field, ok := fieldValue.Kind.(*schema_pb.Value_Int32Value); ok { + if int32Val, ok := compareValue.(int32); ok { + return int32Field.Int32Value < int32Val + } + if intVal, ok := compareValue.(int); ok { + return int32Field.Int32Value < int32(intVal) + } + if int64Val, ok := compareValue.(int64); ok && int64Val >= math.MinInt32 && int64Val <= math.MaxInt32 { + return int32Field.Int32Value < int32(int64Val) + } + } + + // Handle numeric comparisons with type coercion (fallback for other numeric types) + fieldNum := e.convertToNumber(fieldValue) + compareNum := e.convertCompareValueToNumber(compareValue) + + if fieldNum != nil && compareNum != nil { + return *fieldNum < *compareNum + } + + return false +} + +func (e *SQLEngine) valueGreaterThan(fieldValue *schema_pb.Value, compareValue interface{}) bool { + // Handle string comparisons lexicographically + if strField, ok := fieldValue.Kind.(*schema_pb.Value_StringValue); ok { + if strVal, ok := compareValue.(string); ok { + return strField.StringValue > strVal + } + return false + } + + // Handle logical type comparisons + if timestampField, ok := fieldValue.Kind.(*schema_pb.Value_TimestampValue); ok { + if timestampVal, ok := compareValue.(int64); ok { + return timestampField.TimestampValue.TimestampMicros > timestampVal + } + return false + } + + if dateField, ok := fieldValue.Kind.(*schema_pb.Value_DateValue); ok { + if dateVal, ok := compareValue.(int32); ok { + return dateField.DateValue.DaysSinceEpoch > dateVal + } + return false + } + + if timeField, ok := fieldValue.Kind.(*schema_pb.Value_TimeValue); ok { + if timeVal, ok := compareValue.(int64); ok { + return timeField.TimeValue.TimeMicros > timeVal + } + return false + } + + // Handle direct int64 comparisons for timestamp precision (before float64 conversion) + if int64Field, ok := fieldValue.Kind.(*schema_pb.Value_Int64Value); ok { + if int64Val, ok := compareValue.(int64); ok { + return int64Field.Int64Value > int64Val + } + if intVal, ok := compareValue.(int); ok { + return int64Field.Int64Value > int64(intVal) + } + } + + // Handle direct int32 comparisons + if int32Field, ok := fieldValue.Kind.(*schema_pb.Value_Int32Value); ok { + if int32Val, ok := compareValue.(int32); ok { + return int32Field.Int32Value > int32Val + } + if intVal, ok := compareValue.(int); ok { + return int32Field.Int32Value > int32(intVal) + } + if int64Val, ok := compareValue.(int64); ok && int64Val >= math.MinInt32 && int64Val <= math.MaxInt32 { + return int32Field.Int32Value > int32(int64Val) + } + } + + // Handle numeric comparisons with type coercion (fallback for other numeric types) + fieldNum := e.convertToNumber(fieldValue) + compareNum := e.convertCompareValueToNumber(compareValue) + + if fieldNum != nil && compareNum != nil { + return *fieldNum > *compareNum + } + + return false +} + +// valueLike implements SQL LIKE pattern matching with % and _ wildcards +func (e *SQLEngine) valueLike(fieldValue *schema_pb.Value, compareValue interface{}) bool { + // Only support LIKE for string values + stringVal, ok := fieldValue.Kind.(*schema_pb.Value_StringValue) + if !ok { + return false + } + + pattern, ok := compareValue.(string) + if !ok { + return false + } + + // Convert SQL LIKE pattern to Go regex pattern + // % matches any sequence of characters (.*), _ matches single character (.) + regexPattern := strings.ReplaceAll(pattern, "%", ".*") + regexPattern = strings.ReplaceAll(regexPattern, "_", ".") + regexPattern = "^" + regexPattern + "$" // Anchor to match entire string + + // Compile and match regex + regex, err := regexp.Compile(regexPattern) + if err != nil { + return false // Invalid pattern + } + + return regex.MatchString(stringVal.StringValue) +} + +// valueIn implements SQL IN operator for checking if value exists in a list +func (e *SQLEngine) valueIn(fieldValue *schema_pb.Value, compareValue interface{}) bool { + // For now, handle simple case where compareValue is a slice of values + // In a full implementation, this would handle SQL IN expressions properly + values, ok := compareValue.([]interface{}) + if !ok { + return false + } + + // Check if fieldValue matches any value in the list + for _, value := range values { + if e.valuesEqual(fieldValue, value) { + return true + } + } + + return false +} + +// Helper methods for specific operations + +func (e *SQLEngine) showDatabases(ctx context.Context) (*QueryResult, error) { + databases := e.catalog.ListDatabases() + + result := &QueryResult{ + Columns: []string{"Database"}, + Rows: make([][]sqltypes.Value, len(databases)), + } + + for i, db := range databases { + result.Rows[i] = []sqltypes.Value{ + sqltypes.NewVarChar(db), + } + } + + return result, nil +} + +func (e *SQLEngine) showTables(ctx context.Context, dbName string) (*QueryResult, error) { + // Use current database context if no database specified + if dbName == "" { + dbName = e.catalog.GetCurrentDatabase() + if dbName == "" { + dbName = "default" + } + } + + tables, err := e.catalog.ListTables(dbName) + if err != nil { + return &QueryResult{Error: err}, err + } + + result := &QueryResult{ + Columns: []string{"Tables_in_" + dbName}, + Rows: make([][]sqltypes.Value, len(tables)), + } + + for i, table := range tables { + result.Rows[i] = []sqltypes.Value{ + sqltypes.NewVarChar(table), + } + } + + return result, nil +} + +// compareLiteralValues compares two literal values with the given operator +func (e *SQLEngine) compareLiteralValues(left, right interface{}, operator string) bool { + switch operator { + case "=", "==": + return e.literalValuesEqual(left, right) + case "!=", "<>": + return !e.literalValuesEqual(left, right) + case "<": + return e.compareLiteralNumber(left, right) < 0 + case "<=": + return e.compareLiteralNumber(left, right) <= 0 + case ">": + return e.compareLiteralNumber(left, right) > 0 + case ">=": + return e.compareLiteralNumber(left, right) >= 0 + default: + // For unsupported operators, default to false + return false + } +} + +// literalValuesEqual checks if two literal values are equal +func (e *SQLEngine) literalValuesEqual(left, right interface{}) bool { + // Convert both to strings for comparison + leftStr := fmt.Sprintf("%v", left) + rightStr := fmt.Sprintf("%v", right) + return leftStr == rightStr +} + +// compareLiteralNumber compares two values as numbers +func (e *SQLEngine) compareLiteralNumber(left, right interface{}) int { + leftNum, leftOk := e.convertToFloat64(left) + rightNum, rightOk := e.convertToFloat64(right) + + if !leftOk || !rightOk { + // Fall back to string comparison if not numeric + leftStr := fmt.Sprintf("%v", left) + rightStr := fmt.Sprintf("%v", right) + if leftStr < rightStr { + return -1 + } else if leftStr > rightStr { + return 1 + } else { + return 0 + } + } + + if leftNum < rightNum { + return -1 + } else if leftNum > rightNum { + return 1 + } else { + return 0 + } +} + +// convertToFloat64 attempts to convert a value to float64 +func (e *SQLEngine) convertToFloat64(value interface{}) (float64, bool) { + switch v := value.(type) { + case int64: + return float64(v), true + case int32: + return float64(v), true + case int: + return float64(v), true + case float64: + return v, true + case float32: + return float64(v), true + case string: + if num, err := strconv.ParseFloat(v, 64); err == nil { + return num, true + } + return 0, false + default: + return 0, false + } +} + +func (e *SQLEngine) createTable(ctx context.Context, stmt *DDLStatement) (*QueryResult, error) { + // Parse CREATE TABLE statement + // Assumption: Table name format is [database.]table_name + tableName := stmt.NewName.Name.String() + database := "" + + // Check if database is specified in table name + if stmt.NewName.Qualifier.String() != "" { + database = stmt.NewName.Qualifier.String() + } else { + // Use current database context or default + database = e.catalog.GetCurrentDatabase() + if database == "" { + database = "default" + } + } + + // Parse column definitions from CREATE TABLE + // Assumption: stmt.TableSpec contains column definitions + if stmt.TableSpec == nil || len(stmt.TableSpec.Columns) == 0 { + err := fmt.Errorf("CREATE TABLE requires column definitions") + return &QueryResult{Error: err}, err + } + + // Convert SQL columns to MQ schema fields + fields := make([]*schema_pb.Field, len(stmt.TableSpec.Columns)) + for i, col := range stmt.TableSpec.Columns { + fieldType, err := e.convertSQLTypeToMQ(col.Type) + if err != nil { + return &QueryResult{Error: err}, err + } + + fields[i] = &schema_pb.Field{ + Name: col.Name.String(), + Type: fieldType, + } + } + + // Create record type for the topic + recordType := &schema_pb.RecordType{ + Fields: fields, + } + + // Create the topic via broker using configurable partition count + partitionCount := e.catalog.GetDefaultPartitionCount() + err := e.catalog.brokerClient.ConfigureTopic(ctx, database, tableName, partitionCount, recordType, nil) + if err != nil { + return &QueryResult{Error: err}, err + } + + // Register the new topic in catalog + mqSchema := &schema.Schema{ + Namespace: database, + Name: tableName, + RecordType: recordType, + RevisionId: 1, // Initial revision + } + + err = e.catalog.RegisterTopic(database, tableName, mqSchema) + if err != nil { + return &QueryResult{Error: err}, err + } + + // Return success result + result := &QueryResult{ + Columns: []string{"Result"}, + Rows: [][]sqltypes.Value{ + {sqltypes.NewVarChar(fmt.Sprintf("Table '%s.%s' created successfully", database, tableName))}, + }, + } + + return result, nil +} + +// ExecutionPlanBuilder handles building execution plans for queries +type ExecutionPlanBuilder struct { + engine *SQLEngine +} + +// BuildAggregationPlan builds an execution plan for aggregation queries +func (builder *ExecutionPlanBuilder) BuildAggregationPlan( + stmt *SelectStatement, + aggregations []AggregationSpec, + strategy AggregationStrategy, + dataSources *TopicDataSources, +) *QueryExecutionPlan { + + plan := &QueryExecutionPlan{ + QueryType: "SELECT", + ExecutionStrategy: builder.determineExecutionStrategy(stmt, strategy), + DataSources: builder.buildDataSourcesList(strategy, dataSources), + PartitionsScanned: dataSources.PartitionsCount, + ParquetFilesScanned: builder.countParquetFiles(dataSources), + LiveLogFilesScanned: builder.countLiveLogFiles(dataSources), + OptimizationsUsed: builder.buildOptimizationsList(stmt, strategy, dataSources), + Aggregations: builder.buildAggregationsList(aggregations), + Details: make(map[string]interface{}), + } + + // Set row counts based on strategy + if strategy.CanUseFastPath { + // Only live logs and broker buffer rows are actually scanned; parquet uses metadata + plan.TotalRowsProcessed = dataSources.LiveLogRowCount + if dataSources.BrokerUnflushedCount > 0 { + plan.TotalRowsProcessed += dataSources.BrokerUnflushedCount + } + // Set scan method based on what data sources actually exist + if dataSources.ParquetRowCount > 0 && (dataSources.LiveLogRowCount > 0 || dataSources.BrokerUnflushedCount > 0) { + plan.Details["scan_method"] = "Parquet Metadata + Live Log/Broker Counting" + } else if dataSources.ParquetRowCount > 0 { + plan.Details["scan_method"] = "Parquet Metadata Only" + } else { + plan.Details["scan_method"] = "Live Log/Broker Counting Only" + } + } else { + plan.TotalRowsProcessed = dataSources.ParquetRowCount + dataSources.LiveLogRowCount + plan.Details["scan_method"] = "Full Data Scan" + } + + return plan +} + +// determineExecutionStrategy determines the execution strategy based on query characteristics +func (builder *ExecutionPlanBuilder) determineExecutionStrategy(stmt *SelectStatement, strategy AggregationStrategy) string { + if stmt.Where != nil { + return "full_scan" + } + + if strategy.CanUseFastPath { + return "hybrid_fast_path" + } + + return "full_scan" +} + +// buildDataSourcesList builds the list of data sources used +func (builder *ExecutionPlanBuilder) buildDataSourcesList(strategy AggregationStrategy, dataSources *TopicDataSources) []string { + sources := []string{} + + if strategy.CanUseFastPath { + // Only show parquet stats if there are actual parquet files + if dataSources.ParquetRowCount > 0 { + sources = append(sources, "parquet_stats") + } + if dataSources.LiveLogRowCount > 0 { + sources = append(sources, "live_logs") + } + if dataSources.BrokerUnflushedCount > 0 { + sources = append(sources, "broker_buffer") + } + } else { + sources = append(sources, "live_logs", "parquet_files") + } + + // Note: broker_buffer is added dynamically during execution when broker is queried + // See aggregations.go lines 397-409 for the broker buffer data source addition logic + + return sources +} + +// countParquetFiles counts the total number of parquet files across all partitions +func (builder *ExecutionPlanBuilder) countParquetFiles(dataSources *TopicDataSources) int { + count := 0 + for _, fileStats := range dataSources.ParquetFiles { + count += len(fileStats) + } + return count +} + +// countLiveLogFiles returns the total number of live log files across all partitions +func (builder *ExecutionPlanBuilder) countLiveLogFiles(dataSources *TopicDataSources) int { + return dataSources.LiveLogFilesCount +} + +// buildOptimizationsList builds the list of optimizations used +func (builder *ExecutionPlanBuilder) buildOptimizationsList(stmt *SelectStatement, strategy AggregationStrategy, dataSources *TopicDataSources) []string { + optimizations := []string{} + + if strategy.CanUseFastPath { + // Only include parquet statistics if there are actual parquet files + if dataSources.ParquetRowCount > 0 { + optimizations = append(optimizations, "parquet_statistics") + } + if dataSources.LiveLogRowCount > 0 { + optimizations = append(optimizations, "live_log_counting") + } + // Always include deduplication when using fast path + optimizations = append(optimizations, "deduplication") + } + + if stmt.Where != nil { + // Check if "predicate_pushdown" is already in the list + found := false + for _, opt := range optimizations { + if opt == "predicate_pushdown" { + found = true + break + } + } + if !found { + optimizations = append(optimizations, "predicate_pushdown") + } + } + + return optimizations +} + +// buildAggregationsList builds the list of aggregations for display +func (builder *ExecutionPlanBuilder) buildAggregationsList(aggregations []AggregationSpec) []string { + aggList := make([]string, len(aggregations)) + for i, spec := range aggregations { + aggList[i] = fmt.Sprintf("%s(%s)", spec.Function, spec.Column) + } + return aggList +} + +// parseAggregationFunction parses an aggregation function expression +func (e *SQLEngine) parseAggregationFunction(funcExpr *FuncExpr, aliasExpr *AliasedExpr) (*AggregationSpec, error) { + funcName := strings.ToUpper(funcExpr.Name.String()) + + spec := &AggregationSpec{ + Function: funcName, + } + + // Parse function arguments + switch funcName { + case FuncCOUNT: + if len(funcExpr.Exprs) != 1 { + return nil, fmt.Errorf("COUNT function expects exactly 1 argument") + } + + switch arg := funcExpr.Exprs[0].(type) { + case *StarExpr: + spec.Column = "*" + spec.Alias = "COUNT(*)" + case *AliasedExpr: + if colName, ok := arg.Expr.(*ColName); ok { + spec.Column = colName.Name.String() + spec.Alias = fmt.Sprintf("COUNT(%s)", spec.Column) + } else { + return nil, fmt.Errorf("COUNT argument must be a column name or *") + } + default: + return nil, fmt.Errorf("unsupported COUNT argument: %T", arg) + } + + case FuncSUM, FuncAVG, FuncMIN, FuncMAX: + if len(funcExpr.Exprs) != 1 { + return nil, fmt.Errorf("%s function expects exactly 1 argument", funcName) + } + + switch arg := funcExpr.Exprs[0].(type) { + case *AliasedExpr: + if colName, ok := arg.Expr.(*ColName); ok { + spec.Column = colName.Name.String() + spec.Alias = fmt.Sprintf("%s(%s)", funcName, spec.Column) + } else { + return nil, fmt.Errorf("%s argument must be a column name", funcName) + } + default: + return nil, fmt.Errorf("unsupported %s argument: %T", funcName, arg) + } + + default: + return nil, fmt.Errorf("unsupported aggregation function: %s", funcName) + } + + // Override with user-specified alias if provided + if aliasExpr != nil && aliasExpr.As != nil && !aliasExpr.As.IsEmpty() { + spec.Alias = aliasExpr.As.String() + } + + return spec, nil +} + +// computeLiveLogMinMax scans live log files to find MIN/MAX values for a specific column +func (e *SQLEngine) computeLiveLogMinMax(partitionPath string, columnName string, parquetSourceFiles map[string]bool) (interface{}, interface{}, error) { + if e.catalog.brokerClient == nil { + return nil, nil, fmt.Errorf("no broker client available") + } + + filerClient, err := e.catalog.brokerClient.GetFilerClient() + if err != nil { + return nil, nil, fmt.Errorf("failed to get filer client: %v", err) + } + + var minValue, maxValue interface{} + var minSchemaValue, maxSchemaValue *schema_pb.Value + + // Process each live log file + err = filer_pb.ReadDirAllEntries(context.Background(), filerClient, util.FullPath(partitionPath), "", func(entry *filer_pb.Entry, isLast bool) error { + // Skip parquet files and directories + if entry.IsDirectory || strings.HasSuffix(entry.Name, ".parquet") { + return nil + } + // Skip files that have been converted to parquet (deduplication) + if parquetSourceFiles[entry.Name] { + return nil + } + + filePath := partitionPath + "/" + entry.Name + + // Scan this log file for MIN/MAX values + fileMin, fileMax, err := e.computeFileMinMax(filerClient, filePath, columnName) + if err != nil { + fmt.Printf("Warning: failed to compute min/max for file %s: %v\n", filePath, err) + return nil // Continue with other files + } + + // Update global min/max + if fileMin != nil { + if minSchemaValue == nil || e.compareValues(fileMin, minSchemaValue) < 0 { + minSchemaValue = fileMin + minValue = e.extractRawValue(fileMin) + } + } + + if fileMax != nil { + if maxSchemaValue == nil || e.compareValues(fileMax, maxSchemaValue) > 0 { + maxSchemaValue = fileMax + maxValue = e.extractRawValue(fileMax) + } + } + + return nil + }) + + if err != nil { + return nil, nil, fmt.Errorf("failed to process partition directory %s: %v", partitionPath, err) + } + + return minValue, maxValue, nil +} + +// computeFileMinMax scans a single log file to find MIN/MAX values for a specific column +func (e *SQLEngine) computeFileMinMax(filerClient filer_pb.FilerClient, filePath string, columnName string) (*schema_pb.Value, *schema_pb.Value, error) { + var minValue, maxValue *schema_pb.Value + + err := e.eachLogEntryInFile(filerClient, filePath, func(logEntry *filer_pb.LogEntry) error { + // Convert log entry to record value + recordValue, _, err := e.convertLogEntryToRecordValue(logEntry) + if err != nil { + return err // This will stop processing this file but not fail the overall query + } + + // Extract the requested column value + var columnValue *schema_pb.Value + if e.isSystemColumn(columnName) { + // Handle system columns + switch strings.ToLower(columnName) { + case SW_COLUMN_NAME_TIMESTAMP: + columnValue = &schema_pb.Value{Kind: &schema_pb.Value_Int64Value{Int64Value: logEntry.TsNs}} + case SW_COLUMN_NAME_KEY: + columnValue = &schema_pb.Value{Kind: &schema_pb.Value_BytesValue{BytesValue: logEntry.Key}} + case SW_COLUMN_NAME_SOURCE: + columnValue = &schema_pb.Value{Kind: &schema_pb.Value_StringValue{StringValue: "live_log"}} + } + } else { + // Handle regular data columns + if value, exists := recordValue.Fields[columnName]; exists { + columnValue = value + } + } + + if columnValue == nil { + return nil // Skip this record + } + + // Update min/max + if minValue == nil || e.compareValues(columnValue, minValue) < 0 { + minValue = columnValue + } + if maxValue == nil || e.compareValues(columnValue, maxValue) > 0 { + maxValue = columnValue + } + + return nil + }) + + return minValue, maxValue, err +} + +// eachLogEntryInFile reads a log file and calls the provided function for each log entry +func (e *SQLEngine) eachLogEntryInFile(filerClient filer_pb.FilerClient, filePath string, fn func(*filer_pb.LogEntry) error) error { + // Extract directory and filename + // filePath is like "partitionPath/filename" + lastSlash := strings.LastIndex(filePath, "/") + if lastSlash == -1 { + return fmt.Errorf("invalid file path: %s", filePath) + } + + dirPath := filePath[:lastSlash] + fileName := filePath[lastSlash+1:] + + // Get file entry + var fileEntry *filer_pb.Entry + err := filer_pb.ReadDirAllEntries(context.Background(), filerClient, util.FullPath(dirPath), "", func(entry *filer_pb.Entry, isLast bool) error { + if entry.Name == fileName { + fileEntry = entry + } + return nil + }) + + if err != nil { + return fmt.Errorf("failed to find file %s: %v", filePath, err) + } + + if fileEntry == nil { + return fmt.Errorf("file not found: %s", filePath) + } + + lookupFileIdFn := filer.LookupFn(filerClient) + + // eachChunkFn processes each chunk's data (pattern from countRowsInLogFile) + eachChunkFn := func(buf []byte) error { + for pos := 0; pos+4 < len(buf); { + size := util.BytesToUint32(buf[pos : pos+4]) + if pos+4+int(size) > len(buf) { + break + } + + entryData := buf[pos+4 : pos+4+int(size)] + + logEntry := &filer_pb.LogEntry{} + if err := proto.Unmarshal(entryData, logEntry); err != nil { + pos += 4 + int(size) + continue // Skip corrupted entries + } + + // Call the provided function for each log entry + if err := fn(logEntry); err != nil { + return err + } + + pos += 4 + int(size) + } + return nil + } + + // Read file chunks and process them (pattern from countRowsInLogFile) + fileSize := filer.FileSize(fileEntry) + visibleIntervals, _ := filer.NonOverlappingVisibleIntervals(context.Background(), lookupFileIdFn, fileEntry.Chunks, 0, int64(fileSize)) + chunkViews := filer.ViewFromVisibleIntervals(visibleIntervals, 0, int64(fileSize)) + + for x := chunkViews.Front(); x != nil; x = x.Next { + chunk := x.Value + urlStrings, err := lookupFileIdFn(context.Background(), chunk.FileId) + if err != nil { + fmt.Printf("Warning: failed to lookup chunk %s: %v\n", chunk.FileId, err) + continue + } + + if len(urlStrings) == 0 { + continue + } + + // Read chunk data + // urlStrings[0] is already a complete URL (http://server:port/fileId) + data, _, err := util_http.Get(urlStrings[0]) + if err != nil { + fmt.Printf("Warning: failed to read chunk %s from %s: %v\n", chunk.FileId, urlStrings[0], err) + continue + } + + // Process this chunk + if err := eachChunkFn(data); err != nil { + return err + } + } + + return nil +} + +// convertLogEntryToRecordValue helper method (reuse existing logic) +func (e *SQLEngine) convertLogEntryToRecordValue(logEntry *filer_pb.LogEntry) (*schema_pb.RecordValue, string, error) { + // Try to unmarshal as RecordValue first (schematized data) + recordValue := &schema_pb.RecordValue{} + err := proto.Unmarshal(logEntry.Data, recordValue) + if err == nil { + // Successfully unmarshaled as RecordValue (valid protobuf) + // Initialize Fields map if nil + if recordValue.Fields == nil { + recordValue.Fields = make(map[string]*schema_pb.Value) + } + + // Add system columns from LogEntry + recordValue.Fields[SW_COLUMN_NAME_TIMESTAMP] = &schema_pb.Value{ + Kind: &schema_pb.Value_Int64Value{Int64Value: logEntry.TsNs}, + } + recordValue.Fields[SW_COLUMN_NAME_KEY] = &schema_pb.Value{ + Kind: &schema_pb.Value_BytesValue{BytesValue: logEntry.Key}, + } + + return recordValue, "live_log", nil + } + + // Failed to unmarshal as RecordValue - invalid protobuf data + return nil, "", fmt.Errorf("failed to unmarshal log entry protobuf: %w", err) +} + +// extractTimestampFromFilename extracts timestamp from parquet filename +// Format: YYYY-MM-DD-HH-MM-SS.parquet +func (e *SQLEngine) extractTimestampFromFilename(filename string) int64 { + // Remove .parquet extension + filename = strings.TrimSuffix(filename, ".parquet") + + // Parse timestamp format: 2006-01-02-15-04-05 + t, err := time.Parse("2006-01-02-15-04-05", filename) + if err != nil { + return 0 + } + + return t.UnixNano() +} + +// extractParquetSourceFiles extracts source log file names from parquet file metadata for deduplication +func (e *SQLEngine) extractParquetSourceFiles(fileStats []*ParquetFileStats) map[string]bool { + sourceFiles := make(map[string]bool) + + for _, fileStat := range fileStats { + // Each ParquetFileStats should have a reference to the original file entry + // but we need to get it through the hybrid scanner to access Extended metadata + // This is a simplified approach - in practice we'd need to access the filer entry + + // For now, we'll use filename-based deduplication as a fallback + // Extract timestamp from parquet filename (YYYY-MM-DD-HH-MM-SS.parquet) + if strings.HasSuffix(fileStat.FileName, ".parquet") { + timeStr := strings.TrimSuffix(fileStat.FileName, ".parquet") + // Mark this timestamp range as covered by parquet + sourceFiles[timeStr] = true + } + } + + return sourceFiles +} + +// countLiveLogRowsExcludingParquetSources counts live log rows but excludes files that were converted to parquet and duplicate log buffer data +func (e *SQLEngine) countLiveLogRowsExcludingParquetSources(ctx context.Context, partitionPath string, parquetSourceFiles map[string]bool) (int64, error) { + debugEnabled := ctx != nil && isDebugMode(ctx) + filerClient, err := e.catalog.brokerClient.GetFilerClient() + if err != nil { + return 0, err + } + + // First, get the actual source files from parquet metadata + actualSourceFiles, err := e.getParquetSourceFilesFromMetadata(partitionPath) + if err != nil { + // If we can't read parquet metadata, use filename-based fallback + fmt.Printf("Warning: failed to read parquet metadata, using filename-based deduplication: %v\n", err) + actualSourceFiles = parquetSourceFiles + } + + // Second, get duplicate files from log buffer metadata + logBufferDuplicates, err := e.buildLogBufferDeduplicationMap(ctx, partitionPath) + if err != nil { + if debugEnabled { + fmt.Printf("Warning: failed to build log buffer deduplication map: %v\n", err) + } + logBufferDuplicates = make(map[string]bool) + } + + // Debug: Show deduplication status (only in explain mode) + if debugEnabled { + if len(actualSourceFiles) > 0 { + fmt.Printf("Excluding %d converted log files from %s\n", len(actualSourceFiles), partitionPath) + } + if len(logBufferDuplicates) > 0 { + fmt.Printf("Excluding %d duplicate log buffer files from %s\n", len(logBufferDuplicates), partitionPath) + } + } + + totalRows := int64(0) + err = filer_pb.ReadDirAllEntries(context.Background(), filerClient, util.FullPath(partitionPath), "", func(entry *filer_pb.Entry, isLast bool) error { + if entry.IsDirectory || strings.HasSuffix(entry.Name, ".parquet") { + return nil // Skip directories and parquet files + } + + // Skip files that have been converted to parquet + if actualSourceFiles[entry.Name] { + if debugEnabled { + fmt.Printf("Skipping %s (already converted to parquet)\n", entry.Name) + } + return nil + } + + // Skip files that are duplicated due to log buffer metadata + if logBufferDuplicates[entry.Name] { + if debugEnabled { + fmt.Printf("Skipping %s (duplicate log buffer data)\n", entry.Name) + } + return nil + } + + // Count rows in live log file + rowCount, err := e.countRowsInLogFile(filerClient, partitionPath, entry) + if err != nil { + fmt.Printf("Warning: failed to count rows in %s/%s: %v\n", partitionPath, entry.Name, err) + return nil // Continue with other files + } + totalRows += rowCount + return nil + }) + return totalRows, err +} + +// getParquetSourceFilesFromMetadata reads parquet file metadata to get actual source log files +func (e *SQLEngine) getParquetSourceFilesFromMetadata(partitionPath string) (map[string]bool, error) { + filerClient, err := e.catalog.brokerClient.GetFilerClient() + if err != nil { + return nil, err + } + + sourceFiles := make(map[string]bool) + + err = filer_pb.ReadDirAllEntries(context.Background(), filerClient, util.FullPath(partitionPath), "", func(entry *filer_pb.Entry, isLast bool) error { + if entry.IsDirectory || !strings.HasSuffix(entry.Name, ".parquet") { + return nil + } + + // Read source files from Extended metadata + if entry.Extended != nil && entry.Extended["sources"] != nil { + var sources []string + if err := json.Unmarshal(entry.Extended["sources"], &sources); err == nil { + for _, source := range sources { + sourceFiles[source] = true + } + } + } + + return nil + }) + + return sourceFiles, err +} + +// getLogBufferStartFromFile reads buffer start from file extended attributes +func (e *SQLEngine) getLogBufferStartFromFile(entry *filer_pb.Entry) (*LogBufferStart, error) { + if entry.Extended == nil { + return nil, nil + } + + // Only support binary buffer_start format + if startData, exists := entry.Extended["buffer_start"]; exists { + if len(startData) == 8 { + startIndex := int64(binary.BigEndian.Uint64(startData)) + if startIndex > 0 { + return &LogBufferStart{StartIndex: startIndex}, nil + } + } else { + return nil, fmt.Errorf("invalid buffer_start format: expected 8 bytes, got %d", len(startData)) + } + } + + return nil, nil +} + +// buildLogBufferDeduplicationMap creates a map to track duplicate files based on buffer ranges (ultra-efficient) +func (e *SQLEngine) buildLogBufferDeduplicationMap(ctx context.Context, partitionPath string) (map[string]bool, error) { + debugEnabled := ctx != nil && isDebugMode(ctx) + if e.catalog.brokerClient == nil { + return make(map[string]bool), nil + } + + filerClient, err := e.catalog.brokerClient.GetFilerClient() + if err != nil { + return make(map[string]bool), nil // Don't fail the query, just skip deduplication + } + + // Track buffer ranges instead of individual indexes (much more efficient) + type BufferRange struct { + start, end int64 + } + + processedRanges := make([]BufferRange, 0) + duplicateFiles := make(map[string]bool) + + err = filer_pb.ReadDirAllEntries(context.Background(), filerClient, util.FullPath(partitionPath), "", func(entry *filer_pb.Entry, isLast bool) error { + if entry.IsDirectory || strings.HasSuffix(entry.Name, ".parquet") { + return nil // Skip directories and parquet files + } + + // Get buffer start for this file (most efficient) + bufferStart, err := e.getLogBufferStartFromFile(entry) + if err != nil || bufferStart == nil { + return nil // No buffer info, can't deduplicate + } + + // Calculate range for this file: [start, start + chunkCount - 1] + chunkCount := int64(len(entry.GetChunks())) + if chunkCount == 0 { + return nil // Empty file, skip + } + + fileRange := BufferRange{ + start: bufferStart.StartIndex, + end: bufferStart.StartIndex + chunkCount - 1, + } + + // Check if this range overlaps with any processed range + isDuplicate := false + for _, processedRange := range processedRanges { + if fileRange.start <= processedRange.end && fileRange.end >= processedRange.start { + // Ranges overlap - this file contains duplicate buffer indexes + isDuplicate = true + if debugEnabled { + fmt.Printf("Marking %s as duplicate (buffer range [%d-%d] overlaps with [%d-%d])\n", + entry.Name, fileRange.start, fileRange.end, processedRange.start, processedRange.end) + } + break + } + } + + if isDuplicate { + duplicateFiles[entry.Name] = true + } else { + // Add this range to processed ranges + processedRanges = append(processedRanges, fileRange) + } + + return nil + }) + + if err != nil { + return make(map[string]bool), nil // Don't fail the query + } + + return duplicateFiles, nil +} + +// countRowsInLogFile counts rows in a single log file using SeaweedFS patterns +func (e *SQLEngine) countRowsInLogFile(filerClient filer_pb.FilerClient, partitionPath string, entry *filer_pb.Entry) (int64, error) { + lookupFileIdFn := filer.LookupFn(filerClient) + + rowCount := int64(0) + + // eachChunkFn processes each chunk's data (pattern from read_log_from_disk.go) + eachChunkFn := func(buf []byte) error { + for pos := 0; pos+4 < len(buf); { + size := util.BytesToUint32(buf[pos : pos+4]) + if pos+4+int(size) > len(buf) { + break + } + + entryData := buf[pos+4 : pos+4+int(size)] + + logEntry := &filer_pb.LogEntry{} + if err := proto.Unmarshal(entryData, logEntry); err != nil { + pos += 4 + int(size) + continue // Skip corrupted entries + } + + // Skip control messages (publisher control, empty key, or no data) + if isControlLogEntry(logEntry) { + pos += 4 + int(size) + continue + } + + rowCount++ + pos += 4 + int(size) + } + return nil + } + + // Read file chunks and process them (pattern from read_log_from_disk.go) + fileSize := filer.FileSize(entry) + visibleIntervals, _ := filer.NonOverlappingVisibleIntervals(context.Background(), lookupFileIdFn, entry.Chunks, 0, int64(fileSize)) + chunkViews := filer.ViewFromVisibleIntervals(visibleIntervals, 0, int64(fileSize)) + + for x := chunkViews.Front(); x != nil; x = x.Next { + chunk := x.Value + urlStrings, err := lookupFileIdFn(context.Background(), chunk.FileId) + if err != nil { + fmt.Printf("Warning: failed to lookup chunk %s: %v\n", chunk.FileId, err) + continue + } + + if len(urlStrings) == 0 { + continue + } + + // Read chunk data + // urlStrings[0] is already a complete URL (http://server:port/fileId) + data, _, err := util_http.Get(urlStrings[0]) + if err != nil { + fmt.Printf("Warning: failed to read chunk %s from %s: %v\n", chunk.FileId, urlStrings[0], err) + continue + } + + // Process this chunk + if err := eachChunkFn(data); err != nil { + return rowCount, err + } + } + + return rowCount, nil +} + +// isControlLogEntry checks if a log entry is a control entry without actual user data +// Control entries include: +// - DataMessages with populated Ctrl field (publisher control signals) +// - Entries with empty keys (filtered by subscriber) +// - Entries with no data +func isControlLogEntry(logEntry *filer_pb.LogEntry) bool { + // No data: control or placeholder + if len(logEntry.Data) == 0 { + return true + } + + // Empty keys are treated as control entries (consistent with subscriber filtering) + if len(logEntry.Key) == 0 { + return true + } + + // Check if the payload is a DataMessage carrying a control signal + dataMessage := &mq_pb.DataMessage{} + if err := proto.Unmarshal(logEntry.Data, dataMessage); err == nil { + if dataMessage.Ctrl != nil { + return true + } + } + + return false +} + +// discoverTopicPartitions discovers all partitions for a given topic using centralized logic +func (e *SQLEngine) discoverTopicPartitions(namespace, topicName string) ([]string, error) { + // Use centralized topic partition discovery + t := topic.NewTopic(namespace, topicName) + + // Get FilerClient from BrokerClient + filerClient, err := e.catalog.brokerClient.GetFilerClient() + if err != nil { + return nil, err + } + + return t.DiscoverPartitions(context.Background(), filerClient) +} + +// getTopicTotalRowCount returns the total number of rows in a topic (combining parquet and live logs) +func (e *SQLEngine) getTopicTotalRowCount(ctx context.Context, namespace, topicName string) (int64, error) { + // Create a hybrid scanner to access parquet statistics + var filerClient filer_pb.FilerClient + if e.catalog.brokerClient != nil { + var filerClientErr error + filerClient, filerClientErr = e.catalog.brokerClient.GetFilerClient() + if filerClientErr != nil { + return 0, filerClientErr + } + } + + hybridScanner, err := NewHybridMessageScanner(filerClient, e.catalog.brokerClient, namespace, topicName, e) + if err != nil { + return 0, err + } + + // Get all partitions for this topic + // Note: discoverTopicPartitions always returns absolute paths + partitions, err := e.discoverTopicPartitions(namespace, topicName) + if err != nil { + return 0, err + } + + totalRowCount := int64(0) + + // For each partition, count both parquet and live log rows + for _, partition := range partitions { + // Count parquet rows + parquetStats, parquetErr := hybridScanner.ReadParquetStatistics(partition) + if parquetErr == nil { + for _, stats := range parquetStats { + totalRowCount += stats.RowCount + } + } + + // Count live log rows (with deduplication) + parquetSourceFiles := make(map[string]bool) + if parquetErr == nil { + parquetSourceFiles = e.extractParquetSourceFiles(parquetStats) + } + + liveLogCount, liveLogErr := e.countLiveLogRowsExcludingParquetSources(ctx, partition, parquetSourceFiles) + if liveLogErr == nil { + totalRowCount += liveLogCount + } + } + + return totalRowCount, nil +} + +// getActualRowsScannedForFastPath returns only the rows that need to be scanned for fast path aggregations +// (i.e., live log rows that haven't been converted to parquet - parquet uses metadata only) +func (e *SQLEngine) getActualRowsScannedForFastPath(ctx context.Context, namespace, topicName string) (int64, error) { + // Create a hybrid scanner to access parquet statistics + var filerClient filer_pb.FilerClient + if e.catalog.brokerClient != nil { + var filerClientErr error + filerClient, filerClientErr = e.catalog.brokerClient.GetFilerClient() + if filerClientErr != nil { + return 0, filerClientErr + } + } + + hybridScanner, err := NewHybridMessageScanner(filerClient, e.catalog.brokerClient, namespace, topicName, e) + if err != nil { + return 0, err + } + + // Get all partitions for this topic + // Note: discoverTopicPartitions always returns absolute paths + partitions, err := e.discoverTopicPartitions(namespace, topicName) + if err != nil { + return 0, err + } + + totalScannedRows := int64(0) + + // For each partition, count ONLY the live log rows that need scanning + // (parquet files use metadata/statistics, so they contribute 0 to scan count) + for _, partition := range partitions { + // Get parquet files to determine what was converted + parquetStats, parquetErr := hybridScanner.ReadParquetStatistics(partition) + parquetSourceFiles := make(map[string]bool) + if parquetErr == nil { + parquetSourceFiles = e.extractParquetSourceFiles(parquetStats) + } + + // Count only live log rows that haven't been converted to parquet + liveLogCount, liveLogErr := e.countLiveLogRowsExcludingParquetSources(ctx, partition, parquetSourceFiles) + if liveLogErr == nil { + totalScannedRows += liveLogCount + } + + // Note: Parquet files contribute 0 to scan count since we use their metadata/statistics + } + + return totalScannedRows, nil +} + +// findColumnValue performs case-insensitive lookup of column values +// Now includes support for system columns stored in HybridScanResult +func (e *SQLEngine) findColumnValue(result HybridScanResult, columnName string) *schema_pb.Value { + // Check system columns first (stored separately in HybridScanResult) + lowerColumnName := strings.ToLower(columnName) + switch lowerColumnName { + case SW_COLUMN_NAME_TIMESTAMP, SW_DISPLAY_NAME_TIMESTAMP: + // For timestamp column, format as proper timestamp instead of raw nanoseconds + timestamp := time.Unix(result.Timestamp/1e9, result.Timestamp%1e9) + timestampStr := timestamp.UTC().Format("2006-01-02T15:04:05.000000000Z") + return &schema_pb.Value{Kind: &schema_pb.Value_StringValue{StringValue: timestampStr}} + case SW_COLUMN_NAME_KEY: + return &schema_pb.Value{Kind: &schema_pb.Value_BytesValue{BytesValue: result.Key}} + case SW_COLUMN_NAME_SOURCE: + return &schema_pb.Value{Kind: &schema_pb.Value_StringValue{StringValue: result.Source}} + } + + // Then check regular columns in Values map + // First try exact match + if value, exists := result.Values[columnName]; exists { + return value + } + + // Then try case-insensitive match + for key, value := range result.Values { + if strings.ToLower(key) == lowerColumnName { + return value + } + } + + return nil +} + +// discoverAndRegisterTopic attempts to discover an existing topic and register it in the SQL catalog +func (e *SQLEngine) discoverAndRegisterTopic(ctx context.Context, database, tableName string) error { + // First, check if topic exists by trying to get its schema from the broker/filer + recordType, _, _, err := e.catalog.brokerClient.GetTopicSchema(ctx, database, tableName) + if err != nil { + return fmt.Errorf("topic %s.%s not found or no schema available: %v", database, tableName, err) + } + + // Create a schema object from the discovered record type + mqSchema := &schema.Schema{ + Namespace: database, + Name: tableName, + RecordType: recordType, + RevisionId: 1, // Default to revision 1 for discovered topics + } + + // Register the topic in the SQL catalog + err = e.catalog.RegisterTopic(database, tableName, mqSchema) + if err != nil { + return fmt.Errorf("failed to register discovered topic %s.%s: %v", database, tableName, err) + } + + // Note: This is a discovery operation, not query execution, so it's okay to always log + return nil +} + +// getArithmeticExpressionAlias generates a display alias for arithmetic expressions +func (e *SQLEngine) getArithmeticExpressionAlias(expr *ArithmeticExpr) string { + leftAlias := e.getExpressionAlias(expr.Left) + rightAlias := e.getExpressionAlias(expr.Right) + return leftAlias + expr.Operator + rightAlias +} + +// getExpressionAlias generates an alias for any expression node +func (e *SQLEngine) getExpressionAlias(expr ExprNode) string { + switch exprType := expr.(type) { + case *ColName: + return exprType.Name.String() + case *ArithmeticExpr: + return e.getArithmeticExpressionAlias(exprType) + case *SQLVal: + return e.getSQLValAlias(exprType) + default: + return "expr" + } +} + +// evaluateArithmeticExpression evaluates an arithmetic expression for a given record +func (e *SQLEngine) evaluateArithmeticExpression(expr *ArithmeticExpr, result HybridScanResult) (*schema_pb.Value, error) { + // Check for timestamp arithmetic with intervals first + if e.isTimestampArithmetic(expr.Left, expr.Right) && (expr.Operator == "+" || expr.Operator == "-") { + return e.evaluateTimestampArithmetic(expr.Left, expr.Right, expr.Operator) + } + + // Get left operand value + leftValue, err := e.evaluateExpressionValue(expr.Left, result) + if err != nil { + return nil, fmt.Errorf("error evaluating left operand: %v", err) + } + + // Get right operand value + rightValue, err := e.evaluateExpressionValue(expr.Right, result) + if err != nil { + return nil, fmt.Errorf("error evaluating right operand: %v", err) + } + + // Handle string concatenation operator + if expr.Operator == "||" { + return e.Concat(leftValue, rightValue) + } + + // Perform arithmetic operation + var op ArithmeticOperator + switch expr.Operator { + case "+": + op = OpAdd + case "-": + op = OpSub + case "*": + op = OpMul + case "/": + op = OpDiv + case "%": + op = OpMod + default: + return nil, fmt.Errorf("unsupported arithmetic operator: %s", expr.Operator) + } + + return e.EvaluateArithmeticExpression(leftValue, rightValue, op) +} + +// isTimestampArithmetic checks if an arithmetic operation involves timestamps and intervals +func (e *SQLEngine) isTimestampArithmetic(left, right ExprNode) bool { + // Check if left is a timestamp function (NOW, CURRENT_TIMESTAMP, etc.) + leftIsTimestamp := e.isTimestampFunction(left) + + // Check if right is an interval + rightIsInterval := e.isIntervalExpression(right) + + return leftIsTimestamp && rightIsInterval +} + +// isTimestampFunction checks if an expression is a timestamp function +func (e *SQLEngine) isTimestampFunction(expr ExprNode) bool { + if funcExpr, ok := expr.(*FuncExpr); ok { + funcName := strings.ToUpper(funcExpr.Name.String()) + return funcName == "NOW" || funcName == "CURRENT_TIMESTAMP" || funcName == "CURRENT_DATE" || funcName == "CURRENT_TIME" + } + return false +} + +// isIntervalExpression checks if an expression is an interval +func (e *SQLEngine) isIntervalExpression(expr ExprNode) bool { + _, ok := expr.(*IntervalExpr) + return ok +} + +// evaluateExpressionValue evaluates any expression to get its value from a record +func (e *SQLEngine) evaluateExpressionValue(expr ExprNode, result HybridScanResult) (*schema_pb.Value, error) { + switch exprType := expr.(type) { + case *ColName: + columnName := exprType.Name.String() + upperColumnName := strings.ToUpper(columnName) + + // Check if this is actually a string literal that was parsed as ColName + if (strings.HasPrefix(columnName, "'") && strings.HasSuffix(columnName, "'")) || + (strings.HasPrefix(columnName, "\"") && strings.HasSuffix(columnName, "\"")) { + // This is a string literal that was incorrectly parsed as a column name + literal := strings.Trim(strings.Trim(columnName, "'"), "\"") + return &schema_pb.Value{Kind: &schema_pb.Value_StringValue{StringValue: literal}}, nil + } + + // Check if this is actually a function call that was parsed as ColName + if strings.Contains(columnName, "(") && strings.Contains(columnName, ")") { + // This is a function call that was parsed incorrectly as a column name + // We need to manually evaluate it as a function + return e.evaluateColumnNameAsFunction(columnName, result) + } + + // Check if this is a datetime constant + if upperColumnName == FuncCURRENT_DATE || upperColumnName == FuncCURRENT_TIME || + upperColumnName == FuncCURRENT_TIMESTAMP || upperColumnName == FuncNOW { + switch upperColumnName { + case FuncCURRENT_DATE: + return e.CurrentDate() + case FuncCURRENT_TIME: + return e.CurrentTime() + case FuncCURRENT_TIMESTAMP: + return e.CurrentTimestamp() + case FuncNOW: + return e.Now() + } + } + + // Check if this is actually a numeric literal disguised as a column name + if val, err := strconv.ParseInt(columnName, 10, 64); err == nil { + return &schema_pb.Value{Kind: &schema_pb.Value_Int64Value{Int64Value: val}}, nil + } + if val, err := strconv.ParseFloat(columnName, 64); err == nil { + return &schema_pb.Value{Kind: &schema_pb.Value_DoubleValue{DoubleValue: val}}, nil + } + + // Otherwise, treat as a regular column lookup + value := e.findColumnValue(result, columnName) + if value == nil { + return nil, nil + } + return value, nil + case *ArithmeticExpr: + return e.evaluateArithmeticExpression(exprType, result) + case *SQLVal: + // Handle literal values + return e.convertSQLValToSchemaValue(exprType), nil + case *FuncExpr: + // Handle function calls that are part of arithmetic expressions + funcName := strings.ToUpper(exprType.Name.String()) + + // Route to appropriate function evaluator based on function type + if e.isDateTimeFunction(funcName) { + // Use datetime function evaluator + return e.evaluateDateTimeFunction(exprType, result) + } else { + // Use string function evaluator + return e.evaluateStringFunction(exprType, result) + } + case *IntervalExpr: + // Handle interval expressions - evaluate as duration in nanoseconds + nanos, err := e.evaluateInterval(exprType.Value) + if err != nil { + return nil, err + } + return &schema_pb.Value{ + Kind: &schema_pb.Value_Int64Value{Int64Value: nanos}, + }, nil + default: + return nil, fmt.Errorf("unsupported expression type: %T", expr) + } +} + +// convertSQLValToSchemaValue converts SQLVal literal to schema_pb.Value +func (e *SQLEngine) convertSQLValToSchemaValue(sqlVal *SQLVal) *schema_pb.Value { + switch sqlVal.Type { + case IntVal: + if val, err := strconv.ParseInt(string(sqlVal.Val), 10, 64); err == nil { + return &schema_pb.Value{Kind: &schema_pb.Value_Int64Value{Int64Value: val}} + } + case FloatVal: + if val, err := strconv.ParseFloat(string(sqlVal.Val), 64); err == nil { + return &schema_pb.Value{Kind: &schema_pb.Value_DoubleValue{DoubleValue: val}} + } + case StrVal: + return &schema_pb.Value{Kind: &schema_pb.Value_StringValue{StringValue: string(sqlVal.Val)}} + } + // Default to string if parsing fails + return &schema_pb.Value{Kind: &schema_pb.Value_StringValue{StringValue: string(sqlVal.Val)}} +} + +// ConvertToSQLResultWithExpressions converts HybridScanResults to SQL query results with expression evaluation +func (e *SQLEngine) ConvertToSQLResultWithExpressions(hms *HybridMessageScanner, results []HybridScanResult, selectExprs []SelectExpr) *QueryResult { + if len(results) == 0 { + columns := make([]string, 0, len(selectExprs)) + for _, selectExpr := range selectExprs { + switch expr := selectExpr.(type) { + case *AliasedExpr: + // Check if alias is available and use it + if expr.As != nil && !expr.As.IsEmpty() { + columns = append(columns, expr.As.String()) + } else { + // Fall back to expression-based column naming + switch col := expr.Expr.(type) { + case *ColName: + columnName := col.Name.String() + upperColumnName := strings.ToUpper(columnName) + + // Check if this is an arithmetic expression embedded in a ColName + if arithmeticExpr := e.parseColumnLevelCalculation(columnName); arithmeticExpr != nil { + columns = append(columns, e.getArithmeticExpressionAlias(arithmeticExpr)) + } else if upperColumnName == FuncCURRENT_DATE || upperColumnName == FuncCURRENT_TIME || + upperColumnName == FuncCURRENT_TIMESTAMP || upperColumnName == FuncNOW { + // Use lowercase for datetime constants in column headers + columns = append(columns, strings.ToLower(columnName)) + } else { + // Use display name for system columns + displayName := e.getSystemColumnDisplayName(columnName) + columns = append(columns, displayName) + } + case *ArithmeticExpr: + columns = append(columns, e.getArithmeticExpressionAlias(col)) + case *FuncExpr: + columns = append(columns, e.getStringFunctionAlias(col)) + case *SQLVal: + columns = append(columns, e.getSQLValAlias(col)) + default: + columns = append(columns, "expr") + } + } + } + } + + return &QueryResult{ + Columns: columns, + Rows: [][]sqltypes.Value{}, + Database: hms.topic.Namespace, + Table: hms.topic.Name, + } + } + + // Build columns from SELECT expressions + columns := make([]string, 0, len(selectExprs)) + for _, selectExpr := range selectExprs { + switch expr := selectExpr.(type) { + case *AliasedExpr: + // Check if alias is available and use it + if expr.As != nil && !expr.As.IsEmpty() { + columns = append(columns, expr.As.String()) + } else { + // Fall back to expression-based column naming + switch col := expr.Expr.(type) { + case *ColName: + columnName := col.Name.String() + upperColumnName := strings.ToUpper(columnName) + + // Check if this is an arithmetic expression embedded in a ColName + if arithmeticExpr := e.parseColumnLevelCalculation(columnName); arithmeticExpr != nil { + columns = append(columns, e.getArithmeticExpressionAlias(arithmeticExpr)) + } else if upperColumnName == FuncCURRENT_DATE || upperColumnName == FuncCURRENT_TIME || + upperColumnName == FuncCURRENT_TIMESTAMP || upperColumnName == FuncNOW { + // Use lowercase for datetime constants in column headers + columns = append(columns, strings.ToLower(columnName)) + } else { + columns = append(columns, columnName) + } + case *ArithmeticExpr: + columns = append(columns, e.getArithmeticExpressionAlias(col)) + case *FuncExpr: + columns = append(columns, e.getStringFunctionAlias(col)) + case *SQLVal: + columns = append(columns, e.getSQLValAlias(col)) + default: + columns = append(columns, "expr") + } + } + } + } + + // Convert to SQL rows with expression evaluation + rows := make([][]sqltypes.Value, len(results)) + for i, result := range results { + row := make([]sqltypes.Value, len(selectExprs)) + for j, selectExpr := range selectExprs { + switch expr := selectExpr.(type) { + case *AliasedExpr: + switch col := expr.Expr.(type) { + case *ColName: + // Handle regular column, datetime constants, or arithmetic expressions + columnName := col.Name.String() + upperColumnName := strings.ToUpper(columnName) + + // Check if this is an arithmetic expression embedded in a ColName + if arithmeticExpr := e.parseColumnLevelCalculation(columnName); arithmeticExpr != nil { + // Handle as arithmetic expression + if value, err := e.evaluateArithmeticExpression(arithmeticExpr, result); err == nil && value != nil { + row[j] = convertSchemaValueToSQL(value) + } else { + row[j] = sqltypes.NULL + } + } else if upperColumnName == "CURRENT_DATE" || upperColumnName == "CURRENT_TIME" || + upperColumnName == "CURRENT_TIMESTAMP" || upperColumnName == "NOW" { + // Handle as datetime function + var value *schema_pb.Value + var err error + switch upperColumnName { + case FuncCURRENT_DATE: + value, err = e.CurrentDate() + case FuncCURRENT_TIME: + value, err = e.CurrentTime() + case FuncCURRENT_TIMESTAMP: + value, err = e.CurrentTimestamp() + case FuncNOW: + value, err = e.Now() + } + + if err == nil && value != nil { + row[j] = convertSchemaValueToSQL(value) + } else { + row[j] = sqltypes.NULL + } + } else { + // Handle as regular column + if value := e.findColumnValue(result, columnName); value != nil { + row[j] = convertSchemaValueToSQL(value) + } else { + row[j] = sqltypes.NULL + } + } + case *ArithmeticExpr: + // Handle arithmetic expression + if value, err := e.evaluateArithmeticExpression(col, result); err == nil && value != nil { + row[j] = convertSchemaValueToSQL(value) + } else { + row[j] = sqltypes.NULL + } + case *FuncExpr: + // Handle function - route to appropriate evaluator + funcName := strings.ToUpper(col.Name.String()) + var value *schema_pb.Value + var err error + + // Check if it's a datetime function + if e.isDateTimeFunction(funcName) { + value, err = e.evaluateDateTimeFunction(col, result) + } else { + // Default to string function evaluator + value, err = e.evaluateStringFunction(col, result) + } + + if err == nil && value != nil { + row[j] = convertSchemaValueToSQL(value) + } else { + row[j] = sqltypes.NULL + } + case *SQLVal: + // Handle literal value + value := e.convertSQLValToSchemaValue(col) + row[j] = convertSchemaValueToSQL(value) + default: + row[j] = sqltypes.NULL + } + default: + row[j] = sqltypes.NULL + } + } + rows[i] = row + } + + return &QueryResult{ + Columns: columns, + Rows: rows, + Database: hms.topic.Namespace, + Table: hms.topic.Name, + } +} + +// extractBaseColumns recursively extracts base column names from arithmetic expressions +func (e *SQLEngine) extractBaseColumns(expr *ArithmeticExpr, baseColumnsSet map[string]bool) { + // Extract columns from left operand + e.extractBaseColumnsFromExpression(expr.Left, baseColumnsSet) + // Extract columns from right operand + e.extractBaseColumnsFromExpression(expr.Right, baseColumnsSet) +} + +// extractBaseColumnsFromExpression extracts base column names from any expression node +func (e *SQLEngine) extractBaseColumnsFromExpression(expr ExprNode, baseColumnsSet map[string]bool) { + switch exprType := expr.(type) { + case *ColName: + columnName := exprType.Name.String() + // Check if it's a literal number disguised as a column name + if _, err := strconv.ParseInt(columnName, 10, 64); err != nil { + if _, err := strconv.ParseFloat(columnName, 64); err != nil { + // Not a numeric literal, treat as actual column name + baseColumnsSet[columnName] = true + } + } + case *ArithmeticExpr: + // Recursively handle nested arithmetic expressions + e.extractBaseColumns(exprType, baseColumnsSet) + } +} + +// isAggregationFunction checks if a function name is an aggregation function +func (e *SQLEngine) isAggregationFunction(funcName string) bool { + // Convert to uppercase for case-insensitive comparison + upperFuncName := strings.ToUpper(funcName) + switch upperFuncName { + case FuncCOUNT, FuncSUM, FuncAVG, FuncMIN, FuncMAX: + return true + default: + return false + } +} + +// isStringFunction checks if a function name is a string function +func (e *SQLEngine) isStringFunction(funcName string) bool { + switch funcName { + case FuncUPPER, FuncLOWER, FuncLENGTH, FuncTRIM, FuncBTRIM, FuncLTRIM, FuncRTRIM, FuncSUBSTRING, FuncLEFT, FuncRIGHT, FuncCONCAT: + return true + default: + return false + } +} + +// isDateTimeFunction checks if a function name is a datetime function +func (e *SQLEngine) isDateTimeFunction(funcName string) bool { + switch funcName { + case FuncCURRENT_DATE, FuncCURRENT_TIME, FuncCURRENT_TIMESTAMP, FuncNOW, FuncEXTRACT, FuncDATE_TRUNC: + return true + default: + return false + } +} + +// getStringFunctionAlias generates an alias for string functions +func (e *SQLEngine) getStringFunctionAlias(funcExpr *FuncExpr) string { + funcName := funcExpr.Name.String() + if len(funcExpr.Exprs) == 1 { + if aliasedExpr, ok := funcExpr.Exprs[0].(*AliasedExpr); ok { + if colName, ok := aliasedExpr.Expr.(*ColName); ok { + return fmt.Sprintf("%s(%s)", funcName, colName.Name.String()) + } + } + } + return fmt.Sprintf("%s(...)", funcName) +} + +// getDateTimeFunctionAlias generates an alias for datetime functions +func (e *SQLEngine) getDateTimeFunctionAlias(funcExpr *FuncExpr) string { + funcName := funcExpr.Name.String() + + // Handle zero-argument functions like CURRENT_DATE, NOW + if len(funcExpr.Exprs) == 0 { + // Use lowercase for datetime constants in column headers + return strings.ToLower(funcName) + } + + // Handle EXTRACT function specially to create unique aliases + if strings.ToUpper(funcName) == "EXTRACT" && len(funcExpr.Exprs) == 2 { + // Try to extract the date part to make the alias unique + if aliasedExpr, ok := funcExpr.Exprs[0].(*AliasedExpr); ok { + if sqlVal, ok := aliasedExpr.Expr.(*SQLVal); ok && sqlVal.Type == StrVal { + datePart := strings.ToLower(string(sqlVal.Val)) + return fmt.Sprintf("extract_%s", datePart) + } + } + // Fallback to generic if we can't extract the date part + return fmt.Sprintf("%s(...)", funcName) + } + + // Handle other multi-argument functions like DATE_TRUNC + if len(funcExpr.Exprs) == 2 { + return fmt.Sprintf("%s(...)", funcName) + } + + return fmt.Sprintf("%s(...)", funcName) +} + +// extractBaseColumnsFromFunction extracts base columns needed by a string function +func (e *SQLEngine) extractBaseColumnsFromFunction(funcExpr *FuncExpr, baseColumnsSet map[string]bool) { + for _, expr := range funcExpr.Exprs { + if aliasedExpr, ok := expr.(*AliasedExpr); ok { + e.extractBaseColumnsFromExpression(aliasedExpr.Expr, baseColumnsSet) + } + } +} + +// getSQLValAlias generates an alias for SQL literal values +func (e *SQLEngine) getSQLValAlias(sqlVal *SQLVal) string { + switch sqlVal.Type { + case StrVal: + // Escape single quotes by replacing ' with '' (SQL standard escaping) + escapedVal := strings.ReplaceAll(string(sqlVal.Val), "'", "''") + return fmt.Sprintf("'%s'", escapedVal) + case IntVal: + return string(sqlVal.Val) + case FloatVal: + return string(sqlVal.Val) + default: + return "literal" + } +} + +// evaluateStringFunction evaluates a string function for a given record +func (e *SQLEngine) evaluateStringFunction(funcExpr *FuncExpr, result HybridScanResult) (*schema_pb.Value, error) { + funcName := strings.ToUpper(funcExpr.Name.String()) + + // Most string functions require exactly 1 argument + if len(funcExpr.Exprs) != 1 { + return nil, fmt.Errorf("function %s expects exactly 1 argument", funcName) + } + + // Get the argument value + var argValue *schema_pb.Value + if aliasedExpr, ok := funcExpr.Exprs[0].(*AliasedExpr); ok { + var err error + argValue, err = e.evaluateExpressionValue(aliasedExpr.Expr, result) + if err != nil { + return nil, fmt.Errorf("error evaluating function argument: %v", err) + } + } else { + return nil, fmt.Errorf("unsupported function argument type") + } + + if argValue == nil { + return nil, nil // NULL input produces NULL output + } + + // Call the appropriate string function + switch funcName { + case FuncUPPER: + return e.Upper(argValue) + case FuncLOWER: + return e.Lower(argValue) + case FuncLENGTH: + return e.Length(argValue) + case FuncTRIM, FuncBTRIM: // CockroachDB converts TRIM to BTRIM + return e.Trim(argValue) + case FuncLTRIM: + return e.LTrim(argValue) + case FuncRTRIM: + return e.RTrim(argValue) + default: + return nil, fmt.Errorf("unsupported string function: %s", funcName) + } +} + +// evaluateDateTimeFunction evaluates a datetime function for a given record +func (e *SQLEngine) evaluateDateTimeFunction(funcExpr *FuncExpr, result HybridScanResult) (*schema_pb.Value, error) { + funcName := strings.ToUpper(funcExpr.Name.String()) + + switch funcName { + case FuncEXTRACT: + // EXTRACT requires exactly 2 arguments: date part and value + if len(funcExpr.Exprs) != 2 { + return nil, fmt.Errorf("EXTRACT function expects exactly 2 arguments (date_part, value), got %d", len(funcExpr.Exprs)) + } + + // Get the first argument (date part) + var datePartValue *schema_pb.Value + if aliasedExpr, ok := funcExpr.Exprs[0].(*AliasedExpr); ok { + var err error + datePartValue, err = e.evaluateExpressionValue(aliasedExpr.Expr, result) + if err != nil { + return nil, fmt.Errorf("error evaluating EXTRACT date part argument: %v", err) + } + } else { + return nil, fmt.Errorf("unsupported EXTRACT date part argument type") + } + + if datePartValue == nil { + return nil, fmt.Errorf("EXTRACT date part cannot be NULL") + } + + // Convert date part to string + var datePart string + if stringVal, ok := datePartValue.Kind.(*schema_pb.Value_StringValue); ok { + datePart = strings.ToUpper(stringVal.StringValue) + } else { + return nil, fmt.Errorf("EXTRACT date part must be a string") + } + + // Get the second argument (value to extract from) + var extractValue *schema_pb.Value + if aliasedExpr, ok := funcExpr.Exprs[1].(*AliasedExpr); ok { + var err error + extractValue, err = e.evaluateExpressionValue(aliasedExpr.Expr, result) + if err != nil { + return nil, fmt.Errorf("error evaluating EXTRACT value argument: %v", err) + } + } else { + return nil, fmt.Errorf("unsupported EXTRACT value argument type") + } + + if extractValue == nil { + return nil, nil // NULL input produces NULL output + } + + // Call the Extract function + return e.Extract(DatePart(datePart), extractValue) + + case FuncDATE_TRUNC: + // DATE_TRUNC requires exactly 2 arguments: precision and value + if len(funcExpr.Exprs) != 2 { + return nil, fmt.Errorf("DATE_TRUNC function expects exactly 2 arguments (precision, value), got %d", len(funcExpr.Exprs)) + } + + // Get the first argument (precision) + var precisionValue *schema_pb.Value + if aliasedExpr, ok := funcExpr.Exprs[0].(*AliasedExpr); ok { + var err error + precisionValue, err = e.evaluateExpressionValue(aliasedExpr.Expr, result) + if err != nil { + return nil, fmt.Errorf("error evaluating DATE_TRUNC precision argument: %v", err) + } + } else { + return nil, fmt.Errorf("unsupported DATE_TRUNC precision argument type") + } + + if precisionValue == nil { + return nil, fmt.Errorf("DATE_TRUNC precision cannot be NULL") + } + + // Convert precision to string + var precision string + if stringVal, ok := precisionValue.Kind.(*schema_pb.Value_StringValue); ok { + precision = stringVal.StringValue + } else { + return nil, fmt.Errorf("DATE_TRUNC precision must be a string") + } + + // Get the second argument (value to truncate) + var truncateValue *schema_pb.Value + if aliasedExpr, ok := funcExpr.Exprs[1].(*AliasedExpr); ok { + var err error + truncateValue, err = e.evaluateExpressionValue(aliasedExpr.Expr, result) + if err != nil { + return nil, fmt.Errorf("error evaluating DATE_TRUNC value argument: %v", err) + } + } else { + return nil, fmt.Errorf("unsupported DATE_TRUNC value argument type") + } + + if truncateValue == nil { + return nil, nil // NULL input produces NULL output + } + + // Call the DateTrunc function + return e.DateTrunc(precision, truncateValue) + + case FuncCURRENT_DATE: + // CURRENT_DATE is a zero-argument function + if len(funcExpr.Exprs) != 0 { + return nil, fmt.Errorf("CURRENT_DATE function expects no arguments, got %d", len(funcExpr.Exprs)) + } + return e.CurrentDate() + + case FuncCURRENT_TIME: + // CURRENT_TIME is a zero-argument function + if len(funcExpr.Exprs) != 0 { + return nil, fmt.Errorf("CURRENT_TIME function expects no arguments, got %d", len(funcExpr.Exprs)) + } + return e.CurrentTime() + + case FuncCURRENT_TIMESTAMP: + // CURRENT_TIMESTAMP is a zero-argument function + if len(funcExpr.Exprs) != 0 { + return nil, fmt.Errorf("CURRENT_TIMESTAMP function expects no arguments, got %d", len(funcExpr.Exprs)) + } + return e.CurrentTimestamp() + + case FuncNOW: + // NOW is a zero-argument function (but often used with () syntax) + if len(funcExpr.Exprs) != 0 { + return nil, fmt.Errorf("NOW function expects no arguments, got %d", len(funcExpr.Exprs)) + } + return e.Now() + + // PostgreSQL uses EXTRACT(part FROM date) instead of convenience functions like YEAR(date) + + default: + return nil, fmt.Errorf("unsupported datetime function: %s", funcName) + } +} + +// evaluateInterval parses an interval string and returns duration in nanoseconds +func (e *SQLEngine) evaluateInterval(intervalValue string) (int64, error) { + // Parse interval strings like "1 hour", "30 minutes", "2 days" + parts := strings.Fields(strings.TrimSpace(intervalValue)) + if len(parts) != 2 { + return 0, fmt.Errorf("invalid interval format: %s (expected 'number unit')", intervalValue) + } + + // Parse the numeric value + value, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid interval value: %s", parts[0]) + } + + // Parse the unit and convert to nanoseconds + unit := strings.ToLower(parts[1]) + var multiplier int64 + + switch unit { + case "nanosecond", "nanoseconds", "ns": + multiplier = 1 + case "microsecond", "microseconds", "us": + multiplier = 1000 + case "millisecond", "milliseconds", "ms": + multiplier = 1000000 + case "second", "seconds", "s": + multiplier = 1000000000 + case "minute", "minutes", "m": + multiplier = 60 * 1000000000 + case "hour", "hours", "h": + multiplier = 60 * 60 * 1000000000 + case "day", "days", "d": + multiplier = 24 * 60 * 60 * 1000000000 + case "week", "weeks", "w": + multiplier = 7 * 24 * 60 * 60 * 1000000000 + default: + return 0, fmt.Errorf("unsupported interval unit: %s", unit) + } + + return value * multiplier, nil +} + +// convertValueForTimestampColumn converts string timestamp values to nanoseconds for system timestamp columns +func (e *SQLEngine) convertValueForTimestampColumn(columnName string, value interface{}, expr ExprNode) interface{} { + // Special handling for timestamp system columns + if columnName == SW_COLUMN_NAME_TIMESTAMP { + if _, ok := value.(string); ok { + if timeNanos := e.extractTimeValue(expr); timeNanos != 0 { + return timeNanos + } + } + } + return value +} + +// evaluateTimestampArithmetic performs arithmetic operations with timestamps and intervals +func (e *SQLEngine) evaluateTimestampArithmetic(left, right ExprNode, operator string) (*schema_pb.Value, error) { + // Handle timestamp arithmetic: NOW() - INTERVAL '1 hour' + // For timestamp arithmetic, we don't need the result context, so we pass an empty one + emptyResult := HybridScanResult{} + + leftValue, err := e.evaluateExpressionValue(left, emptyResult) + if err != nil { + return nil, fmt.Errorf("failed to evaluate left operand: %v", err) + } + + rightValue, err := e.evaluateExpressionValue(right, emptyResult) + if err != nil { + return nil, fmt.Errorf("failed to evaluate right operand: %v", err) + } + + // Convert left operand (should be timestamp) + var leftTimestamp int64 + if leftValue.Kind != nil { + switch leftKind := leftValue.Kind.(type) { + case *schema_pb.Value_Int64Value: + leftTimestamp = leftKind.Int64Value + case *schema_pb.Value_TimestampValue: + // Convert microseconds to nanoseconds + leftTimestamp = leftKind.TimestampValue.TimestampMicros * 1000 + case *schema_pb.Value_StringValue: + // Parse timestamp string + if ts, err := time.Parse(time.RFC3339, leftKind.StringValue); err == nil { + leftTimestamp = ts.UnixNano() + } else if ts, err := time.Parse("2006-01-02 15:04:05", leftKind.StringValue); err == nil { + leftTimestamp = ts.UnixNano() + } else { + return nil, fmt.Errorf("invalid timestamp format: %s", leftKind.StringValue) + } + default: + return nil, fmt.Errorf("left operand must be a timestamp, got: %T", leftKind) + } + } else { + return nil, fmt.Errorf("left operand value is nil") + } + + // Convert right operand (should be interval in nanoseconds) + var intervalNanos int64 + if rightValue.Kind != nil { + switch rightKind := rightValue.Kind.(type) { + case *schema_pb.Value_Int64Value: + intervalNanos = rightKind.Int64Value + default: + return nil, fmt.Errorf("right operand must be an interval duration") + } + } else { + return nil, fmt.Errorf("right operand value is nil") + } + + // Perform arithmetic + var resultTimestamp int64 + switch operator { + case "+": + resultTimestamp = leftTimestamp + intervalNanos + case "-": + resultTimestamp = leftTimestamp - intervalNanos + default: + return nil, fmt.Errorf("unsupported timestamp arithmetic operator: %s", operator) + } + + // Return as timestamp + return &schema_pb.Value{ + Kind: &schema_pb.Value_Int64Value{Int64Value: resultTimestamp}, + }, nil +} + +// evaluateColumnNameAsFunction handles function calls that were incorrectly parsed as column names +func (e *SQLEngine) evaluateColumnNameAsFunction(columnName string, result HybridScanResult) (*schema_pb.Value, error) { + // Simple parser for basic function calls like TRIM('hello world') + // Extract function name and argument + parenPos := strings.Index(columnName, "(") + if parenPos == -1 { + return nil, fmt.Errorf("invalid function format: %s", columnName) + } + + funcName := strings.ToUpper(strings.TrimSpace(columnName[:parenPos])) + argsString := columnName[parenPos+1:] + + // Find the closing parenthesis (handling nested quotes) + closeParen := strings.LastIndex(argsString, ")") + if closeParen == -1 { + return nil, fmt.Errorf("missing closing parenthesis in function: %s", columnName) + } + + argString := strings.TrimSpace(argsString[:closeParen]) + + // Parse the argument - for now handle simple cases + var argValue *schema_pb.Value + var err error + + if strings.HasPrefix(argString, "'") && strings.HasSuffix(argString, "'") { + // String literal argument + literal := strings.Trim(argString, "'") + argValue = &schema_pb.Value{Kind: &schema_pb.Value_StringValue{StringValue: literal}} + } else if strings.Contains(argString, "(") && strings.Contains(argString, ")") { + // Nested function call - recursively evaluate it + argValue, err = e.evaluateColumnNameAsFunction(argString, result) + if err != nil { + return nil, fmt.Errorf("error evaluating nested function argument: %v", err) + } + } else { + // Column name or other expression + return nil, fmt.Errorf("unsupported argument type in function: %s", argString) + } + + if argValue == nil { + return nil, nil + } + + // Call the appropriate function + switch funcName { + case FuncUPPER: + return e.Upper(argValue) + case FuncLOWER: + return e.Lower(argValue) + case FuncLENGTH: + return e.Length(argValue) + case FuncTRIM, FuncBTRIM: // CockroachDB converts TRIM to BTRIM + return e.Trim(argValue) + case FuncLTRIM: + return e.LTrim(argValue) + case FuncRTRIM: + return e.RTrim(argValue) + // PostgreSQL-only: Use EXTRACT(YEAR FROM date) instead of YEAR(date) + default: + return nil, fmt.Errorf("unsupported function in column name: %s", funcName) + } +} + +// parseColumnLevelCalculation detects and parses arithmetic expressions that contain function calls +// This handles cases where the SQL parser incorrectly treats "LENGTH('hello') + 10" as a single ColName +func (e *SQLEngine) parseColumnLevelCalculation(expression string) *ArithmeticExpr { + // First check if this looks like an arithmetic expression + if !e.containsArithmeticOperator(expression) { + return nil + } + + // Build AST for the arithmetic expression + return e.buildArithmeticAST(expression) +} + +// containsArithmeticOperator checks if the expression contains arithmetic operators outside of function calls +func (e *SQLEngine) containsArithmeticOperator(expr string) bool { + operators := []string{"+", "-", "*", "/", "%", "||"} + + parenLevel := 0 + quoteLevel := false + + for i, char := range expr { + switch char { + case '(': + if !quoteLevel { + parenLevel++ + } + case ')': + if !quoteLevel { + parenLevel-- + } + case '\'': + quoteLevel = !quoteLevel + default: + // Only check for operators outside of parentheses and quotes + if parenLevel == 0 && !quoteLevel { + for _, op := range operators { + if strings.HasPrefix(expr[i:], op) { + return true + } + } + } + } + } + + return false +} + +// buildArithmeticAST builds an Abstract Syntax Tree for arithmetic expressions containing function calls +func (e *SQLEngine) buildArithmeticAST(expr string) *ArithmeticExpr { + // Remove leading/trailing spaces + expr = strings.TrimSpace(expr) + + // Find the main operator (outside of parentheses) + operators := []string{"||", "+", "-", "*", "/", "%"} // Order matters for precedence + + for _, op := range operators { + opPos := e.findMainOperator(expr, op) + if opPos != -1 { + leftExpr := strings.TrimSpace(expr[:opPos]) + rightExpr := strings.TrimSpace(expr[opPos+len(op):]) + + if leftExpr != "" && rightExpr != "" { + return &ArithmeticExpr{ + Left: e.parseASTExpressionNode(leftExpr), + Right: e.parseASTExpressionNode(rightExpr), + Operator: op, + } + } + } + } + + return nil +} + +// findMainOperator finds the position of an operator that's not inside parentheses or quotes +func (e *SQLEngine) findMainOperator(expr string, operator string) int { + parenLevel := 0 + quoteLevel := false + + for i := 0; i <= len(expr)-len(operator); i++ { + char := expr[i] + + switch char { + case '(': + if !quoteLevel { + parenLevel++ + } + case ')': + if !quoteLevel { + parenLevel-- + } + case '\'': + quoteLevel = !quoteLevel + default: + // Check for operator only at top level (not inside parentheses or quotes) + if parenLevel == 0 && !quoteLevel && strings.HasPrefix(expr[i:], operator) { + return i + } + } + } + + return -1 +} + +// parseASTExpressionNode parses an expression into the appropriate ExprNode type +func (e *SQLEngine) parseASTExpressionNode(expr string) ExprNode { + expr = strings.TrimSpace(expr) + + // Check if it's a function call (contains parentheses) + if strings.Contains(expr, "(") && strings.Contains(expr, ")") { + // This should be parsed as a function expression, but since our SQL parser + // has limitations, we'll create a special ColName that represents the function + return &ColName{Name: stringValue(expr)} + } + + // Check if it's a numeric literal + if _, err := strconv.ParseInt(expr, 10, 64); err == nil { + return &SQLVal{Type: IntVal, Val: []byte(expr)} + } + + if _, err := strconv.ParseFloat(expr, 64); err == nil { + return &SQLVal{Type: FloatVal, Val: []byte(expr)} + } + + // Check if it's a string literal + if strings.HasPrefix(expr, "'") && strings.HasSuffix(expr, "'") { + return &SQLVal{Type: StrVal, Val: []byte(strings.Trim(expr, "'"))} + } + + // Check for nested arithmetic expressions + if nestedArithmetic := e.buildArithmeticAST(expr); nestedArithmetic != nil { + return nestedArithmetic + } + + // Default to column name + return &ColName{Name: stringValue(expr)} +} diff --git a/weed/s3api/s3api_object_handlers.go b/weed/s3api/s3api_object_handlers.go new file mode 100644 index 0000000..142665c --- /dev/null +++ b/weed/s3api/s3api_object_handlers.go @@ -0,0 +1,3557 @@ +package s3api + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "mime" + "net/http" + "net/url" + "path/filepath" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/pb/remote_pb" + "github.com/seaweedfs/seaweedfs/weed/remote_storage" + "github.com/seaweedfs/seaweedfs/weed/security" + weed_server "github.com/seaweedfs/seaweedfs/weed/server" + "github.com/seaweedfs/seaweedfs/weed/util" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/seaweedfs/seaweedfs/weed/stats" + util_http "github.com/seaweedfs/seaweedfs/weed/util/http" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// zeroBuf is a reusable buffer of zero bytes for padding operations +// Package-level to avoid per-call allocations in writeZeroBytes +var zeroBuf = make([]byte, 32*1024) + +// countingWriter wraps an io.Writer to count bytes written +type countingWriter struct { + w io.Writer + written int64 + writeErr error +} + +func (cw *countingWriter) Write(p []byte) (int, error) { + n, err := cw.w.Write(p) + cw.written += int64(n) + if err != nil { + cw.writeErr = err + } + return n, err +} + +// commitOnFirstWrite defers the status line until the first body write, so the +// first read from the volume servers must succeed before the 200 is committed +// and a missing needle still gets a clean 5xx instead of a broken 200 body. +type commitOnFirstWrite struct { + w io.Writer + commit func() + committed bool +} + +func (c *commitOnFirstWrite) Write(p []byte) (int, error) { + c.Commit() + return c.w.Write(p) +} + +// Commit commits the response if the first body write has not already done so; +// a zero-length body never writes, so a successful stream must end with this. +func (c *commitOnFirstWrite) Commit() { + if !c.committed { + c.committed = true + c.commit() + } +} + +// adjustRangeForPart adjusts a client's Range header to absolute offsets within a part. +// Parameters: +// - partStartOffset: the absolute start offset of the part in the object +// - partEndOffset: the absolute end offset of the part in the object +// - clientRangeHeader: the Range header value from the client (e.g., "bytes=0-99") +// +// Returns: +// - adjustedStart: the adjusted absolute start offset +// - adjustedEnd: the adjusted absolute end offset +// - error: nil on success, error if the range is invalid +func adjustRangeForPart(partStartOffset, partEndOffset int64, clientRangeHeader string) (adjustedStart, adjustedEnd int64, err error) { + // Validate inputs + if partStartOffset > partEndOffset { + return 0, 0, fmt.Errorf("invalid part boundaries: start %d > end %d", partStartOffset, partEndOffset) + } + + // If no range header, return the full part + if clientRangeHeader == "" || !strings.HasPrefix(clientRangeHeader, "bytes=") { + return partStartOffset, partEndOffset, nil + } + + // Parse client's range request (relative to the part) + rangeSpec := clientRangeHeader[6:] // Remove "bytes=" prefix + parts := strings.Split(rangeSpec, "-") + + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid range format") + } + + partSize := partEndOffset - partStartOffset + 1 + var clientStart, clientEnd int64 + + // Parse start offset + if parts[0] != "" { + clientStart, err = strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, 0, fmt.Errorf("invalid range start: %w", err) + } + } + + // Parse end offset + if parts[1] != "" { + clientEnd, err = strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return 0, 0, fmt.Errorf("invalid range end: %w", err) + } + } else { + // No end specified, read to end of part + clientEnd = partSize - 1 + } + + // Handle suffix-range (e.g., "bytes=-100" means last 100 bytes) + // When parts[0] is empty, the parsed clientEnd value represents the suffix length, + // not the actual end position. We compute the actual start/end from the suffix length. + if parts[0] == "" { + suffixLength := clientEnd // clientEnd temporarily holds the suffix length + if suffixLength > partSize { + suffixLength = partSize + } + clientStart = partSize - suffixLength + clientEnd = partSize - 1 // Now clientEnd holds the actual end position + } + + // Validate range is within part boundaries + if clientStart < 0 || clientStart >= partSize { + return 0, 0, fmt.Errorf("range start %d out of bounds for part size %d", clientStart, partSize) + } + if clientEnd >= partSize { + clientEnd = partSize - 1 + } + if clientStart > clientEnd { + return 0, 0, fmt.Errorf("range start %d > end %d", clientStart, clientEnd) + } + + // Adjust to absolute offsets in the object + adjustedStart = partStartOffset + clientStart + adjustedEnd = partStartOffset + clientEnd + + return adjustedStart, adjustedEnd, nil +} + +// parseAndValidateRange parses the Range header and validates it against the object size. +// It also handles SeaweedFS-specific directory object checks. +// Returns: +// - offset: the absolute start offset in the object +// - size: the number of bytes to read +// - isRangeRequest: true if the client requested a range +// - err: nil on success, StreamError on failure (wraps S3 error response) +func (s3a *S3ApiServer) parseAndValidateRange(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, totalSize int64, bucket, object string) (offset, size int64, isRangeRequest bool, err *StreamError) { + rangeHeader := r.Header.Get("Range") + if rangeHeader == "" || !strings.HasPrefix(rangeHeader, "bytes=") { + return 0, totalSize, false, nil + } + + rangeSpec := rangeHeader[6:] + parts := strings.Split(rangeSpec, "-") + if len(parts) != 2 { + return 0, totalSize, false, nil + } + + // Empty directory: 404. A file promoted to a directory keeps its data and stays retrievable. + if entry.IsDirectory && totalSize == 0 { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("directory object %s/%s cannot be retrieved", bucket, object)) + } + + var startOffset, endOffset int64 + if parts[0] == "" && parts[1] != "" { + // Suffix range: bytes=-N (last N bytes) + if suffixLen, err := strconv.ParseInt(parts[1], 10, 64); err == nil { + // RFC 7233: suffix range on empty object or zero-length suffix is unsatisfiable + if totalSize == 0 || suffixLen <= 0 { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("invalid suffix range for empty object")) + } + if suffixLen > totalSize { + suffixLen = totalSize + } + startOffset = totalSize - suffixLen + endOffset = totalSize - 1 + } else { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("invalid suffix range")) + } + } else { + // Regular range or open-ended range + startOffset = 0 + endOffset = totalSize - 1 + + if parts[0] != "" { + parsed, parseErr := strconv.ParseInt(parts[0], 10, 64) + if parseErr != nil { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("invalid range start: %w", parseErr)) + } + startOffset = parsed + } + if parts[1] != "" { + parsed, parseErr := strconv.ParseInt(parts[1], 10, 64) + if parseErr != nil { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("invalid range end: %w", parseErr)) + } + endOffset = parsed + } + + // Special case: range requests on empty files should return 416 + if totalSize == 0 { + w.Header().Set("Content-Range", "bytes */0") + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("range request on empty file %s/%s", bucket, object)) + } + + // Validate range + if startOffset < 0 || startOffset >= totalSize { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("invalid range start: %d >= %d, range: %s", startOffset, totalSize, rangeHeader)) + } + + if endOffset >= totalSize { + endOffset = totalSize - 1 + } + + if endOffset < startOffset { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return 0, 0, false, newStreamErrorWithResponse(fmt.Errorf("invalid range: end before start")) + } + } + + return startOffset, endOffset - startOffset + 1, true, nil +} + +// StreamError is returned when streaming functions encounter errors. +// It tracks whether an HTTP response has already been written to prevent +// double WriteHeader calls that would create malformed S3 error responses. +type StreamError struct { + // Err is the underlying error + Err error + // ResponseWritten indicates if HTTP headers/status have been written to ResponseWriter + ResponseWritten bool +} + +func (e *StreamError) Error() string { + return e.Err.Error() +} + +func (e *StreamError) Unwrap() error { + return e.Err +} + +// newStreamErrorWithResponse creates a StreamError for cases where response was already written +func newStreamErrorWithResponse(err error) *StreamError { + return &StreamError{Err: err, ResponseWritten: true} +} + +func isCanceledStreamingError(err error) bool { + return errors.Is(err, context.Canceled) || status.Code(err) == codes.Canceled +} + +func shouldWriteStreamingErrorResponse(err error) bool { + return err != nil && !isCanceledStreamingError(err) +} + +func mimeDetect(r *http.Request, dataReader io.Reader) io.ReadCloser { + mimeBuffer := make([]byte, 512) + size, _ := dataReader.Read(mimeBuffer) + if size > 0 { + r.Header.Set("Content-Type", http.DetectContentType(mimeBuffer[:size])) + return io.NopCloser(io.MultiReader(bytes.NewReader(mimeBuffer[:size]), dataReader)) + } + return io.NopCloser(dataReader) +} + +func entryUrlEncode(dir string, entry string, encodingTypeUrl bool) (dirName string, entryName string, prefix string) { + if !encodingTypeUrl { + return dir, entry, entry + } + return urlPathEscape(dir), url.QueryEscape(entry), urlPathEscape(entry) +} + +func urlPathEscape(object string) string { + var escapedParts []string + for _, part := range strings.Split(object, "/") { + escapedParts = append(escapedParts, strings.ReplaceAll(url.PathEscape(part), "+", "%2B")) + } + return strings.Join(escapedParts, "/") +} + +func removeDuplicateSlashes(object string) string { + result := strings.Builder{} + result.Grow(len(object)) + + isLastSlash := false + for _, r := range object { + switch r { + case '/': + if !isLastSlash { + result.WriteRune(r) + } + isLastSlash = true + default: + result.WriteRune(r) + isLastSlash = false + } + } + return result.String() +} + +// hasChildren checks if a path has any child objects (is a directory with contents). +// On unexpected errors, it logs and conservatively returns true to avoid hiding entries. +// +// This helper function is used to distinguish implicit directories from regular files or empty directories. +// An implicit directory is one that exists only because it has children, not because it was explicitly created. +// +// Implementation: +// - Lists the directory with Limit=1 to check for at least one child +// - Returns true if any child exists, false otherwise +// - Efficient: only fetches one entry to minimize overhead +// +// Used by HeadObjectHandler to implement AWS S3-compatible implicit directory behavior: +// - If a 0-byte object or directory has children → it's an implicit directory → HEAD returns 404 +// - If a 0-byte object or directory has no children → it's empty → HEAD returns 200 +// +// Examples: +// +// hasChildren("bucket", "dataset") where "dataset/file.txt" exists → true +// hasChildren("bucket", "empty-dir") where no children exist → false +// +// Performance: ~1-5ms per call (one gRPC LIST request with Limit=1) +func (s3a *S3ApiServer) hasChildren(ctx context.Context, bucket, prefix string) bool { + // Clean up prefix: remove leading slashes + cleanPrefix := strings.TrimPrefix(prefix, "/") + + // The directory to list is bucketDir + cleanPrefix + bucketDir := s3a.bucketDir(bucket) + fullPath := bucketDir + "/" + cleanPrefix + + // List one child object. filer_pb.List cancels the underlying ListEntries + // stream when it returns, so gRPC's per-stream client goroutine is not leaked. + // The caller's request context is propagated so the probe is cancelled if the + // client disconnects. + found := false + err := filer_pb.List(ctx, s3a, fullPath, "", func(*filer_pb.Entry, bool) error { + found = true + return nil + }, "", true, 1) + + if err == nil { + return found + } + if errors.Is(err, filer_pb.ErrNotFound) { + return false + } + glog.V(1).Infof("hasChildren: list entries failed for %s/%s: %v", bucket, cleanPrefix, err) + return true +} + +// isBareDirectory reports whether entry is a directory carrying no object data of its own. +// Such a path is not an S3 object: GET/HEAD answer 404 like AWS does for a prefix, and +// Hadoop-style clients then discover the directory through their LIST fallback. +func isBareDirectory(entry *filer_pb.Entry) bool { + return entry != nil && entry.IsDirectory && filer.FileSize(entry) == 0 && !entry.IsPrefixObject() +} + +// checkDirectoryObject checks if the object is a directory object (ends with "/") and if it exists +// Returns: (entry, isDirectoryObject, error) +// - entry: the directory entry if found and is a directory +// - isDirectoryObject: true if the request was for a directory object (ends with "/") +// - error: any error encountered while checking +func (s3a *S3ApiServer) checkDirectoryObject(bucket, object string) (*filer_pb.Entry, bool, error) { + if !strings.HasSuffix(object, "/") { + return nil, false, nil // Not a directory object + } + + bucketDir := s3a.bucketDir(bucket) + cleanObject := strings.TrimSuffix(object, "/") + + if cleanObject == "" { + return nil, true, nil // Root level directory object, but we don't handle it + } + + // Check if directory exists + dirEntry, err := s3a.getEntry(bucketDir, cleanObject) + if err != nil { + if errors.Is(err, filer_pb.ErrNotFound) { + return nil, true, nil // Directory object requested but doesn't exist + } + return nil, true, err // Other errors should be propagated + } + + if !dirEntry.IsDirectory { + return nil, true, nil // Exists but not a directory + } + + return dirEntry, true, nil +} + +// resolveObjectEntry resolves the object entry for conditional checks: the version the +// request names when the bucket is versioned, otherwise the latest version. Callers +// with no version to target pass an empty versionId. +func (s3a *S3ApiServer) resolveObjectEntry(bucket, object, versionId string) (*filer_pb.Entry, error) { + // Check if versioning is configured + versioningConfigured, err := s3a.isVersioningConfigured(bucket) + if err != nil && !errors.Is(err, filer_pb.ErrNotFound) { + glog.Errorf("resolveObjectEntry: error checking versioning config for %s: %v", bucket, err) + return nil, err + } + + if versioningConfigured { + if versionId != "" { + return s3a.getSpecificObjectVersion(bucket, object, versionId) + } + // For versioned buckets, we must use getLatestObjectVersion to correctly + // find the latest versioned object (in .versions/) or null version. + // Standard getEntry would fail to find objects moved to .versions/. + // Use 1 retry (fast path) for conditional checks to avoid backoff latency. + return s3a.doGetLatestObjectVersion(bucket, object, 1) + } + + // For non-versioned buckets, verify directly + bucketDir := s3a.bucketDir(bucket) + return s3a.getEntry(bucketDir, object) +} + +// serveDirectoryContent serves the content of a directory object directly +func (s3a *S3ApiServer) serveDirectoryContent(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry) { + // Defensive nil checks - entry and attributes should never be nil, but guard against it + if entry == nil || entry.Attributes == nil { + glog.Errorf("serveDirectoryContent: entry or attributes is nil") + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Set content type - use stored MIME type or default. A directory without a stored + // mime and without data of its own answers application/x-directory, the marker type + // Hadoop-style clients (e.g. flink-s3-fs-presto) require to classify the path as a + // directory; defaulting to octet-stream makes them treat it as a 0-byte file. + contentType := entry.Attributes.Mime + if contentType == "" { + if entry.IsDirectoryKeyObject() { + contentType = "application/octet-stream" + } else { + contentType = s3_constants.DirectoryMimeType + } + } + w.Header().Set("Content-Type", contentType) + + // Set content length - use FileSize for accuracy, especially for large files + contentLength := int64(entry.Attributes.FileSize) + w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10)) + + // Set last modified + w.Header().Set("Last-Modified", time.Unix(entry.Attributes.Mtime, 0).UTC().Format(http.TimeFormat)) + + // Set ETag + w.Header().Set("ETag", "\""+filer.ETag(entry)+"\"") + + // For HEAD requests, don't write body + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + + // Write content + w.WriteHeader(http.StatusOK) + if len(entry.Content) > 0 { + if _, err := w.Write(entry.Content); err != nil { + glog.Errorf("serveDirectoryContent: failed to write response: %v", err) + } + } +} + +// handleDirectoryObjectRequest is a helper function that handles directory object requests +// for both GET and HEAD operations, eliminating code duplication +func (s3a *S3ApiServer) handleDirectoryObjectRequest(w http.ResponseWriter, r *http.Request, bucket, object, handlerName string) bool { + // Check if this is a directory object and handle it directly + if dirEntry, isDirectoryObject, err := s3a.checkDirectoryObject(bucket, object); err != nil { + glog.Errorf("%s: error checking directory object %s/%s: %v", handlerName, bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return true // Request was handled (with error) + } else if dirEntry != nil { + glog.V(2).Infof("%s: directory object %s/%s found, serving content", handlerName, bucket, object) + s3a.serveDirectoryContent(w, r, dirEntry) + return true // Request was handled successfully + } else if isDirectoryObject { + // Directory object but doesn't exist + glog.V(2).Infof("%s: directory object %s/%s not found", handlerName, bucket, object) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return true // Request was handled (with not found) + } + + return false // Not a directory object, continue with normal processing +} + +func newListEntry(s3a *S3ApiServer, entry *filer_pb.Entry, key string, dir string, name string, bucketPrefix string, fetchOwner bool, isDirectory bool, encodingTypeUrl bool) (listEntry ListEntry) { + storageClass := "STANDARD" + if v, ok := entry.Extended[s3_constants.AmzStorageClass]; ok { + storageClass = string(v) + } + keyFormat := "%s/%s" + if isDirectory { + keyFormat += "/" + } + if key == "" { + key = fmt.Sprintf(keyFormat, dir, name)[len(bucketPrefix):] + } + if encodingTypeUrl { + key = urlPathEscape(key) + } + // Determine ETag: prioritize ExtETagKey for versioned objects (supports multipart ETags), + // then fall back to filer.ETag() which uses Md5 attribute or calculates from chunks + etag := s3a.getObjectETag(entry) + listEntry = ListEntry{ + Key: key, + LastModified: time.Unix(entry.Attributes.Mtime, 0).UTC(), + ETag: etag, + Size: int64(filer.FileSize(entry)), + StorageClass: StorageClass(storageClass), + } + if fetchOwner { + // Extract owner from S3 metadata (Extended attributes) instead of file system attributes + var ownerID, displayName string + if entry.Extended != nil { + if ownerBytes, exists := entry.Extended[s3_constants.ExtAmzOwnerKey]; exists { + ownerID = string(ownerBytes) + } + } + + // Fallback to anonymous if no S3 owner found + if ownerID == "" { + ownerID = s3_constants.AccountAnonymousId + displayName = "anonymous" + } else { + // Get the proper display name from IAM system + displayName = s3a.iam.GetAccountNameById(ownerID) + // Fallback to ownerID if no display name found + if displayName == "" { + displayName = ownerID + } + } + + listEntry.Owner = &CanonicalUser{ + ID: ownerID, + DisplayName: displayName, + } + } + return listEntry +} + +func (s3a *S3ApiServer) toFilerPath(bucket, object string) string { + // Returns the raw file path - no URL escaping needed + // The path is used directly, not embedded in a URL + object = s3_constants.NormalizeObjectKey(object) + return fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object) +} + +// hasConditionalHeaders checks if the request has any conditional headers +// This is a lightweight check to avoid unnecessary function calls +func (s3a *S3ApiServer) hasConditionalHeaders(r *http.Request) bool { + return r.Header.Get(s3_constants.IfMatch) != "" || + r.Header.Get(s3_constants.IfNoneMatch) != "" || + r.Header.Get(s3_constants.IfModifiedSince) != "" || + r.Header.Get(s3_constants.IfUnmodifiedSince) != "" +} + +// processConditionalHeaders checks conditional headers and writes an error response if a condition fails. +// It returns the result of the check and a boolean indicating if the request has been handled. +func (s3a *S3ApiServer) processConditionalHeaders(w http.ResponseWriter, r *http.Request, bucket, object, handlerName string) (ConditionalHeaderResult, bool) { + if !s3a.hasConditionalHeaders(r) { + return ConditionalHeaderResult{ErrorCode: s3err.ErrNone}, false + } + + result := s3a.checkConditionalHeadersForReads(r, bucket, object) + if result.ErrorCode != s3err.ErrNone { + glog.V(3).Infof("%s: Conditional header check failed for %s/%s with error %v", handlerName, bucket, object, result.ErrorCode) + + // For 304 Not Modified responses, include the ETag header + if result.ErrorCode == s3err.ErrNotModified && result.ETag != "" { + w.Header().Set("ETag", result.ETag) + } + + s3err.WriteErrorResponse(w, r, result.ErrorCode) + return result, true // request handled + } + return result, false // request not handled +} + +func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request) { + + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectHandler %s %s", bucket, object) + if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + + // Check for SOSAPI virtual objects (system.xml, capacity.xml) + // These are dynamically generated and don't exist on disk + if s3a.handleSOSAPIGetObject(w, r, bucket, object) { + return // SOSAPI request was handled + } + + // TTFB Profiling: Track all stages until first byte + tStart := time.Now() + var ( + conditionalHeadersTime time.Duration + versioningCheckTime time.Duration + entryFetchTime time.Duration + streamTime time.Duration + ) + defer func() { + totalTime := time.Since(tStart) + glog.V(4).Infof("GET TTFB PROFILE %s/%s: total=%v | conditional=%v, versioning=%v, entryFetch=%v, stream=%v", + bucket, object, totalTime, conditionalHeadersTime, versioningCheckTime, entryFetchTime, streamTime) + }() + + // Handle directory objects with shared logic + if s3a.handleDirectoryObjectRequest(w, r, bucket, object, "GetObjectHandler") { + return // Directory object request was handled + } + + // Check conditional headers and handle early return if conditions fail + tConditional := time.Now() + result, handled := s3a.processConditionalHeaders(w, r, bucket, object, "GetObjectHandler") + conditionalHeadersTime = time.Since(tConditional) + if handled { + return + } + + // Check for specific version ID in query parameters + versionId := r.URL.Query().Get("versionId") + + var ( + entry *filer_pb.Entry // Declare entry at function scope for SSE processing + versioningConfigured bool + err error + ) + + // Check if versioning is configured for the bucket (Enabled or Suspended) + tVersioning := time.Now() + // Note: We need to check this even if versionId is empty, because versioned buckets + // handle even "get latest version" requests differently (through .versions directory) + versioningConfigured, err = s3a.isVersioningConfigured(bucket) + if err != nil { + if err == filer_pb.ErrNotFound { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return + } + glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + glog.V(3).Infof("GetObject: bucket %s, object %s, versioningConfigured=%v, versionId=%s", bucket, object, versioningConfigured, versionId) + + if versioningConfigured { + // Handle versioned GET - check if specific version requested + var targetVersionId string + + if versionId != "" { + // Request for specific version - must look in .versions directory + glog.V(3).Infof("GetObject: requesting specific version %s for %s/%s", versionId, bucket, object) + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + if err != nil { + glog.Errorf("Failed to get specific version %s: %v", versionId, err) + if errors.Is(err, filer_pb.ErrNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchVersion) + } else { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + } + return + } + targetVersionId = versionId + } else { + // Request for latest version - OPTIMIZATION: + // Check if .versions/ directory exists quickly (no retries) to decide path + // - If .versions/ exists: real versions available, use getLatestObjectVersion + // - If .versions/ doesn't exist (ErrNotFound): only null version at regular path, use it directly + // - If transient error: fall back to getLatestObjectVersion which has retry logic + bucketDir := s3a.bucketDir(bucket) + normalizedObject := s3_constants.NormalizeObjectKey(object) + versionsDir := normalizedObject + s3_constants.VersionsFolder + + // Quick check (no retries) for .versions/ directory + versionsEntry, versionsErr := s3a.getEntry(bucketDir, versionsDir) + + if versionsErr == nil && versionsEntry != nil { + // .versions/ exists, meaning real versions are stored there + // Use getLatestObjectVersion which will properly find the newest version + entry, err = s3a.getLatestObjectVersion(bucket, object) + if err != nil { + glog.Errorf("GetObject: Failed to get latest version for %s/%s: %v", bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } else if errors.Is(versionsErr, filer_pb.ErrNotFound) { + // .versions/ doesn't exist (confirmed not found), check regular path for null version + regularEntry, regularErr := s3a.getEntry(bucketDir, normalizedObject) + if regularErr == nil && regularEntry != nil && !isBareDirectory(regularEntry) { + // Found object at regular path - this is the null version + entry = regularEntry + targetVersionId = "null" + } else { + // No object at regular path either - object doesn't exist + glog.V(3).Infof("GetObject: object not found at regular path or .versions for %s/%s", bucket, object) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } else { + // Transient error checking .versions/, fall back to getLatestObjectVersion with retries + glog.V(2).Infof("GetObject: transient error checking .versions for %s/%s: %v, falling back to getLatestObjectVersion", bucket, object, versionsErr) + entry, err = s3a.getLatestObjectVersion(bucket, object) + if err != nil { + glog.Errorf("GetObject: Failed to get latest version for %s/%s: %v", bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + // Extract version ID if not already set + if targetVersionId == "" { + if entry.Extended != nil { + if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists { + targetVersionId = string(versionIdBytes) + } + } + // If no version ID found in entry, this is a pre-versioning object + if targetVersionId == "" { + targetVersionId = "null" + } + } + } + + // Check if this is a delete marker + if entry.Extended != nil { + if deleteMarker, exists := entry.Extended[s3_constants.ExtDeleteMarkerKey]; exists && string(deleteMarker) == "true" { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + + // For versioned objects, log the target version + if targetVersionId == "null" { + glog.V(2).Infof("GetObject: pre-versioning object %s/%s", bucket, object) + } else { + glog.V(2).Infof("GetObject: version %s for %s/%s", targetVersionId, bucket, object) + } + + // Set version ID in response header + w.Header().Set("x-amz-version-id", targetVersionId) + + // Add object lock metadata to response headers if present + s3a.addObjectLockHeadersToResponse(w, entry) + } + + versioningCheckTime = time.Since(tVersioning) + + // Fetch the correct entry for SSE processing (respects versionId) + // This consolidates entry lookups to avoid multiple filer calls + tEntryFetch := time.Now() + var objectEntryForSSE *filer_pb.Entry + + // Optimization: Reuse already-fetched entry to avoid redundant metadata fetches + if versioningConfigured { + // For versioned objects, reuse the already-fetched entry + objectEntryForSSE = entry + } else { + // For non-versioned objects, try to reuse entry from conditional header check + if result.Entry != nil { + // Reuse entry fetched during conditional header check (optimization) + objectEntryForSSE = result.Entry + glog.V(3).Infof("GetObjectHandler: Reusing entry from conditional header check for %s/%s", bucket, object) + } else { + // Fetch entry for SSE processing + // This is needed for all SSE types (SSE-C, SSE-KMS, SSE-S3) to: + // 1. Detect encryption from object metadata (SSE-KMS/SSE-S3 don't send headers on GET) + // 2. Add proper response headers + // 3. Handle Range requests on encrypted objects + var fetchErr error + objectEntryForSSE, fetchErr = s3a.fetchObjectEntry(bucket, object) + if fetchErr != nil { + glog.Warningf("GetObjectHandler: failed to get entry for %s/%s: %v", bucket, object, fetchErr) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + if objectEntryForSSE == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + } + entryFetchTime = time.Since(tEntryFetch) + + // Safety check: entry must be valid before tag-based policy evaluation + if objectEntryForSSE == nil { + glog.Errorf("GetObjectHandler: objectEntryForSSE is nil for %s/%s (should not happen)", bucket, object) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // A bare directory path is not an object: 404 like AWS instead of an empty 200, + // so Hadoop-style clients fall back to LIST discovery. HEAD and ranged GET already 404. + if !strings.HasSuffix(object, "/") && isBareDirectory(objectEntryForSSE) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + // Re-check bucket policy with object entry for tag-based conditions (e.g., s3:ExistingObjectTag) + if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, string(s3_constants.ACTION_READ), objectEntryForSSE.Extended, "GetObjectHandler"); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Handle remote storage objects: initiate background caching without blocking + // This implements stream-through caching: serve first byte immediately while + // caching happens in the background, improving TTFB for large files + if objectEntryForSSE.IsInRemoteOnly() { + // Start background cache without waiting (non-blocking) + // Only after authorization passes to avoid cache side effects for denied requests + versionId := r.URL.Query().Get("versionId") + cacheVersionId := resolvedSourceVersionId(versionId, objectEntryForSSE) + s3a.startBackgroundRemoteCache(bucket, object, cacheVersionId, objectEntryForSSE) + // Continue with streaming immediately - will serve from remote or cached chunks + } + + // If PartNumber is specified, turn the request into a ranged read of that part + if partNumber := requestedPartNumber(r); partNumber > 0 { + startOffset, endOffset, errCode := s3a.partByteRange(w, r, objectEntryForSSE, partNumber) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + r.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", startOffset, endOffset)) + glog.V(3).Infof("GetObject: part %d served as bytes=%d-%d", partNumber, startOffset, endOffset) + } + + // NEW OPTIMIZATION: Stream directly from volume servers, bypassing filer proxy + // This eliminates the 19ms filer proxy overhead + // SSE decryption is handled inline during streaming + + // Detect SSE encryption type + primarySSEType := s3a.detectPrimarySSEType(objectEntryForSSE) + + // Stream directly from volume servers with SSE support + tStream := time.Now() + err = s3a.streamFromVolumeServersWithSSE(w, r, objectEntryForSSE, primarySSEType, bucket, object, versionId) + streamTime = time.Since(tStream) + if err != nil { + switch { + case isCanceledStreamingError(err): + glog.V(3).Infof("GetObjectHandler: client disconnected while streaming %s/%s: %v", bucket, object, err) + return + case errors.Is(err, context.DeadlineExceeded): + glog.Warningf("GetObjectHandler: deadline exceeded while streaming %s/%s: %v", bucket, object, err) + default: + glog.Errorf("GetObjectHandler: failed to stream %s/%s from volume servers: %v", bucket, object, err) + } + // Check if the streaming function already wrote an HTTP response + var streamErr *StreamError + if errors.As(err, &streamErr) && streamErr.ResponseWritten { + // Response already written (headers + status code), don't write again + // to avoid "superfluous response.WriteHeader call" and malformed S3 error bodies + return + } + // Response not yet written - safe to write S3 error response + // Check if error is due to volume server rate limiting (HTTP 429) + if errors.Is(err, util_http.ErrTooManyRequests) { + s3err.WriteErrorResponse(w, r, s3err.ErrRequestBytesExceed) + } else if shouldWriteStreamingErrorResponse(err) { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + } + return + } +} + +// streamFromVolumeServers streams object data directly from volume servers, bypassing filer proxy +// This eliminates the ~19ms filer proxy overhead by reading chunks directly +func (s3a *S3ApiServer) streamFromVolumeServers(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, sseType string, bucket, object, versionId string) error { + // Profiling: Track overall and stage timings + t0 := time.Now() + var ( + rangeParseTime time.Duration + headerSetTime time.Duration + chunkResolveTime time.Duration + streamPrepTime time.Duration + streamExecTime time.Duration + ) + defer func() { + totalTime := time.Since(t0) + glog.V(2).Infof(" └─ streamFromVolumeServers: total=%v, rangeParse=%v, headerSet=%v, chunkResolve=%v, streamPrep=%v, streamExec=%v", + totalTime, rangeParseTime, headerSetTime, chunkResolveTime, streamPrepTime, streamExecTime) + }() + + if entry == nil { + // Early validation error: write S3-compliant XML error response + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(fmt.Errorf("entry is nil")) + } + + // Get file size + totalSize := int64(filer.FileSize(entry)) + + // Parse Range header if present + tRangeParse := time.Now() + offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, entry, totalSize, bucket, object) + if rangeErr != nil { + return rangeErr + } + rangeParseTime = time.Since(tRangeParse) + + // For small files stored inline in entry.Content - validate BEFORE setting headers + if len(entry.Content) > 0 && totalSize == int64(len(entry.Content)) { + if isRangeRequest { + // Safely convert int64 to int for slice indexing - validate BEFORE WriteHeader + // Use MaxInt32 for portability across 32-bit and 64-bit platforms + if offset < 0 || offset > int64(math.MaxInt32) || size < 0 || size > int64(math.MaxInt32) { + // Early validation error: write S3-compliant error response + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return newStreamErrorWithResponse(fmt.Errorf("range too large for platform: offset=%d, size=%d", offset, size)) + } + start := int(offset) + end := start + int(size) + // Bounds check (should already be validated, but double-check) - BEFORE WriteHeader + if start < 0 || start > len(entry.Content) || end > len(entry.Content) || end < start { + // Early validation error: write S3-compliant error response + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", totalSize)) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return newStreamErrorWithResponse(fmt.Errorf("invalid range for inline content: start=%d, end=%d, len=%d", start, end, len(entry.Content))) + } + // Validation passed - now set headers and write + s3a.setResponseHeaders(w, r, entry, totalSize) + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize)) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + w.WriteHeader(http.StatusPartialContent) + written, err := w.Write(entry.Content[start:end]) + if written > 0 { + BucketTrafficSent(int64(written), r) + } + return err + } + // Non-range request for inline content + s3a.setResponseHeaders(w, r, entry, totalSize) + w.WriteHeader(http.StatusOK) + written, err := w.Write(entry.Content) + if written > 0 { + BucketTrafficSent(int64(written), r) + } + return err + } + + // Get chunks and validate BEFORE setting headers + chunks := entry.GetChunks() + glog.V(4).Infof("streamFromVolumeServers: entry has %d chunks, totalSize=%d, isRange=%v, offset=%d, size=%d", + len(chunks), totalSize, isRangeRequest, offset, size) + + if len(chunks) == 0 { + // Check if this is a remote-only entry + if entry.IsInRemoteOnly() { + glog.V(1).Infof("streamFromVolumeServers: entry is remote-only, attempting stream-through cache") + cacheVersionId := resolvedSourceVersionId(versionId, entry) + cachedEntry, cacheErr := s3a.cacheRemoteObjectForStreamingWithShortTimeout(r, entry, bucket, object, cacheVersionId) + if cacheErr == nil && cachedEntry != nil && len(cachedEntry.GetChunks()) > 0 { + // Cache completed, use cached chunks + chunks = cachedEntry.GetChunks() + entry = cachedEntry + glog.V(1).Infof("streamFromVolumeServers: successfully cached remote object, got %d chunks", len(chunks)) + } else if isFilerNotFound(cacheErr) { + // Authoritative: the entry vanished; the origin cannot resurrect it + glog.Errorf("streamFromVolumeServers: entry not found while caching %s/%s: %v", bucket, object, cacheErr) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return newStreamErrorWithResponse(cacheErr) + } else { + // Client disconnected during the cache wait: report cancellation, not an + // error response, so we don't write to a closed connection. + if ctxErr := r.Context().Err(); ctxErr != nil { + return ctxErr + } + // Cache not ready or failing locally (e.g. no assignable volume): serve + // straight from the origin while the detached cache keeps filling for + // later reads. An entry resolved to a specific version -- even on a + // latest-version read -- has no origin key, so it keeps the error + // paths below. + if cacheVersionId == "" || cacheVersionId == "null" { + if served, streamErr := s3a.serveObjectFromRemoteMount(w, r, entry, bucket, object, offset, size, isRangeRequest, totalSize, t0); served { + if streamErr != nil { + return newStreamErrorWithResponse(streamErr) + } + return nil + } + } + // Origin unreadable. A permanent cache error is final; a transient one + // gets 503 so the client retries the still-filling cache. + if cacheErr != nil && !errors.Is(cacheErr, context.DeadlineExceeded) && !errors.Is(cacheErr, context.Canceled) && status.Code(cacheErr) != codes.DeadlineExceeded && status.Code(cacheErr) != codes.Canceled { + glog.Errorf("streamFromVolumeServers: permanent cache error for %s/%s: %v", bucket, object, cacheErr) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(cacheErr) + } + glog.V(1).Infof("streamFromVolumeServers: remote object %s/%s not cached yet, returning 503 for retry", bucket, object) + w.Header().Set("Retry-After", "2") + s3err.WriteErrorResponse(w, r, s3err.ErrServiceUnavailable) + return newStreamErrorWithResponse(fmt.Errorf("remote object not cached yet, will be available on retry")) + } + } else if totalSize > 0 && len(entry.Content) == 0 { + // Not a remote entry but has size without content - this is a data integrity issue + glog.Errorf("streamFromVolumeServers: Data integrity error - entry reports size %d but has no content or chunks", totalSize) + // Write S3-compliant XML error response + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(fmt.Errorf("data integrity error: size %d reported but no content available", totalSize)) + } else { + // Empty object - set headers and write status + s3a.setResponseHeaders(w, r, entry, totalSize) + w.WriteHeader(http.StatusOK) + return nil + } + } + + // Log chunk details (verbose only - high frequency) + if glog.V(4) { + for i, chunk := range chunks { + glog.Infof(" GET Chunk[%d]: fid=%s, offset=%d, size=%d", i, chunk.GetFileIdString(), chunk.Offset, chunk.Size) + } + } + + // CRITICAL: Resolve chunks and prepare reader BEFORE WriteHeader so failures + // can still return a proper S3 error response. + ctx := r.Context() + lookupFileIdFn := s3a.createLookupFileIdFunction() + + // Resolve chunk manifests into visible intervals for the requested range. + // NonOverlappingVisibleIntervals internally calls ResolveChunkManifest. + tChunkResolve := time.Now() + visibleIntervals, err := filer.NonOverlappingVisibleIntervals(ctx, lookupFileIdFn, chunks, offset, offset+size) + chunkResolveTime = time.Since(tChunkResolve) + if err != nil { + if isCanceledStreamingError(err) { + glog.V(3).Infof("streamFromVolumeServers: request canceled while resolving chunks: %v", err) + return err + } + if errors.Is(err, context.DeadlineExceeded) { + glog.Warningf("streamFromVolumeServers: request deadline exceeded while resolving chunks: %v", err) + } else { + glog.Errorf("streamFromVolumeServers: failed to resolve chunks: %v", err) + } + if s3a.shouldFallBackToRemote(entry, totalSize, versionId) { + glog.V(1).Infof("streamFromVolumeServers: resolving chunks for %s/%s failed, falling back to remote mount", bucket, object) + if served, streamErr := s3a.serveObjectFromRemoteMount(w, r, entry, bucket, object, offset, size, isRangeRequest, totalSize, t0); served { + if streamErr != nil { + return newStreamErrorWithResponse(streamErr) + } + return nil + } + } + // Write S3-compliant XML error response + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(fmt.Errorf("failed to resolve chunks: %v", err)) + } + + // Build a ChunkReadAt backed by the server-wide ReaderCache. This mirrors + // the WebDAV read path (server/webdav_server.go) and outperforms the + // io.Pipe-based streamChunksPrefetched path: ReaderCache prefetches whole + // chunks into memory buffers that the consumer can memcpy out of, so + // prefetchCount translates into actual in-flight bytes rather than just + // parallel TCP handshakes. + // + // We do NOT call reader.Close() here — ChunkReadAt.Close() would destroy + // the ReaderCache's downloaders map, which is shared across concurrent + // requests. Eviction is handled by the ReaderCache's own downloader + // limit. JWT for volume-server requests is generated internally by + // util_http.RetriedFetchChunkData from jwtSigningReadKey, matching the + // WebDAV and mount read paths. + tStreamPrep := time.Now() + chunkViews := filer.ViewFromVisibleIntervals(visibleIntervals, offset, size) + reader := filer.NewChunkReaderAtFromClient(ctx, s3a.readerCache, chunkViews, totalSize, filer.DefaultPrefetchCount) + streamPrepTime = time.Since(tStreamPrep) + + // A cached chunk whose volume server is down, or whose needle was evicted and + // now 404s under retry-backoff, would stall and then 500 after the headers are + // already sent -- serve the authoritative remote instead. + if s3a.shouldFallBackToRemote(entry, totalSize, versionId) { + if probeErr := probeReadable(ctx, reader, offset, localReadProbeTimeout); probeErr != nil { + if isCanceledStreamingError(probeErr) { + glog.V(3).Infof("streamFromVolumeServers: request canceled while probing %s/%s: %v", bucket, object, probeErr) + return probeErr + } + glog.V(1).Infof("streamFromVolumeServers: local read of %s/%s failed (%v), falling back to remote mount", bucket, object, probeErr) + if served, streamErr := s3a.serveObjectFromRemoteMount(w, r, entry, bucket, object, offset, size, isRangeRequest, totalSize, t0); served { + if streamErr != nil { + return newStreamErrorWithResponse(streamErr) + } + return nil + } + glog.Errorf("streamFromVolumeServers: local read of %s/%s failed and remote mount unavailable: %v", bucket, object, probeErr) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(probeErr) + } + } + + // Headers and status are committed on the first body write, once the first + // read from the volume servers has succeeded. The client sees the same wire + // timing either way -- the status line sits in the server's write buffer + // until body bytes arrive -- but a first read that fails now surfaces as a + // clean 5xx instead of a 200 with a broken body. + body := &commitOnFirstWrite{w: w, commit: func() { + tHeaderSet := time.Now() + s3a.setResponseHeaders(w, r, entry, totalSize) + if isRangeRequest { + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize)) + } + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + headerSetTime = time.Since(tHeaderSet) + if isRangeRequest { + w.WriteHeader(http.StatusPartialContent) + } else { + w.WriteHeader(http.StatusOK) + } + TimeToFirstByte(r.Method, t0, r) + }} + + // Stream directly to response with counting wrapper. + // ChunkReadAt's ReadAt is backed by in-memory prefetched chunk buffers, so + // io.CopyBuffer drains them as fast memcpys. + tStreamExec := time.Now() + glog.V(4).Infof("streamFromVolumeServers: starting chunk reader, offset=%d, size=%d", offset, size) + written, err := s3a.streamRangeToClient(body, r, reader, entry, bucket, object, offset, size, totalSize, versionId) + streamExecTime = time.Since(tStreamExec) + // Track traffic even on partial writes for accurate egress accounting + if written > 0 { + BucketTrafficSent(written, r) + } + if err != nil { + if !body.committed { + if isCanceledStreamingError(err) { + glog.V(3).Infof("streamFromVolumeServers: request canceled before first byte of %s/%s: %v", bucket, object, err) + return err + } + glog.Errorf("streamFromVolumeServers: first read of %s/%s failed: %v", bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(err) + } + switch { + case isCanceledStreamingError(err): + // Client disconnected mid-stream (e.g. Nginx upstream timeout, browser cancel) - expected + glog.V(3).Infof("streamFromVolumeServers: client disconnected after writing %d bytes: %v", written, err) + case errors.Is(err, context.DeadlineExceeded): + // Server-side deadline exceeded - unexpected, warrants operator attention + glog.Warningf("streamFromVolumeServers: server-side deadline exceeded after writing %d bytes: %v", written, err) + default: + glog.Errorf("streamFromVolumeServers: streamFn failed after writing %d bytes: %v", written, err) + } + // Streaming error after WriteHeader was called - response already partially written + return newStreamErrorWithResponse(err) + } + body.Commit() + glog.V(4).Infof("streamFromVolumeServers: streamFn completed successfully, wrote %d bytes", written) + return nil +} + +// short, so a down volume server or an evicted needle under retry-backoff falls +// back promptly instead of stalling the request +const localReadProbeTimeout = 2 * time.Second + +// shouldFallBackToRemote reports whether a failed local read may be served from +// the mounted remote instead: openRemoteStream only reads the unversioned key, +// and the sizes must match for the bytes to be identical under the +// already-computed Content-Length. +func (s3a *S3ApiServer) shouldFallBackToRemote(entry *filer_pb.Entry, totalSize int64, versionId string) bool { + if v := resolvedSourceVersionId(versionId, entry); v != "" && v != "null" { + return false + } + remote := entry.GetRemoteEntry() + return totalSize > 0 && remote != nil && remote.GetRemoteSize() == totalSize +} + +// the context-honoring read of filer.ChunkReadAt, so probeReadable can bound it +type ctxReaderAt interface { + ReadAtWithTime(ctx context.Context, p []byte, offset int64) (int, int64, error) +} + +// probeReadable decides whether the local copy is usable by reading one byte at +// offset. Only a returned byte counts -- a zero-byte read, EOF or not, would +// otherwise wave through a stream that then truncates. +func probeReadable(ctx context.Context, reader ctxReaderAt, offset int64, timeout time.Duration) error { + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var b [1]byte + n, _, err := reader.ReadAtWithTime(probeCtx, b[:], offset) + if n == len(b) { + return nil + } + if err == nil { + err = io.ErrUnexpectedEOF + } + return err +} + +// serveObjectFromRemoteMount serves [offset, offset+size) straight from the +// mounted remote. served=false means nothing was written and the caller still +// owns the error path; once served is true the response is committed. +func (s3a *S3ApiServer) serveObjectFromRemoteMount(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, bucket, object string, offset, size int64, isRangeRequest bool, totalSize int64, t0 time.Time) (served bool, err error) { + remoteReader, remoteErr := s3a.openRemoteStream(r.Context(), bucket, object, offset, size, nil) + if remoteErr != nil { + glog.Warningf("streamFromVolumeServers: origin stream %s/%s: %v", bucket, object, remoteErr) + return false, nil + } + defer remoteReader.Close() + + s3a.setResponseHeaders(w, r, entry, totalSize) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + if isRangeRequest { + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize)) + w.WriteHeader(http.StatusPartialContent) + } else { + w.WriteHeader(http.StatusOK) + } + + TimeToFirstByte(r.Method, t0, r) + cw := &countingWriter{w: w} + _, copyErr := io.CopyN(cw, remoteReader, size) + if copyErr == io.EOF { + // the origin returned fewer bytes than the entry's RemoteSize + copyErr = io.ErrUnexpectedEOF + } + if cw.written > 0 { + BucketTrafficSent(cw.written, r) + } + if copyErr != nil { + glog.V(2).Infof("streamFromVolumeServers: origin stream %s/%s ended after %d bytes: %v", bucket, object, cw.written, copyErr) + return true, copyErr + } + return true, nil +} + +// streamRangeToClient copies the local copy into the already-committed response. +// The probe only proved the byte at offset readable, so a later chunk can still +// be gone: finish such a body from the remote -- if it is still the generation +// that was cached -- instead of truncating it under the Content-Length already +// sent. +func (s3a *S3ApiServer) streamRangeToClient(w io.Writer, r *http.Request, reader io.ReaderAt, entry *filer_pb.Entry, bucket, object string, offset, size, totalSize int64, versionId string) (int64, error) { + // small-object GETs shouldn't allocate a 256 KiB scratch buffer per request + const maxCopyBuf = 256 * 1024 + copyBufSize := int64(maxCopyBuf) + if size > 0 && size < copyBufSize { + copyBufSize = size + } + cw := &countingWriter{w: w} + _, err := io.CopyBuffer(cw, io.NewSectionReader(reader, offset, size), make([]byte, copyBufSize)) + if err == nil && cw.written < size { + // a short local read surfaces as a clean EOF + err = io.ErrUnexpectedEOF + } + if err == nil || cw.writeErr != nil || isCanceledStreamingError(err) || !s3a.shouldFallBackToRemote(entry, totalSize, versionId) { + return cw.written, err + } + + remaining := size - cw.written + glog.V(1).Infof("streamFromVolumeServers: local read of %s/%s failed after %d bytes (%v), finishing from the remote mount", bucket, object, cw.written, err) + // the bytes already sent are the cached generation, so splice on the remote + // only while it still is that generation + remoteReader, remoteErr := s3a.openRemoteStream(r.Context(), bucket, object, offset+cw.written, remaining, entry.GetRemoteEntry()) + if remoteErr != nil { + glog.Warningf("streamFromVolumeServers: origin stream %s/%s: %v", bucket, object, remoteErr) + return cw.written, err + } + defer remoteReader.Close() + if _, copyErr := io.CopyN(cw, remoteReader, remaining); copyErr != nil { + if copyErr == io.EOF { + copyErr = io.ErrUnexpectedEOF + } + glog.V(2).Infof("streamFromVolumeServers: origin stream %s/%s ended after %d bytes: %v", bucket, object, cw.written, copyErr) + return cw.written, copyErr + } + return cw.written, nil +} + +// Shared HTTP client for volume server requests (connection pooling) +var volumeServerHTTPClient = &http.Client{ + Timeout: 5 * time.Minute, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + }, +} + +// createLookupFileIdFunction creates a reusable lookup function for resolving volume URLs +// Uses FilerClient's vidMap cache to eliminate per-chunk gRPC overhead +func (s3a *S3ApiServer) createLookupFileIdFunction() func(context.Context, string) ([]string, error) { + // Return the FilerClient's lookup function which uses the battle-tested vidMap cache + return s3a.filerClient.GetLookupFileIdFunction() +} + +// streamFromVolumeServersWithSSE handles streaming with inline SSE decryption +func (s3a *S3ApiServer) streamFromVolumeServersWithSSE(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, sseType string, bucket, object, versionId string) error { + if entry.RemoteEntry != nil && entry.RemoteEntry.RemoteSize > 0 { + // inline content is served locally without chunks + hit := !entry.IsInRemoteOnly() || len(entry.Content) > 0 + stats.RecordRemoteCacheRead(stats.RemoteCacheSourceS3, bucket, hit) + } + + // If not encrypted, use fast path without decryption + if sseType == "" || sseType == "None" { + return s3a.streamFromVolumeServers(w, r, entry, sseType, bucket, object, versionId) + } + + // Profiling: Track SSE decryption stages + t0 := time.Now() + var ( + rangeParseTime time.Duration + keyValidateTime time.Duration + headerSetTime time.Duration + streamFetchTime time.Duration + decryptSetupTime time.Duration + copyTime time.Duration + ) + defer func() { + totalTime := time.Since(t0) + glog.V(2).Infof(" └─ streamFromVolumeServersWithSSE (%s): total=%v, rangeParse=%v, keyValidate=%v, headerSet=%v, streamFetch=%v, decryptSetup=%v, copy=%v", + sseType, totalTime, rangeParseTime, keyValidateTime, headerSetTime, streamFetchTime, decryptSetupTime, copyTime) + }() + + glog.V(2).Infof("streamFromVolumeServersWithSSE: Handling %s encrypted object with inline decryption", sseType) + + // Parse Range header BEFORE key validation + totalSize := int64(filer.FileSize(entry)) + tRangeParse := time.Now() + offset, size, isRangeRequest, rangeErr := s3a.parseAndValidateRange(w, r, entry, totalSize, bucket, object) + if rangeErr != nil { + return rangeErr + } + if isRangeRequest { + glog.V(2).Infof("streamFromVolumeServersWithSSE: Range request bytes %d-%d/%d (size=%d)", offset, offset+size-1, totalSize, size) + } + rangeParseTime = time.Since(tRangeParse) + + // Validate SSE keys BEFORE streaming + tKeyValidate := time.Now() + var decryptionKey interface{} + switch sseType { + case s3_constants.SSETypeC: + customerKey, err := ParseSSECHeaders(r) + if err != nil { + s3err.WriteErrorResponse(w, r, MapSSECErrorToS3Error(err)) + return newStreamErrorWithResponse(err) + } + if customerKey == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrSSECustomerKeyMissing) + return newStreamErrorWithResponse(fmt.Errorf("SSE-C key required")) + } + // Validate key MD5 + if entry.Extended != nil { + storedKeyMD5 := string(entry.Extended[s3_constants.AmzServerSideEncryptionCustomerKeyMD5]) + if storedKeyMD5 != "" && customerKey.KeyMD5 != storedKeyMD5 { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return newStreamErrorWithResponse(fmt.Errorf("SSE-C key mismatch")) + } + } + decryptionKey = customerKey + case s3_constants.SSETypeKMS: + // Extract KMS key from metadata (stored as raw bytes, matching filer behavior) + if entry.Extended == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(fmt.Errorf("no SSE-KMS metadata")) + } + kmsMetadataBytes := entry.Extended[s3_constants.SeaweedFSSSEKMSKey] + sseKMSKey, err := DeserializeSSEKMSMetadata(kmsMetadataBytes) + if err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(err) + } + decryptionKey = sseKMSKey + case s3_constants.SSETypeS3: + // Extract S3 key from metadata (stored as raw bytes, matching filer behavior) + if entry.Extended == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(fmt.Errorf("no SSE-S3 metadata")) + } + keyData := entry.Extended[s3_constants.SeaweedFSSSES3Key] + keyManager := GetSSES3KeyManager() + sseS3Key, err := DeserializeSSES3Metadata(keyData, keyManager) + if err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return newStreamErrorWithResponse(err) + } + decryptionKey = sseS3Key + } + keyValidateTime = time.Since(tKeyValidate) + + // Headers and status are committed on the first body write, once the first + // fetch and decryption step has succeeded -- the same deferral as + // streamFromVolumeServers, so a missing needle or broken decrypt setup + // surfaces as a clean 5xx instead of a 200 with a broken body. + body := &commitOnFirstWrite{w: w, commit: func() { + tHeaderSet := time.Now() + s3a.setResponseHeaders(w, r, entry, totalSize) + s3a.addSSEResponseHeadersFromEntry(w, r, entry, sseType) + if isRangeRequest { + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize)) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + } + headerSetTime = time.Since(tHeaderSet) + if isRangeRequest { + w.WriteHeader(http.StatusPartialContent) + } else { + w.WriteHeader(http.StatusOK) + } + TimeToFirstByte(r.Method, t0, r) + }} + + // Full Range Optimization: Use ViewFromChunks to only fetch/decrypt needed chunks + tDecryptSetup := time.Now() + + // Use range-aware chunk resolution (like filer does) + if isRangeRequest { + glog.V(2).Infof("Using range-aware SSE decryption for offset=%d size=%d", offset, size) + streamFetchTime = 0 // No full stream fetch in range-aware path + written, err := s3a.streamDecryptedRangeFromChunks(r.Context(), body, entry, offset, size, sseType, decryptionKey) + decryptSetupTime = time.Since(tDecryptSetup) + copyTime = decryptSetupTime // Streaming is included in decrypt setup for range-aware path + // Track traffic even on partial writes for accurate egress accounting + if written > 0 { + BucketTrafficSent(written, r) + } + if err != nil { + if !body.committed { + // nothing written yet -- the caller writes the S3 error response + return err + } + return newStreamErrorWithResponse(err) + } + body.Commit() + return nil + } + if _, err := s3a.flattenManifestChunks(r.Context(), entry); err != nil { + return fmt.Errorf("resolve encrypted chunk manifests: %w", err) + } + + // Full object path: Optimize multipart vs single-part + var decryptedReader io.Reader + var err error + + switch sseType { + case s3_constants.SSETypeC: + customerKey := decryptionKey.(*SSECustomerKey) + + // Check if this is a multipart object (multiple chunks with SSE-C metadata) + isMultipartSSEC := false + ssecChunks := 0 + for _, chunk := range entry.GetChunks() { + if chunk.GetSseType() == filer_pb.SSEType_SSE_C && len(chunk.GetSseMetadata()) > 0 { + ssecChunks++ + } + } + isMultipartSSEC = ssecChunks > 1 + glog.V(3).Infof("SSE-C decryption: KeyMD5=%s, entry has %d chunks, isMultipart=%v, ssecChunks=%d", + customerKey.KeyMD5, len(entry.GetChunks()), isMultipartSSEC, ssecChunks) + + if isMultipartSSEC { + // For multipart, skip getEncryptedStreamFromVolumes and fetch chunks directly + // This saves one filer lookup/pipe creation + decryptedReader, err = s3a.createMultipartSSECDecryptedReaderDirect(r.Context(), nil, customerKey, entry) + glog.V(2).Infof("Using multipart SSE-C decryption for object with %d chunks (no prefetch)", len(entry.GetChunks())) + } else { + // For single-part, get encrypted stream and decrypt + tStreamFetch := time.Now() + encryptedReader, streamErr := s3a.getEncryptedStreamFromVolumes(r.Context(), entry, 0, int64(filer.FileSize(entry))) + streamFetchTime = time.Since(tStreamFetch) + if streamErr != nil { + return streamErr + } + defer encryptedReader.Close() + + iv := entry.Extended[s3_constants.SeaweedFSSSEIV] + if len(iv) == 0 { + return fmt.Errorf("SSE-C IV not found in entry metadata") + } + glog.V(2).Infof("SSE-C decryption: IV length=%d, KeyMD5=%s", len(iv), customerKey.KeyMD5) + decryptedReader, err = CreateSSECDecryptedReader(encryptedReader, customerKey, iv) + } + + case s3_constants.SSETypeKMS: + sseKMSKey := decryptionKey.(*SSEKMSKey) + + // Check if this is a multipart object (multiple chunks with SSE-KMS metadata) + isMultipartSSEKMS := false + ssekmsChunks := 0 + for _, chunk := range entry.GetChunks() { + if chunk.GetSseType() == filer_pb.SSEType_SSE_KMS && len(chunk.GetSseMetadata()) > 0 { + ssekmsChunks++ + } + } + isMultipartSSEKMS = ssekmsChunks > 1 + glog.V(3).Infof("SSE-KMS decryption: isMultipart=%v, ssekmsChunks=%d", isMultipartSSEKMS, ssekmsChunks) + + if isMultipartSSEKMS { + // For multipart, skip getEncryptedStreamFromVolumes and fetch chunks directly + decryptedReader, err = s3a.createMultipartSSEKMSDecryptedReaderDirect(r.Context(), nil, entry) + glog.V(2).Infof("Using multipart SSE-KMS decryption for object with %d chunks (no prefetch)", len(entry.GetChunks())) + } else { + // For single-part, get encrypted stream and decrypt + tStreamFetch := time.Now() + encryptedReader, streamErr := s3a.getEncryptedStreamFromVolumes(r.Context(), entry, 0, int64(filer.FileSize(entry))) + streamFetchTime = time.Since(tStreamFetch) + if streamErr != nil { + return streamErr + } + defer encryptedReader.Close() + + glog.V(2).Infof("SSE-KMS decryption: KeyID=%s, IV length=%d", sseKMSKey.KeyID, len(sseKMSKey.IV)) + decryptedReader, err = CreateSSEKMSDecryptedReader(encryptedReader, sseKMSKey) + } + + case s3_constants.SSETypeS3: + sseS3Key := decryptionKey.(*SSES3Key) + + // Check if this is a multipart object (multiple chunks with SSE-S3 metadata) + isMultipartSSES3 := false + sses3Chunks := 0 + for _, chunk := range entry.GetChunks() { + if chunk.GetSseType() == filer_pb.SSEType_SSE_S3 && len(chunk.GetSseMetadata()) > 0 { + sses3Chunks++ + } + } + isMultipartSSES3 = sses3Chunks > 1 + glog.V(3).Infof("SSE-S3 decryption: isMultipart=%v, sses3Chunks=%d", isMultipartSSES3, sses3Chunks) + + if isMultipartSSES3 { + // For multipart, skip getEncryptedStreamFromVolumes and fetch chunks directly + decryptedReader, err = s3a.createMultipartSSES3DecryptedReaderDirect(r.Context(), nil, entry) + glog.V(2).Infof("Using multipart SSE-S3 decryption for object with %d chunks (no prefetch)", len(entry.GetChunks())) + } else { + // For single-part, get encrypted stream and decrypt + tStreamFetch := time.Now() + encryptedReader, streamErr := s3a.getEncryptedStreamFromVolumes(r.Context(), entry, 0, int64(filer.FileSize(entry))) + streamFetchTime = time.Since(tStreamFetch) + if streamErr != nil { + return streamErr + } + defer encryptedReader.Close() + + keyManager := GetSSES3KeyManager() + iv, ivErr := GetSSES3IV(entry, sseS3Key, keyManager) + if ivErr != nil { + return fmt.Errorf("failed to get SSE-S3 IV: %w", ivErr) + } + glog.V(2).Infof("SSE-S3 decryption: KeyID=%s, IV length=%d", sseS3Key.KeyID, len(iv)) + decryptedReader, err = CreateSSES3DecryptedReader(encryptedReader, sseS3Key, iv) + } + } + decryptSetupTime = time.Since(tDecryptSetup) + + if err != nil { + glog.Errorf("SSE decryption error (%s): %v", sseType, err) + return fmt.Errorf("failed to create decrypted reader: %w", err) + } + + // Close the decrypted reader to avoid leaking HTTP bodies + if closer, ok := decryptedReader.(io.Closer); ok { + defer func() { + if closeErr := closer.Close(); closeErr != nil { + glog.V(3).Infof("Error closing decrypted reader: %v", closeErr) + } + }() + } + + // Stream full decrypted object to client + tCopy := time.Now() + buf := make([]byte, 128*1024) + copied, copyErr := io.CopyBuffer(body, decryptedReader, buf) + copyTime = time.Since(tCopy) + // Track traffic even on partial writes for accurate egress accounting + if copied > 0 { + BucketTrafficSent(copied, r) + } + if copyErr != nil { + glog.Errorf("Failed to copy full object: copied %d bytes: %v", copied, copyErr) + if !body.committed { + // nothing written yet -- the caller writes the S3 error response + return copyErr + } + return newStreamErrorWithResponse(copyErr) + } + body.Commit() + glog.V(3).Infof("Full object request: copied %d bytes", copied) + return nil +} + +// streamDecryptedRangeFromChunks streams a range of decrypted data by only fetching needed chunks +// This implements the filer's ViewFromChunks approach for optimal range performance +// Returns the number of bytes written and any error +func (s3a *S3ApiServer) streamDecryptedRangeFromChunks(ctx context.Context, w io.Writer, entry *filer_pb.Entry, offset int64, size int64, sseType string, decryptionKey interface{}) (int64, error) { + lookupFileIdFn := s3a.createLookupFileIdFunction() + resolvedChunks, _, err := filer.ResolveChunkManifest(ctx, lookupFileIdFn, entry.GetChunks(), offset, offset+size, s3a.filerClient) + if err != nil { + return 0, err + } + chunkViews := filer.ViewFromChunks(ctx, nil, resolvedChunks, offset, size) + + totalWritten := int64(0) + targetOffset := offset + + // Stream each chunk view + for x := chunkViews.Front(); x != nil; x = x.Next { + chunkView := x.Value + + // Handle gaps between chunks (write zeros) + if targetOffset < chunkView.ViewOffset { + gap := chunkView.ViewOffset - targetOffset + glog.V(4).Infof("Writing %d zero bytes for gap [%d,%d)", gap, targetOffset, chunkView.ViewOffset) + if err := writeZeroBytes(w, gap); err != nil { + return totalWritten, fmt.Errorf("failed to write zero padding: %w", err) + } + totalWritten += gap + targetOffset = chunkView.ViewOffset + } + + // Find the corresponding FileChunk for this chunkView + var fileChunk *filer_pb.FileChunk + for _, chunk := range resolvedChunks { + if chunk.GetFileIdString() == chunkView.FileId { + fileChunk = chunk + break + } + } + if fileChunk == nil { + return totalWritten, fmt.Errorf("chunk %s not found in entry", chunkView.FileId) + } + + // Fetch and decrypt this chunk view + var decryptedChunkReader io.Reader + var err error + + switch sseType { + case s3_constants.SSETypeC: + decryptedChunkReader, err = s3a.decryptSSECChunkView(ctx, fileChunk, chunkView, decryptionKey.(*SSECustomerKey)) + case s3_constants.SSETypeKMS: + decryptedChunkReader, err = s3a.decryptSSEKMSChunkView(ctx, fileChunk, chunkView) + case s3_constants.SSETypeS3: + decryptedChunkReader, err = s3a.decryptSSES3ChunkView(ctx, fileChunk, chunkView, entry) + default: + // Non-encrypted chunk + decryptedChunkReader, err = s3a.fetchChunkViewData(ctx, chunkView) + } + + if err != nil { + return totalWritten, fmt.Errorf("failed to decrypt chunk view %s: %w", chunkView.FileId, err) + } + + // Copy the decrypted chunk data + written, copyErr := io.Copy(w, decryptedChunkReader) + if closer, ok := decryptedChunkReader.(io.Closer); ok { + closeErr := closer.Close() + if closeErr != nil { + glog.Warningf("streamDecryptedRangeFromChunks: failed to close decrypted chunk reader: %v", closeErr) + } + } + if copyErr != nil { + glog.Errorf("streamDecryptedRangeFromChunks: copy error after writing %d bytes (expected %d): %v", written, chunkView.ViewSize, copyErr) + return totalWritten, fmt.Errorf("failed to copy decrypted chunk data: %w", copyErr) + } + + if written != int64(chunkView.ViewSize) { + glog.Errorf("streamDecryptedRangeFromChunks: size mismatch - wrote %d bytes but expected %d", written, chunkView.ViewSize) + return totalWritten, fmt.Errorf("size mismatch: wrote %d bytes but expected %d for chunk %s", written, chunkView.ViewSize, chunkView.FileId) + } + + totalWritten += written + targetOffset += written + glog.V(2).Infof("streamDecryptedRangeFromChunks: Wrote %d bytes from chunk %s [%d,%d), totalWritten=%d, targetSize=%d", written, chunkView.FileId, chunkView.ViewOffset, chunkView.ViewOffset+int64(chunkView.ViewSize), totalWritten, size) + } + + // Handle trailing zeros if needed + remaining := size - totalWritten + if remaining > 0 { + glog.V(4).Infof("Writing %d trailing zero bytes", remaining) + if err := writeZeroBytes(w, remaining); err != nil { + return totalWritten, fmt.Errorf("failed to write trailing zeros: %w", err) + } + totalWritten += remaining + } + + glog.V(3).Infof("Completed range-aware SSE decryption: wrote %d bytes for range [%d,%d)", totalWritten, offset, offset+size) + return totalWritten, nil +} + +// writeZeroBytes writes n zero bytes to writer using the package-level zero buffer +func writeZeroBytes(w io.Writer, n int64) error { + for n > 0 { + toWrite := min(n, int64(len(zeroBuf))) + written, err := w.Write(zeroBuf[:toWrite]) + if err != nil { + return err + } + n -= int64(written) + } + return nil +} + +// decryptSSECChunkView decrypts a specific chunk view with SSE-C +// +// IV Handling for SSE-C: +// ---------------------- +// SSE-C multipart encryption differs from SSE-KMS/SSE-S3: +// +// 1. Encryption: CreateSSECEncryptedReader generates a RANDOM IV per part +// - Each part starts with a fresh random IV (NOT derived from a base IV) +// - CTR counter starts from 0 for each part: counter₀, counter₁, counter₂, ... +// - PartOffset is stored in metadata to describe where this chunk sits in that encrypted stream +// +// 2. Decryption: Use the stored per-part IV and advance the CTR by PartOffset +// - CreateSSECDecryptedReaderWithOffset internally uses calculateIVWithOffset to advance +// the CTR counter to reach PartOffset within the per-part encrypted stream +// - calculateIVWithOffset is applied to the per-part IV, NOT to derive a global base IV +// - Do NOT compute a single base IV for all parts (unlike SSE-KMS/SSE-S3) +// +// This contrasts with SSE-KMS/SSE-S3 which use: base IV + calculateIVWithOffset(ChunkOffset) +func (s3a *S3ApiServer) decryptSSECChunkView(ctx context.Context, fileChunk *filer_pb.FileChunk, chunkView *filer.ChunkView, customerKey *SSECustomerKey) (io.Reader, error) { + // For multipart SSE-C, each chunk has its own IV in chunk.SseMetadata + if fileChunk.GetSseType() == filer_pb.SSEType_SSE_C && len(fileChunk.GetSseMetadata()) > 0 { + ssecMetadata, err := DeserializeSSECMetadata(fileChunk.GetSseMetadata()) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-C metadata: %w", err) + } + chunkIV, err := base64.StdEncoding.DecodeString(ssecMetadata.IV) + if err != nil { + return nil, fmt.Errorf("failed to decode IV: %w", err) + } + + // Fetch FULL encrypted chunk + // Note: Fetching full chunk is necessary for proper CTR decryption stream + fullChunkReader, err := s3a.fetchFullChunk(ctx, chunkView.FileId) + if err != nil { + return nil, fmt.Errorf("failed to fetch full chunk: %w", err) + } + + partOffset := ssecMetadata.PartOffset + if partOffset < 0 { + fullChunkReader.Close() + return nil, fmt.Errorf("invalid SSE-C part offset %d for chunk %s", partOffset, chunkView.FileId) + } + + // Use stored IV and advance CTR stream by PartOffset within the encrypted stream + decryptedReader, decryptErr := CreateSSECDecryptedReaderWithOffset(fullChunkReader, customerKey, chunkIV, uint64(partOffset)) + if decryptErr != nil { + fullChunkReader.Close() + return nil, fmt.Errorf("failed to create decrypted reader: %w", decryptErr) + } + + // Skip to the position we need in the decrypted stream + if chunkView.OffsetInChunk > 0 { + _, err = io.CopyN(io.Discard, decryptedReader, chunkView.OffsetInChunk) + if err != nil { + if closer, ok := decryptedReader.(io.Closer); ok { + closer.Close() + } + return nil, fmt.Errorf("failed to skip to offset %d: %w", chunkView.OffsetInChunk, err) + } + } + + // Return a reader that only reads ViewSize bytes with proper cleanup + limitedReader := io.LimitReader(decryptedReader, int64(chunkView.ViewSize)) + return &rc{Reader: limitedReader, Closer: fullChunkReader}, nil + } + + // Single-part SSE-C: use object-level IV (should not hit this in range path, but handle it) + encryptedReader, err := s3a.fetchChunkViewData(ctx, chunkView) + if err != nil { + return nil, err + } + // For single-part, the IV is stored at object level, already handled in non-range path + return encryptedReader, nil +} + +// decryptSSEKMSChunkView decrypts a specific chunk view with SSE-KMS +// +// IV Handling for SSE-KMS: +// ------------------------ +// SSE-KMS (and SSE-S3) use a fundamentally different IV scheme than SSE-C: +// +// 1. Encryption: Uses a BASE IV + offset calculation +// - Base IV is generated once for the entire object +// - For each chunk at position N: adjustedIV = calculateIVWithOffset(baseIV, N) +// - This shifts the CTR counter to counterₙ where n = N/16 +// - ChunkOffset is stored in metadata and IS applied during encryption +// +// 2. Decryption: Apply the same offset calculation +// - Use calculateIVWithOffset(baseIV, ChunkOffset) to reconstruct the encryption IV +// - Also handle ivSkip for non-block-aligned offsets (intra-block positioning) +// - This ensures decryption uses the same CTR counter sequence as encryption +// +// This contrasts with SSE-C which uses random IVs without offset calculation. +func (s3a *S3ApiServer) decryptSSEKMSChunkView(ctx context.Context, fileChunk *filer_pb.FileChunk, chunkView *filer.ChunkView) (io.Reader, error) { + if fileChunk.GetSseType() == filer_pb.SSEType_SSE_KMS && len(fileChunk.GetSseMetadata()) > 0 { + sseKMSKey, err := DeserializeSSEKMSMetadata(fileChunk.GetSseMetadata()) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-KMS metadata: %w", err) + } + + // Fetch FULL encrypted chunk + fullChunkReader, err := s3a.fetchFullChunk(ctx, chunkView.FileId) + if err != nil { + return nil, fmt.Errorf("failed to fetch full chunk: %w", err) + } + + // CreateSSEKMSDecryptedReader applies ChunkOffset to the stored base IV. + // Passing a pre-adjusted IV here would apply the offset twice and corrupt + // range reads that cross multipart chunk boundaries. + decryptedReader, decryptErr := CreateSSEKMSDecryptedReader(fullChunkReader, sseKMSKey) + if decryptErr != nil { + fullChunkReader.Close() + return nil, fmt.Errorf("failed to create KMS decrypted reader: %w", decryptErr) + } + + // Skip to position and limit to ViewSize + if chunkView.OffsetInChunk > 0 { + _, err = io.CopyN(io.Discard, decryptedReader, chunkView.OffsetInChunk) + if err != nil { + if closer, ok := decryptedReader.(io.Closer); ok { + closer.Close() + } + return nil, fmt.Errorf("failed to skip to offset: %w", err) + } + } + + limitedReader := io.LimitReader(decryptedReader, int64(chunkView.ViewSize)) + return &rc{Reader: limitedReader, Closer: fullChunkReader}, nil + } + + // Non-KMS encrypted chunk + return s3a.fetchChunkViewData(ctx, chunkView) +} + +// decryptSSES3ChunkView decrypts a specific chunk view with SSE-S3 +// +// IV Handling for SSE-S3: +// ----------------------- +// SSE-S3 uses the same BASE IV + offset scheme as SSE-KMS, but with a subtle difference: +// +// 1. Encryption: Uses BASE IV + offset, but stores the ADJUSTED IV +// - Base IV is generated once for the entire object +// - For each chunk at position N: adjustedIV, skip = calculateIVWithOffset(baseIV, N) +// - The ADJUSTED IV (not base IV) is stored in chunk metadata +// - ChunkOffset calculation is performed during encryption +// +// 2. Decryption: Use the stored adjusted IV directly +// - The stored IV is already block-aligned and ready to use +// - No need to call calculateIVWithOffset again (unlike SSE-KMS) +// - Decrypt full chunk from start, then skip to OffsetInChunk in plaintext +// +// This differs from: +// - SSE-C: Uses random IV per chunk, no offset calculation +// - SSE-KMS: Stores base IV, requires calculateIVWithOffset during decryption +func (s3a *S3ApiServer) decryptSSES3ChunkView(ctx context.Context, fileChunk *filer_pb.FileChunk, chunkView *filer.ChunkView, entry *filer_pb.Entry) (io.Reader, error) { + // For multipart SSE-S3, each chunk has its own IV in chunk.SseMetadata + if fileChunk.GetSseType() == filer_pb.SSEType_SSE_S3 && len(fileChunk.GetSseMetadata()) > 0 { + keyManager := GetSSES3KeyManager() + + // Deserialize per-chunk SSE-S3 metadata to get chunk-specific IV + chunkSSES3Metadata, err := DeserializeSSES3Metadata(fileChunk.GetSseMetadata(), keyManager) + if err != nil { + return nil, fmt.Errorf("failed to deserialize chunk SSE-S3 metadata: %w", err) + } + + // Fetch FULL encrypted chunk (necessary for proper CTR decryption stream) + fullChunkReader, err := s3a.fetchFullChunk(ctx, chunkView.FileId) + if err != nil { + return nil, fmt.Errorf("failed to fetch full chunk: %w", err) + } + + // IMPORTANT: Use the stored IV directly - it's already block-aligned + // During encryption, CreateSSES3EncryptedReaderWithBaseIV called: + // adjustedIV, skip := calculateIVWithOffset(baseIV, partOffset) + // and stored the adjustedIV in metadata. We use it as-is for decryption. + // No need to call calculateIVWithOffset again (unlike SSE-KMS which stores base IV). + iv := chunkSSES3Metadata.IV + + glog.V(4).Infof("Decrypting multipart SSE-S3 chunk %s with chunk-specific IV length=%d", + chunkView.FileId, len(iv)) + + // Decrypt the full chunk starting from offset 0 + decryptedReader, decryptErr := CreateSSES3DecryptedReader(fullChunkReader, chunkSSES3Metadata, iv) + if decryptErr != nil { + fullChunkReader.Close() + return nil, fmt.Errorf("failed to create SSE-S3 decrypted reader: %w", decryptErr) + } + + // Skip to position within the decrypted chunk (plaintext offset, not ciphertext offset) + if chunkView.OffsetInChunk > 0 { + _, err = io.CopyN(io.Discard, decryptedReader, chunkView.OffsetInChunk) + if err != nil { + if closer, ok := decryptedReader.(io.Closer); ok { + closer.Close() + } + return nil, fmt.Errorf("failed to skip to offset %d: %w", chunkView.OffsetInChunk, err) + } + } + + limitedReader := io.LimitReader(decryptedReader, int64(chunkView.ViewSize)) + return &rc{Reader: limitedReader, Closer: fullChunkReader}, nil + } + + // Single-part SSE-S3: use object-level IV and key (fallback path) + keyData := entry.Extended[s3_constants.SeaweedFSSSES3Key] + keyManager := GetSSES3KeyManager() + sseS3Key, err := DeserializeSSES3Metadata(keyData, keyManager) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-S3 metadata: %w", err) + } + + // Fetch FULL encrypted chunk + fullChunkReader, err := s3a.fetchFullChunk(ctx, chunkView.FileId) + if err != nil { + return nil, fmt.Errorf("failed to fetch full chunk: %w", err) + } + + // Get base IV for single-part object + iv, err := GetSSES3IV(entry, sseS3Key, keyManager) + if err != nil { + fullChunkReader.Close() + return nil, fmt.Errorf("failed to get SSE-S3 IV: %w", err) + } + + glog.V(4).Infof("Decrypting single-part SSE-S3 chunk %s with entry-level IV length=%d", + chunkView.FileId, len(iv)) + + decryptedReader, decryptErr := CreateSSES3DecryptedReader(fullChunkReader, sseS3Key, iv) + if decryptErr != nil { + fullChunkReader.Close() + return nil, fmt.Errorf("failed to create S3 decrypted reader: %w", decryptErr) + } + + // Skip to position and limit to ViewSize + if chunkView.OffsetInChunk > 0 { + _, err = io.CopyN(io.Discard, decryptedReader, chunkView.OffsetInChunk) + if err != nil { + if closer, ok := decryptedReader.(io.Closer); ok { + closer.Close() + } + return nil, fmt.Errorf("failed to skip to offset: %w", err) + } + } + + limitedReader := io.LimitReader(decryptedReader, int64(chunkView.ViewSize)) + return &rc{Reader: limitedReader, Closer: fullChunkReader}, nil +} + +// fetchFullChunk fetches the complete encrypted chunk from volume server +func (s3a *S3ApiServer) fetchFullChunk(ctx context.Context, fileId string) (io.ReadCloser, error) { + // Lookup the volume server URLs for this chunk + lookupFileIdFn := s3a.createLookupFileIdFunction() + urlStrings, err := lookupFileIdFn(ctx, fileId) + if err != nil || len(urlStrings) == 0 { + return nil, fmt.Errorf("failed to lookup chunk %s: %w", fileId, err) + } + + // Use the first URL + chunkUrl := urlStrings[0] + + // Generate JWT for volume server authentication (uses config loaded once at startup) + jwt := filer.JwtForVolumeServer(fileId) + + // Create request WITHOUT Range header to get full chunk + req, err := http.NewRequestWithContext(ctx, "GET", chunkUrl, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set JWT for authentication + if jwt != "" { + req.Header.Set("Authorization", security.BearerPrefix+jwt) + } + + // Use shared HTTP client + resp, err := volumeServerHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch chunk: %w", err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("unexpected status code %d for chunk %s", resp.StatusCode, fileId) + } + + return resp.Body, nil +} + +// fetchChunkViewData fetches encrypted data for a chunk view (with range) +func (s3a *S3ApiServer) fetchChunkViewData(ctx context.Context, chunkView *filer.ChunkView) (io.ReadCloser, error) { + // Lookup the volume server URLs for this chunk + lookupFileIdFn := s3a.createLookupFileIdFunction() + urlStrings, err := lookupFileIdFn(ctx, chunkView.FileId) + if err != nil || len(urlStrings) == 0 { + return nil, fmt.Errorf("failed to lookup chunk %s: %w", chunkView.FileId, err) + } + + // Use the first URL (already contains complete URL with fileId) + chunkUrl := urlStrings[0] + + // Generate JWT for volume server authentication (uses config loaded once at startup) + jwt := filer.JwtForVolumeServer(chunkView.FileId) + + // Create request with Range header for the chunk view + // chunkUrl already contains the complete URL including fileId + req, err := http.NewRequestWithContext(ctx, "GET", chunkUrl, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set Range header to fetch only the needed portion of the chunk + if !chunkView.IsFullChunk() { + rangeEnd := chunkView.OffsetInChunk + int64(chunkView.ViewSize) - 1 + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", chunkView.OffsetInChunk, rangeEnd)) + } + + // Set JWT for authentication + if jwt != "" { + req.Header.Set("Authorization", security.BearerPrefix+jwt) + } + + // Use shared HTTP client with connection pooling + resp, err := volumeServerHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch chunk: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + resp.Body.Close() + return nil, fmt.Errorf("unexpected status code %d for chunk %s", resp.StatusCode, chunkView.FileId) + } + + return resp.Body, nil +} + +// getEncryptedStreamFromVolumes gets a raw encrypted data stream from volume +// servers for [offset, offset+size) of the entry. +func (s3a *S3ApiServer) getEncryptedStreamFromVolumes(ctx context.Context, entry *filer_pb.Entry, offset, size int64) (io.ReadCloser, error) { + if size <= 0 { + return io.NopCloser(bytes.NewReader(nil)), nil + } + + // Handle inline content + if len(entry.Content) > 0 { + content := entry.Content + if offset >= int64(len(content)) { + content = nil + } else { + content = content[offset:min(offset+size, int64(len(content)))] + } + return io.NopCloser(bytes.NewReader(content)), nil + } + + // Handle empty files + chunks := entry.GetChunks() + if len(chunks) == 0 { + return io.NopCloser(bytes.NewReader(nil)), nil + } + + // Reuse shared lookup function to keep volume lookup logic in one place + lookupFileIdFn := s3a.createLookupFileIdFunction() + + // Resolve chunks + resolvedChunks, _, err := filer.ResolveChunkManifest(ctx, lookupFileIdFn, chunks, offset, offset+size, s3a.filerClient) + if err != nil { + return nil, err + } + + // Create streaming reader - use filerClient directly for cache invalidation support + streamFn, err := filer.PrepareStreamContentWithPrefetch( + ctx, + s3a.filerClient, + filer.JwtForVolumeServer, // Use filer's JWT function (loads config once, generates JWT locally) + resolvedChunks, + offset, + size, + 0, + 4, // prefetch 4 chunks ahead for overlapped fetching + ) + if err != nil { + return nil, err + } + + // Create a pipe to get io.ReadCloser + pipeReader, pipeWriter := io.Pipe() + go func() { + defer pipeWriter.Close() + if err := streamFn(pipeWriter); err != nil { + glog.Errorf("getEncryptedStreamFromVolumes: streaming error: %v", err) + pipeWriter.CloseWithError(err) + } + }() + + return pipeReader, nil +} + +// addSSEResponseHeadersFromEntry adds appropriate SSE response headers based on entry metadata +func (s3a *S3ApiServer) addSSEResponseHeadersFromEntry(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, sseType string) { + if entry == nil || entry.Extended == nil { + return + } + + switch sseType { + case s3_constants.SSETypeC: + // SSE-C: Echo back algorithm and key MD5 + if algo, exists := entry.Extended[s3_constants.AmzServerSideEncryptionCustomerAlgorithm]; exists { + w.Header().Set(s3_constants.AmzServerSideEncryptionCustomerAlgorithm, string(algo)) + } + if keyMD5, exists := entry.Extended[s3_constants.AmzServerSideEncryptionCustomerKeyMD5]; exists { + w.Header().Set(s3_constants.AmzServerSideEncryptionCustomerKeyMD5, string(keyMD5)) + } + + case s3_constants.SSETypeKMS: + // SSE-KMS: Return algorithm and key ID + w.Header().Set(s3_constants.AmzServerSideEncryption, "aws:kms") + if kmsMetadataBytes, exists := entry.Extended[s3_constants.SeaweedFSSSEKMSKey]; exists { + sseKMSKey, err := DeserializeSSEKMSMetadata(kmsMetadataBytes) + if err == nil { + AddSSEKMSResponseHeaders(w, sseKMSKey) + } + } + + case s3_constants.SSETypeS3: + // SSE-S3: Return algorithm + w.Header().Set(s3_constants.AmzServerSideEncryption, s3_constants.SSEAlgorithmAES256) + } +} + +// setResponseHeaders sets all standard HTTP response headers from entry metadata +func (s3a *S3ApiServer) setResponseHeaders(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, totalSize int64) { + // Safety check: entry must be valid + if entry == nil { + glog.Errorf("setResponseHeaders: entry is nil") + return + } + + // Set content length and accept ranges + w.Header().Set("Content-Length", strconv.FormatInt(totalSize, 10)) + w.Header().Set("Accept-Ranges", "bytes") + + // Set ETag (but don't overwrite if already set, e.g., for part-specific GET requests) + if w.Header().Get("ETag") == "" { + etag := s3a.getObjectETag(entry) + if etag != "" { + w.Header().Set("ETag", etag) + } + } + + // Set Last-Modified in RFC1123 format + if entry.Attributes != nil { + modTime := time.Unix(entry.Attributes.Mtime, 0).UTC() + w.Header().Set("Last-Modified", modTime.Format(http.TimeFormat)) + } + + // Set Content-Type + mimeType := "" + if entry.Attributes != nil && entry.Attributes.Mime != "" { + mimeType = entry.Attributes.Mime + } + if mimeType == "" { + // Try to detect from entry name + if entry.Name != "" { + ext := filepath.Ext(entry.Name) + if ext != "" { + mimeType = mime.TypeByExtension(ext) + } + } + } + if mimeType != "" { + w.Header().Set("Content-Type", mimeType) + } else { + w.Header().Set("Content-Type", "application/octet-stream") + } + + // Set custom headers from entry.Extended (user metadata) + // Use direct map assignment to preserve original header casing (matches proxy behavior) + if entry.Extended != nil { + for k, v := range entry.Extended { + // Skip internal SeaweedFS headers + if !strings.HasPrefix(k, "xattr-") && !s3_constants.IsSeaweedFSInternalHeader(k) { + // Support backward compatibility: migrate old non-canonical format to canonical format + // OLD: "x-amz-meta-foo" → NEW: "X-Amz-Meta-foo" (preserving suffix case) + headerKey := k + if len(k) >= 11 && strings.EqualFold(k[:11], "x-amz-meta-") { + // Normalize to AWS S3 format: "X-Amz-Meta-" prefix with lowercase suffix + // AWS S3 returns user metadata with the suffix in lowercase + suffix := k[len("x-amz-meta-"):] + headerKey = s3_constants.AmzUserMetaPrefix + strings.ToLower(suffix) + if glog.V(4) && k != headerKey { + glog.Infof("Normalizing user metadata header %q to %q in response", k, headerKey) + } + } + w.Header()[headerKey] = []string{string(v)} + } + } + } + + // Set tag count header (matches filer logic) + if entry.Extended != nil { + tagCount := 0 + for k := range entry.Extended { + if strings.HasPrefix(k, s3_constants.AmzObjectTagging+"-") { + tagCount++ + } + } + if tagCount > 0 { + w.Header().Set(s3_constants.AmzTagCount, strconv.Itoa(tagCount)) + } + } + + // Set checksum header if stored in metadata, but only when: + // 1. The request contains "x-amz-checksum-mode: ENABLED" (per AWS S3 spec) + // 2. The response covers the full object (no Range header, no partNumber) + // The stored checksum covers the full object; returning it for partial + // responses causes SDK checksum validation failures. + if r != nil && r.Header.Get("X-Amz-Checksum-Mode") == "ENABLED" && r.Header.Get("Range") == "" && requestedPartNumber(r) == 0 { + if entry.Extended != nil { + if algoName, ok := entry.Extended[s3_constants.ExtChecksumAlgorithm]; ok { + if checksumVal, ok := entry.Extended[s3_constants.ExtChecksumValue]; ok { + w.Header().Set(string(algoName), string(checksumVal)) + if checksumType, ok := entry.Extended[s3_constants.ExtChecksumType]; ok && len(checksumType) > 0 { + w.Header().Set(s3_constants.AmzChecksumType, string(checksumType)) + } + } + } + } + } + + // Apply S3 passthrough headers from query parameters + // AWS S3 supports overriding response headers via query parameters like: + // ?response-cache-control=no-cache&response-content-type=application/json + // This allows presigned URLs to control how browsers handle the downloaded content + if r != nil { + for queryParam, headerValue := range r.URL.Query() { + if normalizedHeader, ok := s3_constants.PassThroughHeaders[strings.ToLower(queryParam)]; ok && len(headerValue) > 0 && headerValue[0] != "" { + w.Header().Set(normalizedHeader, headerValue[0]) + } + } + } +} + +// HeadObjectHandler handles S3 HEAD object requests +// +// Special behavior for implicit directories: +// When a HEAD request is made on a path without a trailing slash, and that path represents +// a directory with children (either a 0-byte file marker or an actual directory), this handler +// returns 404 Not Found instead of 200 OK. This behavior improves compatibility with s3fs and +// matches AWS S3's handling of implicit directories. +// +// Rationale: +// - AWS S3 typically doesn't create directory markers when files are uploaded (e.g., uploading +// "dataset/file.txt" doesn't create a marker at "dataset") +// - Some S3 clients (like PyArrow with s3fs) create directory markers, which can confuse s3fs +// - s3fs's info() method calls HEAD first; if it succeeds with size=0, s3fs incorrectly reports +// the object as a file instead of checking for children +// - By returning 404 for implicit directories, we force s3fs to fall back to LIST-based discovery, +// which correctly identifies directories by checking for children +// +// Examples: +// +// HEAD /bucket/dataset (no trailing slash, has children) → 404 Not Found (implicit directory) +// HEAD /bucket/dataset/ (trailing slash) → 200 OK (explicit directory request) +// HEAD /bucket/empty.txt (0-byte file, no children) → 200 OK (legitimate empty file) +// HEAD /bucket/file.txt (regular file) → 200 OK (normal operation) +// +// This behavior only applies to: +// - Non-versioned buckets (versioned buckets use different semantics) +// - Paths without trailing slashes (trailing slash indicates explicit directory request) +// - Objects that are either 0-byte files or actual directories +// - Objects that have at least one child (checked via hasChildren) +func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request) { + + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("HeadObjectHandler %s %s", bucket, object) + if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + + // Check for SOSAPI virtual objects (system.xml, capacity.xml) + // These are dynamically generated and don't exist on disk + if s3a.handleSOSAPIHeadObject(w, r, bucket, object) { + return // SOSAPI request was handled + } + + // Handle directory objects with shared logic + if s3a.handleDirectoryObjectRequest(w, r, bucket, object, "HeadObjectHandler") { + return // Directory object request was handled + } + + // Check conditional headers and handle early return if conditions fail + result, handled := s3a.processConditionalHeaders(w, r, bucket, object, "HeadObjectHandler") + if handled { + return + } + + // Check for specific version ID in query parameters + versionId := r.URL.Query().Get("versionId") + + var ( + entry *filer_pb.Entry // Declare entry at function scope for SSE processing + versioningConfigured bool + err error + ) + + // Check if versioning is configured for the bucket (Enabled or Suspended) + // Note: We need to check this even if versionId is empty, because versioned buckets + // handle even "get latest version" requests differently (through .versions directory) + versioningConfigured, err = s3a.isVersioningConfigured(bucket) + if err != nil { + if err == filer_pb.ErrNotFound { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return + } + glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + if versioningConfigured { + // Handle versioned HEAD - all versions are stored in .versions directory + var targetVersionId string + + if versionId != "" { + // Request for specific version + glog.V(2).Infof("HeadObject: requesting specific version %s for %s/%s", versionId, bucket, object) + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + if err != nil { + glog.Errorf("Failed to get specific version %s for %s/%s: %v", versionId, bucket, object, err) + if errors.Is(err, filer_pb.ErrNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchVersion) + } else { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + } + return + } + targetVersionId = versionId + } else { + // Request for latest version - OPTIMIZATION: + // Check if .versions/ directory exists quickly (no retries) to decide path + // - If .versions/ exists: real versions available, use getLatestObjectVersion + // - If .versions/ doesn't exist (ErrNotFound): only null version at regular path, use it directly + // - If transient error: fall back to getLatestObjectVersion which has retry logic + bucketDir := s3a.bucketDir(bucket) + normalizedObject := s3_constants.NormalizeObjectKey(object) + versionsDir := normalizedObject + s3_constants.VersionsFolder + + // Quick check (no retries) for .versions/ directory + versionsEntry, versionsErr := s3a.getEntry(bucketDir, versionsDir) + + if versionsErr == nil && versionsEntry != nil { + // .versions/ exists, meaning real versions are stored there + // Use getLatestObjectVersion which will properly find the newest version + entry, err = s3a.getLatestObjectVersion(bucket, object) + if err != nil { + glog.Errorf("HeadObject: Failed to get latest version for %s/%s: %v", bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } else if errors.Is(versionsErr, filer_pb.ErrNotFound) { + // .versions/ doesn't exist (confirmed not found), check regular path for null version + regularEntry, regularErr := s3a.getEntry(bucketDir, normalizedObject) + if regularErr == nil && regularEntry != nil && !isBareDirectory(regularEntry) { + // Found object at regular path - this is the null version + entry = regularEntry + targetVersionId = "null" + } else { + // No object at regular path either - object doesn't exist + glog.V(3).Infof("HeadObject: object not found at regular path or .versions for %s/%s", bucket, object) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } else { + // Transient error checking .versions/, fall back to getLatestObjectVersion with retries + glog.V(2).Infof("HeadObject: transient error checking .versions for %s/%s: %v, falling back to getLatestObjectVersion", bucket, object, versionsErr) + entry, err = s3a.getLatestObjectVersion(bucket, object) + if err != nil { + glog.Errorf("HeadObject: Failed to get latest version for %s/%s: %v", bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + // Extract version ID if not already set + if targetVersionId == "" { + if entry.Extended != nil { + if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists { + targetVersionId = string(versionIdBytes) + } + } + // If no version ID found in entry, this is a pre-versioning object + if targetVersionId == "" { + targetVersionId = "null" + } + } + } + + // Check if this is a delete marker + if entry.Extended != nil { + if deleteMarker, exists := entry.Extended[s3_constants.ExtDeleteMarkerKey]; exists && string(deleteMarker) == "true" { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + + // For versioned objects, log the target version + if targetVersionId == "null" { + glog.V(2).Infof("HeadObject: pre-versioning object %s/%s", bucket, object) + } else { + glog.V(2).Infof("HeadObject: version %s for %s/%s", targetVersionId, bucket, object) + } + + // Set version ID in response header + w.Header().Set("x-amz-version-id", targetVersionId) + + // Add object lock metadata to response headers if present + s3a.addObjectLockHeadersToResponse(w, entry) + } + + // Fetch the correct entry for SSE processing (respects versionId) + // For versioned objects, reuse already-fetched entry; for non-versioned, try to reuse from conditional check + var objectEntryForSSE *filer_pb.Entry + if versioningConfigured { + objectEntryForSSE = entry + } else { + // For non-versioned objects, try to reuse entry from conditional header check + if result.Entry != nil { + // Reuse entry fetched during conditional header check (optimization) + objectEntryForSSE = result.Entry + glog.V(3).Infof("HeadObjectHandler: Reusing entry from conditional header check for %s/%s", bucket, object) + } else { + // Fetch entry for SSE processing + // This is needed for all SSE types (SSE-C, SSE-KMS, SSE-S3) to: + // 1. Detect encryption from object metadata (SSE-KMS/SSE-S3 don't send headers on HEAD) + // 2. Add proper response headers + var fetchErr error + objectEntryForSSE, fetchErr = s3a.fetchObjectEntry(bucket, object) + if fetchErr != nil { + glog.Warningf("HeadObjectHandler: failed to get entry for %s/%s: %v", bucket, object, fetchErr) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + if objectEntryForSSE == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + } + + // Safety check: entry must be valid + if objectEntryForSSE == nil { + glog.Errorf("HeadObjectHandler: objectEntryForSSE is nil for %s/%s (should not happen)", bucket, object) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Re-check bucket policy with object entry for tag-based conditions (e.g., s3:ExistingObjectTag) + if errCode := s3a.recheckPolicyWithObjectEntry(r, bucket, object, string(s3_constants.ACTION_READ), objectEntryForSSE.Extended, "HeadObjectHandler"); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Implicit Directory Handling for s3fs Compatibility + // ==================================================== + // + // Background: + // Some S3 clients (like PyArrow with s3fs) create directory markers when writing datasets. + // These can be either: + // 1. 0-byte files with directory MIME type (e.g., "application/octet-stream") + // 2. Actual directories in the filer (created by PyArrow's write_dataset) + // + // Problem: + // s3fs's info() method calls HEAD on the path. If HEAD returns 200 with size=0, + // s3fs incorrectly reports it as a file (type='file', size=0) instead of checking + // for children. This causes PyArrow to fail with "Parquet file size is 0 bytes". + // + // Solution: + // For non-versioned objects without trailing slash, if the object is a 0-byte file + // or directory AND has children, return 404 instead of 200. This forces s3fs to + // fall back to LIST-based discovery, which correctly identifies it as a directory. + // + // AWS S3 Compatibility: + // AWS S3 typically doesn't create directory markers for implicit directories, so + // HEAD on "dataset" (when only "dataset/file.txt" exists) returns 404. Our behavior + // matches this by returning 404 for implicit directories with children. + // + // Edge Cases Handled: + // - Empty files (0-byte, no children) → 200 OK (legitimate empty file) + // - Empty directories (no children) → 404 Not Found (directories are not objects) + // - Explicit directory requests (trailing slash) → 200 OK (handled earlier) + // - Versioned objects → Skip this check (different semantics) + // + // Performance: + // Only adds overhead for 0-byte files or directories without trailing slash. + // Cost: One LIST operation with Limit=1 (~1-5ms). + // + if !versioningConfigured && !strings.HasSuffix(object, "/") { + // Check if this is an implicit directory (either a 0-byte file or actual directory with children) + // PyArrow may create 0-byte files when writing datasets, or the filer may have actual directories + if objectEntryForSSE.Attributes != nil { + isZeroByteFile := objectEntryForSSE.Attributes.FileSize == 0 && !objectEntryForSSE.IsDirectory + // A directory with data (a promoted file) is retrievable; empty directories 404 for LIST fallback. + if isBareDirectory(objectEntryForSSE) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + if isZeroByteFile { + // Check if it has children (making it an implicit directory) + if s3a.hasChildren(r.Context(), bucket, object) { + // This is an implicit directory with children + // Return 404 to force clients (like s3fs) to use LIST-based discovery + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + } + } + + // For HEAD requests, we already have all metadata - just set headers directly + totalSize := int64(filer.FileSize(objectEntryForSSE)) + responseSize := totalSize + statusCode := http.StatusOK + + // A partNumber HEAD is a ranged HEAD of that part: report the part's size and range + if partNumber := requestedPartNumber(r); partNumber > 0 { + startOffset, endOffset, errCode := s3a.partByteRange(w, r, objectEntryForSSE, partNumber) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + responseSize = endOffset - startOffset + 1 + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", startOffset, endOffset, totalSize)) + statusCode = http.StatusPartialContent + glog.V(3).Infof("HeadObject: part %d is bytes %d-%d/%d", partNumber, startOffset, endOffset, totalSize) + } + + s3a.setResponseHeaders(w, r, objectEntryForSSE, responseSize) + + // Detect and handle SSE + glog.V(3).Infof("HeadObjectHandler: Retrieved entry for %s/%s - %d chunks", bucket, object, len(objectEntryForSSE.Chunks)) + sseType := s3a.detectPrimarySSEType(objectEntryForSSE) + glog.V(2).Infof("HeadObjectHandler: Detected SSE type: %s", sseType) + if sseType != "" && sseType != "None" { + // Validate SSE headers for encrypted objects + switch sseType { + case s3_constants.SSETypeC: + customerKey, err := ParseSSECHeaders(r) + if err != nil { + s3err.WriteErrorResponse(w, r, MapSSECErrorToS3Error(err)) + return + } + if customerKey == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrSSECustomerKeyMissing) + return + } + // Validate key MD5 + if objectEntryForSSE.Extended != nil { + storedKeyMD5 := string(objectEntryForSSE.Extended[s3_constants.AmzServerSideEncryptionCustomerKeyMD5]) + if storedKeyMD5 != "" && customerKey.KeyMD5 != storedKeyMD5 { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + } + } + // Add SSE response headers + s3a.addSSEResponseHeadersFromEntry(w, r, objectEntryForSSE, sseType) + } + + w.WriteHeader(statusCode) +} + +// fetchObjectEntry fetches the filer entry for an object +// Returns nil if not found (not an error), or propagates other errors +func (s3a *S3ApiServer) fetchObjectEntry(bucket, object string) (*filer_pb.Entry, error) { + fetchedEntry, fetchErr := s3a.getObjectEntryRoutedByKey(bucket, object) + if fetchErr != nil { + if errors.Is(fetchErr, filer_pb.ErrNotFound) { + return nil, nil // Not found is not an error for SSE check + } + return nil, fetchErr // Propagate other errors + } + return fetchedEntry, nil +} + +// fetchObjectEntryRequired fetches the filer entry for an object +// Returns an error if the object is not found or any other error occurs +func (s3a *S3ApiServer) fetchObjectEntryRequired(bucket, object string) (*filer_pb.Entry, error) { + fetchedEntry, fetchErr := s3a.getObjectEntryRoutedByKey(bucket, object) + if fetchErr != nil { + return nil, fetchErr // Return error for both not-found and other errors + } + return fetchedEntry, nil +} + +// addObjectLockHeadersToResponse extracts object lock metadata from entry Extended attributes +// and adds the appropriate S3 headers to the response +func (s3a *S3ApiServer) addObjectLockHeadersToResponse(w http.ResponseWriter, entry *filer_pb.Entry) { + if entry == nil || entry.Extended == nil { + return + } + + // Check if this entry has any object lock metadata (indicating it's from an object lock enabled bucket) + hasObjectLockMode := false + hasRetentionDate := false + + // Add object lock mode header if present + if modeBytes, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists && len(modeBytes) > 0 { + w.Header().Set(s3_constants.AmzObjectLockMode, string(modeBytes)) + hasObjectLockMode = true + } + + // Add retention until date header if present + if dateBytes, exists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; exists && len(dateBytes) > 0 { + dateStr := string(dateBytes) + // Convert Unix timestamp to ISO8601 format for S3 compatibility + if timestamp, err := strconv.ParseInt(dateStr, 10, 64); err == nil { + retainUntilDate := time.Unix(timestamp, 0).UTC() + w.Header().Set(s3_constants.AmzObjectLockRetainUntilDate, retainUntilDate.Format(time.RFC3339)) + hasRetentionDate = true + } else { + glog.Errorf("addObjectLockHeadersToResponse: failed to parse retention until date from stored metadata (dateStr: %s): %v", dateStr, err) + } + } + + // Add legal hold header - AWS S3 behavior: always include legal hold for object lock enabled buckets + if legalHoldBytes, exists := entry.Extended[s3_constants.ExtLegalHoldKey]; exists && len(legalHoldBytes) > 0 { + // Return stored S3 standard "ON"/"OFF" values directly + w.Header().Set(s3_constants.AmzObjectLockLegalHold, string(legalHoldBytes)) + } else if hasObjectLockMode || hasRetentionDate { + // If this entry has object lock metadata (indicating object lock enabled bucket) + // but no legal hold specifically set, default to "OFF" as per AWS S3 behavior + w.Header().Set(s3_constants.AmzObjectLockLegalHold, s3_constants.LegalHoldOff) + } +} + +// detectPrimarySSEType determines the primary SSE type by examining chunk metadata +func (s3a *S3ApiServer) detectPrimarySSEType(entry *filer_pb.Entry) string { + if entry == nil { + return "None" + } + + metadataType := "None" + hasSSEC := entry.Extended[s3_constants.AmzServerSideEncryptionCustomerAlgorithm] != nil + if hasSSEC { + metadataType = s3_constants.SSETypeC + } else { + switch string(entry.Extended[s3_constants.AmzServerSideEncryption]) { + case s3_constants.SSEAlgorithmAES256: + metadataType = s3_constants.SSETypeS3 + if _, hasKMSKey := entry.Extended[s3_constants.AmzServerSideEncryptionAwsKmsKeyId]; hasKMSKey { + metadataType = s3_constants.SSETypeKMS + } + case s3_constants.SSEAlgorithmKMS: + metadataType = s3_constants.SSETypeKMS + } + } + + // Count chunk types to determine primary (multipart objects) + ssecChunks := 0 + ssekmsChunks := 0 + sses3Chunks := 0 + + for _, chunk := range entry.GetChunks() { + switch chunk.GetSseType() { + case filer_pb.SSEType_SSE_C: + ssecChunks++ + case filer_pb.SSEType_SSE_KMS: + if len(chunk.GetSseMetadata()) > 0 { + ssekmsChunks++ + } + case filer_pb.SSEType_SSE_S3: + if len(chunk.GetSseMetadata()) > 0 { + sses3Chunks++ + } + } + } + + // Primary type is the one with more chunks + // Note: Tie-breaking follows precedence order SSE-C > SSE-KMS > SSE-S3 + // Mixed encryption in an object indicates potential corruption and should not occur in normal operation + if ssecChunks > ssekmsChunks && ssecChunks > sses3Chunks { + return s3_constants.SSETypeC + } else if ssekmsChunks > ssecChunks && ssekmsChunks > sses3Chunks { + return s3_constants.SSETypeKMS + } else if sses3Chunks > ssecChunks && sses3Chunks > ssekmsChunks { + return s3_constants.SSETypeS3 + } else if ssecChunks > 0 { + // Equal number or ties - precedence: SSE-C first + return s3_constants.SSETypeC + } else if ssekmsChunks > 0 { + return s3_constants.SSETypeKMS + } else if sses3Chunks > 0 { + return s3_constants.SSETypeS3 + } + + return metadataType +} + +// createMultipartSSECDecryptedReaderDirect creates a reader that decrypts each chunk independently for multipart SSE-C objects (direct volume path) +// Note: encryptedStream parameter is unused (always nil) as this function fetches chunks directly to avoid double I/O. +// It's kept in the signature for API consistency with non-Direct versions. +// +// Per-chunk metadata is validated upfront (so a malformed chunk fails fast +// without opening any HTTP connections); chunk fetches happen LAZILY through +// lazyMultipartChunkReader, so at most one volume-server connection is open +// at a time. See buildMultipartSSES3Reader for the rationale (issue #8908). +// +// SSE-C multipart behavior (differs from SSE-KMS/SSE-S3): +// - Upload: CreateSSECEncryptedReader generates a RANDOM IV per part (no base IV + offset). +// - Metadata: PartOffset tracks position within the encrypted stream. +// - Decryption: use stored IV and advance the CTR stream by PartOffset. +// +// SSE-KMS/SSE-S3 instead use base IV + calculateIVWithOffset(partOffset) at +// encryption time. CopyObject currently applies calculateIVWithOffset to SSE-C +// as well, which may be incorrect (TODO: investigate consistency). +func (s3a *S3ApiServer) createMultipartSSECDecryptedReaderDirect(ctx context.Context, encryptedStream io.ReadCloser, customerKey *SSECustomerKey, entry *filer_pb.Entry) (io.Reader, error) { + // Close the original encrypted stream since chunks are fetched individually. + if encryptedStream != nil { + defer encryptedStream.Close() + } + + // Sort a copy of the slice so entry.Chunks is not reordered (other code + // paths, e.g. ETag computation, can rely on the original chunk order). + originalChunks := entry.GetChunks() + chunks := make([]*filer_pb.FileChunk, len(originalChunks)) + copy(chunks, originalChunks) + sort.Slice(chunks, func(i, j int) bool { + return chunks[i].GetOffset() < chunks[j].GetOffset() + }) + + preparedChunks := make([]preparedMultipartChunk, 0, len(chunks)) + for _, chunk := range chunks { + if chunk.GetSseType() != filer_pb.SSEType_SSE_C { + preparedChunks = append(preparedChunks, preparedMultipartChunk{chunk: chunk}) + continue + } + if len(chunk.GetSseMetadata()) == 0 { + return nil, fmt.Errorf("SSE-C chunk %s missing per-chunk metadata", chunk.GetFileIdString()) + } + ssecMetadata, err := DeserializeSSECMetadata(chunk.GetSseMetadata()) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-C metadata for chunk %s: %v", chunk.GetFileIdString(), err) + } + chunkIV, err := base64.StdEncoding.DecodeString(ssecMetadata.IV) + if err != nil { + return nil, fmt.Errorf("failed to decode IV for SSE-C chunk %s: %v", chunk.GetFileIdString(), err) + } + // Guard cipher.NewCTR against a missing/short IV (base64 decode of + // an empty or malformed field would otherwise reach it and panic). + // Uses the shared ValidateIV helper so all three SSE prep paths + // (SSE-S3, SSE-KMS, SSE-C) enforce IV length identically. + if err := ValidateIV(chunkIV, fmt.Sprintf("SSE-C chunk %s IV", chunk.GetFileIdString())); err != nil { + return nil, err + } + if ssecMetadata.PartOffset < 0 { + return nil, fmt.Errorf("invalid SSE-C part offset %d for chunk %s", ssecMetadata.PartOffset, chunk.GetFileIdString()) + } + // Capture per-chunk values into the wrap closure. + fileId := chunk.GetFileIdString() + ivCopy := chunkIV + partOffset := uint64(ssecMetadata.PartOffset) + preparedChunks = append(preparedChunks, preparedMultipartChunk{ + chunk: chunk, + wrap: func(raw io.ReadCloser) (io.Reader, error) { + glog.V(4).Infof("Decrypting SSE-C chunk %s with IV=%x, PartOffset=%d", + fileId, ivCopy[:8], partOffset) + dec, decErr := CreateSSECDecryptedReaderWithOffset(raw, customerKey, ivCopy, partOffset) + if decErr != nil { + return nil, fmt.Errorf("failed to decrypt chunk: %v", decErr) + } + return dec, nil + }, + }) + } + + return &lazyMultipartChunkReader{ + chunks: preparedChunks, + fetch: func(c *filer_pb.FileChunk) (io.ReadCloser, error) { + return s3a.createEncryptedChunkReader(ctx, c) + }, + }, nil +} + +// createMultipartSSEKMSDecryptedReaderDirect creates a reader that decrypts each chunk independently for multipart SSE-KMS objects (direct volume path) +// Note: encryptedStream parameter is unused (always nil) as this function fetches chunks directly to avoid double I/O. +// It's kept in the signature for API consistency with non-Direct versions. +// +// Per-chunk metadata is validated upfront (so a malformed chunk fails fast +// without opening any HTTP connections); chunk fetches happen LAZILY through +// lazyMultipartChunkReader, so at most one volume-server connection is open +// at a time. See buildMultipartSSES3Reader for the rationale (issue #8908). +func (s3a *S3ApiServer) createMultipartSSEKMSDecryptedReaderDirect(ctx context.Context, encryptedStream io.ReadCloser, entry *filer_pb.Entry) (io.Reader, error) { + // Close the original encrypted stream since chunks are fetched individually. + if encryptedStream != nil { + defer encryptedStream.Close() + } + + return buildMultipartSSEKMSReader(entry.GetChunks(), func(chunk *filer_pb.FileChunk) (io.ReadCloser, error) { + return s3a.createEncryptedChunkReader(ctx, chunk) + }) +} + +// buildMultipartSSEKMSReader composes a decrypted reader from a set of +// multipart SSE-KMS chunks. Mirrors buildMultipartSSES3Reader: chunks are +// validated upfront (per-chunk metadata parses, IV has the right length) and +// fetched + decrypted lazily through lazyMultipartChunkReader, so at most one +// volume-server HTTP body is live at a time. Exposed as a free function so +// tests can inject a mock chunk fetcher and pin the "no fetch on bad +// metadata" contract without spinning up an S3ApiServer. +func buildMultipartSSEKMSReader(chunks []*filer_pb.FileChunk, fetchChunk func(*filer_pb.FileChunk) (io.ReadCloser, error)) (io.Reader, error) { + sortedChunks := make([]*filer_pb.FileChunk, len(chunks)) + copy(sortedChunks, chunks) + sort.Slice(sortedChunks, func(i, j int) bool { + return sortedChunks[i].GetOffset() < sortedChunks[j].GetOffset() + }) + + preparedChunks := make([]preparedMultipartChunk, 0, len(sortedChunks)) + for _, chunk := range sortedChunks { + if chunk.GetSseType() != filer_pb.SSEType_SSE_KMS { + preparedChunks = append(preparedChunks, preparedMultipartChunk{chunk: chunk}) + continue + } + if len(chunk.GetSseMetadata()) == 0 { + return nil, fmt.Errorf("SSE-KMS chunk %s missing per-chunk metadata", chunk.GetFileIdString()) + } + kmsKey, err := DeserializeSSEKMSMetadata(chunk.GetSseMetadata()) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-KMS metadata for chunk %s: %v", chunk.GetFileIdString(), err) + } + // Validate IV length up front, mirroring the SSE-S3 / SSE-C + // preparation paths. CreateSSEKMSDecryptedReader does call + // ValidateIV internally, but only when the wrap closure runs -- + // after the chunk's volume-server fetch has already started. We + // want the "reject malformed chunks before any fetch" contract to + // hold for SSE-KMS too, so a missing or short IV must fail here + // in the prep loop rather than turn into a mid-stream error. + if err := ValidateIV(kmsKey.IV, fmt.Sprintf("SSE-KMS chunk %s IV", chunk.GetFileIdString())); err != nil { + return nil, err + } + // Capture kmsKey and chunk into the wrap closure so each prepared + // entry decrypts with its own per-chunk SSE-KMS key. + fileId := chunk.GetFileIdString() + preparedChunks = append(preparedChunks, preparedMultipartChunk{ + chunk: chunk, + wrap: func(raw io.ReadCloser) (io.Reader, error) { + glog.V(4).Infof("Decrypting SSE-KMS chunk %s with KeyID=%s", fileId, kmsKey.KeyID) + dec, decErr := CreateSSEKMSDecryptedReader(raw, kmsKey) + if decErr != nil { + return nil, fmt.Errorf("failed to decrypt chunk: %v", decErr) + } + return dec, nil + }, + }) + } + + return &lazyMultipartChunkReader{ + chunks: preparedChunks, + fetch: fetchChunk, + }, nil +} + +// createMultipartSSES3DecryptedReaderDirect creates a reader that decrypts each chunk independently for multipart SSE-S3 objects (direct volume path) +// Note: encryptedStream parameter is unused (always nil) as this function fetches chunks directly to avoid double I/O. +// It's kept in the signature for API consistency with non-Direct versions. +func (s3a *S3ApiServer) createMultipartSSES3DecryptedReaderDirect(ctx context.Context, encryptedStream io.ReadCloser, entry *filer_pb.Entry) (io.Reader, error) { + // Close the original encrypted stream since chunks are fetched individually. + if encryptedStream != nil { + defer encryptedStream.Close() + } + + return buildMultipartSSES3Reader(entry.GetChunks(), GetSSES3KeyManager(), func(chunk *filer_pb.FileChunk) (io.ReadCloser, error) { + return s3a.createEncryptedChunkReader(ctx, chunk) + }) +} + +// buildMultipartSSES3Reader composes a decrypted reader from a set of multipart +// SSE-S3 chunks. Chunks are fetched via fetchChunk and decrypted using their +// per-chunk metadata (each multipart part has its own DEK and IV). Exposed as a +// standalone helper so tests can inject a mock chunk fetcher. +// +// All per-chunk metadata is validated upfront so a malformed chunk fails fast +// without opening any HTTP connections to volume servers. The actual chunk +// fetch and decryption happens LAZILY as the returned reader is read: at most +// one chunk's HTTP connection is open at a time. Eagerly opening every chunk's +// HTTP response (the previous behavior) caused later chunks' connections to +// sit idle while earlier chunks were still being consumed, which under load +// could trip volume-server idle/keepalive limits and yield truncated reads +// (issue #8908). +func buildMultipartSSES3Reader(chunks []*filer_pb.FileChunk, keyManager *SSES3KeyManager, fetchChunk func(*filer_pb.FileChunk) (io.ReadCloser, error)) (io.Reader, error) { + // Sort a copy of the slice so callers do not observe their input chunks + // reordered (the backing array is shared with entry.Chunks, which other + // code may rely on being in its original order, e.g. for ETag computation). + sortedChunks := make([]*filer_pb.FileChunk, len(chunks)) + copy(sortedChunks, chunks) + sort.Slice(sortedChunks, func(i, j int) bool { + return sortedChunks[i].GetOffset() < sortedChunks[j].GetOffset() + }) + + // Validate every chunk's SSE-S3 metadata before returning a reader. This + // keeps the eager-validation contract that callers and tests rely on + // (malformed metadata fails immediately), without holding open any + // volume-server HTTP connections. + preparedChunks := make([]preparedMultipartChunk, 0, len(sortedChunks)) + for _, chunk := range sortedChunks { + if chunk.GetSseType() != filer_pb.SSEType_SSE_S3 { + preparedChunks = append(preparedChunks, preparedMultipartChunk{chunk: chunk}) + continue + } + if len(chunk.GetSseMetadata()) == 0 { + return nil, fmt.Errorf("SSE-S3 chunk %s missing per-chunk metadata", chunk.GetFileIdString()) + } + meta, err := DeserializeSSES3Metadata(chunk.GetSseMetadata(), keyManager) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-S3 metadata for chunk %s: %v", chunk.GetFileIdString(), err) + } + // DeserializeSSES3Metadata does not require an IV, so validate the + // length here before it reaches cipher.NewCTR, which would otherwise + // panic on a nil or short IV. Uses the shared ValidateIV helper so + // all three SSE prep paths (SSE-S3, SSE-KMS, SSE-C) enforce IV + // length identically. + if err := ValidateIV(meta.IV, fmt.Sprintf("SSE-S3 chunk %s IV", chunk.GetFileIdString())); err != nil { + return nil, err + } + // Capture meta and chunk by-value into the wrap closure so each + // prepared entry decrypts with its own per-chunk key + IV. + fileId := chunk.GetFileIdString() + preparedChunks = append(preparedChunks, preparedMultipartChunk{ + chunk: chunk, + wrap: func(raw io.ReadCloser) (io.Reader, error) { + glog.V(4).Infof("Decrypting SSE-S3 chunk %s with KeyID=%s, IV length=%d", + fileId, meta.KeyID, len(meta.IV)) + dec, err := CreateSSES3DecryptedReader(raw, meta, meta.IV) + if err != nil { + return nil, fmt.Errorf("failed to decrypt SSE-S3 chunk: %v", err) + } + return dec, nil + }, + }) + } + + return &lazyMultipartChunkReader{ + chunks: preparedChunks, + fetch: fetchChunk, + }, nil +} + +// preparedMultipartChunk pairs a chunk with the per-SSE wrapping logic the +// lazy reader applies to its raw HTTP body. wrap is nil for chunks that +// stream as-is (no SSE on the chunk, even though the object is multipart-SSE); +// otherwise wrap is the SSE-specific decryption setup, which receives the +// already-opened raw chunk body and returns the plaintext reader. +type preparedMultipartChunk struct { + chunk *filer_pb.FileChunk + wrap func(raw io.ReadCloser) (io.Reader, error) +} + +// lazyMultipartChunkReader streams a sequence of multipart chunks one at a +// time. It opens each chunk's underlying HTTP fetch (and applies the +// SSE-specific decryption wrapper) only when the previous chunk has been +// fully consumed, so volume-server connections do not pile up for large +// objects. This is the same shape used by all three SSE multipart read +// paths (SSE-S3, SSE-KMS, SSE-C); only the per-chunk wrap closure differs. +type lazyMultipartChunkReader struct { + chunks []preparedMultipartChunk + fetch func(*filer_pb.FileChunk) (io.ReadCloser, error) + idx int + current io.Reader // current chunk's plaintext reader (or raw reader for non-SSE chunks) + closer io.Closer // current chunk's underlying HTTP body, to close on advance/Close + finished bool +} + +func (l *lazyMultipartChunkReader) Read(p []byte) (int, error) { + for { + if l.finished { + return 0, io.EOF + } + if l.current == nil { + if l.idx >= len(l.chunks) { + l.finished = true + return 0, io.EOF + } + pc := l.chunks[l.idx] + l.idx++ + chunkReader, err := l.fetch(pc.chunk) + if err != nil { + l.finished = true + return 0, fmt.Errorf("failed to create chunk reader: %v", err) + } + if pc.wrap == nil { + // Non-SSE chunk in an otherwise SSE-multipart object: stream + // raw bytes through. + l.current = chunkReader + l.closer = chunkReader + glog.V(4).Infof("Streaming non-encrypted chunk %s", pc.chunk.GetFileIdString()) + } else { + wrapped, wrapErr := pc.wrap(chunkReader) + if wrapErr != nil { + chunkReader.Close() + l.finished = true + return 0, wrapErr + } + l.current = wrapped + l.closer = chunkReader + } + } + n, err := l.current.Read(p) + if err == io.EOF { + closeErr := l.closer.Close() + l.current = nil + l.closer = nil + if n > 0 { + return n, nil + } + if closeErr != nil { + glog.V(2).Infof("Error closing chunk reader: %v", closeErr) + } + continue + } + if err != nil { + // Non-EOF read error: the underlying chunk body is in an + // indeterminate state. Mark ourselves finished so a retried + // Read does not try to drain the same broken stream; let + // Close() release the chunk body. This matches the failure + // semantics of the fetch and wrap error paths above. + l.finished = true + } + return n, err + } +} + +func (l *lazyMultipartChunkReader) Close() error { + l.finished = true + if l.closer != nil { + err := l.closer.Close() + l.current = nil + l.closer = nil + return err + } + return nil +} + +// createEncryptedChunkReader creates a reader for a single encrypted chunk +// Context propagation ensures cancellation if the S3 client disconnects +func (s3a *S3ApiServer) createEncryptedChunkReader(ctx context.Context, chunk *filer_pb.FileChunk) (io.ReadCloser, error) { + // Get chunk URL + srcUrl, err := s3a.lookupVolumeUrl(chunk.GetFileIdString()) + if err != nil { + return nil, fmt.Errorf("lookup volume URL for chunk %s: %v", chunk.GetFileIdString(), err) + } + + // Create HTTP request with context for cancellation propagation + req, err := http.NewRequestWithContext(ctx, "GET", srcUrl, nil) + if err != nil { + return nil, fmt.Errorf("create HTTP request for chunk: %v", err) + } + + // Attach volume server JWT for authentication (uses config loaded once at startup) + jwt := filer.JwtForVolumeServer(chunk.GetFileIdString()) + if jwt != "" { + req.Header.Set("Authorization", security.BearerPrefix+jwt) + } + + // Use shared HTTP client with connection pooling + resp, err := volumeServerHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute HTTP request for chunk: %v", err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("HTTP request for chunk failed: %d", resp.StatusCode) + } + + return resp.Body, nil +} + +// MultipartSSEReader wraps multiple readers and ensures all underlying readers are properly closed +type MultipartSSEReader struct { + multiReader io.Reader + readers []io.Reader +} + +// NewMultipartSSEReader creates a new multipart reader that can properly close all underlying readers +func NewMultipartSSEReader(readers []io.Reader) *MultipartSSEReader { + return &MultipartSSEReader{ + multiReader: io.MultiReader(readers...), + readers: readers, + } +} + +// Read implements the io.Reader interface +func (m *MultipartSSEReader) Read(p []byte) (n int, err error) { + return m.multiReader.Read(p) +} + +// Close implements the io.Closer interface and closes all underlying readers that support closing +func (m *MultipartSSEReader) Close() error { + var lastErr error + for i, reader := range m.readers { + if closer, ok := reader.(io.Closer); ok { + if err := closer.Close(); err != nil { + glog.V(2).Infof("Error closing reader %d: %v", i, err) + lastErr = err // Keep track of the last error, but continue closing others + } + } + } + return lastErr +} + +// PartBoundaryInfo holds information about a part's chunk boundaries +type PartBoundaryInfo struct { + PartNumber int `json:"part"` + StartChunk int `json:"start"` + EndChunk int `json:"end"` // exclusive + ETag string `json:"etag"` + // Byte offsets of the part; zero EndOffset means a legacy record carrying + // chunk indexes only. + StartOffset int64 `json:"startOffset,omitempty"` + EndOffset int64 `json:"endOffset,omitempty"` // exclusive +} + +// partRange returns the inclusive byte range of a part, preferring byte +// offsets; ok is false when a legacy record's chunk indexes do not address +// the entry's chunk list. +func partRange(partInfo *PartBoundaryInfo, chunks []*filer_pb.FileChunk) (startOffset, endOffset int64, ok bool) { + if partInfo.EndOffset > partInfo.StartOffset { + return partInfo.StartOffset, partInfo.EndOffset - 1, true + } + if partInfo.StartChunk < 0 || partInfo.EndChunk <= partInfo.StartChunk || partInfo.EndChunk > len(chunks) { + return 0, 0, false + } + lastChunk := chunks[partInfo.EndChunk-1] + return chunks[partInfo.StartChunk].Offset, lastChunk.Offset + int64(lastChunk.Size) - 1, true +} + +// rc is a helper type that wraps a Reader and Closer for proper resource cleanup +type rc struct { + io.Reader + io.Closer +} + +// getMultipartInfo retrieves multipart metadata for a given part number +// Returns: (partsCount, partInfo, hasBoundaries) +// - partsCount: total number of parts in the multipart object +// - partInfo: boundary information for the requested part (nil if not found or not a multipart object) +// - hasBoundaries: the entry carries part boundaries, so a nil partInfo means the object has no such part +func (s3a *S3ApiServer) getMultipartInfo(entry *filer_pb.Entry, partNumber int) (int, *PartBoundaryInfo, bool) { + if entry == nil { + return 0, nil, false + } + if entry.Extended == nil { + // Not a multipart object or no metadata + return len(entry.GetChunks()), nil, false + } + + // Try to get parts count from metadata + partsCount := len(entry.GetChunks()) // default fallback + if partsCountBytes, exists := entry.Extended[s3_constants.SeaweedFSMultipartPartsCount]; exists { + if count, err := strconv.Atoi(string(partsCountBytes)); err == nil && count > 0 { + partsCount = count + } + } + + // Try to get part boundaries from metadata + boundariesJSON, exists := entry.Extended[s3_constants.SeaweedFSMultipartPartBoundaries] + if !exists { + return partsCount, nil, false + } + var boundaries []PartBoundaryInfo + if err := json.Unmarshal(boundariesJSON, &boundaries); err != nil { + glog.Warningf("getMultipartInfo: failed to unmarshal part boundaries: %v", err) + return partsCount, nil, false + } + for i := range boundaries { + if boundaries[i].PartNumber == partNumber { + return partsCount, &boundaries[i], true + } + } + + // The object records its parts and this is not one of them + return partsCount, nil, true +} + +// requestedPartNumber returns the partNumber query parameter, or 0 when it is +// absent or not a positive integer. +func requestedPartNumber(r *http.Request) int { + partNumberStr := r.URL.Query().Get("partNumber") + if partNumberStr == "" { + partNumberStr = r.URL.Query().Get("PartNumber") + } + partNumber, parseErr := strconv.Atoi(partNumberStr) + if parseErr != nil || partNumber <= 0 { + return 0 + } + return partNumber +} + +// partByteRange resolves the inclusive byte range a partNumber request reads, +// narrowed by a client Range within the part, and sets the parts count header. +// The object ETag is kept as is, matching AWS. +func (s3a *S3ApiServer) partByteRange(w http.ResponseWriter, r *http.Request, entry *filer_pb.Entry, partNumber int) (startOffset, endOffset int64, errCode s3err.ErrorCode) { + // Part numbers need not be consecutive, so an object that records its part + // boundaries is asked for the part itself rather than for a count. + partsCount, partInfo, hasBoundaries := s3a.getMultipartInfo(entry, partNumber) + if partInfo == nil && (hasBoundaries || partNumber > partsCount) { + glog.Warningf("partByteRange: object has no part %d, it has %d parts", partNumber, partsCount) + return 0, 0, s3err.ErrInvalidPartNumber + } + + w.Header().Set(s3_constants.AmzMpPartsCount, strconv.Itoa(partsCount)) + + if partInfo != nil { + var ok bool + startOffset, endOffset, ok = partRange(partInfo, entry.Chunks) + if !ok { + glog.Errorf("partByteRange: part %d boundary chunks [%d,%d) out of range (chunks: %d)", partNumber, partInfo.StartChunk, partInfo.EndChunk, len(entry.Chunks)) + return 0, 0, s3err.ErrInternalError + } + } else { + // Fallback: assume 1:1 part-to-chunk mapping (backward compatibility) + chunkIndex := partNumber - 1 + if chunkIndex >= len(entry.Chunks) { + glog.Warningf("partByteRange: part %d chunk index %d out of range (chunks: %d)", partNumber, chunkIndex, len(entry.Chunks)) + return 0, 0, s3err.ErrInvalidPartNumber + } + partChunk := entry.Chunks[chunkIndex] + startOffset, endOffset = partChunk.Offset, partChunk.Offset+int64(partChunk.Size)-1 + } + + // S3 allows both partNumber and Range together, where Range applies within the selected part + if clientRangeHeader := r.Header.Get("Range"); clientRangeHeader != "" { + adjustedStart, adjustedEnd, rangeErr := adjustRangeForPart(startOffset, endOffset, clientRangeHeader) + if rangeErr != nil { + glog.Warningf("partByteRange: invalid Range for part %d: %v", partNumber, rangeErr) + return 0, 0, s3err.ErrInvalidRange + } + startOffset, endOffset = adjustedStart, adjustedEnd + } + + return startOffset, endOffset, s3err.ErrNone +} + +// buildRemoteObjectPath builds the filer directory and object name from S3 bucket/object. +// This is shared by all remote object caching functions. +func (s3a *S3ApiServer) buildRemoteObjectPath(bucket, object string) (dir, name string) { + dir = s3a.bucketDir(bucket) + name = s3_constants.NormalizeObjectKey(object) + if idx := strings.LastIndex(name, "/"); idx > 0 { + dir = dir + "/" + name[:idx] + name = name[idx+1:] + } + return dir, name +} + +// findMountedRemoteMapping resolves the remote mount covering dir from the +// mount mapping kept in the filer. +func (s3a *S3ApiServer) findMountedRemoteMapping(ctx context.Context, dir string) (localMountedDir string, mountedLocation *remote_pb.RemoteStorageLocation, err error) { + err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + mappingContent, readErr := filer.ReadInsideFiler(ctx, client, filer.DirectoryEtcRemote, filer.REMOTE_STORAGE_MOUNT_FILE) + if readErr != nil { + return readErr + } + mappings, unmarshalErr := filer.UnmarshalRemoteStorageMappings(mappingContent) + if unmarshalErr != nil { + return unmarshalErr + } + var findErr error + localMountedDir, mountedLocation, findErr = filer.FindMountedRemoteMapping(mappings, dir) + return findErr + }) + return localMountedDir, mountedLocation, err +} + +// openRemoteStream opens a ranged read of a remote-only object straight from +// its mounted origin, resolving the mount and storage conf from the filer. +// cached, when set, is the remote generation the local copy was made from: the +// stream is refused unless the remote still matches it. +func (s3a *S3ApiServer) openRemoteStream(ctx context.Context, bucket, object string, offset, size int64, cached *filer_pb.RemoteEntry) (io.ReadCloser, error) { + dir, name := s3a.buildRemoteObjectPath(bucket, object) + + localMountedDir, mountedLocation, err := s3a.findMountedRemoteMapping(ctx, dir) + if err != nil { + return nil, err + } + + var storageConf *remote_pb.RemoteConf + err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + confContent, readErr := filer.ReadInsideFiler(ctx, client, filer.DirectoryEtcRemote, mountedLocation.Name+filer.REMOTE_STORAGE_CONF_SUFFIX) + if readErr != nil { + return readErr + } + storageConf = &remote_pb.RemoteConf{} + return proto.Unmarshal(confContent, storageConf) + }) + if err != nil { + return nil, err + } + + client, err := weed_server.BuildGuardedRemoteStorageClient(ctx, storageConf, s3a.option.AllowUntrustedRemoteEndpoints) + if err != nil { + return nil, err + } + streamer, ok := client.(remote_storage.RemoteStorageStreamReader) + if !ok { + return nil, fmt.Errorf("remote storage type %s does not support streaming reads", storageConf.Type) + } + + loc := filer.MapFullPathToRemoteStorageLocation(util.FullPath(localMountedDir), mountedLocation, util.FullPath(dir).Child(name)) + if cached != nil { + current, statErr := client.StatFile(loc) + if statErr != nil { + return nil, statErr + } + if current.GetRemoteSize() != cached.GetRemoteSize() || current.GetRemoteETag() != cached.GetRemoteETag() || current.GetRemoteMtime() != cached.GetRemoteMtime() { + return nil, fmt.Errorf("remote %s/%s changed since it was cached", bucket, object) + } + } + return streamer.ReadFileAsStream(ctx, loc, offset, size) +} + +// doCacheRemoteObject calls the filer's CacheRemoteObjectToLocalCluster gRPC endpoint. +// This is the core caching function used by cacheRemoteObjectForStreaming. +func (s3a *S3ApiServer) doCacheRemoteObject(ctx context.Context, dir, name string) (*filer_pb.Entry, error) { + var cachedEntry *filer_pb.Entry + err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + resp, cacheErr := client.CacheRemoteObjectToLocalCluster(ctx, &filer_pb.CacheRemoteObjectToLocalClusterRequest{ + Directory: dir, + Name: name, + }) + if cacheErr != nil { + return cacheErr + } + if resp != nil && resp.Entry != nil { + cachedEntry = resp.Entry + } + return nil + }) + return cachedEntry, err +} + +func (s3a *S3ApiServer) buildVersionedRemoteObjectPath(bucket, object, versionId string) (dir, name string) { + // versionId names a .versions file, so a value that is not a valid path + // segment falls back to the unversioned remote path rather than escaping it. + if versionId != "" && versionId != "null" && isValidVersionID(versionId) { + normalizedObject := s3_constants.NormalizeObjectKey(object) + return s3a.bucketDir(bucket) + "/" + normalizedObject + s3_constants.VersionsFolder, s3a.getVersionFileName(versionId) + } + return s3a.buildRemoteObjectPath(bucket, object) +} + +// cachedEntryHasLocalData reports whether a cache response carries data the +// copy path can read locally. The streaming caller uses a stricter chunks- +// only check inline since its downstream cannot read from inline Content. +func cachedEntryHasLocalData(entry *filer_pb.Entry) bool { + return entry != nil && (len(entry.GetChunks()) > 0 || len(entry.Content) > 0) +} + +// remoteCacheStreamingTimeoutNS bounds (nanoseconds) how long the streaming read +// path waits for the filer to finish caching a remote-only object. Without a +// bound a multi-GB download outlasts the client's read timeout, so the request +// is canceled before the caller can emit 503 + Retry-After. Kept under common S3 +// client read timeouts (~60s) given the 30s dedup attempt that precedes it. +// Atomic so tests can shorten it without racing the read path. +var remoteCacheStreamingTimeoutNS = int64(20 * time.Second) + +// remoteCacheWait resolves how long a read of dir may wait for the local cache. +// Zero means the mount opted out of caching, so the read is served from the +// origin instead -- except for a version-specific read, which has no origin key +// to fall back to and therefore keeps the size tiers. +func (s3a *S3ApiServer) remoteCacheWait(ctx context.Context, dir string, remoteSize int64, versionId string) time.Duration { + _, mountedLocation, mountErr := s3a.findMountedRemoteMapping(ctx, dir) + if mountErr != nil { + glog.V(2).Infof("remoteCacheWait: find mount for %s: %v", dir, mountErr) + } + wait := remote_storage.CacheWaitTimeout(remoteSize, mountedLocation) + if wait <= 0 && versionId != "" && versionId != "null" { + return remote_storage.CacheWaitTimeout(remoteSize, nil) + } + return wait +} + +// cacheRemoteObjectForStreamingWithShortTimeout polls for cache completion with an adaptive timeout. +// Timeout comes from the file size or the mount's cache_wait_ms: small files wait longer to +// maximize cache hits, large files fail-fast to improve TTFB. Returns the cached entry and error +// to allow callers to distinguish between transient errors (timeout) and permanent errors (not +// found, permission denied). The filer continues caching on detached context, so retry finds +// cached chunks. +func (s3a *S3ApiServer) cacheRemoteObjectForStreamingWithShortTimeout(r *http.Request, entry *filer_pb.Entry, bucket, object, versionId string) (*filer_pb.Entry, error) { + dir, name := s3a.buildVersionedRemoteObjectPath(bucket, object, versionId) + + pollTimeout := s3a.remoteCacheWait(r.Context(), dir, entry.GetRemoteEntry().GetRemoteSize(), versionId) + if pollTimeout <= 0 { + // The mount opted out of caching: report it uncached so the caller serves the origin. + glog.V(2).Infof("cacheRemoteObjectForStreamingWithShortTimeout: caching disabled for %s/%s", dir, name) + return nil, nil + } + + cacheCtx, cancel := context.WithTimeout(r.Context(), pollTimeout) + defer cancel() + + glog.V(2).Infof("cacheRemoteObjectForStreamingWithShortTimeout: polling cache status for %s/%s (timeout=%v)", dir, name, pollTimeout) + + cachedEntry, err := s3a.doCacheRemoteObject(cacheCtx, dir, name) + if err != nil { + // Distinguish transient errors (timeout/cancellation) from permanent errors + if cacheCtx.Err() != nil { + glog.V(2).Infof("cacheRemoteObjectForStreamingWithShortTimeout: %s/%s not cached within %v", dir, name, pollTimeout) + return nil, cacheCtx.Err() + } + glog.V(2).Infof("cacheRemoteObjectForStreamingWithShortTimeout: cache error for %s/%s: %v", dir, name, err) + return nil, err + } + + if cachedEntry != nil && len(cachedEntry.GetChunks()) > 0 { + glog.V(1).Infof("cacheRemoteObjectForStreamingWithShortTimeout: successfully cached %s/%s (%d chunks)", dir, name, len(cachedEntry.GetChunks())) + return cachedEntry, nil + } + + return nil, nil +} + +// cacheRemoteObjectForStreaming caches a remote-only object to the local cluster for streaming. +// Bounded so a slow large-file download returns to the caller (which emits 503 + +// Retry-After) before the client gives up; the filer keeps caching on a detached +// context, so a retry streams from the cached chunks. Returns the cached entry +// only when chunks are present; the caller cannot read inline Content here. +func (s3a *S3ApiServer) cacheRemoteObjectForStreaming(r *http.Request, entry *filer_pb.Entry, bucket, object, versionId string) *filer_pb.Entry { + timeout := time.Duration(atomic.LoadInt64(&remoteCacheStreamingTimeoutNS)) + cacheCtx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + dir, name := s3a.buildVersionedRemoteObjectPath(bucket, object, versionId) + + glog.V(1).Infof("cacheRemoteObjectForStreaming: caching %s/%s (remote size: %d, versionId: %s)", dir, name, entry.RemoteEntry.RemoteSize, versionId) + + cachedEntry, err := s3a.doCacheRemoteObject(cacheCtx, dir, name) + if err != nil { + // A bounded-wait timeout (or client disconnect) is not a cache failure: + // the filer keeps downloading and the caller maps a nil return to 503. + if cacheCtx.Err() != nil { + glog.V(1).Infof("cacheRemoteObjectForStreaming: %s/%s not ready within %v (will retry)", dir, name, timeout) + } else { + glog.Errorf("cacheRemoteObjectForStreaming: failed to cache %s/%s: %v", dir, name, err) + } + return nil + } + + if cachedEntry != nil && len(cachedEntry.GetChunks()) > 0 { + glog.V(1).Infof("cacheRemoteObjectForStreaming: successfully cached %s/%s (%d chunks)", dir, name, len(cachedEntry.GetChunks())) + return cachedEntry + } + + return nil +} + +// cacheRemoteObjectForCopy caches a remote-only source before CopyObject / +// CopyObjectPart reads it; otherwise the copy would write a destination with +// FileSize > 0 but no chunks/content. Bounded so a stuck cache can't hang +// the copy. Returns nil if caching failed or produced no local data. +func (s3a *S3ApiServer) cacheRemoteObjectForCopy(ctx context.Context, bucket, object, versionId string) *filer_pb.Entry { + const cacheTimeout = 30 * time.Second + cacheCtx, cancel := context.WithTimeout(ctx, cacheTimeout) + defer cancel() + + dir, name := s3a.buildVersionedRemoteObjectPath(bucket, object, versionId) + + glog.V(1).Infof("cacheRemoteObjectForCopy: caching %s/%s (versionId: %s)", dir, name, versionId) + + cachedEntry, err := s3a.doCacheRemoteObject(cacheCtx, dir, name) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + glog.Warningf("cacheRemoteObjectForCopy: timeout caching %s/%s after %v", dir, name, cacheTimeout) + } else { + glog.Errorf("cacheRemoteObjectForCopy: failed to cache %s/%s: %v", dir, name, err) + } + return nil + } + + if cachedEntryHasLocalData(cachedEntry) { + glog.V(1).Infof("cacheRemoteObjectForCopy: successfully cached %s/%s (chunks=%d, inline=%d)", dir, name, len(cachedEntry.GetChunks()), len(cachedEntry.Content)) + return cachedEntry + } + + return nil +} + +// startBackgroundRemoteCache initiates caching without blocking the request. +// This enables fast TTFB: the client's request returns immediately (via 503 if not +// cached), while a background task fills the cache. Subsequent requests will find +// cached chunks via singleflight deduplication in the filer's CacheRemoteObjectToLocalCluster. +// Uses detached context with reasonable timeout to prevent goroutine pile-up. +func (s3a *S3ApiServer) startBackgroundRemoteCache(bucket, object, versionId string, entry *filer_pb.Entry) { + if !entry.IsInRemoteOnly() { + return + } + + dir, name := s3a.buildVersionedRemoteObjectPath(bucket, object, versionId) + + // Start background cache without blocking. The filer's CacheRemoteObjectToLocalCluster + // uses singleflight internally, so concurrent requests will all benefit from the same + // cache operation. + go func() { + // Use timeout to bound goroutine and prevent pile-up if RPC stalls under load + bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if s3a.remoteCacheWait(bgCtx, dir, entry.GetRemoteEntry().GetRemoteSize(), versionId) <= 0 { + glog.V(2).Infof("startBackgroundRemoteCache: caching disabled for %s/%s", dir, name) + return + } + _, err := s3a.doCacheRemoteObject(bgCtx, dir, name) + if err != nil { + glog.V(2).Infof("startBackgroundRemoteCache: cache failed for %s/%s: %v", bucket, object, err) + } else { + glog.V(2).Infof("startBackgroundRemoteCache: cached %s/%s in background", bucket, object) + } + }() +} + +// resolvedSourceVersionId falls back to the version recorded on the entry +// when the request didn't carry one — necessary for latest-version reads +// in versioning-enabled buckets, where the entry lives at .versions/v_. +func resolvedSourceVersionId(requestedVersionId string, entry *filer_pb.Entry) string { + if requestedVersionId != "" { + return requestedVersionId + } + if entry == nil || entry.Extended == nil { + return "" + } + if v, ok := entry.Extended[s3_constants.ExtVersionIdKey]; ok { + return string(v) + } + return "" +} diff --git a/weed/s3api/s3api_object_handlers_copy.go b/weed/s3api/s3api_object_handlers_copy.go new file mode 100644 index 0000000..8495134 --- /dev/null +++ b/weed/s3api/s3api_object_handlers_copy.go @@ -0,0 +1,3494 @@ +package s3api + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "reflect" + "strconv" + "strings" + "time" + + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/operation" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/seaweedfs/seaweedfs/weed/security" + "github.com/seaweedfs/seaweedfs/weed/util" + util_http "github.com/seaweedfs/seaweedfs/weed/util/http" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +const ( + DirectiveCopy = "COPY" + DirectiveReplace = "REPLACE" +) + +// AWS default when REPLACE is requested without a Content-Type. +const defaultCopyContentType = "binary/octet-stream" + +// System-metadata headers that REPLACE must rewrite on the destination. +// Content-Type lives on Attributes.Mime, tagging/storage-class have their +// own handling, so they're not in this list. +var copyReplaceSystemHeaders = []string{ + "Cache-Control", + "Content-Encoding", + "Content-Disposition", + "Content-Language", + "Expires", +} + +func resolveDestinationMime(reqHeader http.Header, sourceMime string, replaceMeta bool) string { + if replaceMeta { + if ct := reqHeader.Get("Content-Type"); ct != "" { + return ct + } + return defaultCopyContentType + } + return sourceMime +} + +// Empty means default (COPY). Anything else outside {COPY, REPLACE} must be +// rejected, not silently downgraded. +func isValidDirective(value string) bool { + return value == "" || value == DirectiveCopy || value == DirectiveReplace +} + +// hasPrefixFold reports whether s starts with prefix, ignoring case. +func hasPrefixFold(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) +} + +// prefixObjectSource presents the object stored on a key that other keys are nested +// under as the plain object that key names. The entry itself stays a directory - it +// is where those keys live - so a caller that copies or moves the object works off +// this view and leaves the directory, and everything under it, alone. +func prefixObjectSource(entry *filer_pb.Entry) *filer_pb.Entry { + if entry == nil || !entry.IsPrefixObject() { + return entry + } + flattened := proto.Clone(entry).(*filer_pb.Entry) + flattened.IsDirectory = false + if flattened.Attributes != nil { + flattened.Attributes.FileMode &^= uint32(os.ModeDir) + } + delete(flattened.Extended, s3_constants.SeaweedFSPrefixObject) + return flattened +} + +// classifyCopySourceError maps a copy-source lookup to an S3 error: a missing +// or directory source is NoSuchKey like AWS, but a transient store error +// becomes a retryable 5xx so a resumable copy/commit survives a blip. +func classifyCopySourceError(entry *filer_pb.Entry, err error) s3err.ErrorCode { + if err == nil { + if entry == nil || entry.IsDirectory { + return s3err.ErrNoSuchKey + } + if deleteMarker, ok := entry.Extended[s3_constants.ExtDeleteMarkerKey]; ok && string(deleteMarker) == "true" { + return s3err.ErrNoSuchKey + } + return s3err.ErrNone + } + if errors.Is(err, filer_pb.ErrNotFound) || status.Code(err) == codes.NotFound || + errors.Is(err, errInvalidVersionID) || errors.Is(err, ErrDeleteMarker) { + return s3err.ErrNoSuchKey + } + if isTransientFilerError(err) { + return s3err.ErrServiceUnavailable + } + return s3err.ErrInternalError +} + +func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request) { + t := time.Now().UTC().Truncate(time.Millisecond) + + dstBucket, dstObject := s3_constants.GetBucketAndObject(r) + + // Copy source path. + rawCopySource := r.Header.Get("X-Amz-Copy-Source") + // Use PathUnescape (not QueryUnescape) because the copy source is a path, + // not a query string. QueryUnescape would incorrectly convert '+' to space. + cpSrcPath, err := url.PathUnescape(rawCopySource) + if err != nil { + // Save unescaped string as is. + cpSrcPath = rawCopySource + } + + srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(rawCopySource, cpSrcPath) + + glog.V(3).Infof("CopyObjectHandler %s %s (version: %s) => %s %s", srcBucket, srcObject, srcVersionId, dstBucket, dstObject) + if len(dstObject) > s3_constants.MaxS3ObjectKeyLength { + s3err.WriteErrorResponse(w, r, s3err.ErrKeyTooLongError) + return + } + if err := s3a.validateTableBucketObjectPath(dstBucket, dstObject); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + if srcBucket != "" && srcBucket != dstBucket { + if err := s3a.validateTableBucketObjectPath(srcBucket, srcObject); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + } + + // Validate copy source and destination + if err := validateCopySource(cpSrcPath, srcBucket, srcObject, srcVersionId); err != nil { + glog.V(2).Infof("CopyObjectHandler validation error: %v", err) + errCode := MapCopyValidationError(err) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + if err := ValidateCopyDestination(dstBucket, dstObject); err != nil { + glog.V(2).Infof("CopyObjectHandler validation error: %v", err) + errCode := MapCopyValidationError(err) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + if !isValidDirective(r.Header.Get(s3_constants.AmzUserMetaDirective)) { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidMetadataDirective) + return + } + if !isValidDirective(r.Header.Get(s3_constants.AmzObjectTaggingDirective)) { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidTagDirective) + return + } + + replaceMeta, replaceTagging := replaceDirective(r.Header) + + // If source object is empty or bucket is empty, reply back invalid copy source. + if srcObject == "" || srcBucket == "" { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidCopySource) + return + } + + // CopyObject requires s3:GetObject on the source in addition to s3:PutObject + // on the destination. The Auth middleware only checked the destination, so + // verify the source explicitly here. + if errCode := s3a.authorizeCopySource(r, srcBucket, srcObject, srcVersionId); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Get detailed versioning state for source bucket + srcVersioningState, err := s3a.getVersioningState(srcBucket) + if err != nil { + glog.Errorf("Error checking versioning state for source bucket %s: %v", srcBucket, err) + // A missing source bucket is NoSuchBucket like AWS; a store error must + // stay retryable, matching the destination-bucket lookup. + if errors.Is(err, filer_pb.ErrNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return + } + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + entry, err := s3a.resolveCopySourceEntry(srcBucket, srcObject, srcVersionId, srcVersioningState) + entry = prefixObjectSource(entry) + if errCode := classifyCopySourceError(entry, err); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Cache remote-only sources before copying; otherwise the copy path below + // writes a destination with FileSize > 0 but no chunks/content. + if entry.IsInRemoteOnly() { + cacheVersionId := resolvedSourceVersionId(srcVersionId, entry) + cachedEntry := s3a.cacheRemoteObjectForCopy(r.Context(), srcBucket, srcObject, cacheVersionId) + if cachedEntry == nil { + glog.Errorf("CopyObjectHandler: failed to cache remote-only source %s/%s (version %q)", srcBucket, srcObject, cacheVersionId) + w.Header().Set("Retry-After", "5") + s3err.WriteErrorResponse(w, r, s3err.ErrServiceUnavailable) + return + } + entry = cachedEntry + } + + sameDestination := srcBucket == dstBucket && srcObject == dstObject + // A self-copy into a versioned bucket writes a new version instead of overwriting in + // place, so it is not the no-op AWS rejects. It is how an earlier version is restored. + if sameDestination && !(replaceMeta || replaceTagging) && srcVersioningState != s3_constants.VersioningEnabled { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidCopyDest) + return + } + + // Validate conditional copy headers + if err := s3a.validateConditionalCopyHeaders(r, entry); err != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, err) + return + } + if errCode := s3a.checkConditionalHeaders(r, dstBucket, dstObject); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Validate encryption parameters + if err := ValidateCopyEncryption(entry.Extended, r.Header); err != nil { + glog.V(2).Infof("CopyObjectHandler encryption validation error: %v", err) + errCode := MapCopyValidationError(err) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + dstVersioningState, err := s3a.getVersioningState(dstBucket) + if err != nil { + glog.Errorf("Error checking versioning state for destination bucket %s: %v", dstBucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + replacesSource := copyReplacesSourceEntry(sameDestination, dstVersioningState, srcVersionId) + + if replacesSource && (replaceMeta || replaceTagging) && s3a.canUseMetadataOnlySelfCopy(entry, r, dstBucket, dstObject) { + var dstVersionId string + var etag string + // An in-place metadata replace routes to the owner as a serialized PATCH + // (off the distributed lock); the no-owner bootstrap keeps the lock. + // + // REPLACE can also change Content-Type, which lives on Attributes.Mime, + // not Extended. The routed PATCH only carries Extended keys, so when the + // Mime actually changes keep the lock and take the clone path below: it is + // still metadata-only (reuses the source chunks) but can set the Mime. + owner := s3a.objectWriteOwner(dstBucket, dstObject) + sourceMime := entry.GetAttributes().GetMime() + mimeChanged := resolveDestinationMime(r.Header, sourceMime, replaceMeta) != sourceMime + routeInPlace := owner != "" && !mimeChanged + selfCopyBody := func() s3err.ErrorCode { + currentEntry, currentErr := s3a.resolveCopySourceEntry(srcBucket, srcObject, srcVersionId, srcVersioningState) + currentEntry = prefixObjectSource(currentEntry) + if errCode := classifyCopySourceError(currentEntry, currentErr); errCode != s3err.ErrNone { + return errCode + } + if errCode := s3a.validateConditionalCopyHeaders(r, currentEntry); errCode != s3err.ErrNone { + return errCode + } + updatedMetadata, metadataErr := processMetadataBytes(r.Header, currentEntry.Extended, replaceMeta, replaceTagging) + if metadataErr != nil { + glog.Errorf("CopyObjectHandler ValidateTags error %s: %v", r.URL, metadataErr) + return s3err.ErrInvalidTag + } + if routeInPlace { + if err := s3a.routedMetadataReplace(owner, dstBucket, dstObject, currentEntry, updatedMetadata); err != nil { + return filerErrorToS3Error(err) + } + etag = getEtagFromEntry(currentEntry) + return s3err.ErrNone + } + updatedEntry := cloneProtoEntry(currentEntry) + updatedEntry.Extended = mergeCopyMetadata(updatedEntry.Extended, updatedMetadata) + if updatedEntry.Attributes == nil { + updatedEntry.Attributes = &filer_pb.FuseAttributes{} + } + updatedEntry.Attributes.Mime = resolveDestinationMime(r.Header, currentEntry.GetAttributes().GetMime(), replaceMeta) + updatedEntry.Attributes.Mtime = t.Unix() + var finErr error + dstVersionId, etag, finErr = s3a.finalizeCopyDestination(dstBucket, dstObject, dstVersioningState, updatedEntry) + if finErr != nil { + return filerErrorToS3Error(finErr) + } + return s3err.ErrNone + } + var updateCode s3err.ErrorCode + if routeInPlace { + if updateCode = s3a.checkConditionalHeaders(r, dstBucket, dstObject); updateCode == s3err.ErrNone { + updateCode = selfCopyBody() + } + } else { + updateCode = s3a.withObjectWriteLock(dstBucket, dstObject, func() s3err.ErrorCode { + return s3a.checkConditionalHeaders(r, dstBucket, dstObject) + }, selfCopyBody) + } + if updateCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, updateCode) + return + } + + if dstVersionId != "" { + w.Header().Set("x-amz-version-id", dstVersionId) + } + setEtag(w, etag) + writeSuccessResponseXML(w, r, CopyObjectResult{ + ETag: etag, + LastModified: t, + }) + return + } + + // Determine whether we can reuse the source MD5 (direct copy without encryption changes). + canReuseSourceMd5 := false + var sourceMd5 []byte + if entry.Attributes != nil && len(entry.Attributes.Md5) > 0 { + sourceMd5 = append([]byte(nil), entry.Attributes.Md5...) + srcPath := fmt.Sprintf("%s/%s", s3a.bucketDir(srcBucket), srcObject) + dstPath := fmt.Sprintf("%s/%s", s3a.bucketDir(dstBucket), dstObject) + state := DetectEncryptionStateWithEntry(entry, r, srcPath, dstPath) + s3a.applyCopyBucketDefaultEncryption(state, dstBucket) + if strategy, err := DetermineUnifiedCopyStrategy(state, entry.Extended, r); err == nil && strategy == CopyStrategyDirect { + canReuseSourceMd5 = true + } + } + + // Create new entry for destination + dstEntry := &filer_pb.Entry{ + Attributes: &filer_pb.FuseAttributes{ + FileSize: entry.Attributes.FileSize, + Mtime: t.Unix(), + Crtime: entry.Attributes.Crtime, + Mime: resolveDestinationMime(r.Header, entry.Attributes.Mime, replaceMeta), + FileMode: s3a.resolveFileMode(r), + Uid: filer_pb.OS_UID, + Gid: filer_pb.OS_GID, + }, + Extended: make(map[string][]byte), + } + + // Copy extended attributes from source, filtering out conflicting encryption metadata + // Pre-compute encryption state once for efficiency + srcHasSSEC := IsSSECEncrypted(entry.Extended) + srcHasSSEKMS := IsSSEKMSEncrypted(entry.Extended) + srcHasSSES3 := IsSSES3EncryptedInternal(entry.Extended) + dstWantsSSEC := IsSSECRequest(r) + dstWantsSSEKMS := IsSSEKMSRequest(r) + dstWantsSSES3 := IsSSES3RequestInternal(r) + + for k, v := range entry.Extended { + // Skip encryption-specific headers that might conflict with destination encryption type + skipHeader := false + + // Skip orphaned SSE-S3 headers (header exists but key is missing) + // This prevents confusion about the object's actual encryption state + if isOrphanedSSES3Header(k, entry.Extended) { + skipHeader = true + } + + // Filter conflicting headers for cross-encryption or encrypted→unencrypted copies + // This applies to both inline files (no chunks) and chunked files - fixes GitHub #7562 + if !skipHeader { + skipHeader = shouldSkipEncryptionHeader(k, + srcHasSSEC, srcHasSSEKMS, srcHasSSES3, + dstWantsSSEC, dstWantsSSEKMS, dstWantsSSES3) + } + + if !skipHeader { + dstEntry.Extended[k] = v + } + } + + // Process metadata and tags and apply to destination + // Use dstEntry.Extended (already filtered) as the source, not entry.Extended, + // to preserve the encryption header filtering. Fixes GitHub #7562. + processedMetadata, tagErr := processMetadataBytes(r.Header, dstEntry.Extended, replaceMeta, replaceTagging) + if tagErr != nil { + glog.Errorf("CopyObjectHandler ValidateTags error %s: %v", r.URL, tagErr) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidTag) + return + } + + // mergeCopyMetadata drops stale managed keys before applying the new set, + // so REPLACE doesn't leak source values through the merge. Mirrors the + // self-copy path's routedMetadataReplace. + dstEntry.Extended = mergeCopyMetadata(dstEntry.Extended, processedMetadata) + + // For zero-size files or files without chunks, handle inline content + // This includes encrypted inline files that need decryption/re-encryption + if entry.Attributes.FileSize == 0 || len(entry.GetChunks()) == 0 { + dstEntry.Chunks = nil + + // Handle inline encrypted content - fixes GitHub #7562. + // Also run when the destination requests encryption with no content so + // empty objects get real key metadata, not just a bare algorithm header. + if len(entry.Content) > 0 || dstWantsSSEC || dstWantsSSEKMS || dstWantsSSES3 { + inlineContent, inlineMetadata, inlineErr := s3a.processInlineContentForCopy( + entry, r, dstBucket, dstObject, + srcHasSSEC, srcHasSSEKMS, srcHasSSES3, + dstWantsSSEC, dstWantsSSEKMS, dstWantsSSES3) + if inlineErr != nil { + glog.Errorf("CopyObjectHandler inline content error: %v", inlineErr) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + dstEntry.Content = inlineContent + + // Apply inline destination metadata + if inlineMetadata != nil { + for k, v := range inlineMetadata { + dstEntry.Extended[k] = v + } + } + } + + if dstEntry.Attributes != nil { + if len(dstEntry.Attributes.Md5) == 0 && canReuseSourceMd5 { + dstEntry.Attributes.Md5 = append([]byte(nil), sourceMd5...) + } else if uint64(len(dstEntry.Content)) == dstEntry.Attributes.FileSize { + dstEntry.Attributes.Md5 = util.Md5(dstEntry.Content) + } + } + } else { + // Use unified copy strategy approach + dstChunks, dstMetadata, copyErr := s3a.executeUnifiedCopyStrategy(entry, r, srcBucket, dstBucket, srcObject, dstObject, replacesSource) + if copyErr != nil { + glog.Errorf("CopyObjectHandler unified copy error: %v", copyErr) + // Map errors to appropriate S3 errors + errCode := s3a.mapCopyErrorToS3Error(copyErr) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // re-fold a large copied chunk list, mirroring the PutObject path + dstEntry.Chunks = s3a.manifestizeChunks(fmt.Sprintf("%s/%s", s3a.bucketDir(dstBucket), dstObject), dstBucket, 0, dstChunks) + + // Apply destination-specific metadata (e.g., SSE-C IV and headers) + if dstMetadata != nil { + for k, v := range dstMetadata { + dstEntry.Extended[k] = v + } + glog.V(2).Infof("Applied %d destination metadata entries for copy: %s", len(dstMetadata), r.URL.Path) + } + + if dstEntry.Attributes != nil && len(dstEntry.Attributes.Md5) == 0 && canReuseSourceMd5 { + dstEntry.Attributes.Md5 = append([]byte(nil), sourceMd5...) + } + } + + var dstVersionId string + var etag string + + finalizeCode := s3a.withObjectWriteLock(dstBucket, dstObject, func() s3err.ErrorCode { + return s3a.checkConditionalHeaders(r, dstBucket, dstObject) + }, func() s3err.ErrorCode { + var finalizeErr error + dstVersionId, etag, finalizeErr = s3a.finalizeCopyDestination(dstBucket, dstObject, dstVersioningState, dstEntry) + if finalizeErr != nil { + return filerErrorToS3Error(finalizeErr) + } + return s3err.ErrNone + }) + if finalizeCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, finalizeCode) + return + } + + if dstVersionId != "" { + w.Header().Set("x-amz-version-id", dstVersionId) + } + + setEtag(w, etag) + + response := CopyObjectResult{ + ETag: etag, + LastModified: t, + } + + writeSuccessResponseXML(w, r, response) + +} + +// copyReplacesSourceEntry reports whether a copy writes back to the very entry it +// read, which is what lets a strategy hand the source's chunk fids to the +// destination instead of copying the data. Nothing refcounts a plain shared chunk +// list, so a second live entry on the same chunks loses its data as soon as either +// side is deleted. A versioned destination writes a new version file, a suspended +// one writes the null version next to a .versions/ entry that stays live, and a +// source pinned to a versionId reads a version file that outlives the copy — those +// all need the chunks copied for real, as does any copy to a different key. +func copyReplacesSourceEntry(sameDestination bool, dstVersioningState, srcVersionId string) bool { + return sameDestination && dstVersioningState == "" && srcVersionId == "" +} + +func cloneProtoEntry(entry *filer_pb.Entry) *filer_pb.Entry { + if entry == nil { + return nil + } + return proto.Clone(entry).(*filer_pb.Entry) +} + +func copyEntryETag(entry *filer_pb.Entry) string { + if entry == nil { + return "" + } + if entry.Extended != nil { + if etag, ok := entry.Extended[s3_constants.ExtETagKey]; ok && len(etag) > 0 { + return string(etag) + } + } + attr := filer.Attr{} + if entry.Attributes != nil { + attr = filer.Attr{ + FileSize: entry.Attributes.FileSize, + Mtime: time.Unix(entry.Attributes.Mtime, 0), + Crtime: time.Unix(entry.Attributes.Crtime, 0), + Mime: entry.Attributes.Mime, + Md5: entry.Attributes.Md5, + } + } + return filer.ETagEntry(&filer.Entry{ + Attr: attr, + Chunks: entry.Chunks, + Content: entry.Content, + Remote: entry.RemoteEntry, + }) +} + +func copyEntryToTarget(dst, src *filer_pb.Entry) { + dst.IsDirectory = src.IsDirectory + dst.Attributes = src.Attributes + dst.Extended = src.Extended + dst.Chunks = src.Chunks + dst.Content = src.Content + dst.RemoteEntry = src.RemoteEntry + dst.HardLinkId = src.HardLinkId + dst.HardLinkCounter = src.HardLinkCounter + dst.Quota = src.Quota + dst.WormEnforcedAtTsNs = src.WormEnforcedAtTsNs +} + +func (s3a *S3ApiServer) finalizeCopyDestination(dstBucket, dstObject, dstVersioningState string, dstEntry *filer_pb.Entry) (versionId string, etag string, err error) { + normalizedObject := s3_constants.NormalizeObjectKey(dstObject) + dstPath := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(dstBucket), normalizedObject)) + dstDir, dstName := dstPath.DirAndName() + + if dstEntry.Attributes == nil { + dstEntry.Attributes = &filer_pb.FuseAttributes{} + } + if dstEntry.Extended == nil { + dstEntry.Extended = make(map[string][]byte) + } + + etag = copyEntryETag(dstEntry) + + switch dstVersioningState { + case s3_constants.VersioningEnabled: + versionId = s3a.generateVersionIdForObject(dstBucket, normalizedObject) + glog.V(2).Infof("CopyObjectHandler: creating version %s for destination %s/%s", versionId, dstBucket, normalizedObject) + + dstEntry.Extended[s3_constants.ExtVersionIdKey] = []byte(versionId) + dstEntry.Extended[s3_constants.ExtETagKey] = []byte(etag) + + versionFileName := s3a.getVersionFileName(versionId) + versionObjectPath := normalizedObject + s3_constants.VersionsFolder + "/" + versionFileName + bucketDir := s3a.bucketDir(dstBucket) + + if err = s3a.mkFile(bucketDir, versionObjectPath, dstEntry.Chunks, func(entry *filer_pb.Entry) { + copyEntryToTarget(entry, dstEntry) + }); err != nil { + return "", "", err + } + + // Route the pointer flip to the owner filer when known (off the + // distributed lock); RECOMPUTE_LATEST picks the just-written version. + if owner := s3a.objectWriteOwner(dstBucket, normalizedObject); owner != "" { + if code := s3a.routedVersionedFinalize(owner, dstBucket, normalizedObject, isNewFormatVersionId(versionId)); code != s3err.ErrNone { + if rollbackErr := s3a.rollbackCopyVersion(bucketDir, versionObjectPath); rollbackErr != nil { + glog.Errorf("CopyObjectHandler: failed to rollback version %s for %s/%s after routed finalize error: %v", versionId, dstBucket, normalizedObject, rollbackErr) + } + return "", "", fmt.Errorf("routed finalize for %s/%s: code %d", dstBucket, normalizedObject, code) + } + } else if err = s3a.updateLatestVersionInDirectory(dstBucket, normalizedObject, versionId, versionFileName, dstEntry); err != nil { + if rollbackErr := s3a.rollbackCopyVersion(bucketDir, versionObjectPath); rollbackErr != nil { + glog.Errorf("CopyObjectHandler: failed to rollback version %s for %s/%s after latest pointer update error: %v", versionId, dstBucket, normalizedObject, rollbackErr) + } + glog.Errorf("CopyObjectHandler: failed to update latest version in directory: %v", err) + return "", "", fmt.Errorf("update latest version metadata: %w", err) + } + + return versionId, etag, nil + + case s3_constants.VersioningSuspended: + cleanupVersioningMetadata(dstEntry.Extended) + dstEntry.Extended[s3_constants.ExtVersionIdKey] = []byte("null") + dstEntry.Extended[s3_constants.ExtETagKey] = []byte(etag) + + if err = s3a.mkFile(dstDir, dstName, dstEntry.Chunks, func(entry *filer_pb.Entry) { + copyEntryToTarget(entry, dstEntry) + }); err != nil { + return "", "", err + } + + // mkFile writes through the default filer, so the ownership check reads there too. + if err = s3a.finalizeSuspendedNullWrite("", dstBucket, normalizedObject, s3_constants.ExtETagKey, etag); err != nil { + glog.Warningf("CopyObjectHandler: failed to retire the null delete marker for %s/%s: %v", dstBucket, normalizedObject, err) + } + + return "", etag, nil + + default: + cleanupVersioningMetadata(dstEntry.Extended) + dstEntry.Extended[s3_constants.ExtETagKey] = []byte(etag) + + if err = s3a.mkFile(dstDir, dstName, dstEntry.Chunks, func(entry *filer_pb.Entry) { + copyEntryToTarget(entry, dstEntry) + }); err != nil { + return "", "", err + } + + return "", etag, nil + } +} + +func (s3a *S3ApiServer) rollbackCopyVersion(bucketDir, versionObjectPath string) error { + versionPath := util.FullPath(fmt.Sprintf("%s/%s", bucketDir, versionObjectPath)) + versionDir, versionName := versionPath.DirAndName() + return s3a.rmObject(context.Background(), versionDir, versionName, true, false) +} + +func (s3a *S3ApiServer) resolveCopySourceEntry(bucket, object, versionId, versioningState string) (*filer_pb.Entry, error) { + normalizedObject := s3_constants.NormalizeObjectKey(object) + + if versionId != "" { + return s3a.getSpecificObjectVersion(bucket, normalizedObject, versionId) + } + + switch versioningState { + case s3_constants.VersioningEnabled: + return s3a.getLatestObjectVersion(bucket, normalizedObject) + case s3_constants.VersioningSuspended: + return s3a.resolveSuspendedCopySourceEntry(bucket, normalizedObject, "CopyObject") + default: + srcPath := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), normalizedObject)) + dir, name := srcPath.DirAndName() + return s3a.getEntry(dir, name) + } +} + +func mergeCopyMetadata(existing, updated map[string][]byte) map[string][]byte { + merged := make(map[string][]byte, len(existing)+len(updated)) + for k, v := range existing { + merged[k] = v + } + for k := range merged { + if isManagedCopyMetadataKey(k) { + delete(merged, k) + } + } + for k, v := range updated { + merged[k] = v + } + return merged +} + +func isManagedCopyMetadataKey(key string) bool { + switch key { + case s3_constants.AmzStorageClass, + s3_constants.AmzServerSideEncryption, + s3_constants.AmzServerSideEncryptionAwsKmsKeyId, + s3_constants.AmzServerSideEncryptionContext, + s3_constants.AmzServerSideEncryptionBucketKeyEnabled, + s3_constants.AmzServerSideEncryptionCustomerAlgorithm, + s3_constants.AmzServerSideEncryptionCustomerKeyMD5, + s3_constants.AmzTagCount: + return true + } + for _, h := range copyReplaceSystemHeaders { + if strings.EqualFold(key, h) { + return true + } + } + // Match X-Amz-Meta-* / X-Amz-Tagging case-insensitively so legacy + // non-canonical keys (written by non-S3 paths or older versions) are + // still recognized as managed. + return hasPrefixFold(key, s3_constants.AmzUserMetaPrefix) || + hasPrefixFold(key, s3_constants.AmzObjectTagging) +} + +func (s3a *S3ApiServer) resolveSuspendedCopySourceEntry(bucket, normalizedObject, operation string) (*filer_pb.Entry, error) { + srcPath := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), normalizedObject)) + dir, name := srcPath.DirAndName() + entry, err := s3a.getEntry(dir, name) + if err == nil { + return entry, nil + } + if !errors.Is(err, filer_pb.ErrNotFound) { + return nil, err + } + glog.V(2).Infof("%s: regular file not found for suspended versioning, trying latest version", operation) + return s3a.getLatestObjectVersion(bucket, normalizedObject) +} + +// authorizeCopySource enforces s3:GetObject on the CopyObject / UploadPartCopy +// source. The route's Auth middleware only verifies destination permissions +// because the request URL points at the destination; the source from the +// X-Amz-Copy-Source header must be authorized separately. +func (s3a *S3ApiServer) authorizeCopySource(r *http.Request, srcBucket, srcObject, srcVersionId string) s3err.ErrorCode { + if s3a.iam == nil || !s3a.iam.isEnabled() { + return s3err.ErrNone + } + var identity *Identity + if id, ok := s3_constants.GetIdentityFromContext(r).(*Identity); ok { + identity = id + } + return s3a.iam.AuthorizeCopySource(r, identity, srcBucket, srcObject, srcVersionId) +} + +func (s3a *S3ApiServer) canUseMetadataOnlySelfCopy(entry *filer_pb.Entry, r *http.Request, bucket, object string) bool { + srcPath := fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), s3_constants.NormalizeObjectKey(object)) + state := DetectEncryptionStateWithEntry(entry, r, srcPath, srcPath) + s3a.applyCopyBucketDefaultEncryption(state, bucket) + strategy, err := DetermineUnifiedCopyStrategy(state, entry.Extended, r) + return err == nil && strategy == CopyStrategyDirect +} + +func pathToBucketAndObject(path string) (bucket, object string) { + // Remove leading slash if present + path = strings.TrimPrefix(path, "/") + + // Split by first slash to separate bucket and object + parts := strings.SplitN(path, "/", 2) + if len(parts) == 2 { + bucket = parts[0] + object = parts[1] + return bucket, object + } else if len(parts) == 1 && parts[0] != "" { + // Only bucket provided, no object + return parts[0], "" + } + // Empty path + return "", "" +} + +func pathToBucketObjectAndVersion(rawPath, decodedPath string) (bucket, object, versionId string) { + pathForBucket := decodedPath + + if rawPath != "" { + if idx := strings.Index(rawPath, "?"); idx != -1 { + queryPart := rawPath[idx+1:] + if values, err := url.ParseQuery(queryPart); err == nil && values.Has("versionId") { + versionId = values.Get("versionId") + + rawPathNoQuery := rawPath[:idx] + if unescaped, err := url.PathUnescape(rawPathNoQuery); err == nil { + pathForBucket = unescaped + } else { + pathForBucket = rawPathNoQuery + } + + bucket, object = pathToBucketAndObject(pathForBucket) + return bucket, object, versionId + } + } + } + + bucket, object = pathToBucketAndObject(pathForBucket) + return bucket, object, versionId +} + +type CopyPartResult struct { + LastModified time.Time `xml:"LastModified"` + ETag string `xml:"ETag"` + ChecksumResult +} + +func buildCopyPartResult(etag string, lastModified time.Time, metadata SSEResponseMetadata) CopyPartResult { + result := CopyPartResult{ + ETag: etag, + LastModified: lastModified, + } + result.SetChecksum(metadata.ChecksumHeaderName, metadata.ChecksumValue) + return result +} + +// copyPartLocation returns the destination directory and filename for a +// server-side copy part under the destination bucket's .uploads folder. Copy +// parts carry a fixed "copy" suffix (rather than the random suffix +// genPartUploadPath mints for client uploads) so re-copying a part replaces +// its predecessor in place. +func (s3a *S3ApiServer) copyPartLocation(dstBucket, uploadID string, partID int) (uploadDir, partName string) { + uploadDir = s3a.genUploadsFolder(dstBucket) + "/" + uploadID + partName = fmt.Sprintf("%04d_%s.part", partID, "copy") + return +} + +func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Request) { + t := time.Now().UTC().Truncate(time.Millisecond) + // https://docs.aws.amazon.com/AmazonS3/latest/dev/CopyingObjctsUsingRESTMPUapi.html + // https://docs.aws.amazon.com/AmazonS3/latest/API/API_UploadPartCopy.html + dstBucket, dstObject := s3_constants.GetBucketAndObject(r) + + // Copy source path. + rawCopySource := r.Header.Get("X-Amz-Copy-Source") + + glog.V(4).Infof("CopyObjectPart: Raw copy source header=%q", rawCopySource) + + // Use PathUnescape (not QueryUnescape) because the copy source is a path, + // not a query string. QueryUnescape would incorrectly convert '+' to space. + cpSrcPath, err := url.PathUnescape(rawCopySource) + if err != nil { + // If unescaping fails, log and use original + glog.V(4).Infof("CopyObjectPart: Failed to unescape copy source %q: %v, using as-is", rawCopySource, err) + cpSrcPath = rawCopySource + } + + srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(rawCopySource, cpSrcPath) + + glog.V(4).Infof("CopyObjectPart: Parsed srcBucket=%q, srcObject=%q, srcVersionId=%q", + srcBucket, srcObject, srcVersionId) + + if err := s3a.validateTableBucketObjectPath(dstBucket, dstObject); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + if srcBucket != "" && srcBucket != dstBucket { + if err := s3a.validateTableBucketObjectPath(srcBucket, srcObject); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + } + + // Validate the copy source as CopyObject does. + if err := validateCopySource(cpSrcPath, srcBucket, srcObject, srcVersionId); err != nil { + glog.V(2).Infof("CopyObjectPartHandler validation error: %v", err) + s3err.WriteErrorResponse(w, r, MapCopyValidationError(err)) + return + } + + // UploadPartCopy requires s3:GetObject on the source. The Auth middleware + // only verified s3:PutObject (s3:UploadPart) on the destination. + if errCode := s3a.authorizeCopySource(r, srcBucket, srcObject, srcVersionId); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + partIDString := r.URL.Query().Get("partNumber") + uploadID := r.URL.Query().Get("uploadId") + + partID, err := strconv.Atoi(partIDString) + if err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart) + return + } + + // Check if the upload ID is valid + err = s3a.checkUploadId(dstObject, uploadID) + if err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchUpload) + return + } + + glog.V(3).Infof("CopyObjectPartHandler %s %s => %s part %d upload %s", srcBucket, srcObject, dstBucket, partID, uploadID) + + // check partID with maximum part ID for multipart objects + if partID > s3_constants.MaxS3MultipartParts { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart) + return + } + if partID < 1 { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidPart) + return + } + + // Get detailed versioning state for source bucket + srcVersioningState, err := s3a.getVersioningState(srcBucket) + if err != nil { + glog.Errorf("Error checking versioning state for source bucket %s: %v", srcBucket, err) + // A missing source bucket is NoSuchBucket like AWS; a store error must + // stay retryable, matching the destination-bucket lookup. + if errors.Is(err, filer_pb.ErrNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return + } + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Get the source entry with version awareness based on versioning state + var entry *filer_pb.Entry + if srcVersionId != "" { + // Specific version requested - always use version-aware retrieval + entry, err = s3a.getSpecificObjectVersion(srcBucket, srcObject, srcVersionId) + } else if srcVersioningState == s3_constants.VersioningEnabled { + // Versioning enabled - get latest version from .versions directory + entry, err = s3a.getLatestObjectVersion(srcBucket, srcObject) + } else if srcVersioningState == s3_constants.VersioningSuspended { + // Versioning suspended - current object is stored as regular file ("null" version) + // Try regular file first, fall back to latest version if needed + entry, err = s3a.resolveSuspendedCopySourceEntry(srcBucket, s3_constants.NormalizeObjectKey(srcObject), "CopyObjectPart") + } else { + // No versioning configured - use regular retrieval + srcPath := util.FullPath(fmt.Sprintf("%s/%s", s3a.bucketDir(srcBucket), srcObject)) + dir, name := srcPath.DirAndName() + entry, err = s3a.getEntry(dir, name) + } + + if errCode := classifyCopySourceError(entry, err); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Cache remote-only sources before copying; the part-copy paths below + // iterate entry.GetChunks() and would otherwise produce an empty part. + if entry.IsInRemoteOnly() { + cacheVersionId := resolvedSourceVersionId(srcVersionId, entry) + cachedEntry := s3a.cacheRemoteObjectForCopy(r.Context(), srcBucket, srcObject, cacheVersionId) + if cachedEntry == nil { + glog.Errorf("CopyObjectPartHandler: failed to cache remote-only source %s/%s (version %q)", srcBucket, srcObject, cacheVersionId) + w.Header().Set("Retry-After", "5") + s3err.WriteErrorResponse(w, r, s3err.ErrServiceUnavailable) + return + } + entry = cachedEntry + } + + // per-chunk part-copy needs a flat source; resolved manifests stay with the source + if _, err := s3a.flattenManifestChunks(r.Context(), entry); err != nil { + glog.Errorf("CopyObjectPartHandler: resolve source manifest chunks %s/%s: %v", srcBucket, srcObject, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Validate conditional copy headers + if err := s3a.validateConditionalCopyHeaders(r, entry); err != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, err) + return + } + + // Handle range header if present + rangeHeader := r.Header.Get("x-amz-copy-source-range") + var startOffset, endOffset int64 + if rangeHeader != "" { + startOffset, endOffset, err = parseRangeHeader(rangeHeader, int64(filer.FileSize(entry))) + if err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRange) + return + } + } else { + startOffset = 0 + if entry.Attributes.FileSize == 0 { + endOffset = -1 // For zero-size files, use -1 as endOffset + } else { + endOffset = int64(entry.Attributes.FileSize) - 1 + } + } + + // Fetch the destination upload entry to determine whether the multipart + // upload was created with SSE configured. If either side has SSE, the + // fast raw-byte chunk copy below would leave destination chunks tagged + // inconsistently with the bytes on disk and trigger #8908's deterministic + // byte corruption on GET. Re-encrypt the source bytes in that case so + // destination chunks come out properly tagged. + // + // checkUploadId above only verifies that the uploadID's hash prefix + // matches dstObject; it does NOT prove the upload directory exists. + // Treat a missing upload entry as NoSuchUpload — falling through with + // uploadEntry=nil would silently skip the SSE check on the destination + // side and could send a plain-source copy through the raw-byte fast + // path even though the destination's encryption state is unknown. + uploadEntry, uploadEntryErr := s3a.getEntry(s3a.genUploadsFolder(dstBucket), uploadID) + if uploadEntryErr != nil { + if errors.Is(uploadEntryErr, filer_pb.ErrNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchUpload) + return + } + glog.Errorf("CopyObjectPartHandler: failed to fetch upload entry for %s/%s uploadID=%s: %v", + dstBucket, dstObject, uploadID, uploadEntryErr) + // Distinguish transient from permanent errors: gRPC Unavailable + // (filer briefly unreachable, leader election in flight, etc.) and + // DeadlineExceeded both indicate the client should retry rather than + // give up. Map them to 503 ServiceUnavailable; everything else stays + // as 500 InternalError. + if isTransientFilerError(uploadEntryErr) { + s3err.WriteErrorResponse(w, r, s3err.ErrServiceUnavailable) + return + } + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + if uploadEntryHasSSE(uploadEntry) || sourceEntryIsEncrypted(entry) || uploadEntryHasChecksum(uploadEntry) { + etag, sseMetadata, errCode := s3a.copyObjectPartViaReencryption(r, entry, startOffset, endOffset, dstBucket, dstObject, uploadID, partID, uploadEntry) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + // the copy above re-creates a directory an abort removed mid-copy + if !s3a.checkUploadStillOpen(w, r, dstBucket, dstObject, uploadID) { + return + } + setEtag(w, "\""+strings.Trim(etag, "\"")+"\"") + // Mirror PutObjectPartHandler: write x-amz-server-side-encryption / + // x-amz-server-side-encryption-aws-kms-key-id headers on the response + // so clients can see the destination's encryption state. + s3a.setSSEResponseHeaders(w, r, sseMetadata) + writeSuccessResponseXML(w, r, buildCopyPartResult(etag, t, sseMetadata)) + return + } + + // Fast path: neither source nor destination has SSE. Raw byte copy is + // safe, since the bytes on disk are plaintext on both sides. + + // Create new entry for the part + // Calculate part size, avoiding underflow for invalid ranges + partSize := uint64(0) + if endOffset >= startOffset { + partSize = uint64(endOffset - startOffset + 1) + } + + dstEntry := &filer_pb.Entry{ + Attributes: &filer_pb.FuseAttributes{ + FileSize: partSize, + Mtime: t.Unix(), + Crtime: t.Unix(), + Mime: entry.Attributes.Mime, + FileMode: s3a.resolveFileMode(r), + Uid: filer_pb.OS_UID, + Gid: filer_pb.OS_GID, + }, + Extended: make(map[string][]byte), + } + + uploadDir, partName := s3a.copyPartLocation(dstBucket, uploadID, partID) + + // The copied part entry lives under the destination bucket's .uploads + // folder, but its bytes become the object, so assign destination volumes + // against the destination object's filer path: that is the path filer.conf + // storage rules and the bucket-to-collection mapping are written for. + // r.URL.Path is the S3 request URI (e.g. /bucket/key), not a filer path, so + // passing it would skip the mapping and route the copied bytes to the + // default collection. + dstAssignPath := s3a.toFilerPath(dstBucket, dstObject) + + // Handle zero-size files or empty ranges + if entry.Attributes.FileSize == 0 || endOffset < startOffset { + // For zero-size files or invalid ranges, create an empty part with size 0 + dstEntry.Attributes.FileSize = 0 + dstEntry.Chunks = nil + } else { + // Copy chunks that overlap with the range + dstChunks, err := s3a.copyChunksForRange(entry, startOffset, endOffset, dstAssignPath) + if err != nil { + glog.Errorf("CopyObjectPartHandler copy chunks error: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + dstEntry.Chunks = dstChunks + } + + // Save the part entry to the multipart uploads folder + // Check if part exists and remove it first (allow re-copying same part) + if exists, _ := s3a.exists(uploadDir, partName, false); exists { + if err := s3a.rm(r.Context(), uploadDir, partName, false, false); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + } + + if err := s3a.mkFile(uploadDir, partName, dstEntry.Chunks, func(entry *filer_pb.Entry) { + entry.Attributes = dstEntry.Attributes + entry.Extended = dstEntry.Extended + }); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // the copy above re-creates a directory an abort removed mid-copy + if !s3a.checkUploadStillOpen(w, r, dstBucket, dstObject, uploadID) { + return + } + + // Calculate ETag for the part + etag := copyEntryETag(dstEntry) + setEtag(w, etag) + + writeSuccessResponseXML(w, r, buildCopyPartResult(etag, t, SSEResponseMetadata{})) +} + +func replaceDirective(reqHeader http.Header) (replaceMeta, replaceTagging bool) { + return reqHeader.Get(s3_constants.AmzUserMetaDirective) == DirectiveReplace, reqHeader.Get(s3_constants.AmzObjectTaggingDirective) == DirectiveReplace +} + +func processMetadataBytes(reqHeader http.Header, existing map[string][]byte, replaceMeta, replaceTagging bool) (metadata map[string][]byte, err error) { + metadata = make(map[string][]byte) + + if sc := existing[s3_constants.AmzStorageClass]; len(sc) > 0 { + metadata[s3_constants.AmzStorageClass] = sc + } + if sc := reqHeader.Get(s3_constants.AmzStorageClass); len(sc) > 0 { + metadata[s3_constants.AmzStorageClass] = []byte(sc) + } + + // Handle destination SSE headers from the request when present. + if sseAlgorithm := reqHeader.Get(s3_constants.AmzServerSideEncryption); sseAlgorithm != "" { + metadata[s3_constants.AmzServerSideEncryption] = []byte(sseAlgorithm) + + if sseAlgorithm == s3_constants.SSEAlgorithmKMS { + // KMS Key ID (optional - can use default key) + if kmsKeyID := reqHeader.Get(s3_constants.AmzServerSideEncryptionAwsKmsKeyId); kmsKeyID != "" { + metadata[s3_constants.AmzServerSideEncryptionAwsKmsKeyId] = []byte(kmsKeyID) + } + + // Encryption Context (optional) + if encryptionContext := reqHeader.Get(s3_constants.AmzServerSideEncryptionContext); encryptionContext != "" { + metadata[s3_constants.AmzServerSideEncryptionContext] = []byte(encryptionContext) + } + + // Bucket Key Enabled (optional) + if bucketKeyEnabled := reqHeader.Get(s3_constants.AmzServerSideEncryptionBucketKeyEnabled); bucketKeyEnabled != "" { + metadata[s3_constants.AmzServerSideEncryptionBucketKeyEnabled] = []byte(bucketKeyEnabled) + } + } + } else { + // If not explicitly setting SSE, preserve existing SSE headers from source + for _, sseHeader := range []string{ + s3_constants.AmzServerSideEncryption, + s3_constants.AmzServerSideEncryptionAwsKmsKeyId, + s3_constants.AmzServerSideEncryptionContext, + s3_constants.AmzServerSideEncryptionBucketKeyEnabled, + } { + if existingValue, exists := existing[sseHeader]; exists { + metadata[sseHeader] = existingValue + } + } + } + + // Handle SSE-C headers - these are always processed from request headers if present + if sseCustomerAlgorithm := reqHeader.Get(s3_constants.AmzServerSideEncryptionCustomerAlgorithm); sseCustomerAlgorithm != "" { + metadata[s3_constants.AmzServerSideEncryptionCustomerAlgorithm] = []byte(sseCustomerAlgorithm) + + if sseCustomerKeyMD5 := reqHeader.Get(s3_constants.AmzServerSideEncryptionCustomerKeyMD5); sseCustomerKeyMD5 != "" { + metadata[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(sseCustomerKeyMD5) + } + } else { + // If not explicitly setting SSE-C, preserve existing SSE-C headers from source + for _, ssecHeader := range []string{ + s3_constants.AmzServerSideEncryptionCustomerAlgorithm, + s3_constants.AmzServerSideEncryptionCustomerKeyMD5, + } { + if existingValue, exists := existing[ssecHeader]; exists { + metadata[ssecHeader] = existingValue + } + } + } + + if replaceMeta { + for header, values := range reqHeader { + if strings.HasPrefix(header, s3_constants.AmzUserMetaPrefix) { + // Go's HTTP server canonicalizes headers (e.g., x-amz-meta-foo → X-Amz-Meta-Foo) + // We store them as they come in (after canonicalization) to preserve the user's intent + for _, value := range values { + metadata[header] = []byte(value) + } + } + } + for _, h := range copyReplaceSystemHeaders { + if v := reqHeader.Get(h); v != "" { + metadata[h] = []byte(v) + } + } + } else { + // Two-pass copy keeps the result deterministic when both the + // canonical and a legacy-cased variant of the same header live on + // the source: canonical always wins, legacy only fills holes. + for _, h := range copyReplaceSystemHeaders { + if v, ok := existing[h]; ok { + metadata[h] = v + } + } + for k, v := range existing { + for _, h := range copyReplaceSystemHeaders { + if k == h { + continue + } + if !strings.EqualFold(k, h) { + continue + } + if _, present := metadata[h]; present { + continue + } + metadata[h] = v + } + } + // Copy existing metadata as-is + // Note: Metadata should already be normalized during storage (X-Amz-Meta-*), + // but we handle legacy non-canonical formats for backward compatibility + for k, v := range existing { + if strings.HasPrefix(k, s3_constants.AmzUserMetaPrefix) { + // Already in canonical format + metadata[k] = v + } else if len(k) >= 11 && strings.EqualFold(k[:11], "x-amz-meta-") { + // Backward compatibility: migrate old non-canonical format to canonical format + // This ensures gradual migration of metadata to consistent format + suffix := k[11:] // Extract suffix after "x-amz-meta-" + canonicalKey := s3_constants.AmzUserMetaPrefix + suffix + + if glog.V(3) { + glog.Infof("Migrating legacy user metadata key %q to canonical format %q during copy", k, canonicalKey) + } + + // Check for collision with canonical key + if _, exists := metadata[canonicalKey]; exists { + glog.Warningf("User metadata key collision during copy migration: canonical key %q already exists, skipping legacy key %q", canonicalKey, k) + } else { + metadata[canonicalKey] = v + } + } + } + } + if replaceTagging { + if tags := reqHeader.Get(s3_constants.AmzObjectTagging); tags != "" { + parsedTags, err := parseTagsHeader(tags) + if err != nil { + return nil, err + } + err = ValidateTags(parsedTags) + if err != nil { + return nil, err + } + for k, v := range parsedTags { + metadata[s3_constants.AmzObjectTagging+"-"+k] = []byte(v) + } + } + } else { + // Two passes: canonical exact-prefix wins; legacy variants only + // fill in keys that no canonical entry already provided. Keeps + // the result deterministic when both variants coexist on the + // source. + prefixLen := len(s3_constants.AmzObjectTagging) + for k, v := range existing { + if strings.HasPrefix(k, s3_constants.AmzObjectTagging) { + metadata[k] = v + } + } + for k, v := range existing { + if strings.HasPrefix(k, s3_constants.AmzObjectTagging) { + continue + } + if !hasPrefixFold(k, s3_constants.AmzObjectTagging) { + continue + } + canonicalKey := s3_constants.AmzObjectTagging + k[prefixLen:] + if _, present := metadata[canonicalKey]; present { + continue + } + metadata[canonicalKey] = v + } + delete(metadata, s3_constants.AmzTagCount) + } + + return +} + +// copyChunks replicates chunks from source entry to destination entry +func (s3a *S3ApiServer) copyChunks(entry *filer_pb.Entry, dstPath string) ([]*filer_pb.FileChunk, error) { + dstChunks := make([]*filer_pb.FileChunk, len(entry.GetChunks())) + const defaultChunkCopyConcurrency = 4 + executor := util.NewLimitedConcurrentExecutor(defaultChunkCopyConcurrency) // Limit to configurable concurrent operations + errChan := make(chan error, len(entry.GetChunks())) + + for i, chunk := range entry.GetChunks() { + chunkIndex := i + executor.Execute(func() { + dstChunk, err := s3a.copySingleChunk(chunk, dstPath) + if err != nil { + errChan <- fmt.Errorf("chunk %d: %v", chunkIndex, err) + return + } + dstChunks[chunkIndex] = dstChunk + errChan <- nil + }) + } + + // Wait for all operations to complete and check for errors + for i := 0; i < len(entry.GetChunks()); i++ { + if err := <-errChan; err != nil { + return nil, err + } + } + + return dstChunks, nil +} + +// copySingleChunk copies a single chunk from source to destination, preserving +// the source's SSE tagging (the same-key copy fast path reuses the source +// ciphertext as-is, so the destination chunk must keep the source's SSE_C / +// SSE_KMS / SSE_S3 metadata or the read path will not decrypt — see #9281). +func (s3a *S3ApiServer) copySingleChunk(chunk *filer_pb.FileChunk, dstPath string) (*filer_pb.FileChunk, error) { + // Create destination chunk + dstChunk := s3a.createDestinationChunkPreservingSSE(chunk, chunk.Offset, chunk.Size) + + // Prepare chunk copy (assign new volume and get source URL) + fileId := chunk.GetFileIdString() + assignResult, srcUrl, err := s3a.prepareChunkCopy(fileId, dstPath, chunk.Size) + if err != nil { + return nil, err + } + + // Set file ID on destination chunk + if err := s3a.setChunkFileId(dstChunk, assignResult); err != nil { + return nil, err + } + + // Stream the chunk through io.Pipe when no in-transit transformation is + // required; this holds only ~32 KiB per copy in flight, vs. the + // chunk-sized buffers the buffered path needs. isFullChunk=true asks + // the source volume for compressed bytes, so a gzipped chunk is + // forwarded to the destination without anyone having to decompress. + if canStreamCopyChunk(chunk) { + if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, 0, int64(chunk.Size), true /*isFullChunk*/, assignResult); err != nil { + return nil, fmt.Errorf("stream chunk: %w", err) + } + return dstChunk, nil + } + + // SSE / per-chunk-cipher: bytes need to be transformed in transit, so + // download into a buffer first. + chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) + if err != nil { + return nil, fmt.Errorf("download chunk data: %w", err) + } + + if err := s3a.uploadChunkData(chunkData, assignResult, chunk.IsCompressed); err != nil { + return nil, fmt.Errorf("upload chunk data: %w", err) + } + + return dstChunk, nil +} + +// copySingleChunkForRange copies a portion of a chunk for range operations +func (s3a *S3ApiServer) copySingleChunkForRange(originalChunk, rangeChunk *filer_pb.FileChunk, rangeStart, rangeEnd int64, dstPath string) (*filer_pb.FileChunk, error) { + // Create destination chunk + dstChunk := s3a.createDestinationChunk(rangeChunk, rangeChunk.Offset, rangeChunk.Size) + + // Prepare chunk copy (assign new volume and get source URL) + fileId := originalChunk.GetFileIdString() + assignResult, srcUrl, err := s3a.prepareChunkCopy(fileId, dstPath, rangeChunk.Size) + if err != nil { + return nil, err + } + + // Set file ID on destination chunk + if err := s3a.setChunkFileId(dstChunk, assignResult); err != nil { + return nil, err + } + + // Calculate the portion of the original chunk that we need to copy + chunkStart := originalChunk.Offset + overlapStart := max(rangeStart, chunkStart) + offsetInChunk := overlapStart - chunkStart + + // Stream the byte range through io.Pipe when there's no in-transit + // transformation required (see canStreamCopyChunk for the eligibility + // rules); this is the dominant path for Harbor-style multipart + // assemble loads, which use UploadPartCopy with a CopySourceRange. + // + // When the requested range happens to cover the entire source chunk + // exactly, switch to the full-chunk fetch mode: that asks the source + // volume for compressed bytes (Accept-Encoding: gzip) and forwards + // them as-is, avoiding the volume-side decompression that a Range + // fetch on a gzipped chunk would otherwise pay. Harbor's typical + // part-size = chunk-size assemble pattern hits this branch. + if canStreamCopyChunk(originalChunk) { + isFullChunk := offsetInChunk == 0 && rangeChunk.Size == originalChunk.Size + if err := s3a.streamCopyChunkRange(context.Background(), srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), isFullChunk, assignResult); err != nil { + return nil, fmt.Errorf("stream chunk range: %w", err) + } + return dstChunk, nil + } + + // Download and upload the chunk portion + chunkData, err := s3a.downloadChunkData(srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), originalChunk.CipherKey) + if err != nil { + return nil, fmt.Errorf("download chunk range data: %w", err) + } + + if err := s3a.uploadChunkData(chunkData, assignResult, originalChunk.IsCompressed); err != nil { + return nil, fmt.Errorf("upload chunk range data: %w", err) + } + + return dstChunk, nil +} + +// assignNewVolume assigns a new volume for the chunk +func (s3a *S3ApiServer) assignNewVolume(dstPath string, expectedDataSize uint64) (*filer_pb.AssignVolumeResponse, error) { + var assignResult *filer_pb.AssignVolumeResponse + err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + resp, err := client.AssignVolume(context.Background(), &filer_pb.AssignVolumeRequest{ + Count: 1, + Replication: "", + Collection: "", + DiskType: "", + DataCenter: s3a.option.DataCenter, + Path: dstPath, + ExpectedDataSize: expectedDataSize, + }) + if err != nil { + return fmt.Errorf("assign volume: %w", err) + } + if resp.Error != "" { + return fmt.Errorf("assign volume: %v", resp.Error) + } + assignResult = resp + return nil + }) + if err != nil { + return nil, err + } + return assignResult, nil +} + +// parseRangeHeader parses the x-amz-copy-source-range header against a source of +// fileSize bytes. Unlike a GET, a part copy does not clamp: a range reaching past +// the source is unsatisfiable, and copying it would pad the part with zeros. +func parseRangeHeader(rangeHeader string, fileSize int64) (startOffset, endOffset int64, err error) { + // Remove "bytes=" prefix if present + rangeStr := strings.TrimPrefix(rangeHeader, "bytes=") + parts := strings.Split(rangeStr, "-") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid range format") + } + + startOffset, err = strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return 0, 0, fmt.Errorf("invalid start offset: %w", err) + } + + endOffset, err = strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return 0, 0, fmt.Errorf("invalid end offset: %w", err) + } + + if startOffset < 0 || endOffset < startOffset || endOffset >= fileSize { + return 0, 0, fmt.Errorf("range %s is not satisfiable for a %d byte source", rangeHeader, fileSize) + } + + return startOffset, endOffset, nil +} + +// copyChunksForRange copies chunks that overlap with the specified range +func (s3a *S3ApiServer) copyChunksForRange(entry *filer_pb.Entry, startOffset, endOffset int64, dstPath string) ([]*filer_pb.FileChunk, error) { + var relevantChunks []*filer_pb.FileChunk + var originalChunks []*filer_pb.FileChunk + + // Find chunks that overlap with the range + for _, chunk := range entry.GetChunks() { + chunkStart := chunk.Offset + chunkEnd := chunk.Offset + int64(chunk.Size) + + // Check if chunk overlaps with the range + if chunkStart < endOffset+1 && chunkEnd > startOffset { + // Calculate the overlap + overlapStart := max(startOffset, chunkStart) + overlapEnd := min(endOffset+1, chunkEnd) + + // Create a new chunk with adjusted offset and size relative to the range + newChunk := &filer_pb.FileChunk{ + FileId: chunk.FileId, + Offset: overlapStart - startOffset, // Offset relative to the range start + Size: uint64(overlapEnd - overlapStart), + ModifiedTsNs: time.Now().UnixNano(), + ETag: chunk.ETag, + IsCompressed: chunk.IsCompressed, + CipherKey: chunk.CipherKey, + Fid: chunk.Fid, + } + relevantChunks = append(relevantChunks, newChunk) + originalChunks = append(originalChunks, chunk) + } + } + + // Copy the relevant chunks using a specialized method for range copies + dstChunks := make([]*filer_pb.FileChunk, len(relevantChunks)) + const defaultChunkCopyConcurrency = 4 + executor := util.NewLimitedConcurrentExecutor(defaultChunkCopyConcurrency) + errChan := make(chan error, len(relevantChunks)) + + for i, chunk := range relevantChunks { + chunkIndex := i + originalChunk := originalChunks[i] // Get the corresponding original chunk + executor.Execute(func() { + dstChunk, err := s3a.copySingleChunkForRange(originalChunk, chunk, startOffset, endOffset, dstPath) + if err != nil { + errChan <- fmt.Errorf("chunk %d: %v", chunkIndex, err) + return + } + dstChunks[chunkIndex] = dstChunk + errChan <- nil + }) + } + + // Wait for all operations to complete and check for errors + for i := 0; i < len(relevantChunks); i++ { + if err := <-errChan; err != nil { + return nil, err + } + } + + return dstChunks, nil +} + +// Helper methods for copy operations to avoid code duplication + +// sourceConditionalHeaderNames names the four headers an operation uses to make +// itself conditional on the state of its source object. CopyObject spells them +// x-amz-copy-source-if-*, RenameObject x-amz-rename-source-if-*. +type sourceConditionalHeaderNames struct { + ifMatch string + ifNoneMatch string + ifModifiedSince string + ifUnmodifiedSince string +} + +var copySourceConditionalHeaders = sourceConditionalHeaderNames{ + ifMatch: s3_constants.AmzCopySourceIfMatch, + ifNoneMatch: s3_constants.AmzCopySourceIfNoneMatch, + ifModifiedSince: s3_constants.AmzCopySourceIfModifiedSince, + ifUnmodifiedSince: s3_constants.AmzCopySourceIfUnmodifiedSince, +} + +// validateConditionalCopyHeaders validates the conditional copy headers against the source entry +func (s3a *S3ApiServer) validateConditionalCopyHeaders(r *http.Request, entry *filer_pb.Entry) s3err.ErrorCode { + return validateSourceConditionalHeaders(r, entry, copySourceConditionalHeaders) +} + +// validateSourceConditionalHeaders evaluates the conditional headers against an +// already-resolved source entry, so the source is known to exist here. +// +// The evaluation order is RFC 7232's, the same one validateConditionalHeadersForReads +// applies: an ETag precondition wins over the date precondition on its own side, so a +// matched If-Match makes If-Unmodified-Since moot and a passed If-None-Match makes +// If-Modified-Since moot. AWS documents that precedence for CopyObject too — a +// matching x-amz-copy-source-if-match with a failing x-amz-copy-source-if-unmodified-since +// copies rather than returning 412. +func validateSourceConditionalHeaders(r *http.Request, entry *filer_pb.Entry, names sourceConditionalHeaderNames) s3err.ErrorCode { + sourceETag := strings.Trim(copyEntryETag(entry), `"`) + ifMatch := strings.Trim(r.Header.Get(names.ifMatch), `"`) + ifNoneMatch := strings.Trim(r.Header.Get(names.ifNoneMatch), `"`) + + if ifMatch != "" && ifMatch != "*" && ifMatch != sourceETag { + glog.V(3).Infof("%s failed - expected %s, got %s", names.ifMatch, ifMatch, sourceETag) + return s3err.ErrPreconditionFailed + } + + if ifNoneMatch != "" && (ifNoneMatch == "*" || ifNoneMatch == sourceETag) { + glog.V(3).Infof("%s failed - matched %s", names.ifNoneMatch, sourceETag) + return s3err.ErrPreconditionFailed + } + + if ifModifiedSince := r.Header.Get(names.ifModifiedSince); ifModifiedSince != "" && ifNoneMatch == "" { + t, err := parseHTTPDate(ifModifiedSince) + if err != nil { + glog.V(3).Infof("invalid %s header: %v", names.ifModifiedSince, err) + return s3err.ErrInvalidRequest + } + if !time.Unix(entry.Attributes.Mtime, 0).After(t) { + glog.V(3).Infof("%s failed", names.ifModifiedSince) + return s3err.ErrPreconditionFailed + } + } + + if ifUnmodifiedSince := r.Header.Get(names.ifUnmodifiedSince); ifUnmodifiedSince != "" && ifMatch == "" { + t, err := parseHTTPDate(ifUnmodifiedSince) + if err != nil { + glog.V(3).Infof("invalid %s header: %v", names.ifUnmodifiedSince, err) + return s3err.ErrInvalidRequest + } + if time.Unix(entry.Attributes.Mtime, 0).After(t) { + glog.V(3).Infof("%s failed", names.ifUnmodifiedSince) + return s3err.ErrPreconditionFailed + } + } + + return s3err.ErrNone +} + +// createDestinationChunk creates a new chunk based on the source chunk with modified properties. +// +// SseType and SseMetadata are NOT copied here because most call sites +// re-encrypt the chunk with the destination's keys and then set those fields +// to match the new encryption. The same-key fast path (where the bytes are +// copied as-is and the destination should keep the source's SSE tagging) calls +// createDestinationChunkPreservingSSE instead. +func (s3a *S3ApiServer) createDestinationChunk(sourceChunk *filer_pb.FileChunk, offset int64, size uint64) *filer_pb.FileChunk { + return &filer_pb.FileChunk{ + Offset: offset, + Size: size, + ModifiedTsNs: time.Now().UnixNano(), + ETag: sourceChunk.ETag, + IsCompressed: sourceChunk.IsCompressed, + CipherKey: sourceChunk.CipherKey, + } +} + +// createDestinationChunkPreservingSSE returns a destination chunk that mirrors +// the source's SSE tagging in addition to the usual fields. This is used by the +// same-key copy fast paths where the on-disk bytes are reused as-is and the +// destination must therefore declare the same per-chunk SSE encryption as the +// source (otherwise detectPrimarySSEType returns "None" on read and +// GetObjectHandler serves the still-encrypted bytes raw — issue #9281). +func (s3a *S3ApiServer) createDestinationChunkPreservingSSE(sourceChunk *filer_pb.FileChunk, offset int64, size uint64) *filer_pb.FileChunk { + dst := s3a.createDestinationChunk(sourceChunk, offset, size) + dst.SseType = sourceChunk.SseType + if len(sourceChunk.SseMetadata) > 0 { + dst.SseMetadata = append([]byte(nil), sourceChunk.SseMetadata...) + } + return dst +} + +// lookupVolumeUrl looks up the volume URL for a given file ID using the filer's LookupVolume method +func (s3a *S3ApiServer) lookupVolumeUrl(fileId string) (string, error) { + var srcUrl string + err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + vid, _, err := operation.ParseFileId(fileId) + if err != nil { + return fmt.Errorf("parse file ID: %w", err) + } + + resp, err := client.LookupVolume(context.Background(), &filer_pb.LookupVolumeRequest{ + VolumeIds: []string{vid}, + }) + if err != nil { + return fmt.Errorf("lookup volume: %w", err) + } + + if locations, found := resp.LocationsMap[vid]; found && len(locations.Locations) > 0 { + srcUrl = "http://" + locations.Locations[0].Url + "/" + fileId + } else { + return fmt.Errorf("no location found for volume %s", vid) + } + + return nil + }) + if err != nil { + return "", fmt.Errorf("lookup volume URL: %w", err) + } + return srcUrl, nil +} + +// setChunkFileId sets the file ID on the destination chunk +func (s3a *S3ApiServer) setChunkFileId(chunk *filer_pb.FileChunk, assignResult *filer_pb.AssignVolumeResponse) error { + chunk.FileId = assignResult.FileId + fid, err := filer_pb.ToFileIdObject(assignResult.FileId) + if err != nil { + return fmt.Errorf("parse file ID: %w", err) + } + chunk.Fid = fid + return nil +} + +// prepareChunkCopy prepares a chunk for copying by assigning a new volume and looking up the source URL +func (s3a *S3ApiServer) prepareChunkCopy(sourceFileId, dstPath string, expectedDataSize uint64) (*filer_pb.AssignVolumeResponse, string, error) { + // Assign new volume + assignResult, err := s3a.assignNewVolume(dstPath, expectedDataSize) + if err != nil { + return nil, "", fmt.Errorf("assign volume: %w", err) + } + + // Look up source URL + srcUrl, err := s3a.lookupVolumeUrl(sourceFileId) + if err != nil { + return nil, "", fmt.Errorf("lookup source URL: %w", err) + } + + return assignResult, srcUrl, nil +} + +// uploadChunkData uploads chunk data to the destination using common upload logic +// isCompressed indicates if the data is already compressed and should not be compressed again +func (s3a *S3ApiServer) uploadChunkData(chunkData []byte, assignResult *filer_pb.AssignVolumeResponse, isCompressed bool) error { + uploadOption := newChunkUploadOption(chunkData, assignResult, isCompressed) + uploader, err := operation.NewUploader() + if err != nil { + return fmt.Errorf("create uploader: %w", err) + } + _, err = uploader.UploadData(context.Background(), chunkData, uploadOption) + if err != nil { + return fmt.Errorf("upload chunk: %w", err) + } + + return nil +} + +// multipartFramingOverhead reserves space for the multipart wrapper +// upload_content writes around chunkData (boundary + Content-Disposition + +// optional Content-Type/Content-Encoding/Content-MD5 headers + trailing +// boundary). Real-world overhead is a few hundred bytes; rounding to 1 KiB +// avoids a single grow on the buffer we hand to the multipart writer. +const multipartFramingOverhead = 1024 + +// newChunkUploadOption builds the operation.UploadOption used by every +// chunk-copy upload. It always sets BytesBuffer to a fresh, per-call buffer +// so upload_content does not fall back to the package-global +// valyala/bytebufferpool — that pool retains every high-water buffer for the +// process's lifetime, and under concurrent UploadPartCopy load it hoarded +// one chunk-sized buffer per concurrent upload (see #6541). The per-call +// buffer is GC'd as soon as the upload returns. +func newChunkUploadOption(chunkData []byte, assignResult *filer_pb.AssignVolumeResponse, isCompressed bool) *operation.UploadOption { + dstUrl := fmt.Sprintf("http://%s/%s", assignResult.Location.Url, assignResult.FileId) + if assignResult.Fsync { + dstUrl += "?fsync=true" + } + return &operation.UploadOption{ + UploadUrl: dstUrl, + Cipher: false, // Data is already encrypted if source had CipherKey; don't re-encrypt + IsInputCompressed: isCompressed, + MimeType: "", + PairMap: nil, + Jwt: security.EncodedJwt(assignResult.Auth), + BytesBuffer: bytes.NewBuffer(make([]byte, 0, len(chunkData)+multipartFramingOverhead)), + } +} + +// downloadChunkData downloads chunk data from the source URL +func (s3a *S3ApiServer) downloadChunkData(srcUrl, fileId string, offset, size int64, cipherKey []byte) ([]byte, error) { + jwt := filer.JwtForVolumeServer(fileId) + // Only perform HEAD request for encrypted chunks to get physical size + if offset == 0 && len(cipherKey) > 0 { + req, err := http.NewRequest(http.MethodHead, srcUrl, nil) + if err == nil { + if jwt != "" { + req.Header.Set("Authorization", security.BearerPrefix+string(jwt)) + } + resp, err := util_http.GetGlobalHttpClient().Do(req) + if err == nil { + defer util_http.CloseResponse(resp) + if resp.StatusCode == http.StatusOK { + contentLengthStr := resp.Header.Get("Content-Length") + if contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64); err == nil { + // Validate contentLength fits in int32 range before comparison + if contentLength > int64(2147483647) { // math.MaxInt32 + return nil, fmt.Errorf("content length %d exceeds maximum int32 size", contentLength) + } + if contentLength > size { + size = contentLength + } + } + } + } + } + } + // Validate size fits in int32 range before conversion to int + if size > int64(2147483647) { // math.MaxInt32 + return nil, fmt.Errorf("chunk size %d exceeds maximum int32 size", size) + } + sizeInt := int(size) + // Pre-size the receive buffer to the known chunk size so the streaming + // callback below does not trigger geometric `append`-grow on a nil slice. + // Receiving a 64 MiB chunk through 256 KiB callback ticks would otherwise + // allocate ~2x the chunk size, and with concurrent UploadPartCopy requests + // (Harbor-style assemble loops) this caused the runaway-RSS pattern in + // https://github.com/seaweedfs/seaweedfs/issues/6541. + chunkData := make([]byte, 0, sizeInt) + shouldRetry, err := util_http.ReadUrlAsStream(context.Background(), srcUrl, jwt, nil, false, false, offset, sizeInt, func(data []byte) { + chunkData = append(chunkData, data...) + }) + if err != nil { + return nil, fmt.Errorf("download chunk: %w", err) + } + if shouldRetry { + return nil, fmt.Errorf("download chunk: retry needed") + } + return chunkData, nil +} + +// copyMultipartSSECChunks handles copying multipart SSE-C objects +// Returns chunks and destination metadata that should be applied to the destination entry +func (s3a *S3ApiServer) copyMultipartSSECChunks(entry *filer_pb.Entry, copySourceKey *SSECustomerKey, destKey *SSECustomerKey, dstPath string) ([]*filer_pb.FileChunk, map[string][]byte, error) { + + // For multipart SSE-C, always use decrypt/reencrypt path to ensure proper metadata handling + // The standard copyChunks() doesn't preserve SSE metadata, so we need per-chunk processing + + // Different keys or key changes: decrypt and re-encrypt each chunk individually + glog.V(2).Infof("Multipart SSE-C reencrypt copy (different keys): %s", dstPath) + + var dstChunks []*filer_pb.FileChunk + var destIV []byte + + for _, chunk := range entry.GetChunks() { + if chunk.GetSseType() != filer_pb.SSEType_SSE_C { + // Non-SSE-C chunk, copy directly + copiedChunk, err := s3a.copySingleChunk(chunk, dstPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to copy non-SSE-C chunk: %w", err) + } + dstChunks = append(dstChunks, copiedChunk) + continue + } + + // SSE-C chunk: decrypt with stored per-chunk metadata, re-encrypt with dest key + copiedChunk, chunkDestIV, err := s3a.copyMultipartSSECChunk(chunk, copySourceKey, destKey, dstPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to copy SSE-C chunk %s: %w", chunk.GetFileIdString(), err) + } + + dstChunks = append(dstChunks, copiedChunk) + + // Store the first chunk's IV as the object's IV (for single-part compatibility) + if len(destIV) == 0 { + destIV = chunkDestIV + } + } + + // Create destination metadata + dstMetadata := make(map[string][]byte) + if destKey != nil && len(destIV) > 0 { + // Store the IV and SSE-C headers for single-part compatibility + StoreSSECIVInMetadata(dstMetadata, destIV) + dstMetadata[s3_constants.AmzServerSideEncryptionCustomerAlgorithm] = []byte("AES256") + dstMetadata[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(destKey.KeyMD5) + glog.V(2).Infof("Prepared multipart SSE-C destination metadata: %s", dstPath) + } + + return dstChunks, dstMetadata, nil +} + +// copyMultipartSSEKMSChunks handles copying multipart SSE-KMS objects (unified with SSE-C approach) +// Returns chunks and destination metadata that should be applied to the destination entry +func (s3a *S3ApiServer) copyMultipartSSEKMSChunks(entry *filer_pb.Entry, destKeyID string, encryptionContext map[string]string, bucketKeyEnabled bool, dstPath, bucket string) ([]*filer_pb.FileChunk, map[string][]byte, error) { + + // For multipart SSE-KMS, always use decrypt/reencrypt path to ensure proper metadata handling + // The standard copyChunks() doesn't preserve SSE metadata, so we need per-chunk processing + + // Deserialize the source's entry-level SSE-KMS key once so it can be used + // as a fallback for legacy multipart chunks that lack per-chunk metadata. + // New multipart SSE-KMS uploads always populate per-chunk metadata, but + // objects written by earlier code may have only the entry-level key. + sourceEntrySSEKey := deserializeEntrySSEKMSKey(entry.Extended) + + var dstChunks []*filer_pb.FileChunk + + for _, chunk := range entry.GetChunks() { + if chunk.GetSseType() != filer_pb.SSEType_SSE_KMS { + // Non-SSE-KMS chunk, copy directly + copiedChunk, err := s3a.copySingleChunk(chunk, dstPath) + if err != nil { + return nil, nil, fmt.Errorf("failed to copy non-SSE-KMS chunk: %w", err) + } + dstChunks = append(dstChunks, copiedChunk) + continue + } + + // SSE-KMS chunk: decrypt with stored per-chunk metadata (or entry-level + // fallback for legacy data), re-encrypt with dest key. + copiedChunk, err := s3a.copyMultipartSSEKMSChunk(chunk, sourceEntrySSEKey, destKeyID, encryptionContext, bucketKeyEnabled, dstPath, bucket) + if err != nil { + return nil, nil, fmt.Errorf("failed to copy SSE-KMS chunk %s: %w", chunk.GetFileIdString(), err) + } + + dstChunks = append(dstChunks, copiedChunk) + } + + // Create destination metadata for SSE-KMS. + // + // Prefer the first dst chunk's full per-chunk key (which carries a real + // EDK + IV minted by copyMultipartSSEKMSChunk's + // CreateSSEKMSEncryptedReaderWithBucketKey call) so single-chunk reads on + // the destination can unwrap the EDK on the GET path. Fall back to a stub + // key (KeyID + context + bucket-key only) for 0-byte objects so they're + // still recognised as SSE-KMS encrypted. + dstMetadata := make(map[string][]byte) + if destKeyID != "" { + if len(dstChunks) > 0 && len(dstChunks[0].GetSseMetadata()) > 0 { + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = dstChunks[0].GetSseMetadata() + } else { + if encryptionContext == nil { + encryptionContext = BuildEncryptionContext(bucket, dstPath, bucketKeyEnabled) + } + sseKey := &SSEKMSKey{ + KeyID: destKeyID, + EncryptionContext: encryptionContext, + BucketKeyEnabled: bucketKeyEnabled, + } + if kmsMetadata, serErr := SerializeSSEKMSMetadata(sseKey); serErr == nil { + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = kmsMetadata + } else { + glog.Errorf("Failed to serialize SSE-KMS metadata: %v", serErr) + } + } + } + + return dstChunks, dstMetadata, nil +} + +// copyMultipartSSEKMSChunk copies a single SSE-KMS chunk from a multipart object (unified with SSE-C approach). +// +// sourceEntrySSEKey is the source object's entry-level SSE-KMS key (deserialized +// from entry.Extended[SeaweedFSSSEKMSKey] by the caller). It's used as a +// fallback when this chunk has no per-chunk SSE-KMS metadata of its own — +// legacy multipart SSE-KMS objects may have only the entry-level key. Newer +// uploads populate per-chunk metadata, in which case this fallback is unused. +func (s3a *S3ApiServer) copyMultipartSSEKMSChunk(chunk *filer_pb.FileChunk, sourceEntrySSEKey *SSEKMSKey, destKeyID string, encryptionContext map[string]string, bucketKeyEnabled bool, dstPath, bucket string) (*filer_pb.FileChunk, error) { + // Create destination chunk + dstChunk := s3a.createDestinationChunk(chunk, chunk.Offset, chunk.Size) + + // Prepare chunk copy (assign new volume and get source URL) + fileId := chunk.GetFileIdString() + assignResult, srcUrl, err := s3a.prepareChunkCopy(fileId, dstPath, chunk.Size) + if err != nil { + return nil, err + } + + // Set file ID on destination chunk + if err := s3a.setChunkFileId(dstChunk, assignResult); err != nil { + return nil, err + } + + // Download encrypted chunk data + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) + if err != nil { + return nil, fmt.Errorf("download encrypted chunk data: %w", err) + } + + var finalData []byte + + // Prefer the chunk's own per-chunk SSE-KMS metadata; fall back to the + // source's entry-level key for legacy multipart objects that don't have + // per-chunk metadata. resolveChunkSSEKMSKey centralizes that selection + // so the same logic is used everywhere a chunk needs decryption. + sourceSSEKey, err := resolveChunkSSEKMSKey(chunk, sourceEntrySSEKey) + if err != nil { + return nil, fmt.Errorf("failed to resolve SSE-KMS metadata: %w", err) + } + + // Decrypt the chunk data using the source metadata + decryptedReader, decErr := CreateSSEKMSDecryptedReader(bytes.NewReader(encryptedData), sourceSSEKey) + if decErr != nil { + return nil, fmt.Errorf("create SSE-KMS decrypted reader: %w", decErr) + } + + decryptedData, readErr := io.ReadAll(decryptedReader) + if readErr != nil { + return nil, fmt.Errorf("decrypt chunk data: %w", readErr) + } + finalData = decryptedData + glog.V(4).Infof("Decrypted multipart SSE-KMS chunk: %d bytes → %d bytes", len(encryptedData), len(finalData)) + + // Re-encrypt with destination key if specified + if destKeyID != "" { + // Build encryption context if not provided + if encryptionContext == nil { + encryptionContext = BuildEncryptionContext(bucket, dstPath, bucketKeyEnabled) + } + + // Encrypt with destination key + originalSize := len(finalData) + encryptedReader, destSSEKey, encErr := CreateSSEKMSEncryptedReaderWithBucketKey(bytes.NewReader(finalData), destKeyID, encryptionContext, bucketKeyEnabled) + if encErr != nil { + return nil, fmt.Errorf("create SSE-KMS encrypted reader: %w", encErr) + } + + reencryptedData, readErr := io.ReadAll(encryptedReader) + if readErr != nil { + return nil, fmt.Errorf("re-encrypt chunk data: %w", readErr) + } + finalData = reencryptedData + + // Create per-chunk SSE-KMS metadata for the destination chunk + // For copy operations, reset chunk offset to 0 (similar to SSE-C approach) + // The copied chunks form a new object structure independent of original part boundaries + destSSEKey.ChunkOffset = 0 + kmsMetadata, err := SerializeSSEKMSMetadata(destSSEKey) + if err != nil { + return nil, fmt.Errorf("serialize SSE-KMS metadata: %w", err) + } + + // Set the SSE type and metadata on destination chunk (unified approach) + dstChunk.SseType = filer_pb.SSEType_SSE_KMS + dstChunk.SseMetadata = kmsMetadata + + glog.V(4).Infof("Re-encrypted multipart SSE-KMS chunk: %d bytes → %d bytes", originalSize, len(finalData)) + } + + // Upload the final data + if err := s3a.uploadChunkData(finalData, assignResult, false); err != nil { + return nil, fmt.Errorf("upload chunk data: %w", err) + } + + // Update chunk size + dstChunk.Size = uint64(len(finalData)) + + glog.V(3).Infof("Successfully copied multipart SSE-KMS chunk %s → %s", + chunk.GetFileIdString(), dstChunk.GetFileIdString()) + + return dstChunk, nil +} + +// copyMultipartSSECChunk copies a single SSE-C chunk from a multipart object +func (s3a *S3ApiServer) copyMultipartSSECChunk(chunk *filer_pb.FileChunk, copySourceKey *SSECustomerKey, destKey *SSECustomerKey, dstPath string) (*filer_pb.FileChunk, []byte, error) { + // Create destination chunk + dstChunk := s3a.createDestinationChunk(chunk, chunk.Offset, chunk.Size) + + // Prepare chunk copy (assign new volume and get source URL) + fileId := chunk.GetFileIdString() + assignResult, srcUrl, err := s3a.prepareChunkCopy(fileId, dstPath, chunk.Size) + if err != nil { + return nil, nil, err + } + + // Set file ID on destination chunk + if err := s3a.setChunkFileId(dstChunk, assignResult); err != nil { + return nil, nil, err + } + + // Download encrypted chunk data + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) + if err != nil { + return nil, nil, fmt.Errorf("download encrypted chunk data: %w", err) + } + + var finalData []byte + var destIV []byte + + // Decrypt if source is encrypted + if copySourceKey != nil { + // Get the per-chunk SSE-C metadata + if len(chunk.GetSseMetadata()) == 0 { + return nil, nil, fmt.Errorf("SSE-C chunk missing per-chunk metadata") + } + + // Deserialize the SSE-C metadata + ssecMetadata, err := DeserializeSSECMetadata(chunk.GetSseMetadata()) + if err != nil { + return nil, nil, fmt.Errorf("failed to deserialize SSE-C metadata: %w", err) + } + + // Decode the IV from the metadata + chunkBaseIV, err := base64.StdEncoding.DecodeString(ssecMetadata.IV) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode chunk IV: %w", err) + } + + // Calculate the correct IV for this chunk using within-part offset + var chunkIV []byte + var ivSkip int + if ssecMetadata.PartOffset > 0 { + chunkIV, ivSkip = calculateIVWithOffset(chunkBaseIV, ssecMetadata.PartOffset) + } else { + chunkIV = chunkBaseIV + ivSkip = 0 + } + + // Decrypt the chunk data + decryptedReader, decErr := CreateSSECDecryptedReader(bytes.NewReader(encryptedData), copySourceKey, chunkIV) + if decErr != nil { + return nil, nil, fmt.Errorf("create decrypted reader: %w", decErr) + } + + // CRITICAL: Skip intra-block bytes from CTR decryption (non-block-aligned offset handling) + if ivSkip > 0 { + _, skipErr := io.CopyN(io.Discard, decryptedReader, int64(ivSkip)) + if skipErr != nil { + return nil, nil, fmt.Errorf("failed to skip intra-block bytes (%d): %w", ivSkip, skipErr) + } + } + + decryptedData, readErr := io.ReadAll(decryptedReader) + if readErr != nil { + return nil, nil, fmt.Errorf("decrypt chunk data: %w", readErr) + } + finalData = decryptedData + glog.V(4).Infof("Decrypted multipart SSE-C chunk: %d bytes → %d bytes", len(encryptedData), len(finalData)) + } else { + // Source is unencrypted + finalData = encryptedData + } + + // Re-encrypt if destination should be encrypted + if destKey != nil { + // Generate new IV for this chunk + newIV := make([]byte, s3_constants.AESBlockSize) + if _, err := rand.Read(newIV); err != nil { + return nil, nil, fmt.Errorf("generate IV: %w", err) + } + destIV = newIV + + // Encrypt with new key and IV + originalSize := len(finalData) + encryptedReader, iv, encErr := CreateSSECEncryptedReader(bytes.NewReader(finalData), destKey) + if encErr != nil { + return nil, nil, fmt.Errorf("create encrypted reader: %w", encErr) + } + destIV = iv + + reencryptedData, readErr := io.ReadAll(encryptedReader) + if readErr != nil { + return nil, nil, fmt.Errorf("re-encrypt chunk data: %w", readErr) + } + finalData = reencryptedData + + // Create per-chunk SSE-C metadata for the destination chunk + ssecMetadata, err := SerializeSSECMetadata(destIV, destKey.KeyMD5, 0) // partOffset=0 for copied chunks + if err != nil { + return nil, nil, fmt.Errorf("serialize SSE-C metadata: %w", err) + } + + // Set the SSE type and metadata on destination chunk + dstChunk.SseType = filer_pb.SSEType_SSE_C + dstChunk.SseMetadata = ssecMetadata // Use unified metadata field + + glog.V(4).Infof("Re-encrypted multipart SSE-C chunk: %d bytes → %d bytes", originalSize, len(finalData)) + } + + // Upload the final data + if err := s3a.uploadChunkData(finalData, assignResult, false); err != nil { + return nil, nil, fmt.Errorf("upload chunk data: %w", err) + } + + // Update chunk size + dstChunk.Size = uint64(len(finalData)) + + glog.V(3).Infof("Successfully copied multipart SSE-C chunk %s → %s", + chunk.GetFileIdString(), dstChunk.GetFileIdString()) + + return dstChunk, destIV, nil +} + +// copyMultipartCrossEncryption handles all cross-encryption and decrypt-only copy scenarios +// This unified function supports: SSE-C↔SSE-KMS↔SSE-S3, and any→Plain +func (s3a *S3ApiServer) copyMultipartCrossEncryption(entry *filer_pb.Entry, r *http.Request, state *EncryptionState, dstBucket, dstPath string) ([]*filer_pb.FileChunk, map[string][]byte, error) { + var dstChunks []*filer_pb.FileChunk + + // Parse destination encryption parameters + var destSSECKey *SSECustomerKey + var destKMSKeyID string + var destKMSEncryptionContext map[string]string + var destKMSBucketKeyEnabled bool + var destSSES3Key *SSES3Key + + if state.DstSSEC { + var err error + destSSECKey, err = ParseSSECHeaders(r) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse destination SSE-C headers: %w", err) + } + } else if state.DstSSEKMS { + var err error + destKMSKeyID, destKMSEncryptionContext, destKMSBucketKeyEnabled, err = ParseSSEKMSCopyHeaders(r) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse destination SSE-KMS headers: %w", err) + } + } else if state.DstSSES3 { + // Generate SSE-S3 key for destination + var err error + destSSES3Key, err = GenerateSSES3Key() + if err != nil { + return nil, nil, fmt.Errorf("failed to generate SSE-S3 key: %w", err) + } + } + + // Parse source encryption parameters + var sourceSSECKey *SSECustomerKey + if state.SrcSSEC { + var err error + sourceSSECKey, err = ParseSSECCopySourceHeaders(r) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse source SSE-C headers: %w", err) + } + } + + // Process each chunk with unified cross-encryption logic + for _, chunk := range entry.GetChunks() { + var copiedChunk *filer_pb.FileChunk + var err error + + if chunk.GetSseType() == filer_pb.SSEType_SSE_C { + copiedChunk, err = s3a.copyCrossEncryptionChunk(chunk, sourceSSECKey, destSSECKey, destKMSKeyID, destKMSEncryptionContext, destKMSBucketKeyEnabled, destSSES3Key, dstPath, dstBucket, state) + } else if chunk.GetSseType() == filer_pb.SSEType_SSE_KMS { + copiedChunk, err = s3a.copyCrossEncryptionChunk(chunk, nil, destSSECKey, destKMSKeyID, destKMSEncryptionContext, destKMSBucketKeyEnabled, destSSES3Key, dstPath, dstBucket, state) + } else if chunk.GetSseType() == filer_pb.SSEType_SSE_S3 { + copiedChunk, err = s3a.copyCrossEncryptionChunk(chunk, nil, destSSECKey, destKMSKeyID, destKMSEncryptionContext, destKMSBucketKeyEnabled, destSSES3Key, dstPath, dstBucket, state) + } else { + // Unencrypted chunk - may need encryption if destination requires it + if state.DstSSEC || state.DstSSEKMS || state.DstSSES3 { + copiedChunk, err = s3a.copyCrossEncryptionChunk(chunk, nil, destSSECKey, destKMSKeyID, destKMSEncryptionContext, destKMSBucketKeyEnabled, destSSES3Key, dstPath, dstBucket, state) + } else { + copiedChunk, err = s3a.copySingleChunk(chunk, dstPath) + } + } + + if err != nil { + return nil, nil, fmt.Errorf("failed to copy chunk %s: %w", chunk.GetFileIdString(), err) + } + + dstChunks = append(dstChunks, copiedChunk) + } + + // Create destination metadata based on destination encryption type + dstMetadata := make(map[string][]byte) + + // Clear any previous encryption metadata to avoid routing conflicts + if state.SrcSSEKMS && state.DstSSEC { + // SSE-KMS → SSE-C: Remove SSE-KMS headers + // These will be excluded from dstMetadata, effectively removing them + } else if state.SrcSSEC && state.DstSSEKMS { + // SSE-C → SSE-KMS: Remove SSE-C headers + // These will be excluded from dstMetadata, effectively removing them + } else if !state.DstSSEC && !state.DstSSEKMS { + // Encrypted → Unencrypted: Remove all encryption metadata + // These will be excluded from dstMetadata, effectively removing them + } + + if state.DstSSEC && destSSECKey != nil { + // For SSE-C destination, use first chunk's IV for compatibility + if len(dstChunks) > 0 && dstChunks[0].GetSseType() == filer_pb.SSEType_SSE_C && len(dstChunks[0].GetSseMetadata()) > 0 { + if ssecMetadata, err := DeserializeSSECMetadata(dstChunks[0].GetSseMetadata()); err == nil { + if iv, ivErr := base64.StdEncoding.DecodeString(ssecMetadata.IV); ivErr == nil { + StoreSSECIVInMetadata(dstMetadata, iv) + dstMetadata[s3_constants.AmzServerSideEncryptionCustomerAlgorithm] = []byte("AES256") + dstMetadata[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(destSSECKey.KeyMD5) + } + } + } + } else if state.DstSSEKMS && destKMSKeyID != "" { + // For SSE-KMS destination, create object-level metadata + if destKMSEncryptionContext == nil { + destKMSEncryptionContext = BuildEncryptionContext(dstBucket, dstPath, destKMSBucketKeyEnabled) + } + // Take the first dst chunk's full per-chunk metadata as the canonical + // entry-level key — it includes a real EDK + IV minted by + // copyCrossEncryptionChunk's CreateSSEKMSEncryptedReaderWithBucketKey + // call. Earlier this stored only KeyID/context/bucketKey, leaving the + // EncryptedDataKey empty; single-chunk reads then failed with + // "Invalid ciphertext format" when KMS was asked to unwrap an empty + // EDK (#9281). + if len(dstChunks) > 0 && len(dstChunks[0].GetSseMetadata()) > 0 { + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = dstChunks[0].GetSseMetadata() + } else { + // 0-byte object or no SSE-KMS chunk: fall back to a stub key + // (sufficient for the entry to be recognised as SSE-KMS). + sseKey := &SSEKMSKey{ + KeyID: destKMSKeyID, + EncryptionContext: destKMSEncryptionContext, + BucketKeyEnabled: destKMSBucketKeyEnabled, + } + if kmsMetadata, serErr := SerializeSSEKMSMetadata(sseKey); serErr == nil { + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = kmsMetadata + } else { + glog.Errorf("Failed to serialize SSE-KMS metadata: %v", serErr) + } + } + } else if state.DstSSES3 && destSSES3Key != nil { + // For SSE-S3 destination, create object-level metadata + var sses3Metadata *SSES3Key + if len(dstChunks) == 0 { + // Handle 0-byte files - generate IV for metadata even though there's no content to encrypt + if entry.Attributes.FileSize != 0 { + return nil, nil, fmt.Errorf("internal error: no chunks created for non-empty SSE-S3 destination object") + } + // Generate IV for 0-byte object metadata + iv := make([]byte, s3_constants.AESBlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return nil, nil, fmt.Errorf("generate IV for 0-byte object: %w", err) + } + destSSES3Key.IV = iv + sses3Metadata = destSSES3Key + } else { + // For non-empty objects, use the first chunk's metadata + if dstChunks[0].GetSseType() != filer_pb.SSEType_SSE_S3 || len(dstChunks[0].GetSseMetadata()) == 0 { + return nil, nil, fmt.Errorf("internal error: first chunk is missing expected SSE-S3 metadata for destination object") + } + keyManager := GetSSES3KeyManager() + var err error + sses3Metadata, err = DeserializeSSES3Metadata(dstChunks[0].GetSseMetadata(), keyManager) + if err != nil { + return nil, nil, fmt.Errorf("failed to deserialize SSE-S3 metadata from first chunk: %w", err) + } + } + // Use the derived key with its IV for object-level metadata + keyData, serErr := SerializeSSES3Metadata(sses3Metadata) + if serErr != nil { + return nil, nil, fmt.Errorf("failed to serialize SSE-S3 metadata: %w", serErr) + } + dstMetadata[s3_constants.SeaweedFSSSES3Key] = keyData + dstMetadata[s3_constants.AmzServerSideEncryption] = []byte("AES256") + } + // For unencrypted destination, no metadata needed (dstMetadata remains empty) + + return dstChunks, dstMetadata, nil +} + +// copyCrossEncryptionChunk handles copying a single chunk with cross-encryption support +func (s3a *S3ApiServer) copyCrossEncryptionChunk(chunk *filer_pb.FileChunk, sourceSSECKey *SSECustomerKey, destSSECKey *SSECustomerKey, destKMSKeyID string, destKMSEncryptionContext map[string]string, destKMSBucketKeyEnabled bool, destSSES3Key *SSES3Key, dstPath, dstBucket string, state *EncryptionState) (*filer_pb.FileChunk, error) { + // Create destination chunk + dstChunk := s3a.createDestinationChunk(chunk, chunk.Offset, chunk.Size) + + // Prepare chunk copy (assign new volume and get source URL) + fileId := chunk.GetFileIdString() + assignResult, srcUrl, err := s3a.prepareChunkCopy(fileId, dstPath, chunk.Size) + if err != nil { + return nil, err + } + + // Set file ID on destination chunk + if err := s3a.setChunkFileId(dstChunk, assignResult); err != nil { + return nil, err + } + + // Download encrypted chunk data + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) + if err != nil { + return nil, fmt.Errorf("download encrypted chunk data: %w", err) + } + + var finalData []byte + + // Step 1: Decrypt source data + if chunk.GetSseType() == filer_pb.SSEType_SSE_C { + // Decrypt SSE-C source + if len(chunk.GetSseMetadata()) == 0 { + return nil, fmt.Errorf("SSE-C chunk missing per-chunk metadata") + } + + ssecMetadata, err := DeserializeSSECMetadata(chunk.GetSseMetadata()) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-C metadata: %w", err) + } + + chunkBaseIV, err := base64.StdEncoding.DecodeString(ssecMetadata.IV) + if err != nil { + return nil, fmt.Errorf("failed to decode chunk IV: %w", err) + } + + // Calculate the correct IV for this chunk using within-part offset + var chunkIV []byte + var ivSkip int + if ssecMetadata.PartOffset > 0 { + chunkIV, ivSkip = calculateIVWithOffset(chunkBaseIV, ssecMetadata.PartOffset) + } else { + chunkIV = chunkBaseIV + ivSkip = 0 + } + + decryptedReader, decErr := CreateSSECDecryptedReader(bytes.NewReader(encryptedData), sourceSSECKey, chunkIV) + if decErr != nil { + return nil, fmt.Errorf("create SSE-C decrypted reader: %w", decErr) + } + + // CRITICAL: Skip intra-block bytes from CTR decryption (non-block-aligned offset handling) + if ivSkip > 0 { + _, skipErr := io.CopyN(io.Discard, decryptedReader, int64(ivSkip)) + if skipErr != nil { + return nil, fmt.Errorf("failed to skip intra-block bytes (%d): %w", ivSkip, skipErr) + } + } + + decryptedData, readErr := io.ReadAll(decryptedReader) + if readErr != nil { + return nil, fmt.Errorf("decrypt SSE-C chunk data: %w", readErr) + } + finalData = decryptedData + + } else if chunk.GetSseType() == filer_pb.SSEType_SSE_KMS { + // Decrypt SSE-KMS source + if len(chunk.GetSseMetadata()) == 0 { + return nil, fmt.Errorf("SSE-KMS chunk missing per-chunk metadata") + } + + sourceSSEKey, err := DeserializeSSEKMSMetadata(chunk.GetSseMetadata()) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-KMS metadata: %w", err) + } + + decryptedReader, decErr := CreateSSEKMSDecryptedReader(bytes.NewReader(encryptedData), sourceSSEKey) + if decErr != nil { + return nil, fmt.Errorf("create SSE-KMS decrypted reader: %w", decErr) + } + + decryptedData, readErr := io.ReadAll(decryptedReader) + if readErr != nil { + return nil, fmt.Errorf("decrypt SSE-KMS chunk data: %w", readErr) + } + finalData = decryptedData + + } else if chunk.GetSseType() == filer_pb.SSEType_SSE_S3 { + // Decrypt SSE-S3 source + if len(chunk.GetSseMetadata()) == 0 { + return nil, fmt.Errorf("SSE-S3 chunk missing per-chunk metadata") + } + + keyManager := GetSSES3KeyManager() + sourceSSEKey, err := DeserializeSSES3Metadata(chunk.GetSseMetadata(), keyManager) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-S3 metadata: %w", err) + } + + decryptedReader, decErr := CreateSSES3DecryptedReader(bytes.NewReader(encryptedData), sourceSSEKey, sourceSSEKey.IV) + if decErr != nil { + return nil, fmt.Errorf("create SSE-S3 decrypted reader: %w", decErr) + } + + decryptedData, readErr := io.ReadAll(decryptedReader) + if readErr != nil { + return nil, fmt.Errorf("decrypt SSE-S3 chunk data: %w", readErr) + } + finalData = decryptedData + glog.V(4).Infof("Decrypted SSE-S3 chunk, size: %d", len(finalData)) + + } else { + // Source is unencrypted + finalData = encryptedData + } + + // Step 2: Re-encrypt with destination encryption (if any) + if state.DstSSEC && destSSECKey != nil { + // Encrypt with SSE-C + encryptedReader, iv, encErr := CreateSSECEncryptedReader(bytes.NewReader(finalData), destSSECKey) + if encErr != nil { + return nil, fmt.Errorf("create SSE-C encrypted reader: %w", encErr) + } + + reencryptedData, readErr := io.ReadAll(encryptedReader) + if readErr != nil { + return nil, fmt.Errorf("re-encrypt with SSE-C: %w", readErr) + } + finalData = reencryptedData + + // Create per-chunk SSE-C metadata (offset=0 for cross-encryption copies) + ssecMetadata, err := SerializeSSECMetadata(iv, destSSECKey.KeyMD5, 0) + if err != nil { + return nil, fmt.Errorf("serialize SSE-C metadata: %w", err) + } + + dstChunk.SseType = filer_pb.SSEType_SSE_C + dstChunk.SseMetadata = ssecMetadata + + previewLen := 16 + if len(finalData) < previewLen { + previewLen = len(finalData) + } + + } else if state.DstSSEKMS && destKMSKeyID != "" { + // Encrypt with SSE-KMS + if destKMSEncryptionContext == nil { + destKMSEncryptionContext = BuildEncryptionContext(dstBucket, dstPath, destKMSBucketKeyEnabled) + } + + encryptedReader, destSSEKey, encErr := CreateSSEKMSEncryptedReaderWithBucketKey(bytes.NewReader(finalData), destKMSKeyID, destKMSEncryptionContext, destKMSBucketKeyEnabled) + if encErr != nil { + return nil, fmt.Errorf("create SSE-KMS encrypted reader: %w", encErr) + } + + reencryptedData, readErr := io.ReadAll(encryptedReader) + if readErr != nil { + return nil, fmt.Errorf("re-encrypt with SSE-KMS: %w", readErr) + } + finalData = reencryptedData + + // Create per-chunk SSE-KMS metadata (offset=0 for cross-encryption copies) + destSSEKey.ChunkOffset = 0 + kmsMetadata, err := SerializeSSEKMSMetadata(destSSEKey) + if err != nil { + return nil, fmt.Errorf("serialize SSE-KMS metadata: %w", err) + } + + dstChunk.SseType = filer_pb.SSEType_SSE_KMS + dstChunk.SseMetadata = kmsMetadata + + glog.V(4).Infof("Re-encrypted chunk with SSE-KMS") + + } else if state.DstSSES3 && destSSES3Key != nil { + // Encrypt with SSE-S3 + encryptedReader, iv, encErr := CreateSSES3EncryptedReader(bytes.NewReader(finalData), destSSES3Key) + if encErr != nil { + return nil, fmt.Errorf("create SSE-S3 encrypted reader: %w", encErr) + } + + reencryptedData, readErr := io.ReadAll(encryptedReader) + if readErr != nil { + return nil, fmt.Errorf("re-encrypt with SSE-S3: %w", readErr) + } + finalData = reencryptedData + + // Create per-chunk SSE-S3 metadata with chunk-specific IV + chunkSSEKey := &SSES3Key{ + Key: destSSES3Key.Key, + KeyID: destSSES3Key.KeyID, + Algorithm: destSSES3Key.Algorithm, + IV: iv, + } + sses3Metadata, err := SerializeSSES3Metadata(chunkSSEKey) + if err != nil { + return nil, fmt.Errorf("serialize SSE-S3 metadata: %w", err) + } + + dstChunk.SseType = filer_pb.SSEType_SSE_S3 + dstChunk.SseMetadata = sses3Metadata + + glog.V(4).Infof("Re-encrypted chunk with SSE-S3") + } + // For unencrypted destination, finalData remains as decrypted plaintext + + // Upload the final data + if err := s3a.uploadChunkData(finalData, assignResult, false); err != nil { + return nil, fmt.Errorf("upload chunk data: %w", err) + } + + // Update chunk size + dstChunk.Size = uint64(len(finalData)) + + glog.V(3).Infof("Successfully copied cross-encryption chunk %s → %s", + chunk.GetFileIdString(), dstChunk.GetFileIdString()) + + return dstChunk, nil +} + +// copyChunksWithSSEC handles SSE-C aware copying with smart fast/slow path selection +// Returns chunks and destination metadata that should be applied to the destination entry +// dstPath is the destination object's filer path, so volume assignment targets +// the destination bucket's collection. The caller must not pass r.URL.Path (the +// S3 request URI), which would route copied chunks to the default collection. +func (s3a *S3ApiServer) copyChunksWithSSEC(entry *filer_pb.Entry, r *http.Request, dstPath string) ([]*filer_pb.FileChunk, map[string][]byte, error) { + + // Parse SSE-C headers + copySourceKey, err := ParseSSECCopySourceHeaders(r) + if err != nil { + glog.Errorf("Failed to parse SSE-C copy source headers: %v", err) + return nil, nil, err + } + + destKey, err := ParseSSECHeaders(r) + if err != nil { + glog.Errorf("Failed to parse SSE-C headers: %v", err) + return nil, nil, err + } + + // Check if this is a multipart SSE-C object + isMultipartSSEC := false + sseCChunks := 0 + for i, chunk := range entry.GetChunks() { + glog.V(4).Infof("Chunk %d: sseType=%d, hasMetadata=%t", i, chunk.GetSseType(), len(chunk.GetSseMetadata()) > 0) + if chunk.GetSseType() == filer_pb.SSEType_SSE_C { + sseCChunks++ + } + } + isMultipartSSEC = sseCChunks > 1 + + if isMultipartSSEC { + glog.V(2).Infof("Detected multipart SSE-C object with %d encrypted chunks for copy", sseCChunks) + return s3a.copyMultipartSSECChunks(entry, copySourceKey, destKey, dstPath) + } + + // Single-part SSE-C object: use original logic + // Determine copy strategy + strategy, err := DetermineSSECCopyStrategy(entry.Extended, copySourceKey, destKey) + if err != nil { + return nil, nil, err + } + + glog.V(2).Infof("SSE-C copy strategy for single-part %s: %v", r.URL.Path, strategy) + + switch strategy { + case SSECCopyStrategyDirect: + // FAST PATH: Direct chunk copy + glog.V(2).Infof("Using fast path: direct chunk copy for %s", r.URL.Path) + chunks, err := s3a.copyChunks(entry, dstPath) + return chunks, nil, err + + case SSECCopyStrategyDecryptEncrypt: + // SLOW PATH: Decrypt and re-encrypt + glog.V(2).Infof("Using slow path: decrypt/re-encrypt for %s", r.URL.Path) + chunks, destIV, err := s3a.copyChunksWithReencryption(entry, copySourceKey, destKey, dstPath) + if err != nil { + return nil, nil, err + } + + // Create destination metadata with IV and SSE-C headers + dstMetadata := make(map[string][]byte) + if destKey != nil && len(destIV) > 0 { + // Store the IV + StoreSSECIVInMetadata(dstMetadata, destIV) + + // Store SSE-C algorithm and key MD5 for proper metadata + dstMetadata[s3_constants.AmzServerSideEncryptionCustomerAlgorithm] = []byte("AES256") + dstMetadata[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(destKey.KeyMD5) + + glog.V(2).Infof("Prepared IV and SSE-C metadata for destination copy: %s", r.URL.Path) + } + + return chunks, dstMetadata, nil + + default: + return nil, nil, fmt.Errorf("unknown SSE-C copy strategy: %v", strategy) + } +} + +// copyChunksWithReencryption handles the slow path: decrypt source and re-encrypt for destination +// Returns the destination chunks and the IV used for encryption (if any) +func (s3a *S3ApiServer) copyChunksWithReencryption(entry *filer_pb.Entry, copySourceKey *SSECustomerKey, destKey *SSECustomerKey, dstPath string) ([]*filer_pb.FileChunk, []byte, error) { + dstChunks := make([]*filer_pb.FileChunk, len(entry.GetChunks())) + const defaultChunkCopyConcurrency = 4 + executor := util.NewLimitedConcurrentExecutor(defaultChunkCopyConcurrency) // Limit to configurable concurrent operations + errChan := make(chan error, len(entry.GetChunks())) + + // Generate a single IV for the destination object (if destination is encrypted) + var destIV []byte + if destKey != nil { + destIV = make([]byte, s3_constants.AESBlockSize) + if _, err := io.ReadFull(rand.Reader, destIV); err != nil { + return nil, nil, fmt.Errorf("failed to generate destination IV: %w", err) + } + } + + for i, chunk := range entry.GetChunks() { + chunkIndex := i + executor.Execute(func() { + dstChunk, err := s3a.copyChunkWithReencryption(chunk, copySourceKey, destKey, dstPath, entry.Extended, destIV) + if err != nil { + errChan <- fmt.Errorf("chunk %d: %v", chunkIndex, err) + return + } + dstChunks[chunkIndex] = dstChunk + errChan <- nil + }) + } + + // Wait for all operations to complete and check for errors + for i := 0; i < len(entry.GetChunks()); i++ { + if err := <-errChan; err != nil { + return nil, nil, err + } + } + + return dstChunks, destIV, nil +} + +// copyChunkWithReencryption copies a single chunk with decrypt/re-encrypt +func (s3a *S3ApiServer) copyChunkWithReencryption(chunk *filer_pb.FileChunk, copySourceKey *SSECustomerKey, destKey *SSECustomerKey, dstPath string, srcMetadata map[string][]byte, destIV []byte) (*filer_pb.FileChunk, error) { + // Create destination chunk + dstChunk := s3a.createDestinationChunk(chunk, chunk.Offset, chunk.Size) + + // Prepare chunk copy (assign new volume and get source URL) + fileId := chunk.GetFileIdString() + assignResult, srcUrl, err := s3a.prepareChunkCopy(fileId, dstPath, chunk.Size) + if err != nil { + return nil, err + } + + // Set file ID on destination chunk + if err := s3a.setChunkFileId(dstChunk, assignResult); err != nil { + return nil, err + } + + // Download encrypted chunk data + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) + if err != nil { + return nil, fmt.Errorf("download encrypted chunk data: %w", err) + } + + var finalData []byte + + // Decrypt if source is encrypted + if copySourceKey != nil { + // Get IV from source metadata + srcIV, err := GetSSECIVFromMetadata(srcMetadata) + if err != nil { + return nil, fmt.Errorf("failed to get IV from metadata: %w", err) + } + + // Use counter offset based on chunk position in the original object + decryptedReader, decErr := CreateSSECDecryptedReaderWithOffset(bytes.NewReader(encryptedData), copySourceKey, srcIV, uint64(chunk.Offset)) + if decErr != nil { + return nil, fmt.Errorf("create decrypted reader: %w", decErr) + } + + decryptedData, readErr := io.ReadAll(decryptedReader) + if readErr != nil { + return nil, fmt.Errorf("decrypt chunk data: %w", readErr) + } + finalData = decryptedData + } else { + // Source is unencrypted + finalData = encryptedData + } + + // Re-encrypt if destination should be encrypted + if destKey != nil { + // Use the provided destination IV with counter offset based on chunk position + // This ensures all chunks of the same object use the same IV with different counters + encryptedReader, encErr := CreateSSECEncryptedReaderWithOffset(bytes.NewReader(finalData), destKey, destIV, uint64(chunk.Offset)) + if encErr != nil { + return nil, fmt.Errorf("create encrypted reader: %w", encErr) + } + + reencryptedData, readErr := io.ReadAll(encryptedReader) + if readErr != nil { + return nil, fmt.Errorf("re-encrypt chunk data: %w", readErr) + } + finalData = reencryptedData + dstChunk.Size = uint64(len(finalData)) + + // Tag the destination chunk as SSE-C with per-chunk metadata. Without + // this the chunk's SseType stays NONE, detectPrimarySSEType returns + // "None" on read (it counts SSE-C chunks; an entry whose only chunk + // is NONE shows zero), and GetObjectHandler serves the still-encrypted + // volume bytes raw without decryption — yielding deterministic byte + // corruption on the SSE-C copy path (issue #9281). + ssecMetadata, metaErr := SerializeSSECMetadata(destIV, destKey.KeyMD5, chunk.Offset) + if metaErr != nil { + return nil, fmt.Errorf("serialize SSE-C chunk metadata: %w", metaErr) + } + dstChunk.SseType = filer_pb.SSEType_SSE_C + dstChunk.SseMetadata = ssecMetadata + } + + // Upload the processed data + if err := s3a.uploadChunkData(finalData, assignResult, false); err != nil { + return nil, fmt.Errorf("upload processed chunk data: %w", err) + } + + return dstChunk, nil +} + +// copyChunksWithSSEKMS handles SSE-KMS aware copying with smart fast/slow path selection +// Returns chunks and destination metadata like SSE-C for consistency +func (s3a *S3ApiServer) copyChunksWithSSEKMS(entry *filer_pb.Entry, r *http.Request, bucket string, dstPath string) ([]*filer_pb.FileChunk, map[string][]byte, error) { + + // Parse SSE-KMS headers from copy request + destKeyID, encryptionContext, bucketKeyEnabled, err := ParseSSEKMSCopyHeaders(r) + if err != nil { + return nil, nil, err + } + + // Check if this is a multipart SSE-KMS object + isMultipartSSEKMS := false + sseKMSChunks := 0 + for i, chunk := range entry.GetChunks() { + glog.V(4).Infof("Chunk %d: sseType=%d, hasKMSMetadata=%t", i, chunk.GetSseType(), len(chunk.GetSseMetadata()) > 0) + if chunk.GetSseType() == filer_pb.SSEType_SSE_KMS { + sseKMSChunks++ + } + } + isMultipartSSEKMS = sseKMSChunks > 1 + + if isMultipartSSEKMS { + glog.V(2).Infof("Detected multipart SSE-KMS object with %d encrypted chunks for copy", sseKMSChunks) + return s3a.copyMultipartSSEKMSChunks(entry, destKeyID, encryptionContext, bucketKeyEnabled, dstPath, bucket) + } + + // Single-part SSE-KMS object: use existing logic + // If no SSE-KMS headers and source is not SSE-KMS encrypted, use regular copy + if destKeyID == "" && !IsSSEKMSEncrypted(entry.Extended) { + chunks, err := s3a.copyChunks(entry, dstPath) + return chunks, nil, err + } + + // Apply bucket default encryption if no explicit key specified + if destKeyID == "" { + bucketMetadata, err := s3a.getBucketMetadata(bucket) + if err != nil { + glog.V(2).Infof("Could not get bucket metadata for default encryption: %v", err) + } else if bucketMetadata != nil && bucketMetadata.Encryption != nil && bucketMetadata.Encryption.SseAlgorithm == "aws:kms" { + destKeyID = bucketMetadata.Encryption.KmsKeyId + bucketKeyEnabled = bucketMetadata.Encryption.BucketKeyEnabled + } + } + + // Determine copy strategy. + // + // DetermineSSEKMSCopyStrategy returns Direct when source and destination + // share the same KMS key ID, but that's not enough on its own — if the + // destination request changes the encryption context or the BucketKey + // flag, the source ciphertext (and its embedded EDK + context) does not + // satisfy the destination's request. Force the slow re-encrypt path in + // that case so the destination object gets a freshly-wrapped EDK bound + // to the requested context/flag. + strategy, err := DetermineSSEKMSCopyStrategy(entry.Extended, destKeyID) + if err != nil { + return nil, nil, err + } + if strategy == SSEKMSCopyStrategyDirect && destKeyID != "" && !srcSSEKMSStateMatchesDest(entry.Extended, encryptionContext, bucketKeyEnabled) { + glog.V(2).Infof("SSE-KMS direct copy rejected — encryption context or bucket-key flag differs; falling back to re-encrypt path for %s", dstPath) + strategy = SSEKMSCopyStrategyDecryptEncrypt + } + + glog.V(2).Infof("SSE-KMS copy strategy for %s: %v", dstPath, strategy) + + switch strategy { + case SSEKMSCopyStrategyDirect: + // FAST PATH: Direct chunk copy (same key or both unencrypted) + glog.V(2).Infof("Using fast path: direct chunk copy for %s", dstPath) + chunks, err := s3a.copyChunks(entry, dstPath) + // For direct copy, generate destination metadata if we're encrypting to SSE-KMS + var dstMetadata map[string][]byte + if destKeyID != "" { + dstMetadata = make(map[string][]byte) + if encryptionContext == nil { + encryptionContext = BuildEncryptionContext(bucket, dstPath, bucketKeyEnabled) + } + // Direct (same-key) fast path: chunks were copied as-is and now + // carry the source's per-chunk SSE-KMS metadata (preserved by + // createDestinationChunkPreservingSSE in copySingleChunk). Use + // the first chunk's full key as entry-level so single-chunk + // reads can unwrap the EDK on the GET path. Earlier this stored + // only KeyID/context/bucketKey, which made single-chunk reads + // fail at GET with "Invalid ciphertext format" (#9281). + if len(chunks) > 0 && len(chunks[0].GetSseMetadata()) > 0 { + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = chunks[0].GetSseMetadata() + glog.V(3).Infof("Set entry-level SSE-KMS key from first dst chunk for direct copy: keyID=%s", destKeyID) + } else { + sseKey := &SSEKMSKey{ + KeyID: destKeyID, + EncryptionContext: encryptionContext, + BucketKeyEnabled: bucketKeyEnabled, + } + if kmsMetadata, serializeErr := SerializeSSEKMSMetadata(sseKey); serializeErr == nil { + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = kmsMetadata + glog.V(3).Infof("Generated SSE-KMS metadata for direct copy: keyID=%s", destKeyID) + } else { + glog.Errorf("Failed to serialize SSE-KMS metadata for direct copy: %v", serializeErr) + } + } + } + return chunks, dstMetadata, err + + case SSEKMSCopyStrategyDecryptEncrypt: + // SLOW PATH: Decrypt source and re-encrypt for destination + glog.V(2).Infof("Using slow path: decrypt/re-encrypt for %s", dstPath) + return s3a.copyChunksWithSSEKMSReencryption(entry, destKeyID, encryptionContext, bucketKeyEnabled, dstPath, bucket) + + default: + return nil, nil, fmt.Errorf("unknown SSE-KMS copy strategy: %v", strategy) + } +} + +// deserializeEntrySSEKMSKey returns the SSE-KMS key serialized into +// entry.Extended[SeaweedFSSSEKMSKey], or nil if the entry is not SSE-KMS +// encrypted. Errors are logged and treated as "not present" so the caller +// can fall back to per-chunk metadata or fail safely. +func deserializeEntrySSEKMSKey(entryExtended map[string][]byte) *SSEKMSKey { + keyData, ok := entryExtended[s3_constants.SeaweedFSSSEKMSKey] + if !ok || len(keyData) == 0 { + return nil + } + k, err := DeserializeSSEKMSMetadata(keyData) + if err != nil { + glog.V(2).Infof("deserializeEntrySSEKMSKey: failed to deserialize entry-level SSE-KMS key: %v", err) + return nil + } + return k +} + +// resolveChunkSSEKMSKey picks the right SSE-KMS key to decrypt a chunk with: +// the chunk's own per-chunk metadata if present (the post-#9211 layout for +// new uploads), else the source object's entry-level key (legacy multipart +// objects). Returns nil + an error if neither is available; the caller can +// then surface a clear "missing metadata" error to the client. The selection +// must mirror the encryption side: each chunk is encrypted with the key +// recorded in its per-chunk metadata at write time, and entry-level metadata +// is the legacy fallback for parts that were written before per-chunk keys +// existed. +func resolveChunkSSEKMSKey(chunk *filer_pb.FileChunk, entryFallback *SSEKMSKey) (*SSEKMSKey, error) { + if len(chunk.GetSseMetadata()) > 0 { + return DeserializeSSEKMSMetadata(chunk.GetSseMetadata()) + } + if entryFallback != nil { + glog.V(2).Infof("resolveChunkSSEKMSKey: chunk %s has no per-chunk SSE-KMS metadata; falling back to entry-level key (legacy multipart object)", chunk.GetFileIdString()) + return entryFallback, nil + } + return nil, fmt.Errorf("SSE-KMS chunk %s missing per-chunk metadata and no entry-level key available", chunk.GetFileIdString()) +} + +// srcSSEKMSStateMatchesDest reports whether the source object's stored SSE-KMS +// state (encryption context + bucket-key flag) matches the destination request. +// Used to gate the SSE-KMS direct copy fast path: if either differs the source +// ciphertext can't satisfy the destination's request and we must re-encrypt. +func srcSSEKMSStateMatchesDest(srcMetadata map[string][]byte, dstContext map[string]string, dstBucketKeyEnabled bool) bool { + srcKey := deserializeEntrySSEKMSKey(srcMetadata) + if srcKey == nil { + // Source isn't SSE-KMS encrypted (or its key data is malformed — + // we conservatively let CanDirectCopySSEKMS make the call there). + return true + } + if srcKey.BucketKeyEnabled != dstBucketKeyEnabled { + return false + } + if !encryptionContextEqual(srcKey.EncryptionContext, dstContext) { + return false + } + return true +} + +// encryptionContextEqual treats nil and empty maps as equivalent so a request +// that omits the context header doesn't spuriously diverge from a stored one +// that was serialised as an empty map. reflect.DeepEqual returns false for +// nil-vs-empty, so the empty-case shortcut at the top is required. +func encryptionContextEqual(a, b map[string]string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) +} + +// copyChunksWithSSEKMSReencryption handles the slow path: decrypt source and re-encrypt for destination +// Returns chunks and destination metadata like SSE-C for consistency +func (s3a *S3ApiServer) copyChunksWithSSEKMSReencryption(entry *filer_pb.Entry, destKeyID string, encryptionContext map[string]string, bucketKeyEnabled bool, dstPath, bucket string) ([]*filer_pb.FileChunk, map[string][]byte, error) { + var dstChunks []*filer_pb.FileChunk + + // Deserialize the source's entry-level SSE-KMS key once. Used as the + // per-chunk fallback for legacy multipart objects (see resolveChunkSSEKMSKey). + sourceSSEKey := deserializeEntrySSEKMSKey(entry.Extended) + if sourceSSEKey != nil { + glog.V(3).Infof("Extracted source SSE-KMS key: keyID=%s, bucketKey=%t", sourceSSEKey.KeyID, sourceSSEKey.BucketKeyEnabled) + } + + // Process chunks + for _, chunk := range entry.GetChunks() { + dstChunk, err := s3a.copyChunkWithSSEKMSReencryption(chunk, sourceSSEKey, destKeyID, encryptionContext, bucketKeyEnabled, dstPath, bucket) + if err != nil { + return nil, nil, fmt.Errorf("copy chunk with SSE-KMS re-encryption: %w", err) + } + dstChunks = append(dstChunks, dstChunk) + } + + // Generate destination metadata for SSE-KMS encryption. + // + // For multi-chunk objects (isMultipartSSEKMS=true on read), the read path + // uses per-chunk metadata (already set by copyChunkWithSSEKMSReencryption + // after #9281). For single-chunk objects (isMultipartSSEKMS=false), the + // read path falls back to the entry-level SSE-KMS key — so it must be a + // fully-formed key (with EncryptedDataKey + IV), not just KeyID. Earlier + // this stored only KeyID/context/bucketKey, leaving EncryptedDataKey + // empty; reads then failed with "Invalid ciphertext format" when KMS was + // asked to unwrap an empty EDK. + // + // Take the first destination chunk's full per-chunk metadata as the + // canonical entry-level key — it includes a real EDK + IV minted by + // CreateSSEKMSEncryptedReaderWithBucketKey. + dstMetadata := make(map[string][]byte) + if destKeyID != "" { + if len(dstChunks) > 0 && len(dstChunks[0].GetSseMetadata()) > 0 { + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = dstChunks[0].GetSseMetadata() + glog.V(3).Infof("Set entry-level SSE-KMS key from first dst chunk: keyID=%s, bucketKey=%t", destKeyID, bucketKeyEnabled) + } else { + // 0-byte (no chunks) or no SSE-KMS chunk to crib metadata from: + // fall back to a stub key so the destination entry is still + // recognised as SSE-KMS encrypted on read. Mirrors the fallback + // in copyChunksWithSSEKMS direct branch and copyMultipartCrossEncryption. + if encryptionContext == nil { + encryptionContext = BuildEncryptionContext(bucket, dstPath, bucketKeyEnabled) + } + sseKey := &SSEKMSKey{ + KeyID: destKeyID, + EncryptionContext: encryptionContext, + BucketKeyEnabled: bucketKeyEnabled, + } + if kmsMetadata, serErr := SerializeSSEKMSMetadata(sseKey); serErr == nil { + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = kmsMetadata + } else { + glog.Errorf("Failed to serialize SSE-KMS metadata for 0-byte destination: %v", serErr) + } + } + } + + return dstChunks, dstMetadata, nil +} + +// copyChunkWithSSEKMSReencryption copies a single chunk with SSE-KMS decrypt/re-encrypt +func (s3a *S3ApiServer) copyChunkWithSSEKMSReencryption(chunk *filer_pb.FileChunk, sourceSSEKey *SSEKMSKey, destKeyID string, encryptionContext map[string]string, bucketKeyEnabled bool, dstPath, bucket string) (*filer_pb.FileChunk, error) { + // Create destination chunk + dstChunk := s3a.createDestinationChunk(chunk, chunk.Offset, chunk.Size) + + // Prepare chunk copy (assign new volume and get source URL) + fileId := chunk.GetFileIdString() + assignResult, srcUrl, err := s3a.prepareChunkCopy(fileId, dstPath, chunk.Size) + if err != nil { + return nil, err + } + + // Set file ID on destination chunk + if err := s3a.setChunkFileId(dstChunk, assignResult); err != nil { + return nil, err + } + + // Download chunk data + chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) + if err != nil { + return nil, fmt.Errorf("download chunk data: %w", err) + } + + var finalData []byte + + // Decrypt source data if it's SSE-KMS encrypted. + // Multipart SSE-KMS sources have a different EDK + IV per chunk; the + // per-chunk metadata is the only place those values live, so we MUST use + // the chunk's own metadata for decryption rather than the entry-level + // sourceSSEKey (which only matches single-part objects). Earlier this + // always decrypted with the entry-level key, which produced deterministic + // wrong bytes on a multipart-source COPY (issue #9281). Use the shared + // resolveChunkSSEKMSKey helper which centralises this selection. + var chunkSSEKey *SSEKMSKey + if chunk.GetSseType() == filer_pb.SSEType_SSE_KMS || sourceSSEKey != nil { + var resolveErr error + chunkSSEKey, resolveErr = resolveChunkSSEKMSKey(chunk, sourceSSEKey) + if resolveErr != nil { + return nil, fmt.Errorf("resolve SSE-KMS metadata: %w", resolveErr) + } + } + if chunkSSEKey != nil { + decryptedReader, err := CreateSSEKMSDecryptedReader(bytes.NewReader(chunkData), chunkSSEKey) + if err != nil { + return nil, fmt.Errorf("create SSE-KMS decrypted reader: %w", err) + } + + decryptedData, err := io.ReadAll(decryptedReader) + if err != nil { + return nil, fmt.Errorf("decrypt chunk data: %w", err) + } + finalData = decryptedData + glog.V(4).Infof("Decrypted chunk data: %d bytes → %d bytes", len(chunkData), len(finalData)) + } else { + // Source is not SSE-KMS encrypted, use data as-is + finalData = chunkData + } + + // Re-encrypt if destination should be SSE-KMS encrypted + if destKeyID != "" { + // Encryption context should already be provided by the caller + // But ensure we have a fallback for robustness + if encryptionContext == nil { + encryptionContext = BuildEncryptionContext(bucket, dstPath, bucketKeyEnabled) + } + + encryptedReader, destSSEKey, err := CreateSSEKMSEncryptedReaderWithBucketKey(bytes.NewReader(finalData), destKeyID, encryptionContext, bucketKeyEnabled) + if err != nil { + return nil, fmt.Errorf("create SSE-KMS encrypted reader: %w", err) + } + + reencryptedData, err := io.ReadAll(encryptedReader) + if err != nil { + return nil, fmt.Errorf("re-encrypt chunk data: %w", err) + } + + originalSize := len(finalData) + finalData = reencryptedData + glog.V(4).Infof("Re-encrypted chunk data: %d bytes → %d bytes", originalSize, len(finalData)) + dstChunk.Size = uint64(len(finalData)) + + // Tag the destination chunk as SSE-KMS with per-chunk metadata. Without + // this, the chunk's SseType stays NONE, detectPrimarySSEType returns + // "None" on read, and GetObjectHandler serves still-encrypted volume + // bytes raw without decryption — yielding deterministic byte + // corruption on the SSE-KMS copy path (issue #9281). + // + // CreateSSEKMSEncryptedReaderWithBucketKey returns destSSEKey freshly + // populated with KeyID, EncryptionContext, EncryptedDataKey, IV and + // BucketKey state, with the encryption stream initialised at counter 0 + // for THIS chunk's bytes (each chunk gets its own random IV, not a + // base-IV-plus-offset scheme). ChunkOffset must therefore stay 0 on + // read; setting it to chunk.Offset would advance the decryption IV by + // chunk.Offset/16 blocks past the position the encryption was at, + // producing deterministic garbage on chunks whose chunk.Offset > 0. + destSSEKey.ChunkOffset = 0 + kmsMetadata, metaErr := SerializeSSEKMSMetadata(destSSEKey) + if metaErr != nil { + return nil, fmt.Errorf("serialize SSE-KMS chunk metadata: %w", metaErr) + } + dstChunk.SseType = filer_pb.SSEType_SSE_KMS + dstChunk.SseMetadata = kmsMetadata + } + + // Upload the processed data + if err := s3a.uploadChunkData(finalData, assignResult, false); err != nil { + return nil, fmt.Errorf("upload processed chunk data: %w", err) + } + + glog.V(3).Infof("Successfully processed SSE-KMS chunk re-encryption: src_key=%s, dst_key=%s, size=%d→%d", + getKeyIDString(sourceSSEKey), destKeyID, len(chunkData), len(finalData)) + + return dstChunk, nil +} + +// getKeyIDString safely gets the KeyID from an SSEKMSKey, handling nil cases +func getKeyIDString(key *SSEKMSKey) string { + if key == nil { + return "none" + } + if key.KeyID == "" { + return "default" + } + return key.KeyID +} + +// EncryptionHeaderContext holds encryption type information and header classifications +type EncryptionHeaderContext struct { + SrcSSEC, SrcSSEKMS, SrcSSES3 bool + DstSSEC, DstSSEKMS, DstSSES3 bool + IsSSECHeader, IsSSEKMSHeader, IsSSES3Header bool +} + +// newEncryptionHeaderContext creates a context for encryption header processing +func newEncryptionHeaderContext(headerKey string, srcSSEC, srcSSEKMS, srcSSES3, dstSSEC, dstSSEKMS, dstSSES3 bool) *EncryptionHeaderContext { + return &EncryptionHeaderContext{ + SrcSSEC: srcSSEC, SrcSSEKMS: srcSSEKMS, SrcSSES3: srcSSES3, + DstSSEC: dstSSEC, DstSSEKMS: dstSSEKMS, DstSSES3: dstSSES3, + IsSSECHeader: isSSECHeader(headerKey), + IsSSEKMSHeader: isSSEKMSHeader(headerKey, srcSSEKMS, dstSSEKMS), + IsSSES3Header: isSSES3Header(headerKey, srcSSES3, dstSSES3), + } +} + +// isSSECHeader checks if the header is SSE-C specific +func isSSECHeader(headerKey string) bool { + return headerKey == s3_constants.AmzServerSideEncryptionCustomerAlgorithm || + headerKey == s3_constants.AmzServerSideEncryptionCustomerKeyMD5 || + headerKey == s3_constants.SeaweedFSSSEIV +} + +// isSSEKMSHeader checks if the header is SSE-KMS specific +func isSSEKMSHeader(headerKey string, srcSSEKMS, dstSSEKMS bool) bool { + return (headerKey == s3_constants.AmzServerSideEncryption && (srcSSEKMS || dstSSEKMS)) || + headerKey == s3_constants.AmzServerSideEncryptionAwsKmsKeyId || + headerKey == s3_constants.SeaweedFSSSEKMSKey || + headerKey == s3_constants.SeaweedFSSSEKMSKeyID || + headerKey == s3_constants.SeaweedFSSSEKMSEncryption || + headerKey == s3_constants.SeaweedFSSSEKMSBucketKeyEnabled || + headerKey == s3_constants.SeaweedFSSSEKMSEncryptionContext || + headerKey == s3_constants.SeaweedFSSSEKMSBaseIV +} + +// isSSES3Header checks if the header is SSE-S3 specific +func isSSES3Header(headerKey string, srcSSES3, dstSSES3 bool) bool { + return (headerKey == s3_constants.AmzServerSideEncryption && (srcSSES3 || dstSSES3)) || + headerKey == s3_constants.SeaweedFSSSES3Key || + headerKey == s3_constants.SeaweedFSSSES3Encryption || + headerKey == s3_constants.SeaweedFSSSES3BaseIV || + headerKey == s3_constants.SeaweedFSSSES3KeyData +} + +// shouldSkipCrossEncryptionHeader handles cross-encryption copy scenarios +func (ctx *EncryptionHeaderContext) shouldSkipCrossEncryptionHeader() bool { + // SSE-C to SSE-KMS: skip SSE-C headers + if ctx.SrcSSEC && ctx.DstSSEKMS && ctx.IsSSECHeader { + return true + } + + // SSE-KMS to SSE-C: skip SSE-KMS headers + if ctx.SrcSSEKMS && ctx.DstSSEC && ctx.IsSSEKMSHeader { + return true + } + + // SSE-C to SSE-S3: skip SSE-C headers + if ctx.SrcSSEC && ctx.DstSSES3 && ctx.IsSSECHeader { + return true + } + + // SSE-S3 to SSE-C: skip SSE-S3 headers + if ctx.SrcSSES3 && ctx.DstSSEC && ctx.IsSSES3Header { + return true + } + + // SSE-KMS to SSE-S3: skip SSE-KMS headers + if ctx.SrcSSEKMS && ctx.DstSSES3 && ctx.IsSSEKMSHeader { + return true + } + + // SSE-S3 to SSE-KMS: skip SSE-S3 headers + if ctx.SrcSSES3 && ctx.DstSSEKMS && ctx.IsSSES3Header { + return true + } + + return false +} + +// shouldSkipEncryptedToUnencryptedHeader handles encrypted to unencrypted copy scenarios +func (ctx *EncryptionHeaderContext) shouldSkipEncryptedToUnencryptedHeader() bool { + // Skip all encryption headers when copying from encrypted to unencrypted + hasSourceEncryption := ctx.SrcSSEC || ctx.SrcSSEKMS || ctx.SrcSSES3 + hasDestinationEncryption := ctx.DstSSEC || ctx.DstSSEKMS || ctx.DstSSES3 + isAnyEncryptionHeader := ctx.IsSSECHeader || ctx.IsSSEKMSHeader || ctx.IsSSES3Header + + return hasSourceEncryption && !hasDestinationEncryption && isAnyEncryptionHeader +} + +// cleanupVersioningMetadata removes versioning-related metadata from Extended attributes +// when copying to non-versioned or suspended-versioning buckets. +// This prevents objects in non-versioned buckets from carrying invalid versioning metadata. +// It also removes the source ETag to prevent metadata inconsistency, as a new ETag will be +// calculated for the destination object. +func cleanupVersioningMetadata(metadata map[string][]byte) { + delete(metadata, s3_constants.ExtVersionIdKey) + delete(metadata, s3_constants.ExtDeleteMarkerKey) + delete(metadata, s3_constants.ExtIsLatestKey) + delete(metadata, s3_constants.ExtETagKey) +} + +// isOrphanedSSES3Header checks if a header is an orphaned SSE-S3 encryption header. +// An orphaned header is one where the encryption indicator exists but the actual key is missing. +// This can happen when an object was previously encrypted but then copied without encryption, +// leaving behind the header but removing the key. These orphaned headers should be stripped +// during copy operations to prevent confusion about the object's actual encryption state. +// Fixes GitHub issue #7562. +func isOrphanedSSES3Header(headerKey string, metadata map[string][]byte) bool { + if headerKey != s3_constants.AmzServerSideEncryption { + return false + } + + // The header is AmzServerSideEncryption. Check if its value indicates SSE-S3. + if string(metadata[headerKey]) == "AES256" { + // It's an SSE-S3 header. It's orphaned if the actual encryption key is missing. + _, hasKey := metadata[s3_constants.SeaweedFSSSES3Key] + return !hasKey + } + + return false +} + +// shouldSkipEncryptionHeader determines if a header should be skipped when copying extended attributes +// based on the source and destination encryption types. This consolidates the repetitive logic for +// filtering encryption-related headers during copy operations. +func shouldSkipEncryptionHeader(headerKey string, + srcSSEC, srcSSEKMS, srcSSES3 bool, + dstSSEC, dstSSEKMS, dstSSES3 bool) bool { + + // Create context to reduce complexity and improve testability + ctx := newEncryptionHeaderContext(headerKey, srcSSEC, srcSSEKMS, srcSSES3, dstSSEC, dstSSEKMS, dstSSES3) + + // If it's not an encryption header, don't skip it + if !ctx.IsSSECHeader && !ctx.IsSSEKMSHeader && !ctx.IsSSES3Header { + return false + } + + // Handle cross-encryption scenarios (different encryption types) + if ctx.shouldSkipCrossEncryptionHeader() { + return true + } + + // Handle encrypted to unencrypted scenarios + if ctx.shouldSkipEncryptedToUnencryptedHeader() { + return true + } + + // Default: don't skip the header + return false +} + +// processInlineContentForCopy handles encryption/decryption for inline content during copy +// This fixes GitHub #7562 where small files stored inline weren't properly decrypted/re-encrypted +func (s3a *S3ApiServer) processInlineContentForCopy( + entry *filer_pb.Entry, r *http.Request, dstBucket, dstObject string, + srcSSEC, srcSSEKMS, srcSSES3 bool, + dstSSEC, dstSSEKMS, dstSSES3 bool) ([]byte, map[string][]byte, error) { + + content := entry.Content + var dstMetadata map[string][]byte + + // Check if source is encrypted and needs decryption + srcEncrypted := srcSSEC || srcSSEKMS || srcSSES3 + + // Check if destination needs encryption (explicit request or bucket default) + dstNeedsEncryption := dstSSEC || dstSSEKMS || dstSSES3 + if !dstNeedsEncryption { + // Check bucket default encryption + bucketMetadata, err := s3a.getBucketMetadata(dstBucket) + if err == nil && bucketMetadata != nil && bucketMetadata.Encryption != nil { + switch bucketMetadata.Encryption.SseAlgorithm { + case "aws:kms": + dstSSEKMS = true + dstNeedsEncryption = true + case "AES256": + dstSSES3 = true + dstNeedsEncryption = true + } + } + } + + // Decrypt source content if encrypted + if srcEncrypted { + decryptedContent, decErr := s3a.decryptInlineContent(entry, srcSSEC, srcSSEKMS, srcSSES3, r) + if decErr != nil { + return nil, nil, fmt.Errorf("failed to decrypt inline content: %w", decErr) + } + content = decryptedContent + glog.V(3).Infof("Decrypted inline content: %d bytes", len(content)) + } + + // Re-encrypt if destination needs encryption + if dstNeedsEncryption { + encryptedContent, encMetadata, encErr := s3a.encryptInlineContent(content, dstBucket, dstObject, dstSSEC, dstSSEKMS, dstSSES3, r) + if encErr != nil { + return nil, nil, fmt.Errorf("failed to encrypt inline content: %w", encErr) + } + content = encryptedContent + dstMetadata = encMetadata + glog.V(3).Infof("Encrypted inline content: %d bytes", len(content)) + } + + return content, dstMetadata, nil +} + +// decryptInlineContent decrypts inline content from an encrypted source +func (s3a *S3ApiServer) decryptInlineContent(entry *filer_pb.Entry, srcSSEC, srcSSEKMS, srcSSES3 bool, r *http.Request) ([]byte, error) { + content := entry.Content + + if srcSSES3 { + // Get SSE-S3 key from metadata + keyData, exists := entry.Extended[s3_constants.SeaweedFSSSES3Key] + if !exists { + return nil, fmt.Errorf("SSE-S3 key not found in metadata") + } + + keyManager := GetSSES3KeyManager() + sseKey, err := DeserializeSSES3Metadata(keyData, keyManager) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-S3 key: %w", err) + } + + // Get IV + iv := sseKey.IV + if len(iv) == 0 { + return nil, fmt.Errorf("SSE-S3 IV not found") + } + + // Decrypt content + decryptedReader, err := CreateSSES3DecryptedReader(bytes.NewReader(content), sseKey, iv) + if err != nil { + return nil, fmt.Errorf("failed to create SSE-S3 decrypted reader: %w", err) + } + return io.ReadAll(decryptedReader) + + } else if srcSSEKMS { + // Get SSE-KMS key from metadata + keyData, exists := entry.Extended[s3_constants.SeaweedFSSSEKMSKey] + if !exists { + return nil, fmt.Errorf("SSE-KMS key not found in metadata") + } + + sseKey, err := DeserializeSSEKMSMetadata(keyData) + if err != nil { + return nil, fmt.Errorf("failed to deserialize SSE-KMS key: %w", err) + } + + // Decrypt content + decryptedReader, err := CreateSSEKMSDecryptedReader(bytes.NewReader(content), sseKey) + if err != nil { + return nil, fmt.Errorf("failed to create SSE-KMS decrypted reader: %w", err) + } + return io.ReadAll(decryptedReader) + + } else if srcSSEC { + // Get SSE-C key from request headers + sourceKey, err := ParseSSECCopySourceHeaders(r) + if err != nil { + return nil, fmt.Errorf("failed to parse SSE-C copy source headers: %w", err) + } + + // Get IV from metadata + iv, err := GetSSECIVFromMetadata(entry.Extended) + if err != nil { + return nil, fmt.Errorf("failed to get SSE-C IV: %w", err) + } + + // Decrypt content + decryptedReader, err := CreateSSECDecryptedReader(bytes.NewReader(content), sourceKey, iv) + if err != nil { + return nil, fmt.Errorf("failed to create SSE-C decrypted reader: %w", err) + } + return io.ReadAll(decryptedReader) + } + + // Source not encrypted, return as-is + return content, nil +} + +// encryptInlineContent encrypts inline content for the destination +func (s3a *S3ApiServer) encryptInlineContent(content []byte, dstBucket, dstObject string, + dstSSEC, dstSSEKMS, dstSSES3 bool, r *http.Request) ([]byte, map[string][]byte, error) { + + dstMetadata := make(map[string][]byte) + + if dstSSES3 { + // Generate SSE-S3 key + keyManager := GetSSES3KeyManager() + key, err := keyManager.GetOrCreateKey("") + if err != nil { + return nil, nil, fmt.Errorf("failed to generate SSE-S3 key: %w", err) + } + + // Encrypt content + encryptedReader, iv, err := CreateSSES3EncryptedReader(bytes.NewReader(content), key) + if err != nil { + return nil, nil, fmt.Errorf("failed to create SSE-S3 encrypted reader: %w", err) + } + + encryptedContent, err := io.ReadAll(encryptedReader) + if err != nil { + return nil, nil, fmt.Errorf("failed to read encrypted content: %w", err) + } + + // Store IV on key and serialize metadata + key.IV = iv + keyData, err := SerializeSSES3Metadata(key) + if err != nil { + return nil, nil, fmt.Errorf("failed to serialize SSE-S3 metadata: %w", err) + } + + dstMetadata[s3_constants.SeaweedFSSSES3Key] = keyData + dstMetadata[s3_constants.AmzServerSideEncryption] = []byte("AES256") + + return encryptedContent, dstMetadata, nil + + } else if dstSSEKMS { + // Parse SSE-KMS headers + keyID, encryptionContext, bucketKeyEnabled, err := ParseSSEKMSCopyHeaders(r) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse SSE-KMS headers: %w", err) + } + + // Build encryption context if needed + if encryptionContext == nil { + encryptionContext = BuildEncryptionContext(dstBucket, dstObject, bucketKeyEnabled) + } + + // Encrypt content + encryptedReader, sseKey, err := CreateSSEKMSEncryptedReaderWithBucketKey( + bytes.NewReader(content), keyID, encryptionContext, bucketKeyEnabled) + if err != nil { + return nil, nil, fmt.Errorf("failed to create SSE-KMS encrypted reader: %w", err) + } + + encryptedContent, err := io.ReadAll(encryptedReader) + if err != nil { + return nil, nil, fmt.Errorf("failed to read encrypted content: %w", err) + } + + // Serialize metadata + keyData, err := SerializeSSEKMSMetadata(sseKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to serialize SSE-KMS metadata: %w", err) + } + + dstMetadata[s3_constants.SeaweedFSSSEKMSKey] = keyData + dstMetadata[s3_constants.AmzServerSideEncryption] = []byte("aws:kms") + + return encryptedContent, dstMetadata, nil + + } else if dstSSEC { + // Parse SSE-C headers + destKey, err := ParseSSECHeaders(r) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse SSE-C headers: %w", err) + } + + // Encrypt content + encryptedReader, iv, err := CreateSSECEncryptedReader(bytes.NewReader(content), destKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to create SSE-C encrypted reader: %w", err) + } + + encryptedContent, err := io.ReadAll(encryptedReader) + if err != nil { + return nil, nil, fmt.Errorf("failed to read encrypted content: %w", err) + } + + // Store IV in metadata + StoreSSECIVInMetadata(dstMetadata, iv) + dstMetadata[s3_constants.AmzServerSideEncryptionCustomerAlgorithm] = []byte("AES256") + dstMetadata[s3_constants.AmzServerSideEncryptionCustomerKeyMD5] = []byte(destKey.KeyMD5) + + return encryptedContent, dstMetadata, nil + } + + // No encryption needed + return content, nil, nil +}