diff --git a/docs/0014-GCCP-FACTS.md b/docs/0014-GCCP-FACTS.md new file mode 100644 index 0000000..7a461d6 --- /dev/null +++ b/docs/0014-GCCP-FACTS.md @@ -0,0 +1,30 @@ +# 0014 — GCCP data-plane facts (SPEC-75, contrat gelé W0) + +> Contrat normatif : `spdk-csi/docs/RPC-CONTRACT.md` (gelé 2026-07-13). Cette PR (stackée +> sur #17 seeded-rebuild) livre les faits data-plane que le control-plane GCCP consomme. +> La suite de conformité de modèle (SimDataPlane, `spdk-csi` W1) et ce fork sont testés par +> les MÊMES scénarios — une divergence est une CI rouge. + +## Incréments (un commit/patch par item, dans cet ordre) + +| # | Item | Contrat | Véhicule | +|---|---|---|---| +| 0014.1 ✅ | Epochs noncés : `bdev_cbt_epoch_open(nonce, generation)` ; nonce dans get_bdevs | §5 | module/bdev/cbt (arbre) | +| 0014.2 ✅ | `truncated=true` au resize sous epoch ouvert (phase OPEN incluse) | §5/T-D6 | module/bdev/cbt | +| 0014.3 ✅ | `bdev_cbt_epoch_close(epoch_id, mode: consumed\|preserve)` — consumed exige un token SUCCEEDED-verified local (-EPERM sinon ; consultation registre effective depuis patch 0015) | §4 | module/bdev/cbt | +| 0014.4 ✅ (patch 0014) | Incarnation : `bdev_raid_create(..., incarnation)` ; `expected_incarnation` sur tous les RPC engageants ⇒ `-ESTALE` | §1 | patch raid | +| 0014.5 ✅ (patch 0015) | Registre d'outcomes process-wide (TTL 15 min) : `{token, state RUNNING\|VERIFYING\|SUCCEEDED\|FAILED\|DIVERGENT\|CANCELED, bytes, verified, finished_at}` + `bdev_raid_get_rebuild_outcomes(token?)` ; contrat CANCELED (destroy pendant rebuild ⇒ zéro écriture après retour) ; `epoch_close(consumed)` consulte le registre | §2 | patch raid | +| 0014.6 ✅ (patch 0017) | get_bdevs enrichi par membre : `{state, since, content_generation, view_epoch, epoch_nonce, epoch_state, truncated}` (pont `vbdev_cbt_query_latest_epoch`) | §3 | patch raid+cbt | +| 0014.7 ✅ (patch 0016) | SB étendu : `content_generation` + `view_epoch` par membre, MÊME transaction que l'état (éjection = gen++ survivants ; complétion/skip = adoption du max) ; reassembly étendue ; SB minor 0→1 | §8/V-2 | patch raid (0001 discipline) | +| 0014.8 ✅ (patch 0018) | Verify intégrée au rebuild : K=64 fenêtres stride-uniforme sous quiesce_range (étendue = seed ranges ou raid entier), arbitre = jambe source, re-copie+re-verify avant DIVERGENT (-EILSEQ) ; registre VERIFYING → verified=true (débloque consumed) ; flip CONFIGURED gated | §10a | patch raid | +| 0014.9 ✅ (patch 0021) | Enveloppes : caps rebuild/verify/relocate ×(nominal, maintenance) + borne de concurrence ; `bdev_raid_set/get_envelopes` ; rebuild cap figé à l'alloc du process ; seeded → -EAGAIN au-delà de la borne, auto-attach admis bruyamment | §6 | patch raid/rpc | +| 0014.10 ✅ (patch 0019) | Epoch automatique à l'éjection : le raid ouvre un epoch auto (nonce généré, rapporté via get_bdevs) sur le cbt de CHAQUE survivant ; -EEXIST si un OPEN couvre déjà (jamais de takeover implicite) | §12 | patch raid+cbt | +| 0014.11 ✅ (patch 0020) | Pause H10 × rebuild : jamais d'échec du fait de la pause (structurel : aucun timeout dans le path process, writes parqués côté target) ; force-resume EXPLICITE et audité (`force:true`) au chemin de fence — la barrière percée échoue proprement à SON resume | §13 | patch nvmf (0003) | +| 0014.12 ✅ (patch 0022) | `bdev_raid_verify_ranges {name, ranges?, token?, expected_incarnation?}` : détecteur exhaustif, chunks 1 MiB sous LBA-lock, cadence = enveloppe verify (figée au dispatch), rapporte {verified/divergent_blocks, divergent_ranges ≤128 + flag truncated} et ne répare JAMAIS (arbitrage = CP R≥3) ; registre alimenté (verifying → succeeded-verified/divergent) ; -EBUSY si process | §10b | patch raid | + +Hors périmètre de cette PR : 0014b (enforcement PR) = wrapper C# côté spdk-csi (T-A5 — +vanilla `bdev_nvme_send_cmd` suffit), livré W3. + +## Build + +Label CI `build-images` sur la PR ⇒ image `v26.01-prNN-` à pinner dans spdk-csi. diff --git a/docs/RPC-CONTRACT.md b/docs/RPC-CONTRACT.md index d225646..8e94c20 100644 --- a/docs/RPC-CONTRACT.md +++ b/docs/RPC-CONTRACT.md @@ -186,11 +186,181 @@ a split-brain across disks. after the unquiesce; a crash between the two costs a full rebuild at reboot (conservative, safe). +## Member observation & extended superblock (GCCP 0014.6/0014.7, patches 0016/0017) + +- **0014.7 — extended superblock (V-2).** SB minor 0→1 (carved from reserved + bytes: a minor-0 SB reads back as generation 0 / epoch 0). Per member: + `content_generation` — survivors increment it in the SAME SB transaction + that records a member's ejection (RecordDivergence); a member completing a + rebuild (or skip_rebuild promotion) ADOPTS the survivors' max generation in + the same transaction as its CONFIGURED flip. A lagging generation is the + durable proof of staleness the cold-recovery pass reads. `view_epoch` is + persisted/reassembled here; the view protocol mutates it (W3, 0014b). + Reassembly restores both for ALL slots (a FAILED slot keeps its stale + generation — that lag is the point). +- **0014.6 — per-member observation.** `get_bdevs` → `driver_specific.raid. + base_bdevs_list[]` gains: `state` (derived, precedence: + `failed > write_only > configured > configuring > absent`), `since` (unix + seconds of the last observable state flip — the CP's anti-flap input), + `content_generation`, `view_epoch`, and — when the member is a cbt bdev + tracking a live epoch — `epoch_nonce`/`epoch_state`/`truncated` (the + Decider's EpochObservation source; fields absent otherwise). The raid reads + the cbt facts via `vbdev_cbt_query_latest_epoch()` (most recently opened + live epoch; closed epochs leave the list). + +## Envelopes, ejection epochs, fence-resume, verify_ranges (GCCP 0014.9-.12) + +- **0014.9 — envelopes (patch 0021).** `bdev_raid_set_envelopes {rebuild|verify| + relocate}_{nominal|maintenance}_mb_sec, max_concurrent_rebuilds, regime?` + + `bdev_raid_get_envelopes`. CAPS, never shares; data-plane default 0 = + UNLIMITED (a failed set is a control-plane INCIDENT, never a silent + fallback); unknown regime = -EINVAL. The rebuild cap (under the current + regime) is FROZEN at process allocation and takes precedence over the legacy + `bdev_raid_set_options` bandwidth. Concurrency: an RPC-driven seeded rebuild + beyond the bound is refused -EAGAIN (retryable); the attach-triggered auto + rebuild is admitted LOUDLY (refusing would strand an attached member). The + relocate cap is stored for the tier module (wiring deferred, documented). +- **0014.10 — epoch at ejection (patch 0019).** The raid opens an auto epoch + (`auto-` / nonce `auto`) on EVERY surviving member's cbt in the + ejection path, stale_backend_id = the ejected member. -EEXIST when an OPEN + epoch already bounds the round (never an implicit takeover), -ENODEV when + the member is not cbt-wrapped — both fine. The auto nonce reaches the CP + through get_bdevs (0014.6): that is how the round is adopted. +- **0014.11 — pause × rebuild (patch 0020).** Structural contract: a paused + subsystem QUEUES the rebuild's writes target-side; nothing in the process + path times out, so the outcome is never FAILED because of a pause. + Force-resume (DÉC-11): `nvmf_subsystem_resume {…, force: true}` breaks any + standing barrier — loud (WARNLOG) and audited; the barrier's own tokened + resume then reports `resumed:false/barrier_intact:false`, so the + group-snapshot saga fails cleanly and replays. +- **0014.12 — verify_ranges (patch 0022).** `bdev_raid_verify_ranges {name, + ranges?:[{start_lba,num_blocks}], token?, expected_incarnation?}` — the + EXHAUSTIVE detector (§10a's integrated phase is the probabilistic one). + Two modes, selected by the raid LEVEL (reported back as `mode` in the + result): **raid1 = copy-compare** (below), **raid5f = syndrome** (see the + Erasure-coding section). raid1: first configured leg = implicit arbiter, + compared to every other readable leg, 1 MiB chunks each under a + channel-owned LBA lock (host writes held + replayed); paced by the verify + envelope (frozen at dispatch). REPORTS `{verified_blocks, divergent_blocks, + divergent_ranges (≤128, exact count + truncated flag), mode}` and never + repairs — arbitration is the CP's (R ≥ 3, T-D2). No ranges = the whole + raid; callers drive bounded batches and re-drive, like rebuild_ranges. + -EBUSY with a live background process (and aborts cleanly if one appears + mid-run); registry mirrors progress under the token (verifying → + succeeded+verified on zero divergence / divergent otherwise). + +## Erasure coding — raid5f (SPEC-75G F-b/F-d, patches 0022/0023) + +The raid5f strate is CP-written only (DÉC-EC-0: no host ever writes a +raid5f); these contracts serve the `EcImage` lifecycle (seal-verify, periodic +verify, degraded-service observation). + +- **verify_ranges, syndrome mode (patch 0022, F-b).** On a raid5f, the same + RPC recomputes the RAID-5 consistency relation per stripe: the XOR of all + k+1 members' strips must be zero (the rotating parity position is + irrelevant to the check — no raid5f internals involved). Result field + `mode: "syndrome"`. + - **Preconditions**: ONLINE; no background process (-EBUSY); **every + member readable, else -EAGAIN** ("repair first") — with m = 1 a stripe + missing one strip has nothing left to check against. A member read + error or membership change mid-run also aborts -EAGAIN. Plain-data + bdevs only (interleaved md ⇒ -ENOTSUP). + - **Ranges** must be full-stripe aligned (`full_stripe_blocks` from + get_bdevs, 0008 discipline) — -EINVAL otherwise. No ranges = the whole + raid (aligned by construction). + - **Reporting** is per STRIPE in raid LBA space; report-only, never + repairs. Divergence handling is the CP's: re-fold the stripe from its + source, or raise `EcContentDivergence`. + - Same LBA-lock, verify-envelope pacing (charged on the k+1 strips + actually read), token/outcome-registry and incarnation semantics as the + raid1 mode. +- **Verify-at-seal is EXPLICIT (0018 interplay).** The integrated verify + phase (0014.8) is raid1-only and completes a raid5f rebuild process + UNVERIFIED (clean no-op, registry `verified=false`). A raid5f is therefore + NEVER auto-sealed by a rebuild: the CP must run a syndrome verify_ranges + pass to seal (INV-EC-5) — including after `bdev_raid_rebuild_ranges` and + after a full rebuild process. +- **Degraded-service counters (patch 0023, F-d).** get_bdevs + `driver_specific.raid` gains `reconstruct_reads_absent` (member missing — + vanilla reconstruct-read), `reconstruct_reads_error` (member + present-but-erroring — 0006 fallback), `degraded_write_stripes` (stripes + written at zero margin; F-e: the CP freezes folds on a degraded EcImage, + so nonzero here is a contract-violation gauge), `last_degraded_ts` (unix + seconds, 0 = never). Written on the I/O paths with relaxed atomics + (single reactor today — D5). These feed `EcRebuildDebt.since` and the + "serving degraded since T" incident escalation. The rebuild process's own + reconstruct reads are intentionally NOT counted (they are repair work, + not degraded host service). +- **Seeded rebuild / auto-epoch stay raid1-only by design** (0013 refuses + -EINVAL; 0019 is gated): EC repair = re-provision the chunk + + `bdev_raid_rebuild_ranges` bounded to allocated ranges (thin-preserving, + INV-EC-9), or the native full process for a near-full image. + +## Incarnation & rebuild outcomes (GCCP 0014.4/0014.5, patches 0014/0015) + +- **0014.4 — identity.** `bdev_raid_create {…, incarnation}` (required, ≤63 + chars): no creation without the creating control-plane incarnation's + identity. Engaging RPCs — `bdev_raid_delete`, `bdev_raid_add_base_bdev`, + `bdev_raid_remove_base_bdev`, `bdev_raid_start_seeded_rebuild` — accept + `expected_incarnation?` and refuse with **-ESTALE** on mismatch (never + best-effort execution). Omission is reserved for manual rescue tooling: the + control-plane client ALWAYS sends it. Superblock-reassembled raids are + *unclaimed* (`incarnation` absent from `get_bdevs`); any engaging RPC that + carries `expected_incarnation` against an unclaimed raid is -ESTALE. + `get_bdevs` → `driver_specific.raid.incarnation` exposes ownership. +- **0014.5 — rebuild outcome registry.** Process-wide, survives the raid bdev, + terminal entries purged 15 min after finishing (lazy TTL). + `bdev_raid_start_seeded_rebuild {…, rebuild_token?}` records the attempt + under the CP token (deterministic per attempt — a retry re-opens the same + entry); full rebuilds feed the SAME registry under `auto:::` + tokens. `bdev_raid_get_rebuild_outcomes {token?}` returns + `[{token, raid_bdev, base_bdev, state, bytes, verified, finished_at}]` with + `state ∈ running|verifying|succeeded|failed|divergent|canceled` (lowercase on + the wire), `bytes` = bytes actually copied (seeded fast-skip windows do not + count), `finished_at` = unix seconds (0 while non-terminal). + - **CANCELED contract (T-D5)**: `bdev_raid_delete` (or member removal) with a + rebuild in flight ⇒ the process is cancelled, the outcome is `canceled`, + and **no process write is emitted after the RPC returns** (the delete reply + is gated behind the process fully stopping). The CP deliberately has no + `Cancel` action — cleanliness is guaranteed here. + - `verified` is set by the integrated verify phase (0014.8, patch 0018): + after the copy completes and BEFORE the process concludes (the SB + CONFIGURED flip is gated on it), 64 uniform-stride windows across the + copied extent (seed ranges for seeded rebuilds, whole raid otherwise) are + compared two-legs under `quiesce_range`, arbiter = the source leg. A + mismatch is re-copied from the arbiter and re-verified once; a second + mismatch under the same lock seals the outcome **DIVERGENT** (-EILSEQ). + Success seals `verified=true` — unlocking `epoch_close(consumed)` for the + token. Honest scope (T-C12): a probabilistic process-bug detector + (~64 MiB sampled); the exhaustive one is `bdev_raid_verify_ranges` (§10b). + Raid1 plain-data only; anything else completes unverified. The registry + shows `verifying` while the phase runs; re-copied windows count in `bytes`. + ## CBT epochs / rebuild - `bdev_cbt_epoch_freeze` / `epoch_close` / `epoch_open` (higher generation): return **-EBUSY** while a rebuild is RUNNING on the epoch (**CBT-1/2/c5**) — the control-plane must `bdev_cbt_cancel_rebuild` first. +- **0014.1** — `bdev_cbt_epoch_open {…, nonce?}`: opaque CP-generated nonce + (≤31 chars), stored on the epoch and echoed in `get_bdevs` — kills the + epoch-id ABA across maintenance rounds. A generation takeover re-stamps the + nonce of the NEW round. +- **0014.2** — resize under a live epoch ⇒ `truncated: true` on that epoch + (get_bdevs): the live bitmap does not cover the growth zone — the delta is a + lie, the control-plane must route to a FULL rebuild (D14). Never cleared: + survives generation takeovers, dies with the epoch. +- **0014.3** — `bdev_cbt_epoch_close {…, mode?: "preserve"|"consumed", + rebuild_token?}`: `preserve` (default) restores any unconsumed frozen delta + to the live bitmap (H1 discipline); `consumed` deliberately DISCARDS it under + caller certification and **requires a rebuild_token naming a LOCAL + succeeded+verified outcome-registry entry (-EPERM otherwise)** — validated + against the 0014.5 registry; `verified` is produced by the integrated verify + phase (0014.8, patch 0018). An unknown `mode` is -EINVAL, never a silent + preserve. +- **0014.6 (cbt part)** — `get_bdevs` exposes `driver_specific.cbt.epochs[]`: + `{epoch_id, nonce, state: open|frozen|rebuilding|completed|invalid, + generation, truncated}` — the control-plane's `EpochObservation` source (no + dedicated poll). - `bdev_cbt_partial_rebuild` / `bdev_cbt_start_rebuild`: one rebuild per (cbt, epoch); a second returns -EBUSY (interpret as "already running", not a failure). **CBT-4**: the rebuild FLUSHes the target before COMPLETED. diff --git a/images/spdk/Dockerfile b/images/spdk/Dockerfile index 2f4c04e..6cff4ea 100644 --- a/images/spdk/Dockerfile +++ b/images/spdk/Dockerfile @@ -84,8 +84,10 @@ RUN dnf install -y --setopt=install_weak_deps=False --nodocs liburing-devel liba # # Modules KEPT (required by project features): # bdev_lvol — thin provisioning, snapshots, clones -# bdev_raid — RAID-1 (Nexus), RAID-0 (striping), RAID-10 -# raid5f — Erasure Coding (RAID-5f / RAID-6) +# bdev_raid — raid0, raid1 (Nexus), concat (real levels; no raid6/raid10 +# exists anywhere in SPDK — SPEC-75G) +# raid5f — XOR single-parity full-stripe-write raid (m=1) — the +# EcImage capacity strate (SPEC-75G; NOT Reed-Solomon) # bdev_nvme — NVMe-oF initiator for remote replicas # bdev_malloc — RAM disks for testing / fast scratch # bdev_uring — io_uring bdev for modern kernels (>= 5.1), SATA SSDs @@ -210,6 +212,11 @@ RUN dnf install -y --setopt=install_weak_deps=False --nodocs \ # Binary only (runtime .so provided by Fedora packages above) COPY --from=builder /build/spdk/build/bin/spdk_tgt /usr/local/bin/spdk_tgt +# SPEC-75G G1: spdk_dd is the V-EC bench's full-stripe writer (raid5f only accepts +# full-stripe writes — EC-3 — and remote initiators are MDTS-bounded, so the writer +# must run INSIDE the target pod). Patch 0024 makes its exit code honest. +COPY --from=builder /build/spdk/build/bin/spdk_dd /usr/local/bin/spdk_dd + # SPDK RPC scripts (useful for live debugging: rpc.py, spdkcli, etc.) COPY --from=builder /build/spdk/scripts/ /usr/local/share/spdk/scripts/ @@ -239,7 +246,7 @@ CMD ["-r", "/var/tmp/spdk.sock", "-m", "0x1", "--wait-for-rpc"] FROM scratch LABEL org.opencontainers.image.title="SPDK Data Engine" \ - org.opencontainers.image.description="Ultra-minimal hardened SPDK for NVMe-oF TCP/RDMA" \ + org.opencontainers.image.description="Ultra-minimal hardened SPDK for NVMe-oF TCP (built without RDMA — SPEC-75G SF-11)" \ org.opencontainers.image.vendor="Evariops" \ org.opencontainers.image.source="https://github.com/Evariops/containers" \ org.opencontainers.image.licenses="Apache-2.0" \ diff --git a/module/bdev/cbt/vbdev_cbt.c b/module/bdev/cbt/vbdev_cbt.c index b0df97d..a7b5471 100644 --- a/module/bdev/cbt/vbdev_cbt.c +++ b/module/bdev/cbt/vbdev_cbt.c @@ -22,6 +22,11 @@ #include "spdk/stdinc.h" #include "vbdev_cbt_internal.h" +#include "vbdev_cbt_query.h" /* Evariops 0014.6: raid-facing epoch facts */ +/* Evariops 0014.5: the rebuild outcome registry lives in the raid module (the + * nexus process hosts both); patch 0015 materializes this header at image + * build. Linked via --whole-archive, so the cross-module symbol resolves. */ +#include "../raid/bdev_raid_outcomes.h" #include "spdk/rpc.h" #include "spdk/env.h" #include "spdk/endian.h" @@ -524,10 +529,98 @@ vbdev_cbt_get_io_channel(void *ctx) return spdk_get_io_channel(cbt_node); } +/* 0014.6 (cbt part): stable state names for get_bdevs consumers. */ +static const char * +cbt_epoch_state_name(enum cbt_epoch_state state) +{ + switch (state) { + case CBT_EPOCH_OPEN: return "open"; + case CBT_EPOCH_FROZEN: return "frozen"; + case CBT_EPOCH_REBUILDING: return "rebuilding"; + case CBT_EPOCH_COMPLETED: return "completed"; + case CBT_EPOCH_INVALID: return "invalid"; + default: return "unknown"; + } +} + +/* 0014.10 (RPC-CONTRACT §12): epoch-at-ejection — see vbdev_cbt_query.h. + * Refuses (-EEXIST) when an OPEN epoch already bounds the round: the CP's own + * round (or a previous auto-open) suffices, and an implicit takeover would + * steal the nonce from under the controller. FROZEN/REBUILDING epochs do not + * block: those are prior rounds being digested, while the live bitmap keeps + * tracking — the new epoch bounds the NEW ejection. */ +int +vbdev_cbt_auto_epoch_open(const char *bdev_name, const char *stale_backend_id) +{ + struct vbdev_cbt *cbt; + struct cbt_epoch *ep; + char epoch_id[CBT_EPOCH_ID_MAX]; + char nonce[CBT_NONCE_MAX]; + uint64_t max_gen = 0; + uint64_t ticks = spdk_get_ticks(); + + assert(spdk_get_thread() == spdk_thread_get_app_thread()); + + cbt = cbt_find_by_name(bdev_name); + if (cbt == NULL) { + return -ENODEV; + } + + TAILQ_FOREACH(ep, &cbt->epochs, link) { + if (ep->state == CBT_EPOCH_OPEN) { + return -EEXIST; + } + if (ep->generation > max_gen) { + max_gen = ep->generation; + } + } + + snprintf(epoch_id, sizeof(epoch_id), "auto-%016" PRIx64, ticks); + snprintf(nonce, sizeof(nonce), "auto%08x", (uint32_t)ticks); + + SPDK_NOTICELOG("CBT: auto epoch '%s' (nonce %s) on '%s' — member '%s' ejected\n", + epoch_id, nonce, bdev_name, stale_backend_id); + + return bdev_cbt_epoch_open(bdev_name, epoch_id, stale_backend_id, + max_gen + 1, nonce); +} + +/* 0014.6 (RPC-CONTRACT §3): cross-module query — the raid module publishes + * these facts per member in ITS get_bdevs output (see vbdev_cbt_query.h). + * Epochs are appended at open, so the last list entry is the most recently + * opened one; closed epochs leave the list, so everything here is live. */ +int +vbdev_cbt_query_latest_epoch(const char *bdev_name, struct vbdev_cbt_epoch_facts *out) +{ + struct vbdev_cbt *cbt; + struct cbt_epoch *ep, *latest = NULL; + + assert(spdk_get_thread() == spdk_thread_get_app_thread()); + + cbt = cbt_find_by_name(bdev_name); + if (cbt == NULL) { + return -ENODEV; + } + + TAILQ_FOREACH(ep, &cbt->epochs, link) { + latest = ep; + } + if (latest == NULL) { + return -ENOENT; + } + + snprintf(out->nonce, sizeof(out->nonce), "%s", latest->nonce); + out->state = cbt_epoch_state_name(latest->state); + out->truncated = latest->truncated; + + return 0; +} + static int vbdev_cbt_dump_info_json(void *ctx, struct spdk_json_write_ctx *w) { struct vbdev_cbt *cbt_node = ctx; + struct cbt_epoch *ep; spdk_json_write_name(w, "cbt"); spdk_json_write_object_begin(w); @@ -538,6 +631,22 @@ vbdev_cbt_dump_info_json(void *ctx, struct spdk_json_write_ctx *w) spdk_json_write_named_uint32(w, "chunk_size_kb", cbt_node->chunk_size_kb); spdk_json_write_named_uint64(w, "dirty_chunks", cbt_popcount_bitmap(cbt_node)); spdk_json_write_named_uint64(w, "total_chunks", cbt_node->bitmap_size_bits); + + /* 0014.1/0014.2/0014.6 (RPC-CONTRACT §3/§5): the observation source — the + * control-plane's EpochObservation is built from get_bdevs, never from a + * dedicated poll. Per epoch: {epoch_id, nonce, state, generation, truncated}. */ + spdk_json_write_named_array_begin(w, "epochs"); + TAILQ_FOREACH(ep, &cbt_node->epochs, link) { + spdk_json_write_object_begin(w); + spdk_json_write_named_string(w, "epoch_id", ep->epoch_id); + spdk_json_write_named_string(w, "nonce", ep->nonce); + spdk_json_write_named_string(w, "state", cbt_epoch_state_name(ep->state)); + spdk_json_write_named_uint64(w, "generation", ep->generation); + spdk_json_write_named_bool(w, "truncated", ep->truncated); + spdk_json_write_object_end(w); + } + spdk_json_write_array_end(w); + spdk_json_write_object_end(w); return 0; @@ -647,6 +756,31 @@ static void vbdev_cbt_base_bdev_event_cb(enum spdk_bdev_event_type type, struct spdk_bdev *bdev, void *event_ctx) { + if (type == SPDK_BDEV_EVENT_RESIZE) { + /* 0014.2 (RPC-CONTRACT §5, T-D6): the live bitmap was sized at create — + * a grown base leaves the growth zone tracked by NOTHING. Every live + * epoch becomes a lie: mark it truncated so the control-plane routes to + * a FULL rebuild (D14) instead of trusting a partial delta. */ + struct vbdev_cbt *rnode; + struct cbt_epoch *rep; + + TAILQ_FOREACH(rnode, &g_cbt_nodes, link) { + if (bdev != rnode->base_bdev) { + continue; + } + TAILQ_FOREACH(rep, &rnode->epochs, link) { + if (!rep->truncated) { + rep->truncated = true; + SPDK_WARNLOG("CBT: base bdev '%s' resized — epoch " + "'%s' marked truncated (delta no longer " + "covers the device)\n", + spdk_bdev_get_name(bdev), rep->epoch_id); + } + } + } + return; + } + if (type == SPDK_BDEV_EVENT_REMOVE) { struct vbdev_cbt *node, *tmp; struct cbt_bdev_name *name, *ntmp; @@ -942,7 +1076,8 @@ bdev_cbt_delete_disk(const char *cbt_name, int bdev_cbt_epoch_open(const char *cbt_name, const char *epoch_id, - const char *stale_backend_id, uint64_t generation) + const char *stale_backend_id, uint64_t generation, + const char *nonce) { struct vbdev_cbt *cbt; struct cbt_epoch *ep; @@ -961,6 +1096,10 @@ bdev_cbt_epoch_open(const char *cbt_name, const char *epoch_id, if (strlen(stale_backend_id) >= CBT_BACKEND_ID_MAX) { return -ENAMETOOLONG; } + /* 0014.1: the nonce is opaque and optional (pre-0014 callers pass NULL). */ + if (nonce != NULL && strlen(nonce) >= CBT_NONCE_MAX) { + return -ENAMETOOLONG; + } /* Check if epoch already exists. */ ep = cbt_find_epoch(cbt, epoch_id); @@ -979,10 +1118,13 @@ bdev_cbt_epoch_open(const char *cbt_name, const char *epoch_id, "active rebuild\n", (unsigned long)generation, epoch_id); return -EBUSY; } - /* Replace with higher generation. */ + /* Replace with higher generation. 0014.1: the takeover carries the + * NEW round's nonce; 0014.2: `truncated` deliberately survives — + * the bitmap still does not cover a growth zone from a resize. */ ep->generation = generation; snprintf(ep->stale_backend_id, sizeof(ep->stale_backend_id), "%s", stale_backend_id); + snprintf(ep->nonce, sizeof(ep->nonce), "%s", nonce ? nonce : ""); ep->state = CBT_EPOCH_OPEN; return 0; } @@ -997,10 +1139,23 @@ bdev_cbt_epoch_open(const char *cbt_name, const char *epoch_id, if (!oldest || oldest->state == CBT_EPOCH_FROZEN || oldest->state == CBT_EPOCH_REBUILDING || oldest->state == CBT_EPOCH_OPEN) { - SPDK_ERRLOG("CBT: max epochs reached, cannot evict " - "active epoch '%s' (state=%d)\n", - oldest ? oldest->epoch_id : "?", - oldest ? (int)oldest->state : -1); + /* Diagnostic (§4.5): a FROZEN/REBUILDING oldest is legitimately in flight. + * But the OLDEST epoch being stuck OPEN means it was opened and then NEVER + * frozen/consumed — the tell-tale of an orchestrator that opened it under a + * stale_backend_id the observer never matches (CP node-id vs observer lvol-id), + * so D7/D8/D9 never engage and the epoch leaks until it wedges opens here. */ + if (oldest && oldest->state == CBT_EPOCH_OPEN) { + SPDK_ERRLOG("CBT: max epochs reached, OLDEST epoch '%s' stuck OPEN " + "(stale_backend='%s', never frozen/consumed) — likely an " + "unmatched maintenance epoch (§4.5 key mismatch); new epochs " + "now WEDGED. Verify stale_backend_id keying vs observer join.\n", + oldest->epoch_id, oldest->stale_backend_id); + } else { + SPDK_ERRLOG("CBT: max epochs reached, cannot evict " + "active epoch '%s' (state=%d)\n", + oldest ? oldest->epoch_id : "?", + oldest ? (int)oldest->state : -1); + } return -ENOSPC; } /* C3 (defense-in-depth): with the epoch_invalidate rebuild guard an @@ -1031,14 +1186,15 @@ bdev_cbt_epoch_open(const char *cbt_name, const char *epoch_id, snprintf(ep->epoch_id, sizeof(ep->epoch_id), "%s", epoch_id); snprintf(ep->stale_backend_id, sizeof(ep->stale_backend_id), "%s", stale_backend_id); + snprintf(ep->nonce, sizeof(ep->nonce), "%s", nonce ? nonce : ""); ep->generation = generation; ep->state = CBT_EPOCH_OPEN; TAILQ_INSERT_TAIL(&cbt->epochs, ep, link); cbt->epoch_count++; - SPDK_NOTICELOG("CBT: epoch_open '%s' for stale backend '%s' gen=%lu\n", - epoch_id, stale_backend_id, (unsigned long)generation); + SPDK_NOTICELOG("CBT: epoch_open '%s' nonce='%s' for stale backend '%s' gen=%lu\n", + epoch_id, ep->nonce, stale_backend_id, (unsigned long)generation); return 0; } @@ -1144,7 +1300,9 @@ bdev_cbt_epoch_freeze(const char *cbt_name, const char *epoch_id) } int -bdev_cbt_epoch_close(const char *cbt_name, const char *epoch_id) +bdev_cbt_epoch_close(const char *cbt_name, const char *epoch_id, + enum cbt_epoch_close_mode mode, + const char *rebuild_token) { struct vbdev_cbt *cbt; struct cbt_epoch *ep; @@ -1173,10 +1331,33 @@ bdev_cbt_epoch_close(const char *cbt_name, const char *epoch_id) return -EBUSY; } - /* H1: closing an epoch must not lose dirty history. If the frozen delta - * was exchanged out of the live bitmap and never proven copied (rebuild - * aborted/failed/never run), merge it back before discarding. */ - cbt_epoch_restore_unconsumed_delta(cbt, ep); + /* 0014.3 (RPC-CONTRACT §4): CONSUMED deliberately discards the frozen delta — + * the caller certifies it was copied by a verified-successful rebuild. The + * certification is the outcome-registry token (0014.5): it must name a LOCAL + * registry entry in state succeeded+verified — `verified` is set by the + * integrated verify phase (0014.8, K sampled windows against the arbiter + * leg). Without that proof: -EPERM, never a silent downgrade to PRESERVE. */ + if (mode == CBT_EPOCH_CLOSE_CONSUMED) { + if (rebuild_token == NULL || rebuild_token[0] == '\0') { + SPDK_ERRLOG("CBT: epoch_close '%s' mode=consumed refused: no " + "rebuild token (-EPERM)\n", epoch_id); + return -EPERM; + } + if (!raid_rebuild_outcome_is_succeeded_verified(rebuild_token)) { + SPDK_ERRLOG("CBT: epoch_close '%s' mode=consumed refused: token " + "'%s' is not a succeeded+verified rebuild outcome " + "(-EPERM)\n", epoch_id, rebuild_token); + return -EPERM; + } + SPDK_NOTICELOG("CBT: epoch_close '%s' CONSUMED under token '%s' — " + "frozen delta discarded by certification\n", + epoch_id, rebuild_token); + } else { + /* H1: closing an epoch must not lose dirty history. If the frozen delta + * was exchanged out of the live bitmap and never proven copied (rebuild + * aborted/failed/never run), merge it back before discarding. */ + cbt_epoch_restore_unconsumed_delta(cbt, ep); + } ep->state = CBT_EPOCH_COMPLETED; TAILQ_REMOVE(&cbt->epochs, ep, link); @@ -1184,7 +1365,8 @@ bdev_cbt_epoch_close(const char *cbt_name, const char *epoch_id) free(ep->bitmap_frozen); free(ep); - SPDK_NOTICELOG("CBT: epoch_close '%s'\n", epoch_id); + SPDK_NOTICELOG("CBT: epoch_close '%s' (%s)\n", epoch_id, + mode == CBT_EPOCH_CLOSE_CONSUMED ? "consumed" : "preserve"); return 0; } @@ -2375,7 +2557,7 @@ bdev_cbt_cancel_rebuild(const char *rebuild_id, uint64_t *out_chunks_copied) int bdev_cbt_start_tracking(const char *cbt_name) { - return bdev_cbt_epoch_open(cbt_name, "__legacy__", "__legacy__", 0); + return bdev_cbt_epoch_open(cbt_name, "__legacy__", "__legacy__", 0, NULL); } int diff --git a/module/bdev/cbt/vbdev_cbt.h b/module/bdev/cbt/vbdev_cbt.h index ba3ecf2..5eb4388 100644 --- a/module/bdev/cbt/vbdev_cbt.h +++ b/module/bdev/cbt/vbdev_cbt.h @@ -23,6 +23,8 @@ extern "C" { #define CBT_MAX_RANGES_LIMIT 65536 #define CBT_EPOCH_ID_MAX 64 #define CBT_BACKEND_ID_MAX 128 +#define CBT_NONCE_MAX 32 /* 0014.1: CP-generated epoch nonce */ +#define CBT_REBUILD_TOKEN_MAX 192 /* 0014.3: consumed-close proof token */ #define CBT_REBUILD_DEFAULT_QD 16 #define CBT_REBUILD_MAX_QD 128 #define CBT_REBUILD_GC_DELAY_US 60000000 /* 60s before GC of completed entries */ @@ -57,6 +59,17 @@ struct cbt_epoch { uint64_t generation; enum cbt_epoch_state state; + /* 0014.1 (RPC-CONTRACT §5): opaque CP-generated nonce, echoed in get_bdevs — + * kills the epoch-id ABA across maintenance rounds. Empty = pre-0014 caller. */ + char nonce[CBT_NONCE_MAX]; + + /* 0014.2 (RPC-CONTRACT §5, T-D6): set when the base bdev is RESIZED while this + * epoch is live — the growth zone is covered by NO bitmap, so the delta is a + * lie. A truncated epoch routes the control-plane to a FULL rebuild (D14). + * Never cleared: it survives generation takeovers (the bitmap still does not + * cover the growth) and dies only with the epoch. */ + bool truncated; + /* Per-epoch frozen bitmap (allocated on freeze, freed on close). */ uint8_t *bitmap_frozen; @@ -100,12 +113,43 @@ void bdev_cbt_delete_disk(const char *cbt_name, /* ── Epoch operations ──────────────────────────────────────────────── */ +/* 0014.3 (RPC-CONTRACT §4): how an epoch's frozen delta is disposed of at close. */ +enum cbt_epoch_close_mode { + /* Safe default: an unconsumed exchanged delta is OR-ed back into the live + * bitmap before the epoch is freed (the H1 discipline) — no history is lost. */ + CBT_EPOCH_CLOSE_PRESERVE = 0, + /* The caller CERTIFIES the delta was copied by a verified-successful rebuild + * (raid seeded rebuild, outcome registry token) — the delta is deliberately + * discarded. Requires a non-empty rebuild_token, -EPERM otherwise. */ + CBT_EPOCH_CLOSE_CONSUMED = 1, +}; + +/** + * Open a tracking epoch. + * + * \param nonce 0014.1: opaque CP-generated nonce (may be NULL/empty for + * pre-0014 callers); echoed in get_bdevs, kills epoch-id ABA. + */ int bdev_cbt_epoch_open(const char *cbt_name, const char *epoch_id, - const char *stale_backend_id, uint64_t generation); + const char *stale_backend_id, uint64_t generation, + const char *nonce); int bdev_cbt_epoch_freeze(const char *cbt_name, const char *epoch_id); -int bdev_cbt_epoch_close(const char *cbt_name, const char *epoch_id); +/** + * Close an epoch (0014.3). + * + * \param mode PRESERVE restores any unconsumed delta to the live + * bitmap; CONSUMED discards it under caller certification. + * \param rebuild_token Required non-empty for CONSUMED (-EPERM otherwise); the + * SUCCEEDED-verified outcome-registry token (validated + * against the registry as of 0014.5 — until then it is + * demanded and logged, per the frozen contract the CP + * only sends CONSUMED with a verified token, D12). + */ +int bdev_cbt_epoch_close(const char *cbt_name, const char *epoch_id, + enum cbt_epoch_close_mode mode, + const char *rebuild_token); int bdev_cbt_epoch_invalidate(const char *cbt_name, const char *epoch_id); diff --git a/module/bdev/cbt/vbdev_cbt_query.h b/module/bdev/cbt/vbdev_cbt_query.h new file mode 100644 index 0000000..8d17850 --- /dev/null +++ b/module/bdev/cbt/vbdev_cbt_query.h @@ -0,0 +1,50 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright (C) 2026 Evariops. + * All rights reserved. + */ + +/* Evariops 0014.6 (spdk-csi RPC-CONTRACT §3): minimal cross-module query + * surface so the raid module can publish per-member epoch facts in its own + * get_bdevs output — the control-plane's EpochObservation source. This header + * deliberately exposes NO cbt internals; the raid patch (0017) is its only + * intended consumer. Symbols resolve via --whole-archive at app link. */ + +#ifndef SPDK_VBDEV_CBT_QUERY_H +#define SPDK_VBDEV_CBT_QUERY_H + +#include "spdk/stdinc.h" + +struct vbdev_cbt_epoch_facts { + /* CP-generated nonce (empty for pre-0014 epochs). */ + char nonce[32]; + /* Static string: open|frozen|rebuilding|completed|invalid. */ + const char *state; + /* T-D6: the live bitmap does not cover a growth zone — delta is a lie. */ + bool truncated; +}; + +/** + * Fill \c out with the most recently opened live epoch of the cbt bdev named + * \c bdev_name. + * + * \return 0 on success; -ENOENT if the cbt currently tracks no epoch; + * -ENODEV if \c bdev_name is not a cbt bdev. App thread only. + */ +int vbdev_cbt_query_latest_epoch(const char *bdev_name, struct vbdev_cbt_epoch_facts *out); + +/** + * Evariops 0014.10 (spdk-csi RPC-CONTRACT §12): open a delta epoch the moment + * the raid ejects a member — the unplanned loss is the dominant production + * event, and without this bound the debt path degrades to a FULL rebuild + * (D14). Called by the raid module on each surviving member's cbt when a + * member leaves; the auto-generated epoch id/nonce are REPORTED through + * get_bdevs (0014.6), which is how the control-plane adopts the round. + * + * \return 0 on success; -EEXIST if an OPEN epoch already tracks the round + * (never take over implicitly); -ENODEV if \c bdev_name is not a cbt + * bdev; other negative errno from the underlying epoch machinery. + * App thread only. + */ +int vbdev_cbt_auto_epoch_open(const char *bdev_name, const char *stale_backend_id); + +#endif /* SPDK_VBDEV_CBT_QUERY_H */ diff --git a/module/bdev/cbt/vbdev_cbt_rpc.c b/module/bdev/cbt/vbdev_cbt_rpc.c index 88e4503..ebe4388 100644 --- a/module/bdev/cbt/vbdev_cbt_rpc.c +++ b/module/bdev/cbt/vbdev_cbt_rpc.c @@ -151,6 +151,7 @@ struct rpc_epoch_open { char *epoch_id; char *stale_backend_id; uint64_t generation; + char *nonce; /* 0014.1: optional, opaque CP nonce */ }; static void @@ -159,6 +160,7 @@ free_rpc_epoch_open(struct rpc_epoch_open *r) free(r->name); free(r->epoch_id); free(r->stale_backend_id); + free(r->nonce); } static const struct spdk_json_object_decoder rpc_epoch_open_decoders[] = { @@ -166,6 +168,7 @@ static const struct spdk_json_object_decoder rpc_epoch_open_decoders[] = { {"epoch_id", offsetof(struct rpc_epoch_open, epoch_id), spdk_json_decode_string}, {"stale_backend_id", offsetof(struct rpc_epoch_open, stale_backend_id), spdk_json_decode_string}, {"generation", offsetof(struct rpc_epoch_open, generation), spdk_json_decode_uint64}, + {"nonce", offsetof(struct rpc_epoch_open, nonce), spdk_json_decode_string, true}, }; static void @@ -183,7 +186,7 @@ rpc_bdev_cbt_epoch_open(struct spdk_jsonrpc_request *request, } rc = bdev_cbt_epoch_open(req.name, req.epoch_id, req.stale_backend_id, - req.generation); + req.generation, req.nonce); if (rc != 0) { spdk_jsonrpc_send_error_response(request, rc, spdk_strerror(-rc)); goto cleanup; @@ -252,21 +255,61 @@ SPDK_RPC_REGISTER("bdev_cbt_epoch_freeze", rpc_bdev_cbt_epoch_freeze, SPDK_RPC_R /* bdev_cbt_epoch_close */ /* ================================================================== */ +struct rpc_epoch_close { + char *name; + char *epoch_id; + char *mode; /* 0014.3: "preserve" (default) | "consumed" */ + char *rebuild_token; /* 0014.3: required for consumed */ +}; + +static void +free_rpc_epoch_close(struct rpc_epoch_close *r) +{ + free(r->name); + free(r->epoch_id); + free(r->mode); + free(r->rebuild_token); +} + +static const struct spdk_json_object_decoder rpc_epoch_close_decoders[] = { + {"name", offsetof(struct rpc_epoch_close, name), spdk_json_decode_string}, + {"epoch_id", offsetof(struct rpc_epoch_close, epoch_id), spdk_json_decode_string}, + {"mode", offsetof(struct rpc_epoch_close, mode), spdk_json_decode_string, true}, + {"rebuild_token", offsetof(struct rpc_epoch_close, rebuild_token), spdk_json_decode_string, true}, +}; + static void rpc_bdev_cbt_epoch_close(struct spdk_jsonrpc_request *request, const struct spdk_json_val *params) { - struct rpc_epoch_simple req = {NULL}; + struct rpc_epoch_close req = {NULL}; + enum cbt_epoch_close_mode mode = CBT_EPOCH_CLOSE_PRESERVE; int rc; - if (spdk_json_decode_object(params, rpc_epoch_simple_decoders, - SPDK_COUNTOF(rpc_epoch_simple_decoders), &req)) { + if (spdk_json_decode_object(params, rpc_epoch_close_decoders, + SPDK_COUNTOF(rpc_epoch_close_decoders), &req)) { spdk_jsonrpc_send_error_response(request, SPDK_JSONRPC_ERROR_INTERNAL_ERROR, "Failed to decode parameters"); goto cleanup; } - rc = bdev_cbt_epoch_close(req.name, req.epoch_id); + /* 0014.3: an unknown mode is an error, never a silent PRESERVE fallback. */ + if (req.mode != NULL) { + if (strcmp(req.mode, "consumed") == 0) { + mode = CBT_EPOCH_CLOSE_CONSUMED; + } else if (strcmp(req.mode, "preserve") != 0) { + spdk_jsonrpc_send_error_response(request, -EINVAL, + "mode must be 'preserve' or 'consumed'"); + goto cleanup; + } + } + + if (req.rebuild_token != NULL && strlen(req.rebuild_token) >= CBT_REBUILD_TOKEN_MAX) { + spdk_jsonrpc_send_error_response(request, -ENAMETOOLONG, "rebuild_token too long"); + goto cleanup; + } + + rc = bdev_cbt_epoch_close(req.name, req.epoch_id, mode, req.rebuild_token); if (rc != 0) { spdk_jsonrpc_send_error_response(request, rc, spdk_strerror(-rc)); goto cleanup; @@ -277,7 +320,7 @@ rpc_bdev_cbt_epoch_close(struct spdk_jsonrpc_request *request, spdk_jsonrpc_end_result(request, w); cleanup: - free_rpc_epoch_simple(&req); + free_rpc_epoch_close(&req); } SPDK_RPC_REGISTER("bdev_cbt_epoch_close", rpc_bdev_cbt_epoch_close, SPDK_RPC_RUNTIME) diff --git a/patches/0001-raid-add-skip_rebuild-parameter.patch b/patches/0001-raid-add-skip_rebuild-parameter.patch index 378f012..402f36a 100644 --- a/patches/0001-raid-add-skip_rebuild-parameter.patch +++ b/patches/0001-raid-add-skip_rebuild-parameter.patch @@ -1,19 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:58:25 +0200 -Subject: [PATCH 01/12] raid: add skip_rebuild parameter to - bdev_raid_add_base_bdev - -Evariops 0001. Promotes a CBT-resynced member without a full rebuild. -Includes: CBT-6 channel-promotion error propagation + unwind (never ONLINE -with a NULL channel on a reactor), P-5 runtime slot check (was an -NDEBUG-erased assert), membership rollback on quiesce failure. ---- - module/bdev/raid/bdev_raid.c | 217 ++++++++++++++++++++++++++++++- - module/bdev/raid/bdev_raid.h | 4 + - module/bdev/raid/bdev_raid_rpc.c | 9 +- - 3 files changed, 227 insertions(+), 3 deletions(-) - diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c index 7ab1607..cc515bc 100644 --- a/module/bdev/raid/bdev_raid.c @@ -330,6 +314,3 @@ index b1913c7..b1f3d92 100644 if (rc != 0) { spdk_jsonrpc_send_error_response_fmt(request, rc, "Failed to add base bdev %s to RAID bdev %s: %s", --- -2.50.1 (Apple Git-155) - diff --git a/patches/0002-bdev-lvol-add-get-allocated-ranges-rpc.patch b/patches/0002-bdev-lvol-add-get-allocated-ranges-rpc.patch index ae1f4cf..9fcf9a0 100644 --- a/patches/0002-bdev-lvol-add-get-allocated-ranges-rpc.patch +++ b/patches/0002-bdev-lvol-add-get-allocated-ranges-rpc.patch @@ -1,15 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:32:48 +0200 -Subject: [PATCH 02/12] lvol: add bdev_lvol_get_allocated_ranges RPC (Evariops - 0002) - ---- - module/bdev/lvol/Makefile | 2 +- - module/bdev/lvol/vbdev_lvol_ranges_rpc.c | 102 +++++++++++++++++++++++ - 2 files changed, 103 insertions(+), 1 deletion(-) - create mode 100644 module/bdev/lvol/vbdev_lvol_ranges_rpc.c - diff --git a/module/bdev/lvol/Makefile b/module/bdev/lvol/Makefile index dc0c559..aba6620 100644 --- a/module/bdev/lvol/Makefile @@ -131,6 +119,3 @@ index 0000000..fde05cb +} +SPDK_RPC_REGISTER("bdev_lvol_get_allocated_ranges", rpc_bdev_lvol_get_allocated_ranges, + SPDK_RPC_RUNTIME) --- -2.50.1 (Apple Git-155) - diff --git a/patches/0003-nvmf-add-subsystem-pause-resume-rpc.patch b/patches/0003-nvmf-add-subsystem-pause-resume-rpc.patch index ea9e6ef..06c0cc8 100644 --- a/patches/0003-nvmf-add-subsystem-pause-resume-rpc.patch +++ b/patches/0003-nvmf-add-subsystem-pause-resume-rpc.patch @@ -1,19 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:58:26 +0200 -Subject: [PATCH 03/12] nvmf: add nvmf_subsystem_pause / nvmf_subsystem_resume - RPC - -Evariops 0003. Standing, drain-certified, TTL-guarded I/O barrier. -Includes: registry entry preallocated before the pause dispatch (a Paused -subsystem can no longer end up without entry+TTL on OOM), UUID-based -per-process nonce (P-6), loud TTL clamp (S-8 follow-up). ---- - lib/nvmf/Makefile | 2 +- - lib/nvmf/nvmf_pause_rpc.c | 467 ++++++++++++++++++++++++++++++++++++++ - 2 files changed, 468 insertions(+), 1 deletion(-) - create mode 100644 lib/nvmf/nvmf_pause_rpc.c - diff --git a/lib/nvmf/Makefile b/lib/nvmf/Makefile index 3136f7a..0af5836 100644 --- a/lib/nvmf/Makefile @@ -500,6 +484,3 @@ index 0000000..a162553 + } +} +SPDK_RPC_REGISTER("nvmf_subsystem_resume", rpc_nvmf_subsystem_resume, SPDK_RPC_RUNTIME) --- -2.50.1 (Apple Git-155) - diff --git a/patches/0004-blob-relocate-primitives.patch b/patches/0004-blob-relocate-primitives.patch index a5a24ae..8d75be5 100644 --- a/patches/0004-blob-relocate-primitives.patch +++ b/patches/0004-blob-relocate-primitives.patch @@ -1,21 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:58:26 +0200 -Subject: [PATCH 04/12] blob: relocate primitives + public blob I/O freeze - -Evariops 0004. claim/commit/release relocate primitives (invariants A/B) and -spdk_blob_freeze_io/spdk_blob_unfreeze_io - the blob-level barrier that -closes the C1 lost-write (held I/O re-translates through the updated L2P on -release). m8: assert the bit-pool claim invariant. ---- - include/spdk/bit_pool.h | 16 ++ - include/spdk/blob.h | 69 +++++++++ - lib/blob/blobstore.c | 330 ++++++++++++++++++++++++++++++++++++++++ - lib/blob/spdk_blob.map | 7 + - lib/util/bit_array.c | 6 + - lib/util/spdk_util.map | 1 + - 6 files changed, 429 insertions(+) - diff --git a/include/spdk/bit_pool.h b/include/spdk/bit_pool.h index 316c66a..81297f1 100644 --- a/include/spdk/bit_pool.h @@ -518,6 +500,3 @@ index bf2df25..40ee55b 100644 spdk_bit_pool_allocate_bit; spdk_bit_pool_set_bit_allocated; spdk_bit_pool_free_bit; --- -2.50.1 (Apple Git-155) - diff --git a/patches/0005-lvol-get-cluster-placement-rpc.patch b/patches/0005-lvol-get-cluster-placement-rpc.patch index 8af6507..2559058 100644 --- a/patches/0005-lvol-get-cluster-placement-rpc.patch +++ b/patches/0005-lvol-get-cluster-placement-rpc.patch @@ -1,14 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 15:26:58 +0200 -Subject: [PATCH 05/12] 0005-lvol-get-cluster-placement-rpc.patch - ---- - module/bdev/lvol/Makefile | 3 +- - module/bdev/lvol/vbdev_lvol_tier_rpc.c | 1441 ++++++++++++++++++++++++ - 2 files changed, 1443 insertions(+), 1 deletion(-) - create mode 100644 module/bdev/lvol/vbdev_lvol_tier_rpc.c - diff --git a/module/bdev/lvol/Makefile b/module/bdev/lvol/Makefile index aba6620..a8ed387 100644 --- a/module/bdev/lvol/Makefile @@ -1470,6 +1459,3 @@ index 0000000..6bf3ca1 + free(req.items); +} +SPDK_RPC_REGISTER("bdev_lvol_remap_clusters", rpc_bdev_lvol_remap_clusters, SPDK_RPC_RUNTIME) --- -2.50.1 (Apple Git-155) - diff --git a/patches/0006-raid5f-degraded-read-fallback.patch b/patches/0006-raid5f-degraded-read-fallback.patch index 303ffa1..8907d12 100644 --- a/patches/0006-raid5f-degraded-read-fallback.patch +++ b/patches/0006-raid5f-degraded-read-fallback.patch @@ -1,13 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:32:48 +0200 -Subject: [PATCH 06/12] raid5f: degraded-read fallback + double-fault guard - (Evariops 0006) - ---- - module/bdev/raid/raid5f.c | 55 +++++++++++++++++++++++++++++++++++++-- - 1 file changed, 53 insertions(+), 2 deletions(-) - diff --git a/module/bdev/raid/raid5f.c b/module/bdev/raid/raid5f.c index 0a2274f..1d02ec8 100644 --- a/module/bdev/raid/raid5f.c @@ -88,6 +78,3 @@ index 0a2274f..1d02ec8 100644 stripe_req = TAILQ_FIRST(&r5ch->free_stripe_requests.reconstruct); if (!stripe_req) { return -ENOMEM; --- -2.50.1 (Apple Git-155) - diff --git a/patches/0007-raid-nexus-heat.patch b/patches/0007-raid-nexus-heat.patch index 5ea6889..eb7ef9a 100644 --- a/patches/0007-raid-nexus-heat.patch +++ b/patches/0007-raid-nexus-heat.patch @@ -1,20 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:58:26 +0200 -Subject: [PATCH 07/12] raid: nexus logical heat instrumentation - -Evariops 0007. M7: release-publish / acquire-load of the heat pointer -(arm64-safe), per-region TSC recency instead of a shared tick counter. -N-8/P-4: disable_heat reclaims the sketch after a for_each_channel grace -period (the old "reclaimed at teardown" claim was false - it leaked). ---- - module/bdev/raid/Makefile | 2 +- - module/bdev/raid/bdev_raid.c | 17 ++ - module/bdev/raid/bdev_raid.h | 9 + - module/bdev/raid/bdev_raid_heat.c | 311 ++++++++++++++++++++++++++++++ - 4 files changed, 338 insertions(+), 1 deletion(-) - create mode 100644 module/bdev/raid/bdev_raid_heat.c - diff --git a/module/bdev/raid/Makefile b/module/bdev/raid/Makefile index 3dff369..cb46945 100644 --- a/module/bdev/raid/Makefile @@ -405,6 +388,3 @@ index 0000000..027a55d + free(req.name); +} +SPDK_RPC_REGISTER("vbdev_nexus_get_heat", rpc_vbdev_nexus_get_heat, SPDK_RPC_RUNTIME) --- -2.50.1 (Apple Git-155) - diff --git a/patches/0008-raid-rebuild-ranges.patch b/patches/0008-raid-rebuild-ranges.patch index bb55cfe..d7b2d27 100644 --- a/patches/0008-raid-rebuild-ranges.patch +++ b/patches/0008-raid-rebuild-ranges.patch @@ -1,24 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:58:26 +0200 -Subject: [PATCH 08/12] raid: bdev_raid_rebuild_ranges - read-reconstruct-writeback repair - -Evariops 0008. C2: every read/write-back chunk runs under a channel-owned -LBA range lock (spdk_bdev_lock_lba_range, newly exported from lib/bdev - -the primitive COMPARE_AND_WRITE emulation already uses); host writes to the -chunk are held and replayed after the write-back. P-3: stripe alignment -validated for parity raids, ranges heap-allocated. N-6: REMOVE aborts. ---- - include/spdk/bdev_module.h | 17 ++ - lib/bdev/bdev.c | 25 ++ - lib/bdev/spdk_bdev.map | 2 + - module/bdev/raid/Makefile | 2 +- - module/bdev/raid/bdev_raid.c | 11 + - module/bdev/raid/bdev_raid_repair.c | 406 ++++++++++++++++++++++++++++ - 6 files changed, 462 insertions(+), 1 deletion(-) - create mode 100644 module/bdev/raid/bdev_raid_repair.c - diff --git a/include/spdk/bdev_module.h b/include/spdk/bdev_module.h index fce200b..79b43f0 100644 --- a/include/spdk/bdev_module.h @@ -543,6 +522,3 @@ index 0000000..f0aff59 + free(req.name); +} +SPDK_RPC_REGISTER("bdev_raid_rebuild_ranges", rpc_bdev_raid_rebuild_ranges, SPDK_RPC_RUNTIME) --- -2.50.1 (Apple Git-155) - diff --git a/patches/0009-lvol-raid-enospc-capacity-exceeded.patch b/patches/0009-lvol-raid-enospc-capacity-exceeded.patch index c62a59b..8be2b18 100644 --- a/patches/0009-lvol-raid-enospc-capacity-exceeded.patch +++ b/patches/0009-lvol-raid-enospc-capacity-exceeded.patch @@ -1,21 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:55:35 +0200 -Subject: [PATCH 09/12] lvol/raid: surface thin-pool ENOSPC as NVMe - CAPACITY_EXCEEDED (Evariops 0009, C-1) - -lvol maps blob -ENOSPC to NVMe CAPACITY_EXCEEDED (survives the NVMe-oF hop); -raid1 does NOT fail a member on CAPACITY_EXCEEDED (thin exhaustion is not a -device fault) and the raid_io completes CAPACITY_EXCEEDED to the consumer -even if another leg succeeded - no more silently-diverged, silently-degraded -mirror on asymmetric ENOSPC. ---- - module/bdev/lvol/vbdev_lvol.c | 9 +++++++++ - module/bdev/raid/bdev_raid.c | 10 ++++++++++ - module/bdev/raid/bdev_raid.h | 6 ++++++ - module/bdev/raid/raid1.c | 23 +++++++++++++++++++---- - 4 files changed, 44 insertions(+), 4 deletions(-) - diff --git a/module/bdev/lvol/vbdev_lvol.c b/module/bdev/lvol/vbdev_lvol.c index 4c74027..713f905 100644 --- a/module/bdev/lvol/vbdev_lvol.c @@ -137,6 +119,3 @@ index 4b24829..af633de 100644 } } --- -2.50.1 (Apple Git-155) - diff --git a/patches/0010-rpc-socket-chmod.patch b/patches/0010-rpc-socket-chmod.patch index 00aec6d..8b0bda1 100644 --- a/patches/0010-rpc-socket-chmod.patch +++ b/patches/0010-rpc-socket-chmod.patch @@ -1,13 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 10:56:39 +0200 -Subject: [PATCH 10/12] rpc: chmod 0600 the unix socket after bind (Evariops - 0010, S-2) - ---- - lib/rpc/rpc.c | 23 +++++++++++++++++++++++ - 1 file changed, 23 insertions(+) - diff --git a/lib/rpc/rpc.c b/lib/rpc/rpc.c index ead7c66..7af1f9c 100644 --- a/lib/rpc/rpc.c @@ -67,6 +57,3 @@ index ead7c66..7af1f9c 100644 return server; ret: --- -2.50.1 (Apple Git-155) - diff --git a/patches/0011-jsonrpc-peer-audit.patch b/patches/0011-jsonrpc-peer-audit.patch index f4c7092..ae0ad27 100644 --- a/patches/0011-jsonrpc-peer-audit.patch +++ b/patches/0011-jsonrpc-peer-audit.patch @@ -1,22 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sat, 4 Jul 2026 19:16:48 +0200 -Subject: [PATCH 11/12] jsonrpc: SO_PEERCRED peer-credential audit hook - (Evariops 0011, SEC1) - -Capture the RPC client's Unix-socket credentials (SO_PEERCRED) at accept and -expose spdk_jsonrpc_request_get_peer_ucred() + spdk_jsonrpc_request_audit(), so -destructive mutation handlers (bdev_tier_delete/retire, relocate/remap, pause, -rebuild_ranges) log pid/uid/gid + operation. Applied with the series; earlier -patches (0003/0005/0008) call the helper defined here. ---- - include/spdk/jsonrpc.h | 27 ++++++++++++++++++++++ - lib/jsonrpc/jsonrpc_internal.h | 7 ++++++ - lib/jsonrpc/jsonrpc_server.c | 39 ++++++++++++++++++++++++++++++++ - lib/jsonrpc/jsonrpc_server_tcp.c | 17 ++++++++++++++ - lib/jsonrpc/spdk_jsonrpc.map | 2 ++ - 5 files changed, 92 insertions(+) - diff --git a/include/spdk/jsonrpc.h b/include/spdk/jsonrpc.h index 760039d..7a157ad 100644 --- a/include/spdk/jsonrpc.h @@ -164,6 +145,3 @@ index a62f4a4..5555990 100644 spdk_jsonrpc_conn_add_close_cb; spdk_jsonrpc_conn_del_close_cb; spdk_jsonrpc_begin_result; --- -2.50.1 (Apple Git-155) - diff --git a/patches/0012-lvol-shutdown-unload-observability.patch b/patches/0012-lvol-shutdown-unload-observability.patch index 4f3d3c0..a942930 100644 --- a/patches/0012-lvol-shutdown-unload-observability.patch +++ b/patches/0012-lvol-shutdown-unload-observability.patch @@ -1,24 +1,3 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: Evariops -Date: Sun, 5 Jul 2026 18:20:00 +0200 -Subject: [PATCH 12/12] lvol: NOTICE-level shutdown unload observability - (Evariops 0012) - -A lvolstore whose shutdown unload fails (or never runs because lvols -were still open at fini_start and the unregister-path unload never -fired) leaves the blobstore superblock dirty: the next load performs a -full metadata recovery - tens of seconds on SATA, which inflates every -rollout's node-ready time and the CBT dirty window with it. Observed on -turing: ~70-95s boots on rolled nodes vs ~6s on clean loads, with the -existing INFOLOG outcome invisible in production logs. - -Log all three outcomes (clean unload via fini_start, clean unload via -unregister path, FAILED unload with errno) and the fini_start skip at -NOTICE so field logs pinpoint which path ran. ---- - module/bdev/lvol/vbdev_lvol.c | 19 +++++++++++++++++-- - 1 file changed, 17 insertions(+), 2 deletions(-) - diff --git a/module/bdev/lvol/vbdev_lvol.c b/module/bdev/lvol/vbdev_lvol.c index 713f905..f309354 100644 --- a/module/bdev/lvol/vbdev_lvol.c @@ -65,6 +44,3 @@ index 713f905..f309354 100644 lvs_bdev = vbdev_lvol_store_next(lvs_bdev); } --- -2.50.1 (Apple Git-155) - diff --git a/patches/0014-raid-incarnation-estale.patch b/patches/0014-raid-incarnation-estale.patch new file mode 100644 index 0000000..d4a7e4e --- /dev/null +++ b/patches/0014-raid-incarnation-estale.patch @@ -0,0 +1,429 @@ +diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c +index e89a0cc..9e9ed50 100644 +--- a/module/bdev/raid/bdev_raid.c ++++ b/module/bdev/raid/bdev_raid.c +@@ -408,11 +408,33 @@ raid_bdev_free(struct raid_bdev *raid_bdev) + raid_tier_heat_destroy(raid_bdev->tier_heat); + raid_bdev->tier_heat = NULL; + raid_bdev_free_superblock(raid_bdev); ++ free(raid_bdev->incarnation); + free(raid_bdev->base_bdev_info); + free(raid_bdev->bdev.name); + free(raid_bdev); + } + ++/* Evariops 0014.4 (RPC-CONTRACT §1) */ ++int ++raid_bdev_check_incarnation(struct raid_bdev *raid_bdev, const char *expected_incarnation) ++{ ++ if (expected_incarnation == NULL) { ++ /* Reserved for manual rescue tooling — the control-plane client ++ * always sends expected_incarnation. */ ++ return 0; ++ } ++ ++ if (raid_bdev->incarnation == NULL || ++ strcmp(raid_bdev->incarnation, expected_incarnation) != 0) { ++ SPDK_WARNLOG("raid bdev '%s': stale incarnation '%s' (owner: '%s') — refusing\n", ++ raid_bdev->bdev.name, expected_incarnation, ++ raid_bdev->incarnation ? raid_bdev->incarnation : "(unclaimed)"); ++ return -ESTALE; ++ } ++ ++ return 0; ++} ++ + static void + raid_bdev_cleanup_and_free(struct raid_bdev *raid_bdev) + { +@@ -1143,6 +1165,11 @@ raid_bdev_write_info_json(struct raid_bdev *raid_bdev, struct spdk_json_write_ct + + spdk_json_write_named_uuid(w, "uuid", &raid_bdev->bdev.uuid); + spdk_json_write_named_uint32(w, "strip_size_kb", raid_bdev->strip_size_kb); ++ /* Evariops 0014.4: observability of ownership — NULL only for ++ * superblock-reassembled (unclaimed) raids. */ ++ if (raid_bdev->incarnation != NULL) { ++ spdk_json_write_named_string(w, "incarnation", raid_bdev->incarnation); ++ } + spdk_json_write_named_string(w, "state", raid_bdev_state_to_str(raid_bdev->state)); + spdk_json_write_named_string(w, "raid_level", raid_bdev_level_to_str(raid_bdev->level)); + spdk_json_write_named_bool(w, "superblock", raid_bdev->superblock_enabled); +@@ -1248,6 +1275,11 @@ raid_bdev_write_config_json(struct spdk_bdev *bdev, struct spdk_json_write_ctx * + spdk_json_write_named_object_begin(w, "params"); + spdk_json_write_named_string(w, "name", bdev->name); + spdk_json_write_named_uuid(w, "uuid", &raid_bdev->bdev.uuid); ++ /* Evariops 0014.4: keep the saved config replayable — bdev_raid_create ++ * requires an incarnation. */ ++ if (raid_bdev->incarnation != NULL) { ++ spdk_json_write_named_string(w, "incarnation", raid_bdev->incarnation); ++ } + if (raid_bdev->strip_size_kb != 0) { + spdk_json_write_named_uint32(w, "strip_size_kb", raid_bdev->strip_size_kb); + } +@@ -1708,19 +1740,37 @@ _raid_bdev_create(const char *name, uint32_t strip_size, uint8_t num_base_bdevs, + int + raid_bdev_create(const char *name, uint32_t strip_size, uint8_t num_base_bdevs, + enum raid_level level, bool superblock_enabled, const struct spdk_uuid *uuid, +- struct raid_bdev **raid_bdev_out) ++ const char *incarnation, struct raid_bdev **raid_bdev_out) + { + struct raid_bdev *raid_bdev; + int rc; + + assert(uuid != NULL); + ++ /* Evariops 0014.4 (RPC-CONTRACT §1): no creation without identity. Only ++ * the internal superblock-reassembly path may create an unclaimed raid. */ ++ if (incarnation == NULL || incarnation[0] == '\0') { ++ SPDK_ERRLOG("raid bdev '%s': an incarnation identity is required\n", name); ++ return -EINVAL; ++ } ++ if (strnlen(incarnation, RAID_INCARNATION_MAX) == RAID_INCARNATION_MAX) { ++ SPDK_ERRLOG("raid bdev '%s': incarnation exceeds %d characters\n", ++ name, RAID_INCARNATION_MAX - 1); ++ return -EINVAL; ++ } ++ + rc = _raid_bdev_create(name, strip_size, num_base_bdevs, level, superblock_enabled, uuid, + &raid_bdev); + if (rc != 0) { + return rc; + } + ++ raid_bdev->incarnation = strdup(incarnation); ++ if (raid_bdev->incarnation == NULL) { ++ raid_bdev_cleanup_and_free(raid_bdev); ++ return -ENOMEM; ++ } ++ + if (superblock_enabled && spdk_uuid_is_null(uuid)) { + /* we need to have the uuid to store in the superblock before the bdev is registered */ + spdk_uuid_generate(&raid_bdev->bdev.uuid); +@@ -2334,6 +2384,8 @@ _raid_bdev_remove_base_bdev(struct raid_base_bdev_info *base_info, + * or not. If yes, it takes necessary action on that particular raid bdev. + * params: + * base_bdev - pointer to base bdev which got removed ++ * expected_incarnation - Evariops 0014.4: owner check (-ESTALE on mismatch); ++ * NULL from internal paths (hotremove) skips the check + * cb_fn - callback function + * cb_arg - argument to callback function + * returns: +@@ -2341,9 +2393,11 @@ _raid_bdev_remove_base_bdev(struct raid_base_bdev_info *base_info, + * non zero - failure + */ + int +-raid_bdev_remove_base_bdev(struct spdk_bdev *base_bdev, raid_base_bdev_cb cb_fn, void *cb_ctx) ++raid_bdev_remove_base_bdev(struct spdk_bdev *base_bdev, const char *expected_incarnation, ++ raid_base_bdev_cb cb_fn, void *cb_ctx) + { + struct raid_base_bdev_info *base_info; ++ int rc; + + /* Find the raid_bdev which has claimed this base_bdev */ + base_info = raid_bdev_find_base_info_by_bdev(base_bdev); +@@ -2352,6 +2406,11 @@ raid_bdev_remove_base_bdev(struct spdk_bdev *base_bdev, raid_base_bdev_cb cb_fn, + return -ENODEV; + } + ++ rc = raid_bdev_check_incarnation(base_info->raid_bdev, expected_incarnation); ++ if (rc != 0) { ++ return rc; ++ } ++ + return _raid_bdev_remove_base_bdev(base_info, cb_fn, cb_ctx); + } + +@@ -2484,7 +2543,7 @@ raid_bdev_event_base_bdev(enum spdk_bdev_event_type type, struct spdk_bdev *bdev + + switch (type) { + case SPDK_BDEV_EVENT_REMOVE: +- rc = raid_bdev_remove_base_bdev(bdev, NULL, NULL); ++ rc = raid_bdev_remove_base_bdev(bdev, NULL, NULL, NULL); + if (rc != 0) { + SPDK_ERRLOG("Failed to remove base bdev %s: %s\n", + spdk_bdev_get_name(bdev), spdk_strerror(-rc)); +diff --git a/module/bdev/raid/bdev_raid.h b/module/bdev/raid/bdev_raid.h +index ef387aa..08471e4 100644 +--- a/module/bdev/raid/bdev_raid.h ++++ b/module/bdev/raid/bdev_raid.h +@@ -11,6 +11,10 @@ + + #define RAID_BDEV_MIN_DATA_OFFSET_SIZE (1024*1024) /* 1 MiB */ + ++/* Evariops 0014.4 (spdk-csi RPC-CONTRACT §1): max length of the control-plane ++ * incarnation identity string, including the NUL terminator. */ ++#define RAID_INCARNATION_MAX 64 ++ + enum raid_level { + INVALID_RAID_LEVEL = -1, + RAID0 = 0, +@@ -262,6 +266,14 @@ struct raid_bdev { + /* SPEC-73 M5: logical heat sketch (NULL unless enabled at the nexus). */ + void *tier_heat; + ++ /* Evariops 0014.4 (RPC-CONTRACT §1): identity of the control-plane ++ * incarnation that created this raid. Engaging RPCs carry ++ * expected_incarnation and are refused with -ESTALE on mismatch — a ++ * stale controller can never execute "best effort" against a raid it ++ * no longer owns. NULL only for superblock-reassembled raids ++ * (unclaimed); the create RPC requires it. */ ++ char *incarnation; ++ + /* Superblock */ + bool superblock_enabled; + struct raid_bdev_superblock *sb; +@@ -292,7 +304,11 @@ typedef void (*raid_bdev_destruct_cb)(void *cb_ctx, int rc); + + int raid_bdev_create(const char *name, uint32_t strip_size, uint8_t num_base_bdevs, + enum raid_level level, bool superblock, const struct spdk_uuid *uuid, +- struct raid_bdev **raid_bdev_out); ++ const char *incarnation, struct raid_bdev **raid_bdev_out); ++/* Evariops 0014.4: 0 when expected matches (or expected is NULL — reserved for ++ * manual rescue tooling), -ESTALE otherwise. Engaging RPCs call this before ++ * acting. */ ++int raid_bdev_check_incarnation(struct raid_bdev *raid_bdev, const char *expected_incarnation); + void raid_bdev_delete(struct raid_bdev *raid_bdev, raid_bdev_destruct_cb cb_fn, void *cb_ctx); + int raid_bdev_add_base_bdev(struct raid_bdev *raid_bdev, const char *name, + bool skip_rebuild, bool write_only, +@@ -314,7 +330,8 @@ enum raid_bdev_state raid_bdev_str_to_state(const char *str); + const char *raid_bdev_state_to_str(enum raid_bdev_state state); + const char *raid_bdev_process_to_str(enum raid_process_type value); + void raid_bdev_write_info_json(struct raid_bdev *raid_bdev, struct spdk_json_write_ctx *w); +-int raid_bdev_remove_base_bdev(struct spdk_bdev *base_bdev, raid_base_bdev_cb cb_fn, void *cb_ctx); ++int raid_bdev_remove_base_bdev(struct spdk_bdev *base_bdev, const char *expected_incarnation, ++ raid_base_bdev_cb cb_fn, void *cb_ctx); + + /* + * RAID module descriptor +diff --git a/module/bdev/raid/bdev_raid_rpc.c b/module/bdev/raid/bdev_raid_rpc.c +index a30a613..8a14573 100644 +--- a/module/bdev/raid/bdev_raid_rpc.c ++++ b/module/bdev/raid/bdev_raid_rpc.c +@@ -137,6 +137,10 @@ struct rpc_bdev_raid_create { + + /* If set, information about raid bdev will be stored in superblock on each base bdev */ + bool superblock_enabled; ++ ++ /* Evariops 0014.4 (RPC-CONTRACT §1): required identity of the creating ++ * control-plane incarnation — no creation without identity. */ ++ char *incarnation; + }; + + /* +@@ -184,6 +188,7 @@ static const struct spdk_json_object_decoder rpc_bdev_raid_create_decoders[] = { + {"base_bdevs", offsetof(struct rpc_bdev_raid_create, base_bdevs), decode_base_bdevs}, + {"uuid", offsetof(struct rpc_bdev_raid_create, uuid), spdk_json_decode_uuid, true}, + {"superblock", offsetof(struct rpc_bdev_raid_create, superblock_enabled), spdk_json_decode_bool, true}, ++ {"incarnation", offsetof(struct rpc_bdev_raid_create, incarnation), spdk_json_decode_string}, + }; + + struct rpc_bdev_raid_create_ctx { +@@ -207,6 +212,7 @@ free_rpc_bdev_raid_create_ctx(struct rpc_bdev_raid_create_ctx *ctx) + req = &ctx->req; + + free(req->name); ++ free(req->incarnation); + for (i = 0; i < req->base_bdevs.num_base_bdevs; i++) { + free(req->base_bdevs.base_bdevs[i]); + } +@@ -288,7 +294,8 @@ rpc_bdev_raid_create(struct spdk_jsonrpc_request *request, + } + + rc = raid_bdev_create(req->name, req->strip_size_kb, num_base_bdevs, +- req->level, req->superblock_enabled, &req->uuid, &raid_bdev); ++ req->level, req->superblock_enabled, &req->uuid, ++ req->incarnation, &raid_bdev); + if (rc != 0) { + spdk_jsonrpc_send_error_response_fmt(request, rc, + "Failed to create RAID bdev %s: %s", +@@ -331,6 +338,10 @@ SPDK_RPC_REGISTER("bdev_raid_create", rpc_bdev_raid_create, SPDK_RPC_RUNTIME) + struct rpc_bdev_raid_delete { + /* raid bdev name */ + char *name; ++ ++ /* Evariops 0014.4: -ESTALE on mismatch; omission is reserved for manual ++ * rescue tooling (the control-plane client always sends it) */ ++ char *expected_incarnation; + }; + + /* +@@ -345,6 +356,7 @@ static void + free_rpc_bdev_raid_delete(struct rpc_bdev_raid_delete *req) + { + free(req->name); ++ free(req->expected_incarnation); + } + + /* +@@ -352,6 +364,7 @@ free_rpc_bdev_raid_delete(struct rpc_bdev_raid_delete *req) + */ + static const struct spdk_json_object_decoder rpc_bdev_raid_delete_decoders[] = { + {"name", offsetof(struct rpc_bdev_raid_delete, name), spdk_json_decode_string}, ++ {"expected_incarnation", offsetof(struct rpc_bdev_raid_delete, expected_incarnation), spdk_json_decode_string, true}, + }; + + struct rpc_bdev_raid_delete_ctx { +@@ -427,6 +440,14 @@ rpc_bdev_raid_delete(struct spdk_jsonrpc_request *request, + goto cleanup; + } + ++ /* Evariops 0014.4 (RPC-CONTRACT §1) */ ++ if (raid_bdev_check_incarnation(raid_bdev, ctx->req.expected_incarnation) != 0) { ++ spdk_jsonrpc_send_error_response_fmt(request, -ESTALE, ++ "stale incarnation for raid bdev %s", ++ ctx->req.name); ++ goto cleanup; ++ } ++ + ctx->request = request; + + raid_bdev_delete(raid_bdev, bdev_raid_delete_done, ctx); +@@ -455,6 +476,9 @@ struct rpc_bdev_raid_add_base_bdev { + /* SPEC-74 M6: attach write-only — receives writes immediately, serves no + * reads, SB flip deferred to seeded-rebuild completion */ + bool write_only; ++ ++ /* Evariops 0014.4: -ESTALE on mismatch (see bdev_raid_delete) */ ++ char *expected_incarnation; + }; + + /* +@@ -471,6 +495,7 @@ free_rpc_bdev_raid_add_base_bdev(struct rpc_bdev_raid_add_base_bdev *req) + { + free(req->base_bdev); + free(req->raid_bdev); ++ free(req->expected_incarnation); + } + + /* +@@ -481,6 +506,7 @@ static const struct spdk_json_object_decoder rpc_bdev_raid_add_base_bdev_decoder + {"raid_bdev", offsetof(struct rpc_bdev_raid_add_base_bdev, raid_bdev), spdk_json_decode_string}, + {"skip_rebuild", offsetof(struct rpc_bdev_raid_add_base_bdev, skip_rebuild), spdk_json_decode_bool, true}, + {"write_only", offsetof(struct rpc_bdev_raid_add_base_bdev, write_only), spdk_json_decode_bool, true}, ++ {"expected_incarnation", offsetof(struct rpc_bdev_raid_add_base_bdev, expected_incarnation), spdk_json_decode_string, true}, + }; + + static void +@@ -541,6 +567,14 @@ rpc_bdev_raid_add_base_bdev(struct spdk_jsonrpc_request *request, + goto cleanup; + } + ++ /* Evariops 0014.4 (RPC-CONTRACT §1) */ ++ if (raid_bdev_check_incarnation(raid_bdev, req.expected_incarnation) != 0) { ++ spdk_jsonrpc_send_error_response_fmt(request, -ESTALE, ++ "stale incarnation for raid bdev %s", ++ req.raid_bdev); ++ goto cleanup; ++ } ++ + /* SPEC-74 M6: read exclusion is implemented by the raid1 read selection + * only — a write_only attach on any other level would serve stale reads. */ + if (req.write_only && raid_bdev->level != RAID1) { +@@ -588,6 +622,9 @@ struct rpc_bdev_raid_start_seeded_rebuild { + + /* Heap-allocated in the array decoder (0008 house pattern) */ + struct raid_bdev_seed_range *ranges; ++ ++ /* Evariops 0014.4: -ESTALE on mismatch (see bdev_raid_delete) */ ++ char *expected_incarnation; + }; + + static void +@@ -596,6 +633,7 @@ free_rpc_bdev_raid_start_seeded_rebuild(struct rpc_bdev_raid_start_seeded_rebuil + free(req->name); + free(req->base_bdev); + free(req->ranges); ++ free(req->expected_incarnation); + } + + static int +@@ -631,6 +669,7 @@ static const struct spdk_json_object_decoder rpc_bdev_raid_start_seeded_rebuild_ + {"name", offsetof(struct rpc_bdev_raid_start_seeded_rebuild, name), spdk_json_decode_string}, + {"base_bdev", offsetof(struct rpc_bdev_raid_start_seeded_rebuild, base_bdev), spdk_json_decode_string}, + {"ranges", offsetof(struct rpc_bdev_raid_start_seeded_rebuild, ranges), decode_seed_ranges}, ++ {"expected_incarnation", offsetof(struct rpc_bdev_raid_start_seeded_rebuild, expected_incarnation), spdk_json_decode_string, true}, + }; + + static int +@@ -677,6 +716,14 @@ rpc_bdev_raid_start_seeded_rebuild(struct spdk_jsonrpc_request *request, + goto cleanup; + } + ++ /* Evariops 0014.4 (RPC-CONTRACT §1) */ ++ if (raid_bdev_check_incarnation(raid_bdev, req.expected_incarnation) != 0) { ++ spdk_jsonrpc_send_error_response_fmt(request, -ESTALE, ++ "stale incarnation for raid bdev %s", ++ req.name); ++ goto cleanup; ++ } ++ + if (raid_bdev->process != NULL) { + spdk_jsonrpc_send_error_response_fmt(request, -EBUSY, + "raid bdev %s already has a background process", +@@ -767,11 +814,20 @@ cleanup: + SPDK_RPC_REGISTER("bdev_raid_start_seeded_rebuild", rpc_bdev_raid_start_seeded_rebuild, + SPDK_RPC_RUNTIME) + ++struct rpc_bdev_raid_remove_base_bdev { ++ /* Base bdev name */ ++ char *name; ++ ++ /* Evariops 0014.4: -ESTALE on mismatch (see bdev_raid_delete) */ ++ char *expected_incarnation; ++}; ++ + /* + * Decoder object for RPC bdev_raid_remove_base_bdev + */ + static const struct spdk_json_object_decoder rpc_bdev_raid_remove_base_bdev_decoders[] = { +- {"name", 0, spdk_json_decode_string}, ++ {"name", offsetof(struct rpc_bdev_raid_remove_base_bdev, name), spdk_json_decode_string}, ++ {"expected_incarnation", offsetof(struct rpc_bdev_raid_remove_base_bdev, expected_incarnation), spdk_json_decode_string, true}, + }; + + static void +@@ -801,34 +857,38 @@ static void + rpc_bdev_raid_remove_base_bdev(struct spdk_jsonrpc_request *request, + const struct spdk_json_val *params) + { ++ struct rpc_bdev_raid_remove_base_bdev req = {}; + struct spdk_bdev_desc *desc; +- char *name = NULL; + int rc; + + if (spdk_json_decode_object(params, rpc_bdev_raid_remove_base_bdev_decoders, + SPDK_COUNTOF(rpc_bdev_raid_remove_base_bdev_decoders), +- &name)) { ++ &req)) { + spdk_jsonrpc_send_error_response(request, SPDK_JSONRPC_ERROR_PARSE_ERROR, + "spdk_json_decode_object failed"); + return; + } + +- rc = spdk_bdev_open_ext(name, false, rpc_bdev_raid_event_cb, NULL, &desc); +- free(name); ++ rc = spdk_bdev_open_ext(req.name, false, rpc_bdev_raid_event_cb, NULL, &desc); + if (rc != 0) { + goto err; + } + +- rc = raid_bdev_remove_base_bdev(spdk_bdev_desc_get_bdev(desc), rpc_bdev_raid_remove_base_bdev_done, +- request); ++ /* Evariops 0014.4: the owner check runs inside on the resolved member. */ ++ rc = raid_bdev_remove_base_bdev(spdk_bdev_desc_get_bdev(desc), req.expected_incarnation, ++ rpc_bdev_raid_remove_base_bdev_done, request); + spdk_bdev_close(desc); + if (rc != 0) { + goto err; + } + ++ free(req.name); ++ free(req.expected_incarnation); + return; + err: + rpc_bdev_raid_remove_base_bdev_done(request, rc); ++ free(req.name); ++ free(req.expected_incarnation); + } + SPDK_RPC_REGISTER("bdev_raid_remove_base_bdev", rpc_bdev_raid_remove_base_bdev, SPDK_RPC_RUNTIME) + diff --git a/patches/0015-raid-rebuild-outcome-registry.patch b/patches/0015-raid-rebuild-outcome-registry.patch new file mode 100644 index 0000000..95b0106 --- /dev/null +++ b/patches/0015-raid-rebuild-outcome-registry.patch @@ -0,0 +1,586 @@ +diff --git a/module/bdev/raid/Makefile b/module/bdev/raid/Makefile +index e118002..120700c 100644 +--- a/module/bdev/raid/Makefile ++++ b/module/bdev/raid/Makefile +@@ -10,7 +10,7 @@ SO_VER := 7 + SO_MINOR := 0 + + CFLAGS += -I$(SPDK_ROOT_DIR)/lib/bdev/ +-C_SRCS = bdev_raid.c bdev_raid_rpc.c bdev_raid_sb.c bdev_raid_heat.c bdev_raid_repair.c raid0.c raid1.c concat.c ++C_SRCS = bdev_raid.c bdev_raid_rpc.c bdev_raid_sb.c bdev_raid_heat.c bdev_raid_repair.c bdev_raid_outcomes.c raid0.c raid1.c concat.c + + ifeq ($(CONFIG_RAID5F),y) + C_SRCS += raid5f.c +diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c +index 9e9ed50..a88a237 100644 +--- a/module/bdev/raid/bdev_raid.c ++++ b/module/bdev/raid/bdev_raid.c +@@ -5,6 +5,7 @@ + */ + + #include "bdev_raid.h" ++#include "bdev_raid_outcomes.h" /* Evariops 0014.5 */ + #include "spdk/nvme_spec.h" /* Evariops C-1 */ + #include "spdk/env.h" + #include "spdk/thread.h" +@@ -91,6 +92,10 @@ struct raid_bdev_process { + /* Cursor into seed_ranges — windows only ever advance, so passed + * ranges never need re-scanning. */ + uint32_t seed_range_idx; ++ /* Evariops 0014.5 (RPC-CONTRACT §2): registry entry for this attempt. ++ * NULL when the registry could not allocate — the rebuild still runs, ++ * it is just unobservable there. Sealed exactly once at finish. */ ++ struct raid_rebuild_outcome *outcome; + }; + + struct raid_process_finish_action { +@@ -2726,6 +2731,27 @@ raid_bdev_process_finish_done(void *ctx) + + process->state = RAID_PROCESS_STATE_STOPPED; + ++ /* Evariops 0014.5 (RPC-CONTRACT §2): seal the registry entry. The two ++ * cancellation causes — raid destroy (-ECANCELED) and member removal ++ * (-ENODEV) — are CANCELED, not FAILED: the copy process itself never ++ * broke, its subject went away. All process writes have ceased at this ++ * point, so a bdev_raid_delete reply (which is gated behind this ++ * process stopping) honors "no writes after the RPC returns" (T-D5). ++ * verified stays false until 0014.8 delivers the integrated verify. */ ++ if (process->outcome != NULL) { ++ enum raid_rebuild_outcome_state terminal; ++ ++ if (process->status == 0) { ++ terminal = RAID_REBUILD_OUTCOME_SUCCEEDED; ++ } else if (process->status == -ECANCELED || process->status == -ENODEV) { ++ terminal = RAID_REBUILD_OUTCOME_CANCELED; ++ } else { ++ terminal = RAID_REBUILD_OUTCOME_FAILED; ++ } ++ raid_rebuild_outcome_finish(process->outcome, terminal, false); ++ process->outcome = NULL; ++ } ++ + if (process->status == 0) { + SPDK_NOTICELOG("Finished %s on raid bdev %s\n", + raid_bdev_process_to_str(process->type), +@@ -2919,6 +2945,12 @@ raid_bdev_process_request_complete(struct raid_bdev_process_request *process_req + + if (status != 0) { + process->window_status = status; ++ } else { ++ /* Evariops 0014.5: bytes actually copied (seeded fast-skip ++ * windows never reach here — the count stays honest). */ ++ raid_rebuild_outcome_add_bytes(process->outcome, ++ (uint64_t)process_req->num_blocks * ++ process->raid_bdev->bdev.blocklen); + } + + process->window_remaining -= process_req->num_blocks; +@@ -3314,6 +3346,13 @@ raid_bdev_process_free(struct raid_bdev_process *process) + /* SPEC-74 M6: the seeded process owns its ranges — every teardown path + * (finish or aborted start) goes through here. */ + free(process->seed_ranges); ++ /* Evariops 0014.5: safety net for teardown paths that bypass ++ * raid_bdev_process_finish_done (aborted channel start) — a RUNNING ++ * entry with no owner would otherwise never seal nor expire. */ ++ if (process->outcome != NULL) { ++ raid_rebuild_outcome_finish(process->outcome, RAID_REBUILD_OUTCOME_FAILED, false); ++ process->outcome = NULL; ++ } + free(process); + } + +@@ -3365,6 +3404,7 @@ static int + raid_bdev_start_rebuild(struct raid_base_bdev_info *target) + { + struct raid_bdev_process *process; ++ char token[RAID_REBUILD_TOKEN_MAX]; + + assert(spdk_get_thread() == spdk_thread_get_app_thread()); + +@@ -3373,6 +3413,15 @@ raid_bdev_start_rebuild(struct raid_base_bdev_info *target) + return -ENOMEM; + } + ++ /* Evariops 0014.5 (RPC-CONTRACT §2): the full rebuild feeds the SAME ++ * registry as seeded attempts, under an auto-generated token (there is ++ * no control-plane-provided one on this path). */ ++ snprintf(token, sizeof(token), "auto:%s:%s:%" PRIu64, ++ target->raid_bdev->bdev.name, target->name != NULL ? target->name : "?", ++ spdk_get_ticks()); ++ process->outcome = raid_rebuild_outcome_open(token, target->raid_bdev->bdev.name, ++ target->name); ++ + raid_bdev_process_start(process); + + return 0; +@@ -3386,9 +3435,11 @@ raid_bdev_start_rebuild(struct raid_base_bdev_info *target) + * ownership of \c ranges on success (freed with the process). */ + int + raid_bdev_start_seeded_rebuild(struct raid_base_bdev_info *target, +- struct raid_bdev_seed_range *ranges, uint32_t num_ranges) ++ struct raid_bdev_seed_range *ranges, uint32_t num_ranges, ++ const char *rebuild_token) + { + struct raid_bdev_process *process; ++ char auto_token[RAID_REBUILD_TOKEN_MAX]; + + assert(spdk_get_thread() == spdk_thread_get_app_thread()); + +@@ -3405,6 +3456,18 @@ raid_bdev_start_seeded_rebuild(struct raid_base_bdev_info *target, + process->seed_ranges = ranges; + process->num_seed_ranges = num_ranges; + ++ /* Evariops 0014.5 (RPC-CONTRACT §2): record the attempt under the ++ * control-plane token (deterministic per attempt — retries re-open the ++ * same entry) or an auto one when driven by rescue tooling. */ ++ if (rebuild_token == NULL || rebuild_token[0] == '\0') { ++ snprintf(auto_token, sizeof(auto_token), "auto:%s:%s:%" PRIu64, ++ target->raid_bdev->bdev.name, target->name != NULL ? target->name : "?", ++ spdk_get_ticks()); ++ rebuild_token = auto_token; ++ } ++ process->outcome = raid_rebuild_outcome_open(rebuild_token, ++ target->raid_bdev->bdev.name, target->name); ++ + raid_bdev_process_start(process); + + return 0; +diff --git a/module/bdev/raid/bdev_raid.h b/module/bdev/raid/bdev_raid.h +index 08471e4..43d76da 100644 +--- a/module/bdev/raid/bdev_raid.h ++++ b/module/bdev/raid/bdev_raid.h +@@ -322,7 +322,8 @@ struct raid_bdev_seed_range { + }; + + int raid_bdev_start_seeded_rebuild(struct raid_base_bdev_info *target, +- struct raid_bdev_seed_range *ranges, uint32_t num_ranges); ++ struct raid_bdev_seed_range *ranges, uint32_t num_ranges, ++ const char *rebuild_token); + struct raid_bdev *raid_bdev_find_by_name(const char *name); + enum raid_level raid_bdev_str_to_level(const char *str); + const char *raid_bdev_level_to_str(enum raid_level level); +diff --git a/module/bdev/raid/bdev_raid_outcomes.c b/module/bdev/raid/bdev_raid_outcomes.c +new file mode 100644 +index 0000000..bfbcbb0 +--- /dev/null ++++ b/module/bdev/raid/bdev_raid_outcomes.c +@@ -0,0 +1,236 @@ ++/* SPDX-License-Identifier: BSD-3-Clause ++ * Copyright (C) 2026 Evariops. ++ * All rights reserved. ++ */ ++ ++/* Evariops 0014.5 (spdk-csi RPC-CONTRACT §2): see bdev_raid_outcomes.h. */ ++ ++#include "bdev_raid_outcomes.h" ++ ++#include "spdk/env.h" ++#include "spdk/log.h" ++#include "spdk/queue.h" ++#include "spdk/thread.h" ++ ++struct raid_rebuild_outcome { ++ char token[RAID_REBUILD_TOKEN_MAX]; ++ char *raid_name; ++ char *base_bdev_name; ++ enum raid_rebuild_outcome_state state; ++ uint64_t bytes; ++ bool verified; ++ /* Wall clock, reported to the control plane. 0 while non-terminal. */ ++ int64_t finished_at; ++ /* Monotonic deadline (ticks) for the lazy TTL purge. 0 while non-terminal. */ ++ uint64_t expire_ticks; ++ TAILQ_ENTRY(raid_rebuild_outcome) link; ++}; ++ ++static TAILQ_HEAD(, raid_rebuild_outcome) g_raid_rebuild_outcomes = ++ TAILQ_HEAD_INITIALIZER(g_raid_rebuild_outcomes); ++ ++/* Entries are opened on the app thread but mutated from process threads and ++ * read from RPC (app) and CBT contexts — a spinlock keeps the registry ++ * consistent without cross-thread messaging. */ ++static struct spdk_spinlock g_raid_rebuild_outcomes_lock; ++ ++__attribute__((constructor)) static void ++raid_rebuild_outcomes_init(void) ++{ ++ spdk_spin_init(&g_raid_rebuild_outcomes_lock); ++} ++ ++static void ++raid_rebuild_outcome_free(struct raid_rebuild_outcome *outcome) ++{ ++ free(outcome->raid_name); ++ free(outcome->base_bdev_name); ++ free(outcome); ++} ++ ++/* Lazy TTL purge — must be called with the lock held. */ ++static void ++raid_rebuild_outcomes_purge_expired(void) ++{ ++ struct raid_rebuild_outcome *outcome, *tmp; ++ uint64_t now = spdk_get_ticks(); ++ ++ TAILQ_FOREACH_SAFE(outcome, &g_raid_rebuild_outcomes, link, tmp) { ++ if (outcome->expire_ticks != 0 && now > outcome->expire_ticks) { ++ TAILQ_REMOVE(&g_raid_rebuild_outcomes, outcome, link); ++ raid_rebuild_outcome_free(outcome); ++ } ++ } ++} ++ ++static struct raid_rebuild_outcome * ++raid_rebuild_outcome_find(const char *token) ++{ ++ struct raid_rebuild_outcome *outcome; ++ ++ TAILQ_FOREACH(outcome, &g_raid_rebuild_outcomes, link) { ++ if (strcmp(outcome->token, token) == 0) { ++ return outcome; ++ } ++ } ++ ++ return NULL; ++} ++ ++struct raid_rebuild_outcome * ++raid_rebuild_outcome_open(const char *token, const char *raid_name, const char *base_bdev_name) ++{ ++ struct raid_rebuild_outcome *outcome; ++ ++ assert(token != NULL); ++ assert(strnlen(token, RAID_REBUILD_TOKEN_MAX) < RAID_REBUILD_TOKEN_MAX); ++ ++ spdk_spin_lock(&g_raid_rebuild_outcomes_lock); ++ ++ raid_rebuild_outcomes_purge_expired(); ++ ++ /* Control-plane retries reuse deterministic tokens: a re-open of the ++ * same attempt resets the entry rather than duplicating it. */ ++ outcome = raid_rebuild_outcome_find(token); ++ if (outcome == NULL) { ++ outcome = calloc(1, sizeof(*outcome)); ++ if (outcome == NULL) { ++ spdk_spin_unlock(&g_raid_rebuild_outcomes_lock); ++ SPDK_ERRLOG("Failed to allocate rebuild outcome for token '%s' — " ++ "the attempt will be unobservable in the registry\n", token); ++ return NULL; ++ } ++ snprintf(outcome->token, sizeof(outcome->token), "%s", token); ++ TAILQ_INSERT_TAIL(&g_raid_rebuild_outcomes, outcome, link); ++ } else { ++ free(outcome->raid_name); ++ free(outcome->base_bdev_name); ++ } ++ ++ outcome->raid_name = strdup(raid_name != NULL ? raid_name : ""); ++ outcome->base_bdev_name = strdup(base_bdev_name != NULL ? base_bdev_name : ""); ++ outcome->state = RAID_REBUILD_OUTCOME_RUNNING; ++ outcome->bytes = 0; ++ outcome->verified = false; ++ outcome->finished_at = 0; ++ outcome->expire_ticks = 0; ++ ++ spdk_spin_unlock(&g_raid_rebuild_outcomes_lock); ++ ++ return outcome; ++} ++ ++void ++raid_rebuild_outcome_add_bytes(struct raid_rebuild_outcome *outcome, uint64_t bytes) ++{ ++ if (outcome == NULL) { ++ return; ++ } ++ ++ spdk_spin_lock(&g_raid_rebuild_outcomes_lock); ++ outcome->bytes += bytes; ++ spdk_spin_unlock(&g_raid_rebuild_outcomes_lock); ++} ++ ++void ++raid_rebuild_outcome_set_verifying(struct raid_rebuild_outcome *outcome) ++{ ++ if (outcome == NULL) { ++ return; ++ } ++ ++ spdk_spin_lock(&g_raid_rebuild_outcomes_lock); ++ outcome->state = RAID_REBUILD_OUTCOME_VERIFYING; ++ spdk_spin_unlock(&g_raid_rebuild_outcomes_lock); ++} ++ ++void ++raid_rebuild_outcome_finish(struct raid_rebuild_outcome *outcome, ++ enum raid_rebuild_outcome_state terminal, bool verified) ++{ ++ if (outcome == NULL) { ++ return; ++ } ++ ++ assert(terminal != RAID_REBUILD_OUTCOME_RUNNING && ++ terminal != RAID_REBUILD_OUTCOME_VERIFYING); ++ ++ spdk_spin_lock(&g_raid_rebuild_outcomes_lock); ++ outcome->state = terminal; ++ outcome->verified = verified; ++ outcome->finished_at = (int64_t)time(NULL); ++ outcome->expire_ticks = spdk_get_ticks() + ++ RAID_REBUILD_OUTCOME_TTL_SEC * spdk_get_ticks_hz(); ++ spdk_spin_unlock(&g_raid_rebuild_outcomes_lock); ++} ++ ++bool ++raid_rebuild_outcome_is_succeeded_verified(const char *token) ++{ ++ struct raid_rebuild_outcome *outcome; ++ bool ok = false; ++ ++ if (token == NULL || token[0] == '\0') { ++ return false; ++ } ++ ++ spdk_spin_lock(&g_raid_rebuild_outcomes_lock); ++ raid_rebuild_outcomes_purge_expired(); ++ outcome = raid_rebuild_outcome_find(token); ++ if (outcome != NULL) { ++ ok = outcome->state == RAID_REBUILD_OUTCOME_SUCCEEDED && outcome->verified; ++ } ++ spdk_spin_unlock(&g_raid_rebuild_outcomes_lock); ++ ++ return ok; ++} ++ ++static const char * ++raid_rebuild_outcome_state_name(enum raid_rebuild_outcome_state state) ++{ ++ switch (state) { ++ case RAID_REBUILD_OUTCOME_RUNNING: ++ return "running"; ++ case RAID_REBUILD_OUTCOME_VERIFYING: ++ return "verifying"; ++ case RAID_REBUILD_OUTCOME_SUCCEEDED: ++ return "succeeded"; ++ case RAID_REBUILD_OUTCOME_FAILED: ++ return "failed"; ++ case RAID_REBUILD_OUTCOME_DIVERGENT: ++ return "divergent"; ++ case RAID_REBUILD_OUTCOME_CANCELED: ++ return "canceled"; ++ default: ++ return "unknown"; ++ } ++} ++ ++void ++raid_rebuild_outcomes_write_json(struct spdk_json_write_ctx *w, const char *token_filter) ++{ ++ struct raid_rebuild_outcome *outcome; ++ ++ spdk_json_write_array_begin(w); ++ ++ spdk_spin_lock(&g_raid_rebuild_outcomes_lock); ++ raid_rebuild_outcomes_purge_expired(); ++ TAILQ_FOREACH(outcome, &g_raid_rebuild_outcomes, link) { ++ if (token_filter != NULL && strcmp(outcome->token, token_filter) != 0) { ++ continue; ++ } ++ spdk_json_write_object_begin(w); ++ spdk_json_write_named_string(w, "token", outcome->token); ++ spdk_json_write_named_string(w, "raid_bdev", outcome->raid_name); ++ spdk_json_write_named_string(w, "base_bdev", outcome->base_bdev_name); ++ spdk_json_write_named_string(w, "state", ++ raid_rebuild_outcome_state_name(outcome->state)); ++ spdk_json_write_named_uint64(w, "bytes", outcome->bytes); ++ spdk_json_write_named_bool(w, "verified", outcome->verified); ++ spdk_json_write_named_int64(w, "finished_at", outcome->finished_at); ++ spdk_json_write_object_end(w); ++ } ++ spdk_spin_unlock(&g_raid_rebuild_outcomes_lock); ++ ++ spdk_json_write_array_end(w); ++} +diff --git a/module/bdev/raid/bdev_raid_outcomes.h b/module/bdev/raid/bdev_raid_outcomes.h +new file mode 100644 +index 0000000..a8660cc +--- /dev/null ++++ b/module/bdev/raid/bdev_raid_outcomes.h +@@ -0,0 +1,65 @@ ++/* SPDX-License-Identifier: BSD-3-Clause ++ * Copyright (C) 2026 Evariops. ++ * All rights reserved. ++ */ ++ ++/* Evariops 0014.5 (spdk-csi RPC-CONTRACT §2): process-wide rebuild outcome ++ * registry. Records the fate of every rebuild attempt — seeded or full — under ++ * an opaque token, and survives the raid bdev itself (a destroyed nexus must ++ * still be able to answer "what happened to attempt ?" for 15 minutes). ++ * The registry is the fast-path proof; the durable proof stays the superblock. ++ * ++ * This header is the only coupling surface for other modules: the CBT module ++ * consults raid_rebuild_outcome_is_succeeded_verified() before allowing ++ * bdev_cbt_epoch_close(mode=consumed) to discard a frozen delta (§4). ++ */ ++ ++#ifndef SPDK_BDEV_RAID_OUTCOMES_H ++#define SPDK_BDEV_RAID_OUTCOMES_H ++ ++#include "spdk/stdinc.h" ++#include "spdk/json.h" ++ ++/* Max token length including the NUL terminator (contract: tokens are ++ * control-plane-generated, deterministic per attempt). */ ++#define RAID_REBUILD_TOKEN_MAX 192 ++ ++/* Terminal entries are purged this long after finishing (contract §2). */ ++#define RAID_REBUILD_OUTCOME_TTL_SEC 900 ++ ++enum raid_rebuild_outcome_state { ++ RAID_REBUILD_OUTCOME_RUNNING = 0, ++ RAID_REBUILD_OUTCOME_VERIFYING, ++ RAID_REBUILD_OUTCOME_SUCCEEDED, ++ RAID_REBUILD_OUTCOME_FAILED, ++ RAID_REBUILD_OUTCOME_DIVERGENT, ++ RAID_REBUILD_OUTCOME_CANCELED, ++}; ++ ++struct raid_rebuild_outcome; ++ ++/* Open (or reset — control-plane retries reuse deterministic tokens) the ++ * registry entry for a new rebuild attempt. Returns NULL on allocation ++ * failure; every other function below is NULL-safe so a rebuild without an ++ * entry degrades to "unobservable", never to a crash. */ ++struct raid_rebuild_outcome *raid_rebuild_outcome_open(const char *token, ++ const char *raid_name, const char *base_bdev_name); ++ ++void raid_rebuild_outcome_add_bytes(struct raid_rebuild_outcome *outcome, uint64_t bytes); ++ ++/* 0014.8 wires this: the integrated verify phase reports through the registry. */ ++void raid_rebuild_outcome_set_verifying(struct raid_rebuild_outcome *outcome); ++ ++/* Seal the entry. Must be called exactly once per opened entry; the pointer ++ * must not be used afterwards (the registry owns the entry's lifetime). */ ++void raid_rebuild_outcome_finish(struct raid_rebuild_outcome *outcome, ++ enum raid_rebuild_outcome_state terminal, bool verified); ++ ++/* RPC-CONTRACT §4: bdev_cbt_epoch_close(mode=consumed) gate. */ ++bool raid_rebuild_outcome_is_succeeded_verified(const char *token); ++ ++/* Write the registry (optionally filtered by token) as a JSON array of ++ * {token, state, bytes, verified, finished_at}. */ ++void raid_rebuild_outcomes_write_json(struct spdk_json_write_ctx *w, const char *token_filter); ++ ++#endif /* SPDK_BDEV_RAID_OUTCOMES_H */ +diff --git a/module/bdev/raid/bdev_raid_rpc.c b/module/bdev/raid/bdev_raid_rpc.c +index 8a14573..f65ea35 100644 +--- a/module/bdev/raid/bdev_raid_rpc.c ++++ b/module/bdev/raid/bdev_raid_rpc.c +@@ -6,6 +6,7 @@ + #include "spdk/rpc.h" + #include "spdk/bdev.h" + #include "bdev_raid.h" ++#include "bdev_raid_outcomes.h" /* Evariops 0014.5 */ + #include "spdk/util.h" + #include "spdk/string.h" + #include "spdk/log.h" +@@ -625,6 +626,10 @@ struct rpc_bdev_raid_start_seeded_rebuild { + + /* Evariops 0014.4: -ESTALE on mismatch (see bdev_raid_delete) */ + char *expected_incarnation; ++ ++ /* Evariops 0014.5 (RPC-CONTRACT §2): control-plane token identifying ++ * this attempt in the rebuild outcome registry */ ++ char *rebuild_token; + }; + + static void +@@ -634,6 +639,7 @@ free_rpc_bdev_raid_start_seeded_rebuild(struct rpc_bdev_raid_start_seeded_rebuil + free(req->base_bdev); + free(req->ranges); + free(req->expected_incarnation); ++ free(req->rebuild_token); + } + + static int +@@ -670,6 +676,7 @@ static const struct spdk_json_object_decoder rpc_bdev_raid_start_seeded_rebuild_ + {"base_bdev", offsetof(struct rpc_bdev_raid_start_seeded_rebuild, base_bdev), spdk_json_decode_string}, + {"ranges", offsetof(struct rpc_bdev_raid_start_seeded_rebuild, ranges), decode_seed_ranges}, + {"expected_incarnation", offsetof(struct rpc_bdev_raid_start_seeded_rebuild, expected_incarnation), spdk_json_decode_string, true}, ++ {"rebuild_token", offsetof(struct rpc_bdev_raid_start_seeded_rebuild, rebuild_token), spdk_json_decode_string, true}, + }; + + static int +@@ -776,6 +783,15 @@ rpc_bdev_raid_start_seeded_rebuild(struct spdk_jsonrpc_request *request, + } + } + ++ /* Evariops 0014.5: the registry token is bounded (contract §2). */ ++ if (req.rebuild_token != NULL && ++ strnlen(req.rebuild_token, RAID_REBUILD_TOKEN_MAX) == RAID_REBUILD_TOKEN_MAX) { ++ spdk_jsonrpc_send_error_response_fmt(request, -ENAMETOOLONG, ++ "rebuild_token exceeds %d characters", ++ RAID_REBUILD_TOKEN_MAX - 1); ++ goto cleanup; ++ } ++ + /* SEC1: audit — this arms a background writer on the array (patch 0011 + * discipline for destructive RPCs). */ + snprintf(detail, sizeof(detail), "raid=%s base=%s ranges=%zu", +@@ -793,7 +809,8 @@ rpc_bdev_raid_start_seeded_rebuild(struct spdk_jsonrpc_request *request, + } + req.ranges = NULL; + +- rc = raid_bdev_start_seeded_rebuild(base_info, ranges, (uint32_t)req.num_ranges); ++ rc = raid_bdev_start_seeded_rebuild(base_info, ranges, (uint32_t)req.num_ranges, ++ req.rebuild_token); + if (rc != 0) { + free(ranges); + spdk_jsonrpc_send_error_response_fmt(request, rc, +@@ -892,6 +909,46 @@ err: + } + SPDK_RPC_REGISTER("bdev_raid_remove_base_bdev", rpc_bdev_raid_remove_base_bdev, SPDK_RPC_RUNTIME) + ++/* ---- Evariops 0014.5 (RPC-CONTRACT §2): bdev_raid_get_rebuild_outcomes ++ * {token?} — read the process-wide rebuild outcome registry. Entries survive ++ * the raid bdev (15 min TTL after finishing); the control plane matches by ++ * token (D12 extinguishes debt only against a succeeded+verified entry, D13 ++ * reacts to failed/divergent/canceled ones). ---- */ ++ ++struct rpc_bdev_raid_get_rebuild_outcomes { ++ /* Optional token filter */ ++ char *token; ++}; ++ ++static const struct spdk_json_object_decoder rpc_bdev_raid_get_rebuild_outcomes_decoders[] = { ++ {"token", offsetof(struct rpc_bdev_raid_get_rebuild_outcomes, token), spdk_json_decode_string, true}, ++}; ++ ++static void ++rpc_bdev_raid_get_rebuild_outcomes(struct spdk_jsonrpc_request *request, ++ const struct spdk_json_val *params) ++{ ++ struct rpc_bdev_raid_get_rebuild_outcomes req = {}; ++ struct spdk_json_write_ctx *w; ++ ++ if (params != NULL && ++ spdk_json_decode_object(params, rpc_bdev_raid_get_rebuild_outcomes_decoders, ++ SPDK_COUNTOF(rpc_bdev_raid_get_rebuild_outcomes_decoders), ++ &req)) { ++ spdk_jsonrpc_send_error_response(request, SPDK_JSONRPC_ERROR_PARSE_ERROR, ++ "spdk_json_decode_object failed"); ++ return; ++ } ++ ++ w = spdk_jsonrpc_begin_result(request); ++ raid_rebuild_outcomes_write_json(w, req.token); ++ spdk_jsonrpc_end_result(request, w); ++ ++ free(req.token); ++} ++SPDK_RPC_REGISTER("bdev_raid_get_rebuild_outcomes", rpc_bdev_raid_get_rebuild_outcomes, ++ SPDK_RPC_RUNTIME) ++ + static const struct spdk_json_object_decoder rpc_bdev_raid_set_options_decoders[] = { + {"process_window_size_kb", offsetof(struct spdk_raid_bdev_opts, process_window_size_kb), spdk_json_decode_uint32, true}, + {"process_max_bandwidth_mb_sec", offsetof(struct spdk_raid_bdev_opts, process_max_bandwidth_mb_sec), spdk_json_decode_uint32, true}, diff --git a/patches/0016-raid-extended-superblock.patch b/patches/0016-raid-extended-superblock.patch new file mode 100644 index 0000000..f08603b --- /dev/null +++ b/patches/0016-raid-extended-superblock.patch @@ -0,0 +1,164 @@ +diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c +index a88a237..ae78a08 100644 +--- a/module/bdev/raid/bdev_raid.c ++++ b/module/bdev/raid/bdev_raid.c +@@ -2203,12 +2203,32 @@ raid_bdev_remove_base_bdev_on_quiesced(void *ctx, int status) + + if (sb_base_bdev->state == RAID_SB_BASE_BDEV_CONFIGURED && + sb_base_bdev->slot == slot) { ++ uint8_t j; ++ + if (base_info->is_failed) { + sb_base_bdev->state = RAID_SB_BASE_BDEV_FAILED; + } else { + sb_base_bdev->state = RAID_SB_BASE_BDEV_MISSING; + } + ++ /* Evariops 0014.7 (V-2, RecordDivergence): the ++ * survivors advance their content generation in the ++ * SAME superblock transaction that records the ++ * ejection — from here on, writes land on a content ++ * the ejected member provably does not have. The ++ * ejected slot keeps its (now lagging) generation. */ ++ for (j = 0; j < sb->base_bdevs_size; j++) { ++ struct raid_bdev_sb_base_bdev *survivor = &sb->base_bdevs[j]; ++ ++ if (survivor->state == RAID_SB_BASE_BDEV_CONFIGURED && ++ survivor->slot != slot && ++ survivor->slot < raid_bdev->num_base_bdevs) { ++ survivor->content_generation++; ++ raid_bdev->base_bdev_info[survivor->slot].content_generation = ++ survivor->content_generation; ++ } ++ } ++ + raid_bdev_write_superblock(raid_bdev, raid_bdev_remove_base_bdev_write_sb_cb, base_info); + return; + } +@@ -2621,6 +2641,27 @@ raid_bdev_process_finish_write_sb_cb(int status, struct raid_bdev *raid_bdev, vo + } + } + ++/* Evariops 0014.7 (V-2): the generation the survivors carry — a member joining ++ * the array adopts it in the same superblock transaction as its CONFIGURED ++ * flip (its content is now provably that of the survivors). */ ++static uint64_t ++raid_bdev_sb_max_content_generation(const struct raid_bdev_superblock *sb) ++{ ++ uint64_t max_gen = 0; ++ uint8_t i; ++ ++ for (i = 0; i < sb->base_bdevs_size; i++) { ++ const struct raid_bdev_sb_base_bdev *sb_base_bdev = &sb->base_bdevs[i]; ++ ++ if (sb_base_bdev->state == RAID_SB_BASE_BDEV_CONFIGURED && ++ sb_base_bdev->content_generation > max_gen) { ++ max_gen = sb_base_bdev->content_generation; ++ } ++ } ++ ++ return max_gen; ++} ++ + static void + raid_bdev_process_finish_write_sb(void *ctx) + { +@@ -2628,6 +2669,7 @@ raid_bdev_process_finish_write_sb(void *ctx) + struct raid_bdev_superblock *sb = raid_bdev->sb; + struct raid_bdev_sb_base_bdev *sb_base_bdev; + struct raid_base_bdev_info *base_info; ++ uint64_t adopted_gen = raid_bdev_sb_max_content_generation(sb); + uint8_t i; + + for (i = 0; i < sb->base_bdevs_size; i++) { +@@ -2645,6 +2687,10 @@ raid_bdev_process_finish_write_sb(void *ctx) + sb_base_bdev->state = RAID_SB_BASE_BDEV_CONFIGURED; + sb_base_bdev->data_offset = base_info->data_offset; + spdk_uuid_copy(&sb_base_bdev->uuid, &base_info->uuid); ++ /* Evariops 0014.7: adopt the survivors' generation in ++ * the SAME transaction as the CONFIGURED flip. */ ++ sb_base_bdev->content_generation = adopted_gen; ++ base_info->content_generation = adopted_gen; + } + } + } +@@ -3533,9 +3579,17 @@ raid_bdev_skip_rebuild_unquiesced(void *ctx, int status) + for (i = 0; i < sb->base_bdevs_size; i++) { + sb_base_bdev = &sb->base_bdevs[i]; + if (sb_base_bdev->slot == raid_bdev_base_bdev_slot(base_info)) { ++ /* Evariops 0014.7: compute the adopted generation BEFORE ++ * flipping this slot (a stale slot generation must not ++ * win the max). Same-transaction adoption, like the ++ * rebuild-completion path. */ ++ uint64_t adopted_gen = raid_bdev_sb_max_content_generation(sb); ++ + sb_base_bdev->state = RAID_SB_BASE_BDEV_CONFIGURED; + sb_base_bdev->data_offset = base_info->data_offset; + spdk_uuid_copy(&sb_base_bdev->uuid, &base_info->uuid); ++ sb_base_bdev->content_generation = adopted_gen; ++ base_info->content_generation = adopted_gen; + break; + } + } +@@ -4114,6 +4168,12 @@ raid_bdev_create_from_sb(const struct raid_bdev_superblock *sb, struct raid_bdev + + base_info->data_offset = sb_base_bdev->data_offset; + base_info->data_size = sb_base_bdev->data_size; ++ /* Evariops 0014.7 (V-2): extended reassembly — restore the ++ * per-member generation facts for ALL slots (a FAILED/MISSING ++ * member keeps its stale generation: that lag is exactly what ++ * the control-plane's cold-recovery pass reads). */ ++ base_info->content_generation = sb_base_bdev->content_generation; ++ base_info->view_epoch = sb_base_bdev->view_epoch; + } + + *raid_bdev_out = raid_bdev; +diff --git a/module/bdev/raid/bdev_raid.h b/module/bdev/raid/bdev_raid.h +index 43d76da..7e7351a 100644 +--- a/module/bdev/raid/bdev_raid.h ++++ b/module/bdev/raid/bdev_raid.h +@@ -134,6 +134,12 @@ struct raid_base_bdev_info { + * the SB slot must stay un-promoted (never CONFIGURED while the data + * region may still be stale). */ + bool write_only_pending; ++ ++ /* Evariops 0014.7 (V-2): RAM mirror of the superblock per-member ++ * content_generation / view_epoch (source of truth = the SB; these keep ++ * get_bdevs cheap and survive between SB writes). */ ++ uint64_t content_generation; ++ uint64_t view_epoch; + }; + + struct raid_bdev_io; +@@ -525,7 +531,10 @@ raid_bdev_flush_blocks(struct raid_base_bdev_info *base_info, struct spdk_io_cha + */ + + #define RAID_BDEV_SB_VERSION_MAJOR 1 +-#define RAID_BDEV_SB_VERSION_MINOR 0 ++/* Evariops 0014.7 (spdk-csi RPC-CONTRACT §8, V-2): minor 1 adds per-member ++ * content_generation + view_epoch (carved out of reserved bytes — a minor-0 ++ * superblock reads back as generation 0 / epoch 0). */ ++#define RAID_BDEV_SB_VERSION_MINOR 1 + + #define RAID_BDEV_SB_NAME_SIZE 64 + +@@ -550,7 +559,18 @@ struct raid_bdev_sb_base_bdev { + /* slot number of this base bdev in the raid */ + uint8_t slot; + +- uint8_t reserved[23]; ++ uint8_t reserved0[7]; ++ ++ /* Evariops 0014.7 (V-2): survivors increment this in the SAME superblock ++ * transaction that records a member's ejection (RecordDivergence); a ++ * completing rebuild target adopts the survivors' generation in the same ++ * transaction as its CONFIGURED flip. A member whose generation lags is ++ * provably stale — the cold-recovery pass reads exactly this. */ ++ uint64_t content_generation; ++ ++ /* Evariops 0014.7 (V-2): view epoch this member last participated in. ++ * Persisted/reassembled here; mutated by the view protocol (W3). */ ++ uint64_t view_epoch; + }; + SPDK_STATIC_ASSERT(sizeof(struct raid_bdev_sb_base_bdev) == 64, "incorrect size"); + diff --git a/patches/0017-raid-member-observation.patch b/patches/0017-raid-member-observation.patch new file mode 100644 index 0000000..ceb8805 --- /dev/null +++ b/patches/0017-raid-member-observation.patch @@ -0,0 +1,160 @@ +diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c +index ae78a08..de4ec92 100644 +--- a/module/bdev/raid/bdev_raid.c ++++ b/module/bdev/raid/bdev_raid.c +@@ -6,6 +6,12 @@ + + #include "bdev_raid.h" + #include "bdev_raid_outcomes.h" /* Evariops 0014.5 */ ++/* Evariops 0014.6: per-member epoch facts come from the cbt module wrapping ++ * each member (nexus topology: raid1 → cbt → nvme). The module dir is injected ++ * before patches apply (Dockerfile copy-before-apply); symbol resolves via ++ * --whole-archive. This patch (0017) requires the cbt module, like 0005 ++ * requires tier. */ ++#include "../cbt/vbdev_cbt_query.h" + #include "spdk/nvme_spec.h" /* Evariops C-1 */ + #include "spdk/env.h" + #include "spdk/thread.h" +@@ -447,6 +453,14 @@ raid_bdev_cleanup_and_free(struct raid_bdev *raid_bdev) + raid_bdev_free(raid_bdev); + } + ++/* Evariops 0014.6 (RPC-CONTRACT §3): stamp an observable member-state change — ++ * the control-plane's anti-flap input ("since" in get_bdevs). */ ++static void ++raid_bdev_base_bdev_state_touch(struct raid_base_bdev_info *base_info) ++{ ++ base_info->state_since = (int64_t)time(NULL); ++} ++ + static void + raid_bdev_deconfigure_base_bdev(struct raid_base_bdev_info *base_info) + { +@@ -457,6 +471,7 @@ raid_bdev_deconfigure_base_bdev(struct raid_base_bdev_info *base_info) + raid_bdev->num_base_bdevs_discovered--; + base_info->is_configured = false; + base_info->is_process_target = false; ++ raid_bdev_base_bdev_state_touch(base_info); + /* SPEC-74 M6: per-add request flags and seeded-rebuild state die with the + * membership — a reused slot (or an SB re-examine of a returning disk) + * must not inherit a stale skip_rebuild/write_only request or a stale +@@ -489,6 +504,7 @@ raid_bdev_free_base_bdev_resource(struct raid_base_bdev_info *base_info) + spdk_uuid_set_null(&base_info->uuid); + } + base_info->is_failed = false; ++ raid_bdev_base_bdev_state_touch(base_info); + + /* clear `data_offset` to allow it to be recalculated during configuration */ + base_info->data_offset = 0; +@@ -1160,6 +1176,28 @@ raid_bdev_get_io_channel(void *ctxt) + return spdk_get_io_channel(raid_bdev); + } + ++/* Evariops 0014.6 (RPC-CONTRACT §3): one derived state per member — the ++ * control-plane consumes THIS, not the raw flag soup. Precedence: a failed ++ * member is failed whatever else it claims; a write_only-pending member serves ++ * writes but is not yet content-complete; configured = full member. */ ++static const char * ++raid_bdev_base_bdev_state_name(const struct raid_base_bdev_info *base_info) ++{ ++ if (base_info->is_failed) { ++ return "failed"; ++ } ++ if (base_info->write_only_pending) { ++ return "write_only"; ++ } ++ if (base_info->is_configured) { ++ return "configured"; ++ } ++ if (base_info->name != NULL) { ++ return "configuring"; ++ } ++ return "absent"; ++} ++ + void + raid_bdev_write_info_json(struct raid_bdev *raid_bdev, struct spdk_json_write_ctx *w) + { +@@ -1221,6 +1259,26 @@ raid_bdev_write_info_json(struct raid_bdev *raid_bdev, struct spdk_json_write_ct + spdk_json_write_named_bool(w, "is_configured", base_info->is_configured); + spdk_json_write_named_uint64(w, "data_offset", base_info->data_offset); + spdk_json_write_named_uint64(w, "data_size", base_info->data_size); ++ /* Evariops 0014.6 (RPC-CONTRACT §3): the control-plane's member ++ * observation — derived state + hysteresis stamp + V-2 generation ++ * facts + the wrapping cbt's live-epoch facts (EpochObservation ++ * source; absent when the member is not cbt-wrapped or tracks no ++ * epoch). */ ++ spdk_json_write_named_string(w, "state", ++ raid_bdev_base_bdev_state_name(base_info)); ++ spdk_json_write_named_int64(w, "since", base_info->state_since); ++ spdk_json_write_named_uint64(w, "content_generation", ++ base_info->content_generation); ++ spdk_json_write_named_uint64(w, "view_epoch", base_info->view_epoch); ++ if (base_info->name != NULL) { ++ struct vbdev_cbt_epoch_facts facts; ++ ++ if (vbdev_cbt_query_latest_epoch(base_info->name, &facts) == 0) { ++ spdk_json_write_named_string(w, "epoch_nonce", facts.nonce); ++ spdk_json_write_named_string(w, "epoch_state", facts.state); ++ spdk_json_write_named_bool(w, "truncated", facts.truncated); ++ } ++ } + spdk_json_write_object_end(w); + } + spdk_json_write_array_end(w); +@@ -2447,6 +2505,7 @@ raid_bdev_fail_base_remove_cb(void *ctx, int status) + if (status != 0) { + SPDK_WARNLOG("Failed to remove base bdev %s\n", base_info->name); + base_info->is_failed = false; ++ raid_bdev_base_bdev_state_touch(base_info); + } + } + +@@ -2460,6 +2519,7 @@ _raid_bdev_fail_base_bdev(void *ctx) + return; + } + base_info->is_failed = true; ++ raid_bdev_base_bdev_state_touch(base_info); + + SPDK_NOTICELOG("Failing base bdev in slot %d ('%s') of raid bdev '%s'\n", + raid_bdev_base_bdev_slot(base_info), base_info->name, base_info->raid_bdev->bdev.name); +@@ -2876,6 +2936,7 @@ raid_bdev_process_finish_quiesced(void *ctx, int status) + * member on the vanilla removal path. */ + process->target->read_excluded = false; + process->target->write_only_pending = false; ++ raid_bdev_base_bdev_state_touch(process->target); + } + + spdk_for_each_channel(process->raid_bdev, raid_bdev_channel_process_finish, process, +@@ -3733,6 +3794,7 @@ raid_bdev_skip_rebuild_quiesced(void *ctx, int status) + * the flags die with the member (raid_bdev_deconfigure_base_bdev). */ + base_info->read_excluded = true; + base_info->write_only_pending = true; ++ raid_bdev_base_bdev_state_touch(base_info); + } + + base_info->is_process_target = false; +@@ -3764,6 +3826,7 @@ raid_bdev_configure_base_bdev_cont(struct raid_base_bdev_info *base_info) + } + + base_info->is_configured = true; ++ raid_bdev_base_bdev_state_touch(base_info); + + raid_bdev->num_base_bdevs_discovered++; + assert(raid_bdev->num_base_bdevs_discovered <= raid_bdev->num_base_bdevs); +diff --git a/module/bdev/raid/bdev_raid.h b/module/bdev/raid/bdev_raid.h +index 7e7351a..2c36491 100644 +--- a/module/bdev/raid/bdev_raid.h ++++ b/module/bdev/raid/bdev_raid.h +@@ -140,6 +140,11 @@ struct raid_base_bdev_info { + * get_bdevs cheap and survive between SB writes). */ + uint64_t content_generation; + uint64_t view_epoch; ++ ++ /* Evariops 0014.6 (RPC-CONTRACT §3): unix seconds of the last observable ++ * member-state change (configured/failed/write_only flips) — the ++ * control-plane's anti-flap/hysteresis input. 0 = never transitioned. */ ++ int64_t state_since; + }; + + struct raid_bdev_io; diff --git a/patches/0018-raid-integrated-verify.patch b/patches/0018-raid-integrated-verify.patch new file mode 100644 index 0000000..b161051 --- /dev/null +++ b/patches/0018-raid-integrated-verify.patch @@ -0,0 +1,514 @@ +diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c +index de4ec92..a523787 100644 +--- a/module/bdev/raid/bdev_raid.c ++++ b/module/bdev/raid/bdev_raid.c +@@ -102,6 +102,14 @@ struct raid_bdev_process { + * NULL when the registry could not allocate — the rebuild still runs, + * it is just unobservable there. Sealed exactly once at finish. */ + struct raid_rebuild_outcome *outcome; ++ /* Evariops 0014.8 (RPC-CONTRACT §10a): integrated verify phase state. ++ * verify_done guards re-entry; verified feeds the registry seal (and ++ * thereby gates bdev_cbt_epoch_close(consumed)); outcome_divergent ++ * routes the seal to DIVERGENT instead of FAILED. */ ++ struct raid_process_verify *verify; ++ bool verify_done; ++ bool verified; ++ bool outcome_divergent; + }; + + struct raid_process_finish_action { +@@ -2843,18 +2851,21 @@ raid_bdev_process_finish_done(void *ctx) + * broke, its subject went away. All process writes have ceased at this + * point, so a bdev_raid_delete reply (which is gated behind this + * process stopping) honors "no writes after the RPC returns" (T-D5). +- * verified stays false until 0014.8 delivers the integrated verify. */ ++ * Evariops 0014.8: verified comes from the integrated verify phase; ++ * a verify double-mismatch seals DIVERGENT, not FAILED. */ + if (process->outcome != NULL) { + enum raid_rebuild_outcome_state terminal; + + if (process->status == 0) { + terminal = RAID_REBUILD_OUTCOME_SUCCEEDED; ++ } else if (process->outcome_divergent) { ++ terminal = RAID_REBUILD_OUTCOME_DIVERGENT; + } else if (process->status == -ECANCELED || process->status == -ENODEV) { + terminal = RAID_REBUILD_OUTCOME_CANCELED; + } else { + terminal = RAID_REBUILD_OUTCOME_FAILED; + } +- raid_rebuild_outcome_finish(process->outcome, terminal, false); ++ raid_rebuild_outcome_finish(process->outcome, terminal, process->verified); + process->outcome = NULL; + } + +@@ -3248,6 +3259,442 @@ raid_bdev_process_lock_window_range(struct raid_bdev_process *process) + return true; + } + ++/* ── Evariops 0014.8 (RPC-CONTRACT §10a): integrated verify phase ────────── ++ * ++ * After the copy completes (window_offset == blockcnt) and before the process ++ * concludes, sample RAID_PROCESS_VERIFY_WINDOWS windows across the copied ++ * extent (the seed ranges for a seeded rebuild, the whole raid otherwise) and ++ * compare the two legs under quiesce_range. A mismatch is re-copied from the ++ * arbiter (the source leg) and re-verified once; a second mismatch under the ++ * same lock proves the copy process itself is broken → DIVERGENT. Honest ++ * scope (T-C12): ~64 MiB of a multi-TiB extent — a PROBABILISTIC process-bug ++ * detector, not an exhaustive one (that is bdev_raid_verify_ranges, §10b). ++ * Success seals the registry entry verified=true, which is exactly what ++ * bdev_cbt_epoch_close(mode=consumed) demands before discarding a delta. ++ * The CONFIGURED flip happens at finish — promotion is gated on this phase. */ ++ ++#define RAID_PROCESS_VERIFY_WINDOWS 64 ++ ++enum raid_process_verify_phase { ++ RAID_PROCESS_VERIFY_READS, ++ RAID_PROCESS_VERIFY_RECOPY_WRITE, ++ RAID_PROCESS_VERIFY_REREAD, ++}; ++ ++struct raid_process_verify { ++ struct raid_base_bdev_info *source; ++ void *buf_src; ++ void *buf_tgt; ++ struct iovec iov_src; ++ struct iovec iov_tgt; ++ /* Nominal verify window (blocks) — process->max_window_size shrinks on ++ * the last copy window, so it cannot be reused here. */ ++ uint64_t window_size; ++ uint64_t offsets[RAID_PROCESS_VERIFY_WINDOWS]; ++ uint32_t num_windows; ++ uint32_t window_idx; ++ uint64_t window_offset; ++ uint64_t window_blocks; ++ bool range_locked; ++ bool recopied; ++ enum raid_process_verify_phase phase; ++ int ios_remaining; ++ int status; ++}; ++ ++static void raid_bdev_process_verify_next_window(struct raid_bdev_process *process); ++ ++static void ++raid_bdev_process_verify_free(struct raid_process_verify *verify) ++{ ++ spdk_dma_free(verify->buf_src); ++ spdk_dma_free(verify->buf_tgt); ++ free(verify); ++} ++ ++/* Terminal for the whole phase — the window range is already unlocked. */ ++static void ++raid_bdev_process_verify_conclude(struct raid_bdev_process *process) ++{ ++ struct raid_process_verify *verify = process->verify; ++ int status = verify->status; ++ bool complete = verify->window_idx >= verify->num_windows; ++ uint32_t num_windows = verify->num_windows; ++ ++ assert(verify->range_locked == false); ++ ++ process->verify = NULL; ++ process->verify_done = true; ++ raid_bdev_process_verify_free(verify); ++ ++ if (process->state >= RAID_PROCESS_STATE_STOPPING) { ++ /* Cancelled mid-verify (status/-ECANCELED already recorded) — ++ * unwind exactly like the copy loop does. */ ++ raid_bdev_process_do_finish(process); ++ return; ++ } ++ ++ if (status == 0 && complete) { ++ process->verified = true; ++ SPDK_NOTICELOG("raid bdev '%s': verify passed (%u windows) on '%s'\n", ++ process->raid_bdev->bdev.name, num_windows, ++ process->target->name); ++ } ++ ++ raid_bdev_process_finish(process, status); ++} ++ ++static void ++raid_bdev_process_verify_range_unlocked(void *ctx, int status) ++{ ++ struct raid_bdev_process *process = ctx; ++ struct raid_process_verify *verify = process->verify; ++ ++ verify->range_locked = false; ++ if (status != 0 && verify->status == 0) { ++ verify->status = status; ++ } ++ ++ if (verify->status != 0 || process->state >= RAID_PROCESS_STATE_STOPPING) { ++ raid_bdev_process_verify_conclude(process); ++ return; ++ } ++ ++ verify->window_idx++; ++ raid_bdev_process_verify_next_window(process); ++} ++ ++static void ++raid_bdev_process_verify_unlock(struct raid_bdev_process *process) ++{ ++ struct raid_process_verify *verify = process->verify; ++ int rc; ++ ++ assert(verify->range_locked == true); ++ ++ rc = spdk_bdev_unquiesce_range(&process->raid_bdev->bdev, &g_raid_if, ++ verify->window_offset, verify->window_blocks, ++ raid_bdev_process_verify_range_unlocked, process); ++ if (rc != 0) { ++ raid_bdev_process_verify_range_unlocked(process, rc); ++ } ++} ++ ++static void raid_bdev_process_verify_window_io_done(struct raid_bdev_process *process); ++ ++static void ++raid_bdev_process_verify_io_cb(struct spdk_bdev_io *bdev_io, bool success, void *cb_arg) ++{ ++ struct raid_bdev_process *process = cb_arg; ++ struct raid_process_verify *verify = process->verify; ++ ++ spdk_bdev_free_io(bdev_io); ++ ++ if (!success && verify->status == 0) { ++ verify->status = -EIO; ++ } ++ ++ assert(verify->ios_remaining > 0); ++ if (--verify->ios_remaining > 0) { ++ return; ++ } ++ ++ raid_bdev_process_verify_window_io_done(process); ++} ++ ++static void ++raid_bdev_process_verify_window_io_done(struct raid_bdev_process *process) ++{ ++ struct raid_process_verify *verify = process->verify; ++ struct raid_bdev *raid_bdev = process->raid_bdev; ++ uint64_t len = verify->window_blocks * raid_bdev->bdev.blocklen; ++ int rc; ++ ++ assert(spdk_get_thread() == process->thread); ++ ++ if (verify->status != 0 || process->state >= RAID_PROCESS_STATE_STOPPING) { ++ raid_bdev_process_verify_unlock(process); ++ return; ++ } ++ ++ switch (verify->phase) { ++ case RAID_PROCESS_VERIFY_READS: ++ case RAID_PROCESS_VERIFY_REREAD: ++ if (memcmp(verify->buf_src, verify->buf_tgt, len) == 0) { ++ if (verify->phase == RAID_PROCESS_VERIFY_REREAD) { ++ SPDK_NOTICELOG("raid bdev '%s': verify window [%" PRIu64 ++ ", +%" PRIu64 ") healed by re-copy on '%s'\n", ++ raid_bdev->bdev.name, verify->window_offset, ++ verify->window_blocks, process->target->name); ++ } ++ raid_bdev_process_verify_unlock(process); ++ return; ++ } ++ if (verify->recopied) { ++ /* Second mismatch under the SAME lock: no host write can ++ * explain it — the copy process is broken. Arbiter (the ++ * source leg) stays authoritative; the target is divergent. */ ++ SPDK_ERRLOG("raid bdev '%s': verify window [%" PRIu64 ", +%" PRIu64 ++ ") DIVERGENT on '%s' after re-copy\n", ++ raid_bdev->bdev.name, verify->window_offset, ++ verify->window_blocks, process->target->name); ++ process->outcome_divergent = true; ++ verify->status = -EILSEQ; ++ raid_bdev_process_verify_unlock(process); ++ return; ++ } ++ /* First mismatch: re-copy the window from the arbiter, then ++ * re-verify. buf_src already holds the source content and the ++ * range is still quiesced. */ ++ verify->recopied = true; ++ verify->phase = RAID_PROCESS_VERIFY_RECOPY_WRITE; ++ verify->ios_remaining = 1; ++ rc = raid_bdev_writev_blocks_ext(process->target, ++ process->raid_ch->process.target_ch, ++ &verify->iov_src, 1, verify->window_offset, ++ verify->window_blocks, ++ raid_bdev_process_verify_io_cb, process, NULL); ++ if (rc != 0) { ++ verify->status = rc; ++ raid_bdev_process_verify_unlock(process); ++ } ++ return; ++ case RAID_PROCESS_VERIFY_RECOPY_WRITE: ++ /* The re-copy is a real copy — it counts (registry bytes). */ ++ raid_rebuild_outcome_add_bytes(process->outcome, len); ++ verify->phase = RAID_PROCESS_VERIFY_REREAD; ++ verify->ios_remaining = 1; ++ rc = raid_bdev_readv_blocks_ext(process->target, ++ process->raid_ch->process.target_ch, ++ &verify->iov_tgt, 1, verify->window_offset, ++ verify->window_blocks, ++ raid_bdev_process_verify_io_cb, process, NULL); ++ if (rc != 0) { ++ verify->status = rc; ++ raid_bdev_process_verify_unlock(process); ++ } ++ return; ++ default: ++ assert(false); ++ } ++} ++ ++static void ++raid_bdev_process_verify_range_locked(void *ctx, int status) ++{ ++ struct raid_bdev_process *process = ctx; ++ struct raid_process_verify *verify = process->verify; ++ struct spdk_io_channel *src_ch; ++ int rc; ++ ++ if (status != 0) { ++ verify->status = status; ++ raid_bdev_process_verify_conclude(process); ++ return; ++ } ++ verify->range_locked = true; ++ ++ if (process->state >= RAID_PROCESS_STATE_STOPPING) { ++ raid_bdev_process_verify_unlock(process); ++ return; ++ } ++ ++ verify->iov_src.iov_len = verify->window_blocks * process->raid_bdev->bdev.blocklen; ++ verify->iov_tgt.iov_len = verify->iov_src.iov_len; ++ verify->phase = RAID_PROCESS_VERIFY_READS; ++ verify->ios_remaining = 2; ++ ++ src_ch = raid_bdev_channel_get_base_channel(process->raid_ch, ++ raid_bdev_base_bdev_slot(verify->source)); ++ rc = raid_bdev_readv_blocks_ext(verify->source, src_ch, &verify->iov_src, 1, ++ verify->window_offset, verify->window_blocks, ++ raid_bdev_process_verify_io_cb, process, NULL); ++ if (rc != 0) { ++ verify->status = rc; ++ verify->ios_remaining = 0; ++ raid_bdev_process_verify_unlock(process); ++ return; ++ } ++ ++ rc = raid_bdev_readv_blocks_ext(process->target, process->raid_ch->process.target_ch, ++ &verify->iov_tgt, 1, verify->window_offset, ++ verify->window_blocks, ++ raid_bdev_process_verify_io_cb, process, NULL); ++ if (rc != 0) { ++ if (verify->status == 0) { ++ verify->status = rc; ++ } ++ /* The source read is in flight — its completion routes through ++ * io_done, which sees status != 0 and unlocks. */ ++ verify->ios_remaining = 1; ++ } ++} ++ ++static void ++raid_bdev_process_verify_next_window(struct raid_bdev_process *process) ++{ ++ struct raid_process_verify *verify = process->verify; ++ struct raid_bdev *raid_bdev = process->raid_bdev; ++ int rc; ++ ++ if (process->state >= RAID_PROCESS_STATE_STOPPING || ++ verify->window_idx >= verify->num_windows) { ++ raid_bdev_process_verify_conclude(process); ++ return; ++ } ++ ++ verify->window_offset = verify->offsets[verify->window_idx]; ++ verify->window_blocks = spdk_min(verify->window_size, ++ raid_bdev->bdev.blockcnt - verify->window_offset); ++ verify->recopied = false; ++ ++ rc = spdk_bdev_quiesce_range(&raid_bdev->bdev, &g_raid_if, ++ verify->window_offset, verify->window_blocks, ++ raid_bdev_process_verify_range_locked, process); ++ if (rc != 0) { ++ verify->status = rc; ++ raid_bdev_process_verify_conclude(process); ++ } ++} ++ ++/* Map the k-th sampled logical offset to a physical block offset. For a ++ * seeded rebuild the copied extent is the seed ranges (logical = cumulative ++ * position inside them); otherwise it is the whole raid. A window may spill ++ * past a seed range's end — those blocks were write-replicated live, so ++ * comparing them is valid (and strictly stronger). */ ++static uint64_t ++raid_bdev_process_verify_translate(struct raid_bdev_process *process, uint64_t logical) ++{ ++ uint32_t i; ++ ++ if (!process->seeded || process->num_seed_ranges == 0) { ++ return logical; ++ } ++ ++ for (i = 0; i < process->num_seed_ranges; i++) { ++ const struct raid_bdev_seed_range *range = &process->seed_ranges[i]; ++ ++ if (logical < range->num_blocks) { ++ return range->offset_blocks + logical; ++ } ++ logical -= range->num_blocks; ++ } ++ ++ /* Off the end (rounding) — verify the tail of the last range. */ ++ return process->seed_ranges[process->num_seed_ranges - 1].offset_blocks; ++} ++ ++static void ++raid_bdev_process_verify_start(struct raid_bdev_process *process) ++{ ++ struct raid_bdev *raid_bdev = process->raid_bdev; ++ struct raid_process_verify *verify; ++ struct raid_base_bdev_info *base_info, *source = NULL; ++ uint64_t total_blocks, step, buf_len, prev_end = 0; ++ uint32_t i, k; ++ ++ assert(spdk_get_thread() == process->thread); ++ assert(process->state == RAID_PROCESS_STATE_RUNNING); ++ ++ process->verify_done = true; /* re-entry guard, whatever path exits */ ++ ++ /* Scope: raid1 nexus with plain data. Anything else completes ++ * UNVERIFIED — the registry entry stays verified=false, and ++ * bdev_cbt_epoch_close(consumed) stays locked for its token. */ ++ if (raid_bdev->level != RAID1 || ++ spdk_bdev_get_dif_type(&raid_bdev->bdev) != SPDK_DIF_DISABLE) { ++ raid_bdev_process_finish(process, 0); ++ return; ++ } ++ ++ RAID_FOR_EACH_BASE_BDEV(raid_bdev, base_info) { ++ if (base_info != process->target && base_info->desc != NULL && ++ base_info->is_configured && !base_info->read_excluded) { ++ source = base_info; ++ break; ++ } ++ } ++ if (source == NULL) { ++ SPDK_WARNLOG("raid bdev '%s': no arbiter leg for verify — completing " ++ "unverified\n", raid_bdev->bdev.name); ++ raid_bdev_process_finish(process, 0); ++ return; ++ } ++ ++ verify = calloc(1, sizeof(*verify)); ++ if (verify == NULL) { ++ SPDK_ERRLOG("raid bdev '%s': verify allocation failed — completing " ++ "unverified\n", raid_bdev->bdev.name); ++ raid_bdev_process_finish(process, 0); ++ return; ++ } ++ ++ /* Nominal copy window (process->max_window_size shrank on the last ++ * window) — same formula as raid_bdev_process_alloc. */ ++ verify->window_size = spdk_max(spdk_divide_round_up(g_opts.process_window_size_kb * 1024UL, ++ spdk_bdev_get_data_block_size(&raid_bdev->bdev)), ++ raid_bdev->bdev.write_unit_size); ++ verify->window_size = spdk_min(verify->window_size, raid_bdev->bdev.blockcnt); ++ ++ buf_len = verify->window_size * raid_bdev->bdev.blocklen; ++ verify->buf_src = spdk_dma_malloc(buf_len, 4096, NULL); ++ verify->buf_tgt = spdk_dma_malloc(buf_len, 4096, NULL); ++ if (verify->buf_src == NULL || verify->buf_tgt == NULL) { ++ SPDK_ERRLOG("raid bdev '%s': verify buffers allocation failed — " ++ "completing unverified\n", raid_bdev->bdev.name); ++ raid_bdev_process_verify_free(verify); ++ raid_bdev_process_finish(process, 0); ++ return; ++ } ++ verify->iov_src.iov_base = verify->buf_src; ++ verify->iov_tgt.iov_base = verify->buf_tgt; ++ verify->source = source; ++ ++ if (process->seeded && process->num_seed_ranges > 0) { ++ total_blocks = 0; ++ for (i = 0; i < process->num_seed_ranges; i++) { ++ total_blocks += process->seed_ranges[i].num_blocks; ++ } ++ } else { ++ total_blocks = raid_bdev->bdev.blockcnt; ++ } ++ ++ /* Uniform-stride sampling: honest, reproducible, no RNG needed. Windows ++ * never overlap (monotonic physical offsets enforced below). */ ++ step = spdk_max(total_blocks / RAID_PROCESS_VERIFY_WINDOWS, verify->window_size); ++ for (k = 0; k < RAID_PROCESS_VERIFY_WINDOWS; k++) { ++ uint64_t logical = (uint64_t)k * step; ++ uint64_t offset; ++ ++ if (logical >= total_blocks) { ++ break; ++ } ++ offset = raid_bdev_process_verify_translate(process, logical); ++ if (offset >= raid_bdev->bdev.blockcnt) { ++ break; ++ } ++ if (verify->num_windows > 0 && offset < prev_end) { ++ continue; ++ } ++ verify->offsets[verify->num_windows++] = offset; ++ prev_end = offset + verify->window_size; ++ } ++ if (verify->num_windows == 0) { ++ verify->offsets[verify->num_windows++] = ++ raid_bdev_process_verify_translate(process, 0); ++ } ++ ++ SPDK_NOTICELOG("raid bdev '%s': verifying '%s' against arbiter '%s' " ++ "(%u sampled windows of %" PRIu64 " blocks)\n", ++ raid_bdev->bdev.name, process->target->name, source->name, ++ verify->num_windows, verify->window_size); ++ ++ raid_rebuild_outcome_set_verifying(process->outcome); ++ process->verify = verify; ++ process->verify_done = false; /* armed: conclude() is the only exit */ ++ raid_bdev_process_verify_next_window(process); ++} ++ + static int + raid_bdev_process_continue_poll(void *arg) + { +@@ -3275,6 +3722,14 @@ raid_bdev_process_thread_run(struct raid_bdev_process *process) + + if (process->window_offset == raid_bdev->bdev.blockcnt) { + SPDK_DEBUGLOG(bdev_raid, "process completed on %s\n", raid_bdev->bdev.name); ++ /* Evariops 0014.8: the copy is complete — run the integrated verify ++ * phase before concluding. Both the registry seal and the SB ++ * CONFIGURED flip happen at finish, so promotion is gated on it. */ ++ if (process->type == RAID_PROCESS_REBUILD && !process->verify_done && ++ process->status == 0 && process->window_status == 0) { ++ raid_bdev_process_verify_start(process); ++ return; ++ } + raid_bdev_process_finish(process, 0); + return; + } +@@ -3460,6 +3915,12 @@ raid_bdev_process_free(struct raid_bdev_process *process) + raid_rebuild_outcome_finish(process->outcome, RAID_REBUILD_OUTCOME_FAILED, false); + process->outcome = NULL; + } ++ /* Evariops 0014.8: same defensive discipline for the verify state — ++ * conclude() frees it on every live path. */ ++ if (process->verify != NULL) { ++ raid_bdev_process_verify_free(process->verify); ++ process->verify = NULL; ++ } + free(process); + } + diff --git a/patches/0019-raid-auto-epoch-at-ejection.patch b/patches/0019-raid-auto-epoch-at-ejection.patch new file mode 100644 index 0000000..49c3ca9 --- /dev/null +++ b/patches/0019-raid-auto-epoch-at-ejection.patch @@ -0,0 +1,43 @@ +diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c +index a523787..94e0b34 100644 +--- a/module/bdev/raid/bdev_raid.c ++++ b/module/bdev/raid/bdev_raid.c +@@ -2251,6 +2251,7 @@ raid_bdev_remove_base_bdev_on_quiesced(void *ctx, int status) + { + struct raid_base_bdev_info *base_info = ctx; + struct raid_bdev *raid_bdev = base_info->raid_bdev; ++ struct raid_base_bdev_info *survivor; + + if (status != 0) { + SPDK_ERRLOG("Failed to quiesce raid bdev %s: %s\n", +@@ -2259,6 +2260,30 @@ raid_bdev_remove_base_bdev_on_quiesced(void *ctx, int status) + return; + } + ++ /* Evariops 0014.10 (RPC-CONTRACT §12): bound the delta the moment the ++ * member leaves — open an auto epoch on every surviving member's cbt ++ * (each survivor sees every post-ejection write; the auto nonce is ++ * reported through get_bdevs). -EEXIST = a live round already bounds ++ * it; -ENODEV = the member is not cbt-wrapped. Both are fine. */ ++ if (raid_bdev->level == RAID1 && base_info->name != NULL) { ++ RAID_FOR_EACH_BASE_BDEV(raid_bdev, survivor) { ++ int rc; ++ ++ if (survivor == base_info || !survivor->is_configured || ++ survivor->name == NULL) { ++ continue; ++ } ++ rc = vbdev_cbt_auto_epoch_open(survivor->name, base_info->name); ++ if (rc != 0 && rc != -EEXIST && rc != -ENODEV) { ++ SPDK_WARNLOG("raid bdev '%s': auto epoch on '%s' for " ++ "ejected '%s' failed: %s — the debt path " ++ "will degrade to a full rebuild (D14)\n", ++ raid_bdev->bdev.name, survivor->name, ++ base_info->name, spdk_strerror(-rc)); ++ } ++ } ++ } ++ + if (raid_bdev->sb) { + struct raid_bdev_superblock *sb = raid_bdev->sb; + uint8_t slot = raid_bdev_base_bdev_slot(base_info); diff --git a/patches/0020-nvmf-force-resume.patch b/patches/0020-nvmf-force-resume.patch new file mode 100644 index 0000000..ba9f3d4 --- /dev/null +++ b/patches/0020-nvmf-force-resume.patch @@ -0,0 +1,46 @@ +diff --git a/lib/nvmf/nvmf_pause_rpc.c b/lib/nvmf/nvmf_pause_rpc.c +index a162553..1e731c1 100644 +--- a/lib/nvmf/nvmf_pause_rpc.c ++++ b/lib/nvmf/nvmf_pause_rpc.c +@@ -346,6 +346,13 @@ struct rpc_resume_ctx { + char *nqn; + char *tgt_name; + char *token; /* optional: pins the barrier to verify continuity */ ++ /* Evariops 0014.11 (RPC-CONTRACT §13, DÉC-11): the fence path resumes the ++ * subsystem BEFORE preempt-and-abort, whatever barrier is standing — ++ * failover availability is substance, the snapshot barrier is comfort. ++ * force=true overrides any pinned token and is audited; the barrier's ++ * own (tokened) resume later reports resumed:false / barrier_intact: ++ * false, so the group-snapshot saga fails CLEANLY and replays. */ ++ bool force; + bool barrier_intact; /* computed at dispatch, reported in resume_done */ + struct paused_subsystem *entry; + }; +@@ -354,6 +361,7 @@ static const struct spdk_json_object_decoder rpc_resume_decoders[] = { + {"nqn", offsetof(struct rpc_resume_ctx, nqn), spdk_json_decode_string}, + {"tgt_name", offsetof(struct rpc_resume_ctx, tgt_name), spdk_json_decode_string, true}, + {"token", offsetof(struct rpc_resume_ctx, token), spdk_json_decode_string, true}, ++ {"force", offsetof(struct rpc_resume_ctx, force), spdk_json_decode_bool, true}, + }; + + static void +@@ -445,6 +453,19 @@ rpc_nvmf_subsystem_resume(struct spdk_jsonrpc_request *request, + rpc_resume_ctx_free(ctx); + return; + } ++ ++ /* Evariops 0014.11 (DÉC-11): the fence path force-resumes whatever ++ * barrier is standing. Loud + audited — this deliberately breaks a live ++ * group-snapshot barrier, whose own tokened resume will then report it. */ ++ if (ctx->force) { ++ SPDK_WARNLOG("nvmf pause: FORCED resume of %s (fence path) — standing " ++ "barrier epoch %" PRIu64 " is broken by design\n", ++ ctx->nqn, p->epoch); ++ spdk_jsonrpc_request_audit(request, "nvmf_subsystem_resume", ++ "forced (fence path)"); ++ have_token = false; ++ } ++ + /* With a pinned token, only resume the *matching* standing pause: a mismatch means + * our barrier already ended (TTL/crash/other) and a different one may now be live — + * report it broken and leave that one untouched (RISK R1/R3). */ diff --git a/patches/0021-raid-envelopes.patch b/patches/0021-raid-envelopes.patch new file mode 100644 index 0000000..0ebddff --- /dev/null +++ b/patches/0021-raid-envelopes.patch @@ -0,0 +1,370 @@ +diff --git a/module/bdev/raid/Makefile b/module/bdev/raid/Makefile +index 120700c..c90b4a2 100644 +--- a/module/bdev/raid/Makefile ++++ b/module/bdev/raid/Makefile +@@ -10,7 +10,7 @@ SO_VER := 7 + SO_MINOR := 0 + + CFLAGS += -I$(SPDK_ROOT_DIR)/lib/bdev/ +-C_SRCS = bdev_raid.c bdev_raid_rpc.c bdev_raid_sb.c bdev_raid_heat.c bdev_raid_repair.c bdev_raid_outcomes.c raid0.c raid1.c concat.c ++C_SRCS = bdev_raid.c bdev_raid_rpc.c bdev_raid_sb.c bdev_raid_heat.c bdev_raid_repair.c bdev_raid_outcomes.c bdev_raid_envelopes.c raid0.c raid1.c concat.c + + ifeq ($(CONFIG_RAID5F),y) + C_SRCS += raid5f.c +diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c +index 94e0b34..762e892 100644 +--- a/module/bdev/raid/bdev_raid.c ++++ b/module/bdev/raid/bdev_raid.c +@@ -6,6 +6,7 @@ + + #include "bdev_raid.h" + #include "bdev_raid_outcomes.h" /* Evariops 0014.5 */ ++#include "bdev_raid_envelopes.h" /* Evariops 0014.9 */ + /* Evariops 0014.6: per-member epoch facts come from the cbt module wrapping + * each member (nexus topology: raid1 → cbt → nvme). The module dir is injected + * before patches apply (Dockerfile copy-before-apply); symbol resolves via +@@ -3971,13 +3972,24 @@ raid_bdev_process_alloc(struct raid_bdev *raid_bdev, enum raid_process_type type + TAILQ_INIT(&process->requests); + TAILQ_INIT(&process->finish_actions); + +- if (g_opts.process_max_bandwidth_mb_sec != 0) { +- process->qos.enable_qos = true; +- process->qos.last_tsc = spdk_get_ticks(); +- process->qos.bytes_per_tsc = g_opts.process_max_bandwidth_mb_sec * 1024 * 1024.0 / +- spdk_get_ticks_hz(); +- process->qos.bytes_max = g_opts.process_max_bandwidth_mb_sec * 1024 * 1024.0 / SPDK_SEC_TO_MSEC; +- process->qos.bytes_available = 0.0; ++ /* Evariops 0014.9 (RPC-CONTRACT §6): the rebuild envelope (under the ++ * current regime) takes precedence over the legacy global option and is ++ * FROZEN here — a regime change only applies to processes started after ++ * it (bytes_per_tsc is per-process by design). 0 = unlimited. */ ++ { ++ uint32_t max_bw_mb_sec = raid_envelope_active_mb_sec(RAID_ENVELOPE_REBUILD); ++ ++ if (max_bw_mb_sec == 0) { ++ max_bw_mb_sec = g_opts.process_max_bandwidth_mb_sec; ++ } ++ if (max_bw_mb_sec != 0) { ++ process->qos.enable_qos = true; ++ process->qos.last_tsc = spdk_get_ticks(); ++ process->qos.bytes_per_tsc = max_bw_mb_sec * 1024 * 1024.0 / ++ spdk_get_ticks_hz(); ++ process->qos.bytes_max = max_bw_mb_sec * 1024 * 1024.0 / SPDK_SEC_TO_MSEC; ++ process->qos.bytes_available = 0.0; ++ } + } + + for (i = 0; i < RAID_BDEV_PROCESS_MAX_QD; i++) { +@@ -4001,6 +4013,16 @@ raid_bdev_start_rebuild(struct raid_base_bdev_info *target) + + assert(spdk_get_thread() == spdk_thread_get_app_thread()); + ++ /* Evariops 0014.9: the auto rebuild at attach is ADMITTED over the ++ * concurrency bound — refusing here would leave an attached member ++ * silently unrebuilt with nothing to retry it. Loud, not blocking: ++ * the control plane paces attaches itself. */ ++ if (!raid_envelopes_admit_rebuild()) { ++ SPDK_WARNLOG("raid bdev '%s': rebuild concurrency bound exceeded — " ++ "admitting the attach-triggered rebuild anyway\n", ++ target->raid_bdev->bdev.name); ++ } ++ + process = raid_bdev_process_alloc(target->raid_bdev, RAID_PROCESS_REBUILD, target); + if (process == NULL) { + return -ENOMEM; +@@ -4040,6 +4062,13 @@ raid_bdev_start_seeded_rebuild(struct raid_base_bdev_info *target, + return -EINVAL; + } + ++ /* Evariops 0014.9 (RPC-CONTRACT §6): RPC-driven rebuilds respect the ++ * per-node concurrency bound — N rebuilds are N × cap otherwise. The ++ * control plane maps -EAGAIN to a retry, nothing is lost. */ ++ if (!raid_envelopes_admit_rebuild()) { ++ return -EAGAIN; ++ } ++ + process = raid_bdev_process_alloc(target->raid_bdev, RAID_PROCESS_REBUILD, target); + if (process == NULL) { + return -ENOMEM; +diff --git a/module/bdev/raid/bdev_raid_envelopes.c b/module/bdev/raid/bdev_raid_envelopes.c +new file mode 100644 +index 0000000..7fd32b7 +--- /dev/null ++++ b/module/bdev/raid/bdev_raid_envelopes.c +@@ -0,0 +1,73 @@ ++/* SPDX-License-Identifier: BSD-3-Clause ++ * Copyright (C) 2026 Evariops. ++ * All rights reserved. ++ */ ++ ++/* Evariops 0014.9 (spdk-csi RPC-CONTRACT §6): see bdev_raid_envelopes.h. */ ++ ++#include "bdev_raid.h" ++#include "bdev_raid_envelopes.h" ++ ++#include "spdk/log.h" ++#include "spdk/thread.h" ++ ++static struct raid_envelopes g_raid_envelopes; ++ ++void ++raid_envelopes_get(struct raid_envelopes *out) ++{ ++ assert(spdk_get_thread() == spdk_thread_get_app_thread()); ++ *out = g_raid_envelopes; ++} ++ ++void ++raid_envelopes_set(const struct raid_envelopes *envelopes) ++{ ++ assert(spdk_get_thread() == spdk_thread_get_app_thread()); ++ g_raid_envelopes = *envelopes; ++ ++ SPDK_NOTICELOG("raid envelopes: regime=%s rebuild=%u/%u verify=%u/%u " ++ "relocate=%u/%u MiB/s (nominal/maintenance, 0=unlimited), " ++ "max_concurrent_rebuilds=%u\n", ++ g_raid_envelopes.maintenance_regime ? "maintenance" : "nominal", ++ g_raid_envelopes.classes[RAID_ENVELOPE_REBUILD].nominal_mb_sec, ++ g_raid_envelopes.classes[RAID_ENVELOPE_REBUILD].maintenance_mb_sec, ++ g_raid_envelopes.classes[RAID_ENVELOPE_VERIFY].nominal_mb_sec, ++ g_raid_envelopes.classes[RAID_ENVELOPE_VERIFY].maintenance_mb_sec, ++ g_raid_envelopes.classes[RAID_ENVELOPE_RELOCATE].nominal_mb_sec, ++ g_raid_envelopes.classes[RAID_ENVELOPE_RELOCATE].maintenance_mb_sec, ++ g_raid_envelopes.max_concurrent_rebuilds); ++} ++ ++uint32_t ++raid_envelope_active_mb_sec(enum raid_envelope_class klass) ++{ ++ const struct raid_envelope *env; ++ ++ assert(klass < RAID_ENVELOPE_CLASS_MAX); ++ env = &g_raid_envelopes.classes[klass]; ++ ++ return g_raid_envelopes.maintenance_regime ? env->maintenance_mb_sec ++ : env->nominal_mb_sec; ++} ++ ++bool ++raid_envelopes_admit_rebuild(void) ++{ ++ struct raid_bdev *raid_bdev; ++ uint32_t active = 0; ++ ++ assert(spdk_get_thread() == spdk_thread_get_app_thread()); ++ ++ if (g_raid_envelopes.max_concurrent_rebuilds == 0) { ++ return true; ++ } ++ ++ TAILQ_FOREACH(raid_bdev, &g_raid_bdev_list, global_link) { ++ if (raid_bdev->process != NULL) { ++ active++; ++ } ++ } ++ ++ return active < g_raid_envelopes.max_concurrent_rebuilds; ++} +diff --git a/module/bdev/raid/bdev_raid_envelopes.h b/module/bdev/raid/bdev_raid_envelopes.h +new file mode 100644 +index 0000000..a5aa0db +--- /dev/null ++++ b/module/bdev/raid/bdev_raid_envelopes.h +@@ -0,0 +1,55 @@ ++/* SPDX-License-Identifier: BSD-3-Clause ++ * Copyright (C) 2026 Evariops. ++ * All rights reserved. ++ */ ++ ++/* Evariops 0014.9 (spdk-csi RPC-CONTRACT §6): process-wide I/O envelopes. ++ * ++ * CAPS, never shares (there is no mClock/WFQ here): one cap per executor ++ * class, each in TWO values (nominal, maintenance), plus a per-node bound on ++ * concurrent rebuilds — N rebuilds would otherwise consume N × cap. The ++ * data-plane default is 0 = UNLIMITED: the control plane MUST set these at ++ * handshake, and a failed set is an INCIDENT there, never a silent fallback. ++ * The rebuild cap is consumed at process allocation (bytes_per_tsc is frozen ++ * there — a regime change only applies to processes started after it); the ++ * verify cap is consumed by bdev_raid_verify_ranges (§10b; the integrated ++ * verify phase samples ~64 MiB and is deliberately unpaced); the relocate cap ++ * is stored here for the tier module to consume. ++ */ ++ ++#ifndef SPDK_BDEV_RAID_ENVELOPES_H ++#define SPDK_BDEV_RAID_ENVELOPES_H ++ ++#include "spdk/stdinc.h" ++ ++enum raid_envelope_class { ++ RAID_ENVELOPE_REBUILD = 0, ++ RAID_ENVELOPE_VERIFY, ++ RAID_ENVELOPE_RELOCATE, ++ RAID_ENVELOPE_CLASS_MAX, ++}; ++ ++struct raid_envelope { ++ /* MiB/s caps; 0 = unlimited (the loud default). */ ++ uint32_t nominal_mb_sec; ++ uint32_t maintenance_mb_sec; ++}; ++ ++struct raid_envelopes { ++ struct raid_envelope classes[RAID_ENVELOPE_CLASS_MAX]; ++ /* 0 = unbounded. */ ++ uint32_t max_concurrent_rebuilds; ++ bool maintenance_regime; ++}; ++ ++/* All app-thread only (RPC handlers + process allocation). */ ++void raid_envelopes_get(struct raid_envelopes *out); ++void raid_envelopes_set(const struct raid_envelopes *envelopes); ++ ++/* Active cap for a class under the current regime; 0 = unlimited. */ ++uint32_t raid_envelope_active_mb_sec(enum raid_envelope_class klass); ++ ++/* Concurrency bound (counts raids with a live background process). */ ++bool raid_envelopes_admit_rebuild(void); ++ ++#endif /* SPDK_BDEV_RAID_ENVELOPES_H */ +diff --git a/module/bdev/raid/bdev_raid_rpc.c b/module/bdev/raid/bdev_raid_rpc.c +index f65ea35..92e33e5 100644 +--- a/module/bdev/raid/bdev_raid_rpc.c ++++ b/module/bdev/raid/bdev_raid_rpc.c +@@ -7,6 +7,7 @@ + #include "spdk/bdev.h" + #include "bdev_raid.h" + #include "bdev_raid_outcomes.h" /* Evariops 0014.5 */ ++#include "bdev_raid_envelopes.h" /* Evariops 0014.9 */ + #include "spdk/util.h" + #include "spdk/string.h" + #include "spdk/log.h" +@@ -909,6 +910,129 @@ err: + } + SPDK_RPC_REGISTER("bdev_raid_remove_base_bdev", rpc_bdev_raid_remove_base_bdev, SPDK_RPC_RUNTIME) + ++/* ---- Evariops 0014.9 (RPC-CONTRACT §6): bdev_raid_set_envelopes / ++ * bdev_raid_get_envelopes — process-wide caps (never shares), each class in ++ * two values (nominal, maintenance) + the per-node rebuild concurrency bound ++ * + the current regime. Data-plane default is 0 = UNLIMITED: the control ++ * plane sets these at handshake and treats a failed set as an INCIDENT. ---- */ ++ ++struct rpc_bdev_raid_set_envelopes { ++ uint32_t rebuild_nominal_mb_sec; ++ uint32_t rebuild_maintenance_mb_sec; ++ uint32_t verify_nominal_mb_sec; ++ uint32_t verify_maintenance_mb_sec; ++ uint32_t relocate_nominal_mb_sec; ++ uint32_t relocate_maintenance_mb_sec; ++ uint32_t max_concurrent_rebuilds; ++ char *regime; ++}; ++ ++static const struct spdk_json_object_decoder rpc_bdev_raid_set_envelopes_decoders[] = { ++ {"rebuild_nominal_mb_sec", offsetof(struct rpc_bdev_raid_set_envelopes, rebuild_nominal_mb_sec), spdk_json_decode_uint32, true}, ++ {"rebuild_maintenance_mb_sec", offsetof(struct rpc_bdev_raid_set_envelopes, rebuild_maintenance_mb_sec), spdk_json_decode_uint32, true}, ++ {"verify_nominal_mb_sec", offsetof(struct rpc_bdev_raid_set_envelopes, verify_nominal_mb_sec), spdk_json_decode_uint32, true}, ++ {"verify_maintenance_mb_sec", offsetof(struct rpc_bdev_raid_set_envelopes, verify_maintenance_mb_sec), spdk_json_decode_uint32, true}, ++ {"relocate_nominal_mb_sec", offsetof(struct rpc_bdev_raid_set_envelopes, relocate_nominal_mb_sec), spdk_json_decode_uint32, true}, ++ {"relocate_maintenance_mb_sec", offsetof(struct rpc_bdev_raid_set_envelopes, relocate_maintenance_mb_sec), spdk_json_decode_uint32, true}, ++ {"max_concurrent_rebuilds", offsetof(struct rpc_bdev_raid_set_envelopes, max_concurrent_rebuilds), spdk_json_decode_uint32, true}, ++ {"regime", offsetof(struct rpc_bdev_raid_set_envelopes, regime), spdk_json_decode_string, true}, ++}; ++ ++static void ++rpc_bdev_raid_envelopes_write(struct spdk_json_write_ctx *w, const struct raid_envelopes *env) ++{ ++ spdk_json_write_object_begin(w); ++ spdk_json_write_named_string(w, "regime", env->maintenance_regime ? "maintenance" : "nominal"); ++ spdk_json_write_named_uint32(w, "rebuild_nominal_mb_sec", env->classes[RAID_ENVELOPE_REBUILD].nominal_mb_sec); ++ spdk_json_write_named_uint32(w, "rebuild_maintenance_mb_sec", env->classes[RAID_ENVELOPE_REBUILD].maintenance_mb_sec); ++ spdk_json_write_named_uint32(w, "verify_nominal_mb_sec", env->classes[RAID_ENVELOPE_VERIFY].nominal_mb_sec); ++ spdk_json_write_named_uint32(w, "verify_maintenance_mb_sec", env->classes[RAID_ENVELOPE_VERIFY].maintenance_mb_sec); ++ spdk_json_write_named_uint32(w, "relocate_nominal_mb_sec", env->classes[RAID_ENVELOPE_RELOCATE].nominal_mb_sec); ++ spdk_json_write_named_uint32(w, "relocate_maintenance_mb_sec", env->classes[RAID_ENVELOPE_RELOCATE].maintenance_mb_sec); ++ spdk_json_write_named_uint32(w, "max_concurrent_rebuilds", env->max_concurrent_rebuilds); ++ spdk_json_write_object_end(w); ++} ++ ++static void ++rpc_bdev_raid_set_envelopes(struct spdk_jsonrpc_request *request, ++ const struct spdk_json_val *params) ++{ ++ struct rpc_bdev_raid_set_envelopes req = {}; ++ struct raid_envelopes env; ++ struct spdk_json_write_ctx *w; ++ ++ /* Start from the current state so a partial set is an update, and the ++ * regime can flip alone at a maintenance-intent change. */ ++ raid_envelopes_get(&env); ++ req.rebuild_nominal_mb_sec = env.classes[RAID_ENVELOPE_REBUILD].nominal_mb_sec; ++ req.rebuild_maintenance_mb_sec = env.classes[RAID_ENVELOPE_REBUILD].maintenance_mb_sec; ++ req.verify_nominal_mb_sec = env.classes[RAID_ENVELOPE_VERIFY].nominal_mb_sec; ++ req.verify_maintenance_mb_sec = env.classes[RAID_ENVELOPE_VERIFY].maintenance_mb_sec; ++ req.relocate_nominal_mb_sec = env.classes[RAID_ENVELOPE_RELOCATE].nominal_mb_sec; ++ req.relocate_maintenance_mb_sec = env.classes[RAID_ENVELOPE_RELOCATE].maintenance_mb_sec; ++ req.max_concurrent_rebuilds = env.max_concurrent_rebuilds; ++ ++ if (params != NULL && ++ spdk_json_decode_object(params, rpc_bdev_raid_set_envelopes_decoders, ++ SPDK_COUNTOF(rpc_bdev_raid_set_envelopes_decoders), &req)) { ++ spdk_jsonrpc_send_error_response(request, SPDK_JSONRPC_ERROR_PARSE_ERROR, ++ "spdk_json_decode_object failed"); ++ free(req.regime); ++ return; ++ } ++ ++ if (req.regime != NULL) { ++ if (strcmp(req.regime, "nominal") == 0) { ++ env.maintenance_regime = false; ++ } else if (strcmp(req.regime, "maintenance") == 0) { ++ env.maintenance_regime = true; ++ } else { ++ /* Unknown regime is -EINVAL, never a silent fallback. */ ++ spdk_jsonrpc_send_error_response_fmt(request, -EINVAL, ++ "unknown regime '%s'", req.regime); ++ free(req.regime); ++ return; ++ } ++ } ++ ++ env.classes[RAID_ENVELOPE_REBUILD].nominal_mb_sec = req.rebuild_nominal_mb_sec; ++ env.classes[RAID_ENVELOPE_REBUILD].maintenance_mb_sec = req.rebuild_maintenance_mb_sec; ++ env.classes[RAID_ENVELOPE_VERIFY].nominal_mb_sec = req.verify_nominal_mb_sec; ++ env.classes[RAID_ENVELOPE_VERIFY].maintenance_mb_sec = req.verify_maintenance_mb_sec; ++ env.classes[RAID_ENVELOPE_RELOCATE].nominal_mb_sec = req.relocate_nominal_mb_sec; ++ env.classes[RAID_ENVELOPE_RELOCATE].maintenance_mb_sec = req.relocate_maintenance_mb_sec; ++ env.max_concurrent_rebuilds = req.max_concurrent_rebuilds; ++ ++ raid_envelopes_set(&env); ++ free(req.regime); ++ ++ w = spdk_jsonrpc_begin_result(request); ++ rpc_bdev_raid_envelopes_write(w, &env); ++ spdk_jsonrpc_end_result(request, w); ++} ++SPDK_RPC_REGISTER("bdev_raid_set_envelopes", rpc_bdev_raid_set_envelopes, ++ SPDK_RPC_STARTUP | SPDK_RPC_RUNTIME) ++ ++static void ++rpc_bdev_raid_get_envelopes(struct spdk_jsonrpc_request *request, ++ const struct spdk_json_val *params) ++{ ++ struct raid_envelopes env; ++ struct spdk_json_write_ctx *w; ++ ++ if (params != NULL) { ++ spdk_jsonrpc_send_error_response(request, SPDK_JSONRPC_ERROR_INVALID_PARAMS, ++ "bdev_raid_get_envelopes takes no parameters"); ++ return; ++ } ++ ++ raid_envelopes_get(&env); ++ w = spdk_jsonrpc_begin_result(request); ++ rpc_bdev_raid_envelopes_write(w, &env); ++ spdk_jsonrpc_end_result(request, w); ++} ++SPDK_RPC_REGISTER("bdev_raid_get_envelopes", rpc_bdev_raid_get_envelopes, SPDK_RPC_RUNTIME) ++ + /* ---- Evariops 0014.5 (RPC-CONTRACT §2): bdev_raid_get_rebuild_outcomes + * {token?} — read the process-wide rebuild outcome registry. Entries survive + * the raid bdev (15 min TTL after finishing); the control plane matches by diff --git a/patches/0022-raid-verify-ranges.patch b/patches/0022-raid-verify-ranges.patch new file mode 100644 index 0000000..69429ef --- /dev/null +++ b/patches/0022-raid-verify-ranges.patch @@ -0,0 +1,876 @@ +diff --git a/module/bdev/raid/Makefile b/module/bdev/raid/Makefile +index c90b4a2..d381b3b 100644 +--- a/module/bdev/raid/Makefile ++++ b/module/bdev/raid/Makefile +@@ -10,7 +10,7 @@ SO_VER := 7 + SO_MINOR := 0 + + CFLAGS += -I$(SPDK_ROOT_DIR)/lib/bdev/ +-C_SRCS = bdev_raid.c bdev_raid_rpc.c bdev_raid_sb.c bdev_raid_heat.c bdev_raid_repair.c bdev_raid_outcomes.c bdev_raid_envelopes.c raid0.c raid1.c concat.c ++C_SRCS = bdev_raid.c bdev_raid_rpc.c bdev_raid_sb.c bdev_raid_heat.c bdev_raid_repair.c bdev_raid_outcomes.c bdev_raid_envelopes.c bdev_raid_verify_ranges.c raid0.c raid1.c concat.c + + ifeq ($(CONFIG_RAID5F),y) + C_SRCS += raid5f.c +diff --git a/module/bdev/raid/bdev_raid_verify_ranges.c b/module/bdev/raid/bdev_raid_verify_ranges.c +new file mode 100644 +index 0000000..cca7fc4 +--- /dev/null ++++ b/module/bdev/raid/bdev_raid_verify_ranges.c +@@ -0,0 +1,857 @@ ++/* SPDX-License-Identifier: BSD-3-Clause ++ * Copyright (C) 2026 Evariops. ++ * All rights reserved. ++ */ ++ ++/* Evariops 0014.12 + SPEC-75G F-b (spdk-csi RPC-CONTRACT §10b): ++ * bdev_raid_verify_ranges — the EXHAUSTIVE divergence detector (the ++ * integrated verify phase, §10a, is the probabilistic one). Two modes, ++ * selected by the raid level: ++ * ++ * - raid1 (copy-compare): compares the first configured leg (implicit ++ * arbiter) against every other configured leg, chunk by chunk. ++ * - raid5f (syndrome, SPEC-75G F-b): recomputes the RAID-5 consistency ++ * relation per stripe — the XOR of all k+1 members' strips must be ++ * zero (see the syndrome section below). Requires EVERY member ++ * readable (-EAGAIN otherwise: with m = 1 there is no redundancy left ++ * to check against — repair first, verify after). ++ * ++ * Each chunk runs under a channel-owned LBA lock (host writes overlapping ++ * the chunk are held and replayed — patch 0008 discipline). It REPORTS ++ * divergent ranges and never repairs: arbitration is the control plane's ++ * (mirror: R >= 3 vote, or DivergenceUnarbitrated at R == 2 — T-D2; ++ * raid5f: re-fold the stripe from its source, or EcContentDivergence). ++ * ++ * Pacing: the `verify` envelope class (0014.9), frozen at dispatch. The ++ * caller drives bounded batches and re-drives the tail, exactly like ++ * bdev_raid_rebuild_ranges. Progress and the terminal state are mirrored in ++ * the rebuild-outcome registry under the request token (observability; a ++ * zero-divergence completion seals succeeded+verified). */ ++ ++#include "bdev_raid.h" ++#include "bdev_raid_outcomes.h" ++#include "bdev_raid_envelopes.h" ++ ++#include "spdk/env.h" ++#include "spdk/json.h" ++#include "spdk/log.h" ++#include "spdk/rpc.h" ++#include "spdk/string.h" ++#include "spdk/thread.h" ++#include "spdk/util.h" ++/* spdk_bdev_lock_lba_range comes from spdk/bdev_module.h (patch 0008), which ++ * bdev_raid.h already includes. */ ++ ++#define RAID_VERIFY_CHUNK_BLOCKS 2048u /* 1 MiB @ 512B, like repair */ ++#define RAID_VERIFY_MAX_RANGES 4096u ++#define RAID_VERIFY_MAX_REPORTED 128u ++ ++struct raid_verify_range { ++ uint64_t start_lba; ++ uint64_t num_blocks; ++}; ++ ++struct raid_verify_ctx { ++ struct spdk_jsonrpc_request *request; ++ struct raid_bdev *raid_bdev; ++ struct spdk_bdev_desc *desc; /* on the raid bdev (lock owner) */ ++ struct spdk_io_channel *ch; /* raid channel for the LBA lock */ ++ struct raid_base_bdev_info *arbiter; ++ struct raid_verify_range *ranges; ++ uint32_t num_ranges; ++ uint32_t cur; ++ uint64_t cur_off; ++ uint64_t io_lba; ++ uint64_t io_blocks; ++ bool locked; ++ bool removed; ++ int rc; ++ void *buf_arb; ++ void *buf_leg; ++ struct iovec iov_arb; ++ struct iovec iov_leg; ++ /* Leg currently being compared for the in-flight chunk (slot walk). ++ * The syndrome mode reuses it as the member being accumulated. */ ++ uint8_t leg_slot; ++ int ios_remaining; ++ int io_status; ++ /* raid5f syndrome mode (level == RAID5F): geometry + strip walk. ++ * io_lba/io_blocks stay in RAID LBA space (the locked stripe); ++ * member reads use member LBA = stripe_index × strip_blocks. */ ++ bool syndrome; ++ uint64_t strip_blocks; /* per-member strip (blocks) */ ++ uint64_t stripe_blocks; /* full stripe, raid LBA (blocks) */ ++ uint64_t member_off; /* progress within the strip (blocks) */ ++ uint64_t member_len; /* in-flight sub-read (blocks) */ ++ /* Pacing (verify envelope, frozen at dispatch; 0 = unlimited). */ ++ double bytes_per_tsc; ++ double bytes_available; ++ double bytes_max; ++ uint64_t last_tsc; ++ struct spdk_poller *pace_poller; ++ /* Results. */ ++ uint64_t verified_blocks; ++ uint64_t divergent_blocks; ++ struct raid_verify_range divergent[RAID_VERIFY_MAX_REPORTED]; ++ uint32_t num_divergent; ++ struct raid_rebuild_outcome *outcome; ++}; ++ ++static void raid_verify_next(struct raid_verify_ctx *c); ++ ++static void ++raid_verify_complete(struct raid_verify_ctx *c, int rc) ++{ ++ struct spdk_json_write_ctx *w; ++ uint32_t i; ++ ++ spdk_poller_unregister(&c->pace_poller); ++ if (c->ch != NULL) { ++ spdk_put_io_channel(c->ch); ++ } ++ if (c->desc != NULL) { ++ spdk_bdev_close(c->desc); ++ } ++ spdk_dma_free(c->buf_arb); ++ spdk_dma_free(c->buf_leg); ++ ++ if (c->outcome != NULL) { ++ enum raid_rebuild_outcome_state terminal; ++ bool verified = false; ++ ++ if (rc != 0) { ++ terminal = RAID_REBUILD_OUTCOME_FAILED; ++ } else if (c->divergent_blocks > 0) { ++ terminal = RAID_REBUILD_OUTCOME_DIVERGENT; ++ } else { ++ terminal = RAID_REBUILD_OUTCOME_SUCCEEDED; ++ verified = true; ++ } ++ raid_rebuild_outcome_finish(c->outcome, terminal, verified); ++ } ++ ++ if (rc == 0) { ++ w = spdk_jsonrpc_begin_result(c->request); ++ spdk_json_write_object_begin(w); ++ spdk_json_write_named_uint64(w, "verified_blocks", c->verified_blocks); ++ spdk_json_write_named_uint64(w, "divergent_blocks", c->divergent_blocks); ++ spdk_json_write_named_array_begin(w, "divergent_ranges"); ++ for (i = 0; i < c->num_divergent; i++) { ++ spdk_json_write_object_begin(w); ++ spdk_json_write_named_uint64(w, "start_lba", c->divergent[i].start_lba); ++ spdk_json_write_named_uint64(w, "num_blocks", c->divergent[i].num_blocks); ++ spdk_json_write_object_end(w); ++ } ++ spdk_json_write_array_end(w); ++ /* Silent-cap honesty: the caller must know when the report is ++ * truncated (the count is still exact). */ ++ spdk_json_write_named_bool(w, "divergent_ranges_truncated", ++ c->divergent_blocks > 0 && ++ c->num_divergent == RAID_VERIFY_MAX_REPORTED); ++ spdk_json_write_named_string(w, "mode", ++ c->syndrome ? "syndrome" : "copy-compare"); ++ spdk_json_write_object_end(w); ++ spdk_jsonrpc_end_result(c->request, w); ++ } else { ++ spdk_jsonrpc_send_error_response_fmt(c->request, rc, "verify_ranges failed: %s", ++ spdk_strerror(rc < 0 ? -rc : rc)); ++ } ++ free(c->ranges); ++ free(c); ++} ++ ++static void ++raid_verify_finish_unlocked(struct lba_range *range, void *cb_arg, int status) ++{ ++ struct raid_verify_ctx *c = cb_arg; ++ ++ (void)range; ++ (void)status; ++ raid_verify_complete(c, c->rc); ++} ++ ++/* Terminal path: release a held chunk lock first (host I/O queued behind it ++ * would hang forever otherwise), then reply. */ ++static void ++raid_verify_finish(struct raid_verify_ctx *c, int rc) ++{ ++ c->rc = rc; ++ if (c->locked) { ++ c->locked = false; ++ if (spdk_bdev_unlock_lba_range(c->desc, c->ch, c->io_lba, c->io_blocks, ++ raid_verify_finish_unlocked, c) == 0) { ++ return; ++ } ++ SPDK_ERRLOG("raid verify: terminal unlock dispatch failed — host I/O on " ++ "[%" PRIu64 ", +%" PRIu64 ") left held!\n", c->io_lba, c->io_blocks); ++ } ++ raid_verify_complete(c, rc); ++} ++ ++static struct raid_base_bdev_info * ++raid_verify_next_leg(struct raid_verify_ctx *c) ++{ ++ struct raid_bdev *raid_bdev = c->raid_bdev; ++ ++ while (c->leg_slot < raid_bdev->num_base_bdevs) { ++ struct raid_base_bdev_info *base_info = ++ &raid_bdev->base_bdev_info[c->leg_slot]; ++ ++ if (base_info != c->arbiter && base_info->is_configured && ++ !base_info->read_excluded && base_info->desc != NULL) { ++ return base_info; ++ } ++ c->leg_slot++; ++ } ++ ++ return NULL; ++} ++ ++static void raid_verify_compare_leg(struct raid_verify_ctx *c); ++ ++static void ++raid_verify_chunk_unlocked(struct lba_range *range, void *cb_arg, int status) ++{ ++ struct raid_verify_ctx *c = cb_arg; ++ ++ (void)range; ++ (void)status; ++ c->cur_off += c->io_blocks; ++ raid_verify_next(c); ++} ++ ++static void ++raid_verify_chunk_done(struct raid_verify_ctx *c) ++{ ++ assert(c->locked); ++ c->locked = false; ++ c->verified_blocks += c->io_blocks; ++ /* Registry telemetry: bytes = volume verified so far. */ ++ raid_rebuild_outcome_add_bytes(c->outcome, ++ c->io_blocks * c->raid_bdev->bdev.blocklen); ++ if (spdk_bdev_unlock_lba_range(c->desc, c->ch, c->io_lba, c->io_blocks, ++ raid_verify_chunk_unlocked, c) != 0) { ++ SPDK_ERRLOG("raid verify: chunk unlock dispatch failed\n"); ++ c->locked = true; /* still held — go through the terminal path */ ++ raid_verify_finish(c, -EIO); ++ } ++} ++ ++static void ++raid_verify_record_divergence(struct raid_verify_ctx *c) ++{ ++ SPDK_ERRLOG("raid verify: '%s' legs '%s' vs '%s' DIVERGE on [%" PRIu64 ++ ", +%" PRIu64 ")\n", c->raid_bdev->bdev.name, c->arbiter->name, ++ c->raid_bdev->base_bdev_info[c->leg_slot].name, ++ c->io_lba, c->io_blocks); ++ c->divergent_blocks += c->io_blocks; ++ if (c->num_divergent < RAID_VERIFY_MAX_REPORTED) { ++ c->divergent[c->num_divergent].start_lba = c->io_lba; ++ c->divergent[c->num_divergent].num_blocks = c->io_blocks; ++ c->num_divergent++; ++ } ++} ++ ++static void ++raid_verify_io_cb(struct spdk_bdev_io *bdev_io, bool success, void *cb_arg) ++{ ++ struct raid_verify_ctx *c = cb_arg; ++ ++ spdk_bdev_free_io(bdev_io); ++ if (!success && c->io_status == 0) { ++ c->io_status = -EIO; ++ } ++ assert(c->ios_remaining > 0); ++ if (--c->ios_remaining > 0) { ++ return; ++ } ++ ++ if (c->io_status != 0) { ++ raid_verify_finish(c, c->io_status); ++ return; ++ } ++ ++ if (memcmp(c->buf_arb, c->buf_leg, ++ c->io_blocks * c->raid_bdev->bdev.blocklen) != 0) { ++ raid_verify_record_divergence(c); ++ } ++ ++ c->leg_slot++; ++ raid_verify_compare_leg(c); ++} ++ ++/* Compare the locked chunk on the next remaining leg, or finish the chunk. */ ++static void ++raid_verify_compare_leg(struct raid_verify_ctx *c) ++{ ++ struct raid_base_bdev_info *leg; ++ int rc; ++ ++ if (c->removed) { ++ raid_verify_finish(c, -ENODEV); ++ return; ++ } ++ /* A background process appeared (rebuild) — its writes would show up ++ * as phantom divergences. Abort cleanly; the caller re-drives. */ ++ if (c->raid_bdev->process != NULL) { ++ raid_verify_finish(c, -EBUSY); ++ return; ++ } ++ ++ leg = raid_verify_next_leg(c); ++ if (leg == NULL) { ++ raid_verify_chunk_done(c); ++ return; ++ } ++ ++ c->iov_arb.iov_len = c->io_blocks * c->raid_bdev->bdev.blocklen; ++ c->iov_leg.iov_len = c->iov_arb.iov_len; ++ c->ios_remaining = 2; ++ c->io_status = 0; ++ ++ rc = raid_bdev_readv_blocks_ext(c->arbiter, c->arbiter->app_thread_ch, ++ &c->iov_arb, 1, c->io_lba, c->io_blocks, ++ raid_verify_io_cb, c, NULL); ++ if (rc != 0) { ++ raid_verify_finish(c, rc); ++ return; ++ } ++ rc = raid_bdev_readv_blocks_ext(leg, leg->app_thread_ch, ++ &c->iov_leg, 1, c->io_lba, c->io_blocks, ++ raid_verify_io_cb, c, NULL); ++ if (rc != 0) { ++ /* The arbiter read is in flight; let its completion route the ++ * failure through io_cb. */ ++ c->io_status = rc; ++ c->ios_remaining = 1; ++ } ++} ++ ++/* ── raid5f syndrome mode (SPEC-75G F-b) ───────────────────────────────── ++ * Mirror legs are copies; raid5f members hold DISTINCT strips, so equality ++ * is meaningless there. The checkable invariant is the RAID-5 consistency ++ * relation itself: parity == XOR of the k data strips, i.e. the XOR of ALL ++ * k+1 members' strips is zero for every stripe — whichever member holds ++ * the rotating parity (the rotation is irrelevant to the check). Every ++ * member stores stripe S's strip at member LBA S × strip_size ++ * (raid5f_chunk_submit), so the walk needs only generic raid_bdev geometry ++ * — no raid5f internals, no parity-position math. ++ * ++ * The strip is accumulated in bounded sub-reads (≤ RAID_VERIFY_CHUNK_BLOCKS) ++ * so memory stays two buffers whatever the strip size: buf_arb is the XOR ++ * accumulator, buf_leg the per-member read scratch. Strictly QD=1. ++ * ++ * All members must stay readable: a member read error or a membership ++ * change mid-walk aborts with -EAGAIN — with m = 1 a stripe missing one ++ * strip has nothing left to check against; the contract is repair first ++ * (rebuild_ranges 0008 / process), verify after. Divergence is reported ++ * per STRIPE in raid LBA space; like the mirror mode this REPORTS and ++ * never repairs. */ ++ ++static void raid_verify_syndrome_next_member(struct raid_verify_ctx *c); ++ ++static void ++raid_verify_syndrome_subchunk(struct raid_verify_ctx *c) ++{ ++ c->member_len = spdk_min(c->strip_blocks - c->member_off, ++ (uint64_t)RAID_VERIFY_CHUNK_BLOCKS); ++ memset(c->buf_arb, 0, c->member_len * c->raid_bdev->bdev.blocklen); ++ c->leg_slot = 0; ++ raid_verify_syndrome_next_member(c); ++} ++ ++static void ++raid_verify_record_divergent_stripe(struct raid_verify_ctx *c) ++{ ++ SPDK_ERRLOG("raid verify: '%s' stripe %" PRIu64 " [%" PRIu64 ", +%" PRIu64 ++ ") syndrome DIVERGES (xor of %u strips != 0)\n", ++ c->raid_bdev->bdev.name, c->io_lba / c->stripe_blocks, ++ c->io_lba, c->io_blocks, c->raid_bdev->num_base_bdevs); ++ c->divergent_blocks += c->io_blocks; ++ if (c->num_divergent < RAID_VERIFY_MAX_REPORTED) { ++ c->divergent[c->num_divergent].start_lba = c->io_lba; ++ c->divergent[c->num_divergent].num_blocks = c->io_blocks; ++ c->num_divergent++; ++ } ++} ++ ++static void ++raid_verify_syndrome_read_cb(struct spdk_bdev_io *bdev_io, bool success, void *cb_arg) ++{ ++ struct raid_verify_ctx *c = cb_arg; ++ uint64_t *acc = c->buf_arb; ++ const uint64_t *src = c->buf_leg; ++ uint64_t i, n = c->member_len * c->raid_bdev->bdev.blocklen / sizeof(uint64_t); ++ ++ spdk_bdev_free_io(bdev_io); ++ if (!success) { ++ /* A present member failed to read: the stripe cannot be ++ * syndrome-checked and the raid needs REPAIR, not a retry ++ * here — same contract as degraded-at-entry. */ ++ SPDK_ERRLOG("raid verify: '%s' member '%s' read failed in stripe %" PRIu64 ++ " — repair first\n", c->raid_bdev->bdev.name, ++ c->raid_bdev->base_bdev_info[c->leg_slot].name, ++ c->io_lba / c->stripe_blocks); ++ raid_verify_finish(c, -EAGAIN); ++ return; ++ } ++ ++ for (i = 0; i < n; i++) { ++ acc[i] ^= src[i]; ++ } ++ c->leg_slot++; ++ raid_verify_syndrome_next_member(c); ++} ++ ++static void ++raid_verify_syndrome_next_member(struct raid_verify_ctx *c) ++{ ++ struct raid_bdev *raid_bdev = c->raid_bdev; ++ struct raid_base_bdev_info *base_info; ++ uint64_t member_lba; ++ int rc; ++ ++ if (c->removed) { ++ raid_verify_finish(c, -ENODEV); ++ return; ++ } ++ /* Same guard as the mirror walk: a background process (rebuild) ++ * rewrites strips under us — phantom divergences. Abort cleanly; ++ * the caller re-drives. */ ++ if (raid_bdev->process != NULL) { ++ raid_verify_finish(c, -EBUSY); ++ return; ++ } ++ ++ if (c->leg_slot == raid_bdev->num_base_bdevs) { ++ /* Sub-chunk accumulated across all members — a consistent ++ * stripe XORs to exactly zero. */ ++ const uint64_t *acc = c->buf_arb; ++ uint64_t i, n = c->member_len * raid_bdev->bdev.blocklen / sizeof(uint64_t); ++ ++ for (i = 0; i < n; i++) { ++ if (acc[i] != 0) { ++ raid_verify_record_divergent_stripe(c); ++ /* The control plane re-folds whole stripes: ++ * skip the rest of this one. */ ++ c->member_off = c->strip_blocks; ++ break; ++ } ++ } ++ if (i == n) { ++ c->member_off += c->member_len; ++ } ++ if (c->member_off >= c->strip_blocks) { ++ /* Stripe covered — unlock and advance one stripe ++ * (io_blocks == stripe_blocks). */ ++ raid_verify_chunk_done(c); ++ } else { ++ raid_verify_syndrome_subchunk(c); ++ } ++ return; ++ } ++ ++ base_info = &raid_bdev->base_bdev_info[c->leg_slot]; ++ if (!base_info->is_configured || base_info->read_excluded || ++ base_info->desc == NULL || base_info->app_thread_ch == NULL) { ++ /* Membership changed since admission — degraded now. */ ++ SPDK_ERRLOG("raid verify: '%s' member slot %u became unreadable — repair first\n", ++ raid_bdev->bdev.name, c->leg_slot); ++ raid_verify_finish(c, -EAGAIN); ++ return; ++ } ++ ++ c->iov_leg.iov_len = c->member_len * raid_bdev->bdev.blocklen; ++ member_lba = (c->io_lba / c->stripe_blocks) * c->strip_blocks + c->member_off; ++ rc = raid_bdev_readv_blocks_ext(base_info, base_info->app_thread_ch, ++ &c->iov_leg, 1, member_lba, c->member_len, ++ raid_verify_syndrome_read_cb, c, NULL); ++ if (rc != 0) { ++ raid_verify_finish(c, rc); ++ } ++} ++ ++static void ++raid_verify_chunk_locked(struct lba_range *range, void *cb_arg, int status) ++{ ++ struct raid_verify_ctx *c = cb_arg; ++ ++ (void)range; ++ if (status != 0) { ++ raid_verify_finish(c, status); ++ return; ++ } ++ c->locked = true; ++ if (c->syndrome) { ++ c->member_off = 0; ++ raid_verify_syndrome_subchunk(c); ++ } else { ++ c->leg_slot = 0; ++ raid_verify_compare_leg(c); ++ } ++} ++ ++static int ++raid_verify_pace_poll(void *arg) ++{ ++ struct raid_verify_ctx *c = arg; ++ ++ spdk_poller_unregister(&c->pace_poller); ++ raid_verify_next(c); ++ return SPDK_POLLER_BUSY; ++} ++ ++/* Token bucket on the verify envelope. Returns true when the next chunk may ++ * run now; otherwise arms a poller and returns false. */ ++static bool ++raid_verify_pace_admit(struct raid_verify_ctx *c, uint64_t chunk_bytes) ++{ ++ uint64_t now; ++ ++ if (c->bytes_per_tsc == 0.0) { ++ return true; ++ } ++ ++ now = spdk_get_ticks(); ++ c->bytes_available = spdk_min(c->bytes_max, ++ c->bytes_available + ++ (now - c->last_tsc) * c->bytes_per_tsc); ++ c->last_tsc = now; ++ if (c->bytes_available > 0.0) { ++ c->bytes_available -= chunk_bytes; ++ return true; ++ } ++ ++ c->pace_poller = SPDK_POLLER_REGISTER(raid_verify_pace_poll, c, 10000); ++ return false; ++} ++ ++static void ++raid_verify_next(struct raid_verify_ctx *c) ++{ ++ struct raid_verify_range *range; ++ uint64_t remaining, pace_bytes; ++ int rc; ++ ++ if (c->removed) { ++ raid_verify_finish(c, -ENODEV); ++ return; ++ } ++ ++ while (c->cur < c->num_ranges) { ++ range = &c->ranges[c->cur]; ++ remaining = range->num_blocks - c->cur_off; ++ if (remaining == 0) { ++ c->cur++; ++ c->cur_off = 0; ++ continue; ++ } ++ ++ c->io_lba = range->start_lba + c->cur_off; ++ if (c->syndrome) { ++ /* One full stripe per lock — ranges are validated ++ * stripe-aligned, so remaining is a stripe multiple. ++ * Pace on the bytes actually READ (all k+1 strips, ++ * not the k-data raid LBA span). */ ++ c->io_blocks = c->stripe_blocks; ++ pace_bytes = (uint64_t)c->raid_bdev->num_base_bdevs * ++ c->strip_blocks * c->raid_bdev->bdev.blocklen; ++ } else { ++ c->io_blocks = spdk_min(remaining, (uint64_t)RAID_VERIFY_CHUNK_BLOCKS); ++ pace_bytes = c->io_blocks * c->raid_bdev->bdev.blocklen; ++ } ++ ++ if (!raid_verify_pace_admit(c, pace_bytes)) { ++ return; /* poller re-enters raid_verify_next */ ++ } ++ ++ rc = spdk_bdev_lock_lba_range(c->desc, c->ch, c->io_lba, c->io_blocks, ++ raid_verify_chunk_locked, c); ++ if (rc != 0) { ++ raid_verify_finish(c, rc); ++ } ++ return; ++ } ++ ++ raid_verify_finish(c, 0); ++} ++ ++static void ++raid_verify_event_cb(enum spdk_bdev_event_type type, struct spdk_bdev *bdev, void *event_ctx) ++{ ++ struct raid_verify_ctx *c = event_ctx; ++ ++ (void)bdev; ++ if (type == SPDK_BDEV_EVENT_REMOVE) { ++ c->removed = true; /* abort at the next chunk boundary */ ++ } ++} ++ ++/* ── RPC ─────────────────────────────────────────────────────────────── */ ++ ++struct rpc_bdev_raid_verify_ranges { ++ char *name; ++ size_t num_ranges; ++ struct raid_verify_range *ranges; ++ char *expected_incarnation; ++ char *token; ++}; ++ ++static int ++decode_verify_range(const struct spdk_json_val *val, void *out) ++{ ++ const struct spdk_json_object_decoder decoders[] = { ++ {"start_lba", offsetof(struct raid_verify_range, start_lba), spdk_json_decode_uint64}, ++ {"num_blocks", offsetof(struct raid_verify_range, num_blocks), spdk_json_decode_uint64}, ++ }; ++ ++ return spdk_json_decode_object(val, decoders, SPDK_COUNTOF(decoders), ++ (struct raid_verify_range *)out) ? -1 : 0; ++} ++ ++static int ++decode_verify_ranges(const struct spdk_json_val *val, void *out) ++{ ++ struct rpc_bdev_raid_verify_ranges *req = ++ SPDK_CONTAINEROF(out, struct rpc_bdev_raid_verify_ranges, ranges); ++ ++ if (req->ranges == NULL) { ++ req->ranges = calloc(RAID_VERIFY_MAX_RANGES, sizeof(struct raid_verify_range)); ++ if (req->ranges == NULL) { ++ return -ENOMEM; ++ } ++ } ++ return spdk_json_decode_array(val, decode_verify_range, req->ranges, ++ RAID_VERIFY_MAX_RANGES, &req->num_ranges, ++ sizeof(struct raid_verify_range)); ++} ++ ++static const struct spdk_json_object_decoder rpc_bdev_raid_verify_ranges_decoders[] = { ++ {"name", offsetof(struct rpc_bdev_raid_verify_ranges, name), spdk_json_decode_string}, ++ {"ranges", offsetof(struct rpc_bdev_raid_verify_ranges, ranges), decode_verify_ranges, true}, ++ {"expected_incarnation", offsetof(struct rpc_bdev_raid_verify_ranges, expected_incarnation), spdk_json_decode_string, true}, ++ {"token", offsetof(struct rpc_bdev_raid_verify_ranges, token), spdk_json_decode_string, true}, ++}; ++ ++static void ++free_rpc_bdev_raid_verify_ranges(struct rpc_bdev_raid_verify_ranges *req) ++{ ++ free(req->name); ++ free(req->ranges); ++ free(req->expected_incarnation); ++ free(req->token); ++} ++ ++static void ++rpc_bdev_raid_verify_ranges(struct spdk_jsonrpc_request *request, ++ const struct spdk_json_val *params) ++{ ++ struct rpc_bdev_raid_verify_ranges req = {}; ++ struct raid_verify_ctx *c = NULL; ++ struct raid_bdev *raid_bdev; ++ struct raid_base_bdev_info *base_info, *arbiter = NULL; ++ uint32_t configured = 0, verify_mb_sec, i; ++ uint64_t buf_len, strip_blocks = 0, stripe_blocks = 0; ++ bool syndrome; ++ char detail[192]; ++ char auto_token[RAID_REBUILD_TOKEN_MAX]; ++ const char *token; ++ int rc; ++ ++ if (spdk_json_decode_object(params, rpc_bdev_raid_verify_ranges_decoders, ++ SPDK_COUNTOF(rpc_bdev_raid_verify_ranges_decoders), &req)) { ++ spdk_jsonrpc_send_error_response(request, SPDK_JSONRPC_ERROR_PARSE_ERROR, ++ "spdk_json_decode_object failed"); ++ goto cleanup; ++ } ++ ++ raid_bdev = raid_bdev_find_by_name(req.name); ++ if (raid_bdev == NULL) { ++ spdk_jsonrpc_send_error_response_fmt(request, -ENODEV, ++ "raid bdev %s not found", req.name); ++ goto cleanup; ++ } ++ ++ if (raid_bdev_check_incarnation(raid_bdev, req.expected_incarnation) != 0) { ++ spdk_jsonrpc_send_error_response_fmt(request, -ESTALE, ++ "stale incarnation for raid bdev %s", ++ req.name); ++ goto cleanup; ++ } ++ ++ if ((raid_bdev->level != RAID1 && raid_bdev->level != RAID5F) || ++ raid_bdev->state != RAID_BDEV_STATE_ONLINE) { ++ spdk_jsonrpc_send_error_response(request, -EINVAL, ++ "verify_ranges requires an ONLINE raid1 or raid5f bdev"); ++ goto cleanup; ++ } ++ syndrome = raid_bdev->level == RAID5F; ++ if (syndrome && ++ (raid_bdev->bdev.md_len != 0 || raid_bdev->bdev.blocklen % sizeof(uint64_t) != 0)) { ++ /* EcImage chunks are plain lvols; interleaved metadata would ++ * need md-aware parity accounting — honest bound, not a TODO. */ ++ spdk_jsonrpc_send_error_response(request, -ENOTSUP, ++ "raid5f syndrome verify supports plain data bdevs only"); ++ goto cleanup; ++ } ++ if (raid_bdev->process != NULL) { ++ spdk_jsonrpc_send_error_response(request, -EBUSY, ++ "raid bdev has a background process"); ++ goto cleanup; ++ } ++ ++ RAID_FOR_EACH_BASE_BDEV(raid_bdev, base_info) { ++ if (base_info->is_configured && !base_info->read_excluded && ++ base_info->desc != NULL) { ++ if (arbiter == NULL) { ++ arbiter = base_info; ++ } ++ configured++; ++ } ++ } ++ if (syndrome) { ++ /* m = 1: with any member unreadable there is no redundancy ++ * left to check against — repair first (0008), verify after. */ ++ if (configured != raid_bdev->num_base_bdevs) { ++ spdk_jsonrpc_send_error_response_fmt(request, -EAGAIN, ++ "syndrome verify requires all %u members readable " ++ "(%u are) — repair first", ++ raid_bdev->num_base_bdevs, configured); ++ goto cleanup; ++ } ++ strip_blocks = raid_bdev->strip_size; ++ stripe_blocks = strip_blocks * raid_bdev->min_base_bdevs_operational; ++ if (strip_blocks == 0 || stripe_blocks == 0) { ++ spdk_jsonrpc_send_error_response(request, -EINVAL, ++ "raid bdev has no stripe geometry"); ++ goto cleanup; ++ } ++ } else if (configured < 2) { ++ spdk_jsonrpc_send_error_response(request, -EINVAL, ++ "verify_ranges needs at least 2 readable legs"); ++ goto cleanup; ++ } ++ ++ if (req.token != NULL && ++ strnlen(req.token, RAID_REBUILD_TOKEN_MAX) == RAID_REBUILD_TOKEN_MAX) { ++ spdk_jsonrpc_send_error_response_fmt(request, -ENAMETOOLONG, ++ "token exceeds %d characters", ++ RAID_REBUILD_TOKEN_MAX - 1); ++ goto cleanup; ++ } ++ ++ /* Default: verify every allocated block — the whole raid (the caller ++ * bounds batches with explicit ranges when it wants increments). */ ++ if (req.num_ranges == 0) { ++ free(req.ranges); ++ req.ranges = calloc(1, sizeof(struct raid_verify_range)); ++ if (req.ranges == NULL) { ++ spdk_jsonrpc_send_error_response(request, -ENOMEM, spdk_strerror(ENOMEM)); ++ goto cleanup; ++ } ++ req.ranges[0].start_lba = 0; ++ req.ranges[0].num_blocks = raid_bdev->bdev.blockcnt; ++ req.num_ranges = 1; ++ } ++ ++ for (i = 0; i < req.num_ranges; i++) { ++ const struct raid_verify_range *range = &req.ranges[i]; ++ ++ if (range->num_blocks == 0 || range->start_lba >= raid_bdev->bdev.blockcnt || ++ range->num_blocks > raid_bdev->bdev.blockcnt - range->start_lba) { ++ spdk_jsonrpc_send_error_response_fmt(request, ++ SPDK_JSONRPC_ERROR_INVALID_PARAMS, ++ "range %u is empty or exceeds the raid size", i); ++ goto cleanup; ++ } ++ /* Syndrome walks whole stripes — 0008's alignment discipline, ++ * validated up front. (The whole-device default is aligned by ++ * construction: blockcnt = total_stripes × stripe_blocks.) */ ++ if (syndrome && (range->start_lba % stripe_blocks != 0 || ++ range->num_blocks % stripe_blocks != 0)) { ++ spdk_jsonrpc_send_error_response_fmt(request, ++ SPDK_JSONRPC_ERROR_INVALID_PARAMS, ++ "range %u [%" PRIu64 ", +%" PRIu64 ") not aligned to the " ++ "full stripe (%" PRIu64 " blocks)", i, ++ range->start_lba, range->num_blocks, stripe_blocks); ++ goto cleanup; ++ } ++ } ++ ++ c = calloc(1, sizeof(*c)); ++ if (c == NULL) { ++ spdk_jsonrpc_send_error_response(request, -ENOMEM, spdk_strerror(ENOMEM)); ++ goto cleanup; ++ } ++ ++ buf_len = (uint64_t)RAID_VERIFY_CHUNK_BLOCKS * raid_bdev->bdev.blocklen; ++ c->buf_arb = spdk_dma_malloc(buf_len, 4096, NULL); ++ c->buf_leg = spdk_dma_malloc(buf_len, 4096, NULL); ++ if (c->buf_arb == NULL || c->buf_leg == NULL) { ++ spdk_dma_free(c->buf_arb); ++ spdk_dma_free(c->buf_leg); ++ free(c); ++ spdk_jsonrpc_send_error_response(request, -ENOMEM, spdk_strerror(ENOMEM)); ++ goto cleanup; ++ } ++ c->iov_arb.iov_base = c->buf_arb; ++ c->iov_leg.iov_base = c->buf_leg; ++ ++ rc = spdk_bdev_open_ext(req.name, false, raid_verify_event_cb, c, &c->desc); ++ if (rc != 0) { ++ spdk_dma_free(c->buf_arb); ++ spdk_dma_free(c->buf_leg); ++ free(c); ++ spdk_jsonrpc_send_error_response_fmt(request, rc, "open failed: %s", ++ spdk_strerror(-rc)); ++ goto cleanup; ++ } ++ c->ch = spdk_bdev_get_io_channel(c->desc); ++ if (c->ch == NULL) { ++ spdk_bdev_close(c->desc); ++ spdk_dma_free(c->buf_arb); ++ spdk_dma_free(c->buf_leg); ++ free(c); ++ spdk_jsonrpc_send_error_response(request, -ENOMEM, "io channel"); ++ goto cleanup; ++ } ++ ++ /* Pacing under the verify envelope, frozen now (0014.9 discipline). */ ++ verify_mb_sec = raid_envelope_active_mb_sec(RAID_ENVELOPE_VERIFY); ++ if (verify_mb_sec != 0) { ++ c->bytes_per_tsc = verify_mb_sec * 1024 * 1024.0 / spdk_get_ticks_hz(); ++ c->bytes_max = verify_mb_sec * 1024 * 1024.0 / SPDK_SEC_TO_MSEC; ++ c->last_tsc = spdk_get_ticks(); ++ } ++ ++ if (req.token == NULL || req.token[0] == '\0') { ++ snprintf(auto_token, sizeof(auto_token), "auto:verify:%s:%" PRIu64, ++ req.name, spdk_get_ticks()); ++ token = auto_token; ++ } else { ++ token = req.token; ++ } ++ c->outcome = raid_rebuild_outcome_open(token, req.name, "(verify_ranges)"); ++ raid_rebuild_outcome_set_verifying(c->outcome); ++ ++ /* SEC1 audit (0011 discipline): long-lived reader holding LBA locks. */ ++ snprintf(detail, sizeof(detail), "raid=%s ranges=%zu mode=%s", req.name, ++ req.num_ranges, syndrome ? "syndrome" : "copy-compare"); ++ spdk_jsonrpc_request_audit(request, "bdev_raid_verify_ranges", detail); ++ ++ c->request = request; ++ c->raid_bdev = raid_bdev; ++ c->arbiter = arbiter; ++ c->syndrome = syndrome; ++ c->strip_blocks = strip_blocks; ++ c->stripe_blocks = stripe_blocks; ++ c->ranges = (struct raid_verify_range *)req.ranges; ++ c->num_ranges = (uint32_t)req.num_ranges; ++ req.ranges = NULL; /* ownership moved */ ++ ++ raid_verify_next(c); ++ ++cleanup: ++ free_rpc_bdev_raid_verify_ranges(&req); ++} ++SPDK_RPC_REGISTER("bdev_raid_verify_ranges", rpc_bdev_raid_verify_ranges, SPDK_RPC_RUNTIME) diff --git a/patches/0023-raid5f-degraded-observability.patch b/patches/0023-raid5f-degraded-observability.patch new file mode 100644 index 0000000..6feff51 --- /dev/null +++ b/patches/0023-raid5f-degraded-observability.patch @@ -0,0 +1,102 @@ +diff --git a/module/bdev/raid/bdev_raid.c b/module/bdev/raid/bdev_raid.c +index 762e892..c9ee0e4 100644 +--- a/module/bdev/raid/bdev_raid.c ++++ b/module/bdev/raid/bdev_raid.c +@@ -1240,6 +1240,21 @@ raid_bdev_write_info_json(struct raid_bdev *raid_bdev, struct spdk_json_write_ct + (uint64_t)raid_bdev->strip_size * + raid_bdev->min_base_bdevs_operational); + } ++ /* Evariops 0023 (SPEC-75G F-d): degraded-service counters — the control ++ * plane's "serving degraded since T" gauge (EcRebuildDebt.since). 0 = ++ * never. Loads mirror the relaxed-atomic writers on the I/O paths. */ ++ spdk_json_write_named_uint64(w, "reconstruct_reads_absent", ++ __atomic_load_n(&raid_bdev->reconstruct_reads_absent, ++ __ATOMIC_RELAXED)); ++ spdk_json_write_named_uint64(w, "reconstruct_reads_error", ++ __atomic_load_n(&raid_bdev->reconstruct_reads_error, ++ __ATOMIC_RELAXED)); ++ spdk_json_write_named_uint64(w, "degraded_write_stripes", ++ __atomic_load_n(&raid_bdev->degraded_write_stripes, ++ __ATOMIC_RELAXED)); ++ spdk_json_write_named_int64(w, "last_degraded_ts", ++ __atomic_load_n(&raid_bdev->last_degraded_ts, ++ __ATOMIC_RELAXED)); + if (raid_bdev->process) { + struct raid_bdev_process *process = raid_bdev->process; + uint64_t offset = process->window_offset; +diff --git a/module/bdev/raid/bdev_raid.h b/module/bdev/raid/bdev_raid.h +index 2c36491..343615e 100644 +--- a/module/bdev/raid/bdev_raid.h ++++ b/module/bdev/raid/bdev_raid.h +@@ -296,6 +296,17 @@ struct raid_bdev { + /* Raid bdev background process, e.g. rebuild */ + struct raid_bdev_process *process; + ++ /* Evariops 0023 (SPEC-75G F-d): degraded-service observability — how ++ * long/how much this raid has been SERVING in degraded mode, the ++ * control plane's "EcRebuildDebt.since" gauge. Written on raid5f I/O ++ * paths with relaxed atomics (single reactor today — D5 —, still safe ++ * if the core mask ever widens); read by get_bdevs. Generic slots: ++ * other levels may feed them later. 0 = never. */ ++ uint64_t reconstruct_reads_absent; /* member missing (vanilla path) */ ++ uint64_t reconstruct_reads_error; /* member present-but-erroring (0006) */ ++ uint64_t degraded_write_stripes; /* stripes written at zero margin */ ++ int64_t last_degraded_ts; /* unix seconds of the last event */ ++ + /* Callback and context for raid_bdev configuration */ + raid_bdev_configure_cb configure_cb; + void *configure_cb_ctx; +diff --git a/module/bdev/raid/raid5f.c b/module/bdev/raid/raid5f.c +index 1d02ec8..e7122c9 100644 +--- a/module/bdev/raid/raid5f.c ++++ b/module/bdev/raid/raid5f.c +@@ -16,6 +16,17 @@ + /* Maximum concurrent full stripe writes per io channel */ + #define RAID5F_MAX_STRIPES 32 + ++/* Evariops 0023 (SPEC-75G F-d): stamp a degraded-service event — counter + ++ * last_degraded_ts. Relaxed atomics: single reactor today (D5), safe if the ++ * core mask ever widens; never ordered against data I/O. */ ++static inline void ++raid5f_note_degraded(struct raid_bdev *raid_bdev, uint64_t *counter) ++{ ++ __atomic_fetch_add(counter, 1, __ATOMIC_RELAXED); ++ __atomic_store_n(&raid_bdev->last_degraded_ts, (int64_t)time(NULL), ++ __ATOMIC_RELAXED); ++} ++ + struct chunk { + /* Corresponds to base_bdev index */ + uint8_t index; +@@ -436,6 +447,11 @@ raid5f_chunk_submit(struct chunk *chunk) + switch (stripe_req->type) { + case STRIPE_REQ_WRITE: + if (base_ch == NULL) { ++ /* Evariops 0023: the stripe is being written with this ++ * member missing — reconstructible, but at zero margin ++ * (F-e: the CP freezes folds on a degraded EcImage; a ++ * nonzero counter here is the violation gauge). */ ++ raid5f_note_degraded(raid_bdev, &raid_bdev->degraded_write_stripes); + raid_bdev_io_complete_part(raid_io, 1, SPDK_BDEV_IO_STATUS_SUCCESS); + return 0; + } +@@ -685,6 +701,9 @@ raid5f_chunk_read_complete(struct spdk_bdev_io *bdev_io, bool success, void *cb_ + chunk_idx = chunk_data_idx < p_idx ? chunk_data_idx : chunk_data_idx + 1; + chunk_offset = stripe_offset - ((uint64_t)chunk_data_idx << raid_bdev->strip_size_shift); + ++ /* Evariops 0023: degraded service on a PRESENT member (error path). */ ++ raid5f_note_degraded(raid_bdev, &raid_bdev->reconstruct_reads_error); ++ + ret = raid5f_submit_reconstruct_read(raid_io, stripe_index, chunk_idx, chunk_offset, + raid5f_stripe_request_reconstruct_xor_done); + if (ret != 0) { +@@ -830,6 +849,9 @@ raid5f_submit_read_request(struct raid_bdev_io *raid_io, uint64_t stripe_index, + + raid5f_init_ext_io_opts(&io_opts, raid_io); + if (base_ch == NULL) { ++ /* Evariops 0023: degraded service, member ABSENT (vanilla ++ * reconstruct-read path). */ ++ raid5f_note_degraded(raid_bdev, &raid_bdev->reconstruct_reads_absent); + return raid5f_submit_reconstruct_read(raid_io, stripe_index, chunk_idx, chunk_offset, + raid5f_stripe_request_reconstruct_xor_done); + } diff --git a/patches/0024-dd-propagate-io-errors.patch b/patches/0024-dd-propagate-io-errors.patch new file mode 100644 index 0000000..8436f12 --- /dev/null +++ b/patches/0024-dd-propagate-io-errors.patch @@ -0,0 +1,43 @@ +diff --git a/app/spdk_dd/spdk_dd.c b/app/spdk_dd/spdk_dd.c +index 2bbcca1..63b0eec 100644 +--- a/app/spdk_dd/spdk_dd.c ++++ b/app/spdk_dd/spdk_dd.c +@@ -339,6 +339,13 @@ _dd_write_bdev_done(struct spdk_bdev_io *bdev_io, + { + struct dd_io *io = cb_arg; + ++ /* Evariops 0024: a failed bdev write MUST fail the copy — upstream ++ * ignored `success`, so dd exited 0 while the target rejected every ++ * write (e.g. a raid5f write_unit rejection). */ ++ if (!success && g_error == 0) { ++ SPDK_ERRLOG("bdev write failed at offset %" PRIu64 "\n", io->offset); ++ g_error = -EIO; ++ } + assert(g_job.outstanding > 0); + g_job.outstanding--; + spdk_bdev_free_io(bdev_io); +@@ -412,6 +419,12 @@ _dd_read_bdev_done(struct spdk_bdev_io *bdev_io, + + spdk_bdev_free_io(bdev_io); + ++ /* Evariops 0024: a failed bdev read is WORSE than a failed write when ++ * ignored — the stale buffer gets written out as if it were data. */ ++ if (!success && g_error == 0) { ++ SPDK_ERRLOG("bdev read failed at offset %" PRIu64 "\n", io->offset); ++ g_error = -EIO; ++ } + assert(g_job.outstanding > 0); + g_job.outstanding--; + dd_target_write(io); +@@ -472,6 +485,11 @@ _dd_target_populate_buffer_done(struct spdk_bdev_io *bdev_io, + { + struct dd_io *io = cb_arg; + ++ /* Evariops 0024: same contract as read/write completions. */ ++ if (!success && g_error == 0) { ++ SPDK_ERRLOG("bdev populate read failed at offset %" PRIu64 "\n", io->offset); ++ g_error = -EIO; ++ } + assert(g_job.outstanding > 0); + g_job.outstanding--; + spdk_bdev_free_io(bdev_io); diff --git a/patches/README.md b/patches/README.md index a87e779..0069bf1 100644 --- a/patches/README.md +++ b/patches/README.md @@ -26,6 +26,18 @@ IS the contract; do not rely on any other ordering. Order matters: | 0010 | rpc socket chmod 0600 | lib/rpc | — | | 0011 | jsonrpc SO_PEERCRED audit hook (SEC1) | lib/jsonrpc | — | | 0012 | lvol shutdown-unload observability | bdev_lvol | — | +| 0013 | raid1 seeded rebuild (write_only attach + range-seeded backfill) | bdev_raid | 0001, 0008, 0011 | +| 0014 | raid incarnation identity + `expected_incarnation` ⇒ -ESTALE (GCCP 0014.4) | bdev_raid | 0013 | +| 0015 | raid rebuild-outcome registry + CANCELED contract + `bdev_raid_get_rebuild_outcomes` (GCCP 0014.5) | bdev_raid (adds `bdev_raid_outcomes.{c,h}`); `module/bdev/cbt` consults it for `epoch_close(consumed)` | 0013, 0014 | +| 0016 | raid extended superblock — per-member `content_generation`/`view_epoch`, same-transaction (GCCP 0014.7, V-2) | bdev_raid (SB minor 0→1, carved from reserved bytes) | 0013 | +| 0017 | raid per-member observation — `state`/`since`/generations + cbt live-epoch facts in get_bdevs (GCCP 0014.6) | bdev_raid; `#include`s `../cbt/vbdev_cbt_query.h` (requires the cbt module, like 0005 requires tier) | 0013, 0016, cbt module | +| 0018 | raid integrated verify — K=64 sampled windows post-copy under quiesce_range, re-copy before DIVERGENT, `verified` registry seal gating `epoch_close(consumed)` (GCCP 0014.8) | bdev_raid | 0013, 0015, 0016 | +| 0019 | raid auto epoch at member ejection — survivors' cbt bounds the delta, nonce reported via get_bdevs (GCCP 0014.10) | bdev_raid; calls `vbdev_cbt_auto_epoch_open` (cbt module) | 0013, cbt module | +| 0020 | nvmf explicit audited force-resume — fence path breaks a standing barrier by design, DÉC-11 (GCCP 0014.11) | lib/nvmf (nvmf_pause_rpc.c) | 0003, 0011 | +| 0021 | raid envelopes — per-class caps ×(nominal, maintenance) + rebuild concurrency bound, `bdev_raid_set/get_envelopes` (GCCP 0014.9) | bdev_raid (adds `bdev_raid_envelopes.{c,h}`) | 0013 | +| 0022 | raid verify_ranges — exhaustive divergence detector, LBA-locked chunks, verify-envelope paced, reports & never repairs (GCCP 0014.12). **Bi-mode** (SPEC-75G F-b): raid1 = copy-compare; raid5f = XOR-syndrome per stripe (all members required, -EAGAIN when degraded; ranges full-stripe-aligned) | bdev_raid (adds `bdev_raid_verify_ranges.c`) | 0008, 0014, 0015, 0021 | +| 0023 | raid5f degraded-service observability (SPEC-75G F-d) — `reconstruct_reads_absent`/`reconstruct_reads_error`/`degraded_write_stripes`/`last_degraded_ts` in get_bdevs, relaxed-atomic counters on the I/O paths | bdev_raid (raid5f.c increments, bdev_raid.c emission) | 0006, 0008 | +| 0024 | spdk_dd propagates bdev I/O errors — upstream ignores `success` in all three bdev completion callbacks, so dd exited 0 while every write was rejected (found by vec-smoke); a silently failed READ is worse (stale buffer written as data) | app/spdk_dd | — | 0005 `#include`s `vbdev_tier.h` and adds `-I module/bdev/tier` to the lvol module CFLAGS via its own Makefile hunk; the Dockerfile injects the module dirs before @@ -80,15 +92,22 @@ scripts/patches.sh regen /path/to/spdk-worktree ### To change a patch -1. Apply the series into a clean SPDK checkout at the pinned commit, committing - one patch per commit (in order). -2. Edit the source, `git commit --amend` (or a fixup) into the owning commit. +1. Build a worktree: clean SPDK checkout at the pinned commit (`vendor/spdk` + clones offline), apply the series **one commit per patch, in order**, with + `git apply` + `git commit -m ""` — the commit SUBJECT + must be the `NNNN-name` stem, it becomes the regenerated filename. +2. Edit the source, `git commit --amend` (or a fixup) into the owning commit; + a NEW patch = a new commit with the next `NNNN-name` subject. 3. `scripts/patches.sh regen ` to rewrite `patches/*.patch`. -4. `scripts/patches.sh verify` to confirm the whole series still applies. - -`git format-patch --zero-commit` is used so the `From ` line is a constant -(all-zero) instead of a per-regeneration hash — this keeps the patch files stable -under review. The `From: 000…` header line is therefore expected, not a bug. +4. `scripts/patches.sh verify` (or `check` against a pinned clone) to confirm + the whole series still applies. + +**Series format (homogenized 2026-07-16): RAW `git diff` output, no mail +header.** `git apply` (Dockerfile + `check`/`apply`) is the only consumer; +**`git am` is NOT part of the contract** (it chokes on raw diffs — the series +was format-mixed mbox/raw until 2026-07-16, which is how that was learned). +`regen` emits raw per-commit diffs with the commit subject as filename, so a +regen of an untouched series is byte-stable. Never hand-edit hunks. ## Upstreaming (UP4) diff --git a/scripts/patches.sh b/scripts/patches.sh index 1ff6f3a..6d8b358 100755 --- a/scripts/patches.sh +++ b/scripts/patches.sh @@ -71,15 +71,28 @@ cmd_apply() { done < <(patches) } +# Regenerate patches/*.patch as RAW git diffs, one per commit on top of the +# pinned base, in order. The commit SUBJECT is the patch filename stem and +# must match NNNN-name. Raw diffs (no mail header) are the series format: +# `git apply` consumes them (Dockerfile + check/apply); `git am` is NOT part +# of the contract (it chokes on raw diffs — build the worktree with +# `git apply` + one commit per patch, subject = filename stem). cmd_regen() { local wt="${1:?usage: patches.sh regen }" - local base + local base commit subject n=0 base="$(pinned_sha)" - local n - n="$(patches_count)" rm -f "$PATCH_DIR"/[0-9]*.patch - git -C "$wt" format-patch --zero-commit "$base..HEAD" -o "$PATCH_DIR" >/dev/null - echo "Regenerated $(patches_count) patches from $wt ($base..HEAD); was $n." + while IFS= read -r commit; do + subject="$(git -C "$wt" log -1 --format=%s "$commit")" + case "$subject" in + [0-9][0-9][0-9][0-9]-*) ;; + *) echo "FATAL: commit $commit subject '$subject' is not NNNN-name (the subject IS the patch filename)" >&2 + return 1;; + esac + git -C "$wt" diff "$commit^..$commit" > "$PATCH_DIR/$subject.patch" + n=$((n + 1)) + done < <(git -C "$wt" rev-list --reverse "$base..HEAD") + echo "Regenerated $n raw-diff patches from $wt ($base..HEAD)." } cmd_verify() { diff --git a/scripts/vec-smoke.sh b/scripts/vec-smoke.sh new file mode 100755 index 0000000..12560e7 --- /dev/null +++ b/scripts/vec-smoke.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026 Evariops. +# +# vec-smoke.sh — single-node raid5f EC smoke (SPEC-75G G1 pre-flight). +# +# Exercises the fork's EC data-plane contract on file-backed AIO bdevs: +# 1. create raid5f 2+1 (strip 64K, incarnation) and write full stripes +# (spdk_dd) — sub-stripe writes are REJECTED (EC-3, negative test); +# 2. syndrome verify_ranges (patch 0022, F-b) → zero divergence; +# 3. corrupt the PARITY strip of stripe 0 on the backing file → +# verify reports the stripe DIVERGENT (mode=syndrome, report-only); +# 4. bdev_raid_rebuild_ranges (patch 0008) over that stripe → re-verify +# clean. (Parity-strip corruption is the honestly repairable case; a +# DATA-strip corruption would be "repaired" into consistent-but-wrong, +# which is exactly why verify is report-only and the CP re-folds from +# the source — SPEC-75G §4.3.) +# 5. delete one member → syndrome verify refuses -EAGAIN ("repair first"); +# 6. unaligned range → -EINVAL; degraded-service counters visible (0023). +# +# This smoke is NOT the V-EC certification (cross-node, real disk pulls, on +# turing — spdk-csi QA harness). It is the fork-local pre-flight that must +# be green before any image ships raid5f primitives. +# +# Requirements: linux, spdk_tgt + spdk_dd binaries (builder image has both), +# python3. Hugepages if available, else falls back to --no-huge. +# +# Usage: +# scripts/vec-smoke.sh [workdir] +# SPDK_TGT=/build/spdk/build/bin/spdk_tgt SPDK_DD=/build/spdk/build/bin/spdk_dd \ +# scripts/vec-smoke.sh /tmp/vecsmoke + +set -euo pipefail + +WORK="${1:-/tmp/vecsmoke}" +SOCK="$WORK/rpc.sock" +SPDK_TGT="${SPDK_TGT:-spdk_tgt}" +SPDK_DD="${SPDK_DD:-spdk_dd}" +INCARNATION="vecsmoke-$$" + +STRIP_KB=64 # per-member strip +STRIPE_BYTES=$((2 * STRIP_KB * 1024)) # k=2 data strips = 128 KiB full stripe +STRIPE_BLOCKS=$((STRIPE_BYTES / 512)) # 256 blocks @512 +MEMBER_MB=64 +WRITE_MB=16 # 128 full stripes of payload + +TGT_PID="" +FAILURES=0 + +say() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; } +pass() { printf '\033[32mPASS\033[0m %s\n' "$*"; } +fail() { printf '\033[31mFAIL\033[0m %s\n' "$*"; FAILURES=$((FAILURES + 1)); } + +cleanup() { + [ -n "$TGT_PID" ] && kill "$TGT_PID" 2>/dev/null || true + wait 2>/dev/null || true +} +trap cleanup EXIT + +# Hugepages or fallback. 2 GiB: the iscsi subsystem compiled into spdk_tgt +# fails its PDU pool allocation below ~1 GiB of legacy mem. +MEMOPTS=(-s 2048) +if ! grep -q '^HugePages_Total:\s*[1-9]' /proc/meminfo 2>/dev/null; then + MEMOPTS+=(--no-huge) + echo "(no hugepages — using --no-huge)" +fi + +# spdk_dd reads/writes FILES through io_uring by default; Docker's default +# seccomp profile blocks io_uring_setup → force AIO (bdev side unaffected). +DDOPTS=(--aio) + +# ── raw JSON-RPC over the unix socket (no rpc.py wrappers: the fork RPCs +# and the incarnation param are not in vanilla rpc.py) ────────────────── +rpc_raw() { + # rpc_raw → prints the raw JSON response + python3 - "$SOCK" "$1" "$2" <<'EOF' +import json, socket, sys +sock_path, method, params = sys.argv[1], sys.argv[2], sys.argv[3] +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect(sock_path) +req = {"jsonrpc": "2.0", "id": 1, "method": method} +if params.strip(): + req["params"] = json.loads(params) +s.sendall(json.dumps(req).encode()) +buf = b"" +while True: + chunk = s.recv(65536) + if not chunk: + break + buf += chunk + try: + json.loads(buf) + break + except ValueError: + continue +print(buf.decode()) +EOF +} + +rpc_ok() { # asserts the call succeeds, prints result + local out + out=$(rpc_raw "$1" "$2") + if echo "$out" | grep -q '"error"'; then + echo "$out" >&2 + return 1 + fi + echo "$out" +} + +rpc_expect_err() { # rpc_expect_err + local out + out=$(rpc_raw "$1" "$2") + echo "$out" | grep -q '"error"' && echo "$out" | grep -qi "$3" +} + +json_field() { # json_field + python3 -c "import json,sys; r=json.loads(sys.argv[1])['result']; print(sys.argv[2] and eval(sys.argv[2]))" "$1" "$2" +} + +wait_sock() { + for _ in $(seq 1 100); do + [ -S "$SOCK" ] && rpc_raw framework_wait_init "" >/dev/null 2>&1 && return 0 + sleep 0.2 + done + echo "spdk_tgt socket never came up" >&2 + return 1 +} + +start_tgt() { + "$SPDK_TGT" -r "$SOCK" -m 0x1 "${MEMOPTS[@]}" & + TGT_PID=$! + wait_sock +} + +stop_tgt() { + kill "$TGT_PID" 2>/dev/null || true + wait "$TGT_PID" 2>/dev/null || true + TGT_PID="" +} + +create_bdevs() { # aio members + raid5f (no superblock: file offset == member LBA) + for i in 0 1 2; do + rpc_ok bdev_aio_create "{\"filename\": \"$WORK/m$i.img\", \"name\": \"aio$i\", \"block_size\": 512}" >/dev/null + done + rpc_ok bdev_raid_create "{\"name\": \"ecsmoke\", \"raid_level\": \"raid5f\", \"strip_size_kb\": $STRIP_KB, \"base_bdevs\": [\"aio0\", \"aio1\", \"aio2\"], \"incarnation\": \"$INCARNATION\"}" >/dev/null +} + +# ── setup ──────────────────────────────────────────────────────────────── +say "setup: $WORK (strip ${STRIP_KB}K, full stripe ${STRIPE_BYTES} B / ${STRIPE_BLOCKS} blocks)" +# $WORK may be a mount point (container volume): clear its CONTENT, never +# the directory itself. +mkdir -p "$WORK" +rm -rf "${WORK:?}"/* 2>/dev/null || true +for i in 0 1 2; do + dd if=/dev/zero of="$WORK/m$i.img" bs=1M count=$MEMBER_MB status=none +done +dd if=/dev/urandom of="$WORK/payload.bin" bs=1M count=$WRITE_MB status=none +dd if=/dev/urandom of="$WORK/payload4k.bin" bs=4096 count=1 status=none + +# spdk_dd runs its own app instance: same bdevs via a JSON config. +cat > "$WORK/dd-config.json" < "$WORK/dd-write.log" 2>&1 || true +"$SPDK_DD" "${MEMOPTS[@]}" "${DDOPTS[@]}" -c "$WORK/dd-config.json" \ + --ib ecsmoke --of "$WORK/readback.bin" --bs 1048576 --count $WRITE_MB \ + > "$WORK/dd-readback.log" 2>&1 || true +if cmp -s -n $((WRITE_MB * 1024 * 1024)) "$WORK/payload.bin" "$WORK/readback.bin"; then + pass "full-stripe writes landed (readback byte-identical over $WRITE_MB MiB)" +else + fail "readback differs from payload (write did not land — see dd-write.log)" + tail -5 "$WORK/dd-write.log" || true +fi +"$SPDK_DD" "${MEMOPTS[@]}" "${DDOPTS[@]}" -c "$WORK/dd-config.json" \ + --if "$WORK/payload4k.bin" --ob ecsmoke --bs 4096 > "$WORK/dd-4k.log" 2>&1 || true +if grep -q "does not match the write_unit_size" "$WORK/dd-4k.log"; then + pass "4K sub-stripe writes rejected (write_unit_size discipline)" +else + fail "4K write was NOT rejected by the write_unit discipline" + tail -5 "$WORK/dd-4k.log" || true +fi + +# ── 2. clean syndrome verify ───────────────────────────────────────────── +say "2. syndrome verify on pristine data" +start_tgt +create_bdevs +OUT=$(rpc_ok bdev_raid_verify_ranges '{"name": "ecsmoke", "token": "smoke:clean"}') +MODE=$(json_field "$OUT" "r['mode']") +DIV=$(json_field "$OUT" "r['divergent_blocks']") +[ "$MODE" = "syndrome" ] && pass "mode=syndrome" || fail "mode=$MODE (expected syndrome)" +[ "$DIV" = "0" ] && pass "pristine raid: 0 divergent blocks" || fail "pristine raid reports $DIV divergent blocks" + +# unaligned range → -EINVAL +if rpc_expect_err bdev_raid_verify_ranges \ + "{\"name\": \"ecsmoke\", \"ranges\": [{\"start_lba\": 8, \"num_blocks\": $STRIPE_BLOCKS}]}" \ + "not aligned"; then + pass "unaligned range refused" +else + fail "unaligned range was not refused" +fi +stop_tgt + +# ── 3. corrupt the PARITY strip of stripe 0 → divergent ────────────────── +# p_idx(stripe 0) = k - 0 % n = 2 → slot 2 (aio2) holds stripe 0's parity. +# No superblock → member LBA == file offset; flip 64 bytes at offset 0. +say "3. corrupt stripe-0 parity strip (aio2 @0) → verify must report it" +dd if=/dev/urandom of="$WORK/m2.img" bs=64 count=1 conv=notrunc status=none +start_tgt +create_bdevs +OUT=$(rpc_ok bdev_raid_verify_ranges '{"name": "ecsmoke", "token": "smoke:corrupt"}') +DIV=$(json_field "$OUT" "r['divergent_blocks']") +FIRST=$(json_field "$OUT" "r['divergent_ranges'][0]['start_lba'] if r['divergent_ranges'] else -1") +if [ "$DIV" -ge "$STRIPE_BLOCKS" ] 2>/dev/null && [ "$FIRST" = "0" ]; then + pass "divergence detected on stripe 0 ($DIV blocks)" +else + fail "divergence NOT detected (divergent_blocks=$DIV first=$FIRST)" +fi + +# ── 4. rebuild_ranges over the stripe → re-verify clean ────────────────── +say "4. rebuild_ranges stripe 0 (parity recompute) → re-verify clean" +rpc_ok bdev_raid_rebuild_ranges "{\"name\": \"ecsmoke\", \"ranges\": [{\"start_lba\": 0, \"num_blocks\": $STRIPE_BLOCKS}]}" >/dev/null +OUT=$(rpc_ok bdev_raid_verify_ranges '{"name": "ecsmoke", "token": "smoke:repaired"}') +DIV=$(json_field "$OUT" "r['divergent_blocks']") +[ "$DIV" = "0" ] && pass "post-repair verify clean" || fail "post-repair verify still divergent ($DIV blocks)" + +# ── 5. degraded → -EAGAIN + counters (0023) ────────────────────────────── +say "5. degraded raid refuses syndrome verify (-EAGAIN) + 0023 counters" +rpc_ok bdev_aio_delete '{"name": "aio2"}' >/dev/null +sleep 0.5 +if rpc_expect_err bdev_raid_verify_ranges '{"name": "ecsmoke"}' "repair first"; then + pass "degraded raid refused with 'repair first'" +else + fail "degraded raid did NOT refuse the syndrome verify" +fi +OUT=$(rpc_ok bdev_get_bdevs '{"name": "ecsmoke"}') +CTR=$(python3 -c "import json,sys; r=json.loads(sys.argv[1])['result'][0]['driver_specific']['raid']; print(r.get('reconstruct_reads_absent', 'MISSING'), r.get('last_degraded_ts', 'MISSING'))" "$OUT") +case "$CTR" in + *MISSING*) fail "0023 counters missing from get_bdevs ($CTR)";; + *) pass "0023 counters present (reconstruct_reads_absent last_degraded_ts = $CTR)";; +esac +stop_tgt + +# ── verdict ────────────────────────────────────────────────────────────── +say "verdict" +if [ "$FAILURES" -eq 0 ]; then + echo "vec-smoke: ALL GREEN" +else + echo "vec-smoke: $FAILURES FAILURE(S)" + exit 1 +fi