From 66df37ff8fd843479060f49b6acd26f56e3d4b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Mon, 13 Jul 2026 15:45:20 +0200 Subject: [PATCH 01/10] =?UTF-8?q?docs:=200014=20GCCP=20facts=20=E2=80=94?= =?UTF-8?q?=20plan=20des=20incr=C3=A9ments=20(contrat=20gel=C3=A9=20spdk-c?= =?UTF-8?q?si/RPC-CONTRACT.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked sur feat/seeded-rebuild (#17). 12 incréments, un commit/patch chacun : epochs noncés + truncated-au-resize + close consumed|preserve (cbt), incarnation/-ESTALE + registre d'outcomes CANCELED + get_bdevs enrichi + SB étendu + verify intégrée + enveloppes + epoch-auto + pause×rebuild (raid/nvmf). Conformité : mêmes scénarios que SimDataPlane (spdk-csi W1). --- docs/0014-GCCP-FACTS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 docs/0014-GCCP-FACTS.md diff --git a/docs/0014-GCCP-FACTS.md b/docs/0014-GCCP-FACTS.md new file mode 100644 index 0000000..98b7e83 --- /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) | §4 | module/bdev/cbt | +| 0014.4 | Incarnation : `bdev_raid_create(..., incarnation)` ; `expected_incarnation` sur tous les RPC engageants ⇒ `-ESTALE` | §1 | patch raid | +| 0014.5 | 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) | §2 | patch raid | +| 0014.6 | get_bdevs enrichi par membre : `{state, since, content_generation, view_epoch, epoch_nonce, epoch_state, truncated}` | §3 | patch raid+cbt | +| 0014.7 | SB étendu : `content_generation` + `view_epoch` par membre, MÊME transaction que l'état ; reassembly étendue | §8/V-2 | patch raid (0001 discipline) | +| 0014.8 | Verify intégrée au rebuild : K=64 fenêtres échantillonnées sous quiesce_range, re-copie+re-verify avant DIVERGENT ; alimente le registre (VERIFYING) | §10a | patch raid | +| 0014.9 | Enveloppes : caps rebuild/verify/relocate ×(nominal, maintenance) + bornes de concurrence par node ; pose par RPC ; défaut 0=illimité ⇒ l'échec de pose est un incident CP | §6 | patch raid/rpc | +| 0014.10 | Epoch automatique à l'éjection d'un membre (nonce fourni au create ou généré+rapporté) | §12 (échéance W3) | patch raid+cbt | +| 0014.11 | Contrat pause H10 × rebuild : la pause ne fait JAMAIS échouer un rebuild en vol ; + force-resume au chemin de fence (DÉC-11) | §13 | patch nvmf (0003) | +| 0014.12 | `bdev_raid_verify_ranges(name, ranges\|allocated, rate_class=verify)` | §10b (W6) | 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. From 902e8f61ff6ed0454768b0f1f49ff65da8e4bf28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Mon, 13 Jul 2026 16:13:02 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat(cbt):=200014.1-.3=20=E2=80=94=20nonc?= =?UTF-8?q?e'd=20epochs,=20truncated-on-resize,=20close=20modes=20(SPEC-75?= =?UTF-8?q?=20contract)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0014.1 (RPC-CONTRACT §5): bdev_cbt_epoch_open gains an optional opaque nonce (CP-generated, <=31 chars) stored on the epoch and echoed in get_bdevs — kills the epoch-id ABA across maintenance rounds; a generation takeover re-stamps it. 0014.2 (§5, T-D6): a base-bdev RESIZE marks every live epoch truncated (the live bitmap does not cover the growth zone — the delta is a lie). Never cleared: survives takeovers, dies with the epoch. Routes the CP to D14 full. 0014.3 (§4): bdev_cbt_epoch_close gains mode preserve|consumed. preserve (default) keeps the H1 restore-unconsumed-delta discipline; consumed discards the frozen delta under caller certification and REQUIRES a non-empty rebuild_token (-EPERM otherwise; registry validation lands with 0014.5). Unknown mode is -EINVAL, never a silent preserve. 0014.6 (cbt part): get_bdevs driver_specific.cbt.epochs[] = {epoch_id, nonce, state, generation, truncated} — the EpochObservation source, no dedicated poll. Validated: -fsyntax-only clean (vendored headers + config stub); standalone suite 27/27; conformance mirror = SimDataPlane scenarios (spdk-csi W1). --- docs/RPC-CONTRACT.md | 19 ++++++ module/bdev/cbt/vbdev_cbt.c | 106 ++++++++++++++++++++++++++++---- module/bdev/cbt/vbdev_cbt.h | 48 ++++++++++++++- module/bdev/cbt/vbdev_cbt_rpc.c | 55 +++++++++++++++-- 4 files changed, 209 insertions(+), 19 deletions(-) diff --git a/docs/RPC-CONTRACT.md b/docs/RPC-CONTRACT.md index d225646..99f0254 100644 --- a/docs/RPC-CONTRACT.md +++ b/docs/RPC-CONTRACT.md @@ -191,6 +191,25 @@ a split-brain across disks. - `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 non-empty rebuild_token (-EPERM + otherwise)** — the SUCCEEDED-verified outcome-registry token (validated + against the registry as of 0014.5). 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/module/bdev/cbt/vbdev_cbt.c b/module/bdev/cbt/vbdev_cbt.c index b0df97d..cb5ecc6 100644 --- a/module/bdev/cbt/vbdev_cbt.c +++ b/module/bdev/cbt/vbdev_cbt.c @@ -524,10 +524,25 @@ 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"; + } +} + 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 +553,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 +678,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 +998,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 +1018,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 +1040,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; } @@ -1031,14 +1095,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 +1209,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 +1240,26 @@ 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: demanded non-empty here, checked + * against the registry as of 0014.5 (the raid-side registry increment). Without + * a token: -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; + } + 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 +1267,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 +2459,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_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) From cd50f5cf1f6b60f6b57aa2c293fde912ba6ef6d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Mon, 13 Jul 2026 16:55:14 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(raid):=200014.4-.5=20=E2=80=94=20inc?= =?UTF-8?q?arnation=20identity=20(-ESTALE)=20+=20rebuild=20outcome=20regis?= =?UTF-8?q?try=20(CANCELED=20contract)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - patch 0014: bdev_raid_create requires incarnation; delete/add/remove/ seeded-rebuild accept expected_incarnation => -ESTALE on mismatch (never best-effort). Unclaimed SB-reassembled raids refuse any expected_incarnation. - patch 0015: process-wide rebuild outcome registry (survives the raid, 15 min lazy TTL), tokens per attempt (CP-deterministic or auto), full rebuilds feed the same registry, bdev_raid_get_rebuild_outcomes {token?}, CANCELED contract (destroy/member-removal with rebuild in flight => no process write after the RPC returns). - cbt: epoch_close(mode=consumed) now validates the token against the registry (succeeded+verified) — unusable by design until 0014.8 (verify). Contract: spdk-csi/docs/RPC-CONTRACT.md §1/§2/§4 (frozen W0 2026-07-13). --- docs/0014-GCCP-FACTS.md | 10 +- docs/RPC-CONTRACT.md | 37 +- module/bdev/cbt/vbdev_cbt.c | 18 +- patches/0014-raid-incarnation-estale.patch | 429 +++++++++++++ .../0015-raid-rebuild-outcome-registry.patch | 586 ++++++++++++++++++ patches/README.md | 3 + 6 files changed, 1072 insertions(+), 11 deletions(-) create mode 100644 patches/0014-raid-incarnation-estale.patch create mode 100644 patches/0015-raid-rebuild-outcome-registry.patch diff --git a/docs/0014-GCCP-FACTS.md b/docs/0014-GCCP-FACTS.md index 98b7e83..214f574 100644 --- a/docs/0014-GCCP-FACTS.md +++ b/docs/0014-GCCP-FACTS.md @@ -9,11 +9,11 @@ | # | 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) | §4 | module/bdev/cbt | -| 0014.4 | Incarnation : `bdev_raid_create(..., incarnation)` ; `expected_incarnation` sur tous les RPC engageants ⇒ `-ESTALE` | §1 | patch raid | -| 0014.5 | 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) | §2 | patch raid | +| 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 | get_bdevs enrichi par membre : `{state, since, content_generation, view_epoch, epoch_nonce, epoch_state, truncated}` | §3 | patch raid+cbt | | 0014.7 | SB étendu : `content_generation` + `view_epoch` par membre, MÊME transaction que l'état ; reassembly étendue | §8/V-2 | patch raid (0001 discipline) | | 0014.8 | Verify intégrée au rebuild : K=64 fenêtres échantillonnées sous quiesce_range, re-copie+re-verify avant DIVERGENT ; alimente le registre (VERIFYING) | §10a | patch raid | diff --git a/docs/RPC-CONTRACT.md b/docs/RPC-CONTRACT.md index 99f0254..616c9d2 100644 --- a/docs/RPC-CONTRACT.md +++ b/docs/RPC-CONTRACT.md @@ -186,6 +186,36 @@ a split-brain across disks. after the unquiesce; a crash between the two costs a full rebuild at reboot (conservative, safe). +## 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` stays `false` until 0014.8 (integrated verify) — so + `bdev_cbt_epoch_close(mode=consumed)` is deliberately unusable until then. + ## CBT epochs / rebuild - `bdev_cbt_epoch_freeze` / `epoch_close` / `epoch_open` (higher generation): @@ -202,9 +232,10 @@ a split-brain across disks. - **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 non-empty rebuild_token (-EPERM - otherwise)** — the SUCCEEDED-verified outcome-registry token (validated - against the registry as of 0014.5). An unknown `mode` is -EINVAL, never a + caller certification and **requires a rebuild_token naming a LOCAL + succeeded+verified outcome-registry entry (-EPERM otherwise)** — validated + against the 0014.5 registry (effective since patch 0015; unusable-by-design + until 0014.8 makes entries `verified`). 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, diff --git a/module/bdev/cbt/vbdev_cbt.c b/module/bdev/cbt/vbdev_cbt.c index cb5ecc6..22b5033 100644 --- a/module/bdev/cbt/vbdev_cbt.c +++ b/module/bdev/cbt/vbdev_cbt.c @@ -22,6 +22,10 @@ #include "spdk/stdinc.h" #include "vbdev_cbt_internal.h" +/* 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" @@ -1242,15 +1246,23 @@ bdev_cbt_epoch_close(const char *cbt_name, const char *epoch_id, /* 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: demanded non-empty here, checked - * against the registry as of 0014.5 (the raid-side registry increment). Without - * a token: -EPERM, never a silent downgrade to PRESERVE. */ + * certification is the outcome-registry token (0014.5): it must name a LOCAL + * registry entry in state succeeded+verified. Without that proof: -EPERM, + * never a silent downgrade to PRESERVE. Note: until 0014.8 delivers the + * integrated verify phase no entry is ever `verified`, so consumed is + * deliberately unusable — honest increments over premature certification. */ 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); 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/README.md b/patches/README.md index a87e779..91f836e 100644 --- a/patches/README.md +++ b/patches/README.md @@ -26,6 +26,9 @@ 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 | 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 From 13b30c16994e2ef63854aef774557c96f2d8a715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Mon, 13 Jul 2026 17:33:40 +0200 Subject: [PATCH 04/10] =?UTF-8?q?feat(raid):=200014.6-.7=20=E2=80=94=20ext?= =?UTF-8?q?ended=20superblock=20(V-2)=20+=20per-member=20observation=20in?= =?UTF-8?q?=20get=5Fbdevs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - patch 0016: SB minor 0->1, per-member content_generation/view_epoch carved from reserved bytes. RecordDivergence: survivors gen++ in the SAME SB transaction as the ejection; rebuild-completion/skip_rebuild promotion adopts the survivors' max in the same transaction as the CONFIGURED flip. Extended reassembly restores both for ALL slots (a lagging generation is the durable staleness proof the cold-recovery pass reads). - patch 0017: get_bdevs base_bdevs_list[] gains state (derived precedence failed > write_only > configured > configuring > absent), since (unix seconds of last state flip — CP anti-flap input), generation facts, and the wrapping cbt's live-epoch facts (epoch_nonce/epoch_state/truncated) via the new vbdev_cbt_query_latest_epoch() cross-module query. - cbt: vbdev_cbt_query.h — minimal raid-facing query surface (no internals). Contract: spdk-csi/docs/RPC-CONTRACT.md §3/§8 (frozen W0 2026-07-13). --- docs/0014-GCCP-FACTS.md | 4 +- docs/RPC-CONTRACT.md | 22 +++ module/bdev/cbt/vbdev_cbt.c | 32 ++++ module/bdev/cbt/vbdev_cbt_query.h | 35 +++++ patches/0016-raid-extended-superblock.patch | 164 ++++++++++++++++++++ patches/0017-raid-member-observation.patch | 160 +++++++++++++++++++ patches/README.md | 2 + 7 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 module/bdev/cbt/vbdev_cbt_query.h create mode 100644 patches/0016-raid-extended-superblock.patch create mode 100644 patches/0017-raid-member-observation.patch diff --git a/docs/0014-GCCP-FACTS.md b/docs/0014-GCCP-FACTS.md index 214f574..81a76ae 100644 --- a/docs/0014-GCCP-FACTS.md +++ b/docs/0014-GCCP-FACTS.md @@ -14,8 +14,8 @@ | 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 | get_bdevs enrichi par membre : `{state, since, content_generation, view_epoch, epoch_nonce, epoch_state, truncated}` | §3 | patch raid+cbt | -| 0014.7 | SB étendu : `content_generation` + `view_epoch` par membre, MÊME transaction que l'état ; reassembly étendue | §8/V-2 | patch raid (0001 discipline) | +| 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 | Verify intégrée au rebuild : K=64 fenêtres échantillonnées sous quiesce_range, re-copie+re-verify avant DIVERGENT ; alimente le registre (VERIFYING) | §10a | patch raid | | 0014.9 | Enveloppes : caps rebuild/verify/relocate ×(nominal, maintenance) + bornes de concurrence par node ; pose par RPC ; défaut 0=illimité ⇒ l'échec de pose est un incident CP | §6 | patch raid/rpc | | 0014.10 | Epoch automatique à l'éjection d'un membre (nonce fourni au create ou généré+rapporté) | §12 (échéance W3) | patch raid+cbt | diff --git a/docs/RPC-CONTRACT.md b/docs/RPC-CONTRACT.md index 616c9d2..ad83856 100644 --- a/docs/RPC-CONTRACT.md +++ b/docs/RPC-CONTRACT.md @@ -186,6 +186,28 @@ 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). + ## Incarnation & rebuild outcomes (GCCP 0014.4/0014.5, patches 0014/0015) - **0014.4 — identity.** `bdev_raid_create {…, incarnation}` (required, ≤63 diff --git a/module/bdev/cbt/vbdev_cbt.c b/module/bdev/cbt/vbdev_cbt.c index 22b5033..6a82fbd 100644 --- a/module/bdev/cbt/vbdev_cbt.c +++ b/module/bdev/cbt/vbdev_cbt.c @@ -22,6 +22,7 @@ #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. */ @@ -542,6 +543,37 @@ cbt_epoch_state_name(enum cbt_epoch_state state) } } +/* 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) { diff --git a/module/bdev/cbt/vbdev_cbt_query.h b/module/bdev/cbt/vbdev_cbt_query.h new file mode 100644 index 0000000..d3518f5 --- /dev/null +++ b/module/bdev/cbt/vbdev_cbt_query.h @@ -0,0 +1,35 @@ +/* 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); + +#endif /* SPDK_VBDEV_CBT_QUERY_H */ 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/README.md b/patches/README.md index 91f836e..1610786 100644 --- a/patches/README.md +++ b/patches/README.md @@ -29,6 +29,8 @@ IS the contract; do not rely on any other ordering. Order matters: | 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 | 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 From fec6fb743160210b9f33e0dda4c6cdac148d76a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Mon, 13 Jul 2026 17:54:34 +0200 Subject: [PATCH 05/10] =?UTF-8?q?feat(raid):=200014.8=20=E2=80=94=20integr?= =?UTF-8?q?ated=20verify=20phase=20(K=3D64=20sampled=20windows,=20DIVERGEN?= =?UTF-8?q?T=20on=20double=20mismatch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the copy completes and BEFORE the process concludes (SB CONFIGURED flip and registry seal both gated): sample 64 uniform-stride windows across the copied extent (seed ranges for seeded rebuilds, whole raid otherwise), compare source leg (arbiter) vs target under quiesce_range. First mismatch: re-copy from the arbiter + re-verify (counts in registry bytes, logged as healed). Second mismatch under the same lock: outcome DIVERGENT (-EILSEQ) — no host write can explain it, the copy process is broken. Success: verified=true, which is exactly what bdev_cbt_epoch_close(mode=consumed) demands (§4). Scope: raid1 plain-data (no DIF); anything else completes unverified. Honest coverage (T-C12): probabilistic process-bug detector (~64 MiB sampled); the exhaustive detector is bdev_raid_verify_ranges (0014.12, §10b). Cancellation mid-verify unwinds like the copy loop (CANCELED seal, no writes after delete returns). Contract: spdk-csi/docs/RPC-CONTRACT.md §10a (frozen W0 2026-07-13). --- docs/0014-GCCP-FACTS.md | 2 +- docs/RPC-CONTRACT.md | 20 +- module/bdev/cbt/vbdev_cbt.c | 7 +- patches/0018-raid-integrated-verify.patch | 514 ++++++++++++++++++++++ patches/README.md | 1 + 5 files changed, 534 insertions(+), 10 deletions(-) create mode 100644 patches/0018-raid-integrated-verify.patch diff --git a/docs/0014-GCCP-FACTS.md b/docs/0014-GCCP-FACTS.md index 81a76ae..c071347 100644 --- a/docs/0014-GCCP-FACTS.md +++ b/docs/0014-GCCP-FACTS.md @@ -16,7 +16,7 @@ | 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 | Verify intégrée au rebuild : K=64 fenêtres échantillonnées sous quiesce_range, re-copie+re-verify avant DIVERGENT ; alimente le registre (VERIFYING) | §10a | patch raid | +| 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 | Enveloppes : caps rebuild/verify/relocate ×(nominal, maintenance) + bornes de concurrence par node ; pose par RPC ; défaut 0=illimité ⇒ l'échec de pose est un incident CP | §6 | patch raid/rpc | | 0014.10 | Epoch automatique à l'éjection d'un membre (nonce fourni au create ou généré+rapporté) | §12 (échéance W3) | patch raid+cbt | | 0014.11 | Contrat pause H10 × rebuild : la pause ne fait JAMAIS échouer un rebuild en vol ; + force-resume au chemin de fence (DÉC-11) | §13 | patch nvmf (0003) | diff --git a/docs/RPC-CONTRACT.md b/docs/RPC-CONTRACT.md index ad83856..709c996 100644 --- a/docs/RPC-CONTRACT.md +++ b/docs/RPC-CONTRACT.md @@ -235,8 +235,18 @@ a split-brain across disks. 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` stays `false` until 0014.8 (integrated verify) — so - `bdev_cbt_epoch_close(mode=consumed)` is deliberately unusable until then. + - `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 @@ -256,9 +266,9 @@ a split-brain across disks. 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 (effective since patch 0015; unusable-by-design - until 0014.8 makes entries `verified`). An unknown `mode` is -EINVAL, never a - silent preserve. + 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 diff --git a/module/bdev/cbt/vbdev_cbt.c b/module/bdev/cbt/vbdev_cbt.c index 6a82fbd..f1592f7 100644 --- a/module/bdev/cbt/vbdev_cbt.c +++ b/module/bdev/cbt/vbdev_cbt.c @@ -1279,10 +1279,9 @@ bdev_cbt_epoch_close(const char *cbt_name, const char *epoch_id, /* 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. Without that proof: -EPERM, - * never a silent downgrade to PRESERVE. Note: until 0014.8 delivers the - * integrated verify phase no entry is ever `verified`, so consumed is - * deliberately unusable — honest increments over premature certification. */ + * 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 " 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/README.md b/patches/README.md index 1610786..309ea43 100644 --- a/patches/README.md +++ b/patches/README.md @@ -31,6 +31,7 @@ IS the contract; do not rely on any other ordering. Order matters: | 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 | 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 From eb7e00d90cfbaa7bd9bbf78e98619d764243ff6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Mon, 13 Jul 2026 18:12:40 +0200 Subject: [PATCH 06/10] =?UTF-8?q?feat:=200014.9-.12=20=E2=80=94=20envelope?= =?UTF-8?q?s,=20epoch-at-ejection,=20audited=20force-resume,=20verify=5Fra?= =?UTF-8?q?nges=20(0014=20COMPLETE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - patch 0019 (0014.10): the raid opens an auto epoch on every surviving member's cbt at ejection (vbdev_cbt_auto_epoch_open — never an implicit takeover, -EEXIST when an OPEN round already bounds it); the auto nonce is reported via get_bdevs, which is how the CP adopts the round. Without this, the unplanned-loss path degrades to D14 full rebuilds. - patch 0020 (0014.11, DEC-11): explicit audited force-resume — the fence path resumes whatever barrier stands before preempt-and-abort; the broken barrier's own tokened resume reports it, the snapshot saga replays cleanly. - patch 0021 (0014.9): process-wide envelopes — per-class caps x(nominal, maintenance), regime switch, rebuild concurrency bound. Rebuild cap frozen at process alloc; seeded beyond the bound = -EAGAIN; auto-attach admitted loudly. Default 0 = unlimited: a failed set is a CP incident. - patch 0022 (0014.12): bdev_raid_verify_ranges — exhaustive divergence detector, 1 MiB LBA-locked chunks, verify-envelope paced, reports (<=128 ranges + exact count + truncated flag) and never repairs; registry mirrors progress under the token. All 12 increments of the 0014 GCCP data-plane facts are now implemented. Contract: spdk-csi/docs/RPC-CONTRACT.md (frozen W0 2026-07-13). --- docs/0014-GCCP-FACTS.md | 8 +- docs/RPC-CONTRACT.md | 38 + module/bdev/cbt/vbdev_cbt.c | 42 ++ module/bdev/cbt/vbdev_cbt_query.h | 15 + .../0019-raid-auto-epoch-at-ejection.patch | 43 ++ patches/0020-nvmf-force-resume.patch | 46 ++ patches/0021-raid-envelopes.patch | 370 ++++++++++ patches/0022-raid-verify-ranges.patch | 651 ++++++++++++++++++ patches/README.md | 4 + 9 files changed, 1213 insertions(+), 4 deletions(-) create mode 100644 patches/0019-raid-auto-epoch-at-ejection.patch create mode 100644 patches/0020-nvmf-force-resume.patch create mode 100644 patches/0021-raid-envelopes.patch create mode 100644 patches/0022-raid-verify-ranges.patch diff --git a/docs/0014-GCCP-FACTS.md b/docs/0014-GCCP-FACTS.md index c071347..7a461d6 100644 --- a/docs/0014-GCCP-FACTS.md +++ b/docs/0014-GCCP-FACTS.md @@ -17,10 +17,10 @@ | 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 | Enveloppes : caps rebuild/verify/relocate ×(nominal, maintenance) + bornes de concurrence par node ; pose par RPC ; défaut 0=illimité ⇒ l'échec de pose est un incident CP | §6 | patch raid/rpc | -| 0014.10 | Epoch automatique à l'éjection d'un membre (nonce fourni au create ou généré+rapporté) | §12 (échéance W3) | patch raid+cbt | -| 0014.11 | Contrat pause H10 × rebuild : la pause ne fait JAMAIS échouer un rebuild en vol ; + force-resume au chemin de fence (DÉC-11) | §13 | patch nvmf (0003) | -| 0014.12 | `bdev_raid_verify_ranges(name, ranges\|allocated, rate_class=verify)` | §10b (W6) | 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. diff --git a/docs/RPC-CONTRACT.md b/docs/RPC-CONTRACT.md index 709c996..d2c9c41 100644 --- a/docs/RPC-CONTRACT.md +++ b/docs/RPC-CONTRACT.md @@ -208,6 +208,44 @@ a split-brain across disks. 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). + 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)}` 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). + ## Incarnation & rebuild outcomes (GCCP 0014.4/0014.5, patches 0014/0015) - **0014.4 — identity.** `bdev_raid_create {…, incarnation}` (required, ≤63 diff --git a/module/bdev/cbt/vbdev_cbt.c b/module/bdev/cbt/vbdev_cbt.c index f1592f7..fc3f485 100644 --- a/module/bdev/cbt/vbdev_cbt.c +++ b/module/bdev/cbt/vbdev_cbt.c @@ -543,6 +543,48 @@ cbt_epoch_state_name(enum cbt_epoch_state state) } } +/* 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 diff --git a/module/bdev/cbt/vbdev_cbt_query.h b/module/bdev/cbt/vbdev_cbt_query.h index d3518f5..8d17850 100644 --- a/module/bdev/cbt/vbdev_cbt_query.h +++ b/module/bdev/cbt/vbdev_cbt_query.h @@ -32,4 +32,19 @@ struct vbdev_cbt_epoch_facts { */ 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/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..2f293ff --- /dev/null +++ b/patches/0022-raid-verify-ranges.patch @@ -0,0 +1,651 @@ +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..6ae24bf +--- /dev/null ++++ b/module/bdev/raid/bdev_raid_verify_ranges.c +@@ -0,0 +1,632 @@ ++/* SPDX-License-Identifier: BSD-3-Clause ++ * Copyright (C) 2026 Evariops. ++ * All rights reserved. ++ */ ++ ++/* Evariops 0014.12 (spdk-csi RPC-CONTRACT §10b): bdev_raid_verify_ranges — ++ * the EXHAUSTIVE divergence detector (the integrated verify phase, §10a, is ++ * the probabilistic one). Compares the first configured leg (implicit ++ * arbiter) against every other configured leg of a raid1, chunk by chunk, ++ * each chunk 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 (R >= 3 vote, ++ * or DivergenceUnarbitrated at R == 2 — T-D2). ++ * ++ * 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). */ ++ uint8_t leg_slot; ++ int ios_remaining; ++ int io_status; ++ /* 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_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; ++ } ++} ++ ++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; ++ 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; ++ 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; ++ c->io_blocks = spdk_min(remaining, (uint64_t)RAID_VERIFY_CHUNK_BLOCKS); ++ ++ if (!raid_verify_pace_admit(c, c->io_blocks * c->raid_bdev->bdev.blocklen)) { ++ 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; ++ 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->state != RAID_BDEV_STATE_ONLINE) { ++ spdk_jsonrpc_send_error_response(request, -EINVAL, ++ "verify_ranges requires an ONLINE raid1 bdev"); ++ 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 (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; ++ } ++ } ++ ++ 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", req.name, req.num_ranges); ++ spdk_jsonrpc_request_audit(request, "bdev_raid_verify_ranges", detail); ++ ++ c->request = request; ++ c->raid_bdev = raid_bdev; ++ c->arbiter = arbiter; ++ 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/README.md b/patches/README.md index 309ea43..9892dcd 100644 --- a/patches/README.md +++ b/patches/README.md @@ -32,6 +32,10 @@ IS the contract; do not rely on any other ordering. Order matters: | 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) | bdev_raid (adds `bdev_raid_verify_ranges.c`) | 0008, 0014, 0015, 0021 | 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 From b0f35fad63efc67918804c1ec44eeb301ce8fd21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Thu, 16 Jul 2026 00:20:34 +0200 Subject: [PATCH 07/10] =?UTF-8?q?diag(cbt=20=C2=A74.5):=20sharpen=20max-ep?= =?UTF-8?q?ochs-wedge=20log=20for=20stuck-OPEN=20epoch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the oldest epoch is stuck OPEN (opened but never frozen/consumed) and wedges new epoch_open with -ENOSPC, emit a specific diagnostic naming the stale_backend_id and pointing at the CP node-id-vs-observer-lvol-id key mismatch (§4.5). Additive log only; no behaviour change. --- module/bdev/cbt/vbdev_cbt.c | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/module/bdev/cbt/vbdev_cbt.c b/module/bdev/cbt/vbdev_cbt.c index fc3f485..a7b5471 100644 --- a/module/bdev/cbt/vbdev_cbt.c +++ b/module/bdev/cbt/vbdev_cbt.c @@ -1139,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 From 88eaa003ebc07b04cf7f7985ff4d5070ded02200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Thu, 16 Jul 2026 15:28:27 +0200 Subject: [PATCH 08/10] chore(patches): homogenize the series to raw per-commit diffs; rewrite regen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The series was format-mixed (format-patch mboxes vs raw diffs), which broke git am and made the documented regen round-trip unable to reproduce the current bytes. patches.sh regen now emits one raw git diff per commit, with the commit subject (NNNN-name) as the filename — a regen of an untouched series is byte-stable. git apply (Dockerfile + check/apply) is the only consumer and is unchanged. patches.sh check: 24/24 green on a pristine pinned clone. --- ...0001-raid-add-skip_rebuild-parameter.patch | 19 --------------- ...ev-lvol-add-get-allocated-ranges-rpc.patch | 15 ------------ ...-nvmf-add-subsystem-pause-resume-rpc.patch | 19 --------------- patches/0004-blob-relocate-primitives.patch | 21 ---------------- .../0005-lvol-get-cluster-placement-rpc.patch | 14 ----------- .../0006-raid5f-degraded-read-fallback.patch | 13 ---------- patches/0007-raid-nexus-heat.patch | 20 ---------------- patches/0008-raid-rebuild-ranges.patch | 24 ------------------- ...9-lvol-raid-enospc-capacity-exceeded.patch | 21 ---------------- patches/0010-rpc-socket-chmod.patch | 13 ---------- patches/0011-jsonrpc-peer-audit.patch | 22 ----------------- ...2-lvol-shutdown-unload-observability.patch | 24 ------------------- scripts/patches.sh | 23 ++++++++++++++---- 13 files changed, 18 insertions(+), 230 deletions(-) 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/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() { From c07023bbdf0ff4e3fec1eb0f14909be93bc09717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Thu, 16 Jul 2026 15:28:28 +0200 Subject: [PATCH 09/10] =?UTF-8?q?feat(ec):=20SPEC-75G=20F-b/F-d=20?= =?UTF-8?q?=E2=80=94=20raid5f=20syndrome=20verify=20+=20degraded=20observa?= =?UTF-8?q?bility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0022 (amended): bdev_raid_verify_ranges becomes bi-mode. raid1 keeps the copy-compare walk; raid5f gets a SYNDROME mode — XOR of all k+1 members' strips must be zero per stripe (rotating parity position irrelevant; no raid5f internals, generic geometry only). All members readable or -EAGAIN ('repair first' — m=1 leaves nothing to check against); ranges must be full-stripe aligned; per-stripe report, report-only; envelope pacing charged on the k+1 strips actually read; LBA lock / outcome registry / incarnation reused. Result gains a 'mode' field. - 0023 (new): degraded-service counters in get_bdevs driver_specific.raid — reconstruct_reads_absent / reconstruct_reads_error (0006 fallback) / degraded_write_stripes (F-e violation gauge) / last_degraded_ts. Relaxed atomics on the I/O paths; process-rebuild reconstructs deliberately not counted (repair work, not degraded host service). - 0024 (new): spdk_dd propagates bdev I/O completion errors — upstream ignores 'success' in all three bdev callbacks, so dd exited 0 while every write was rejected (found by vec-smoke); a silently failed READ is worse (stale buffer written out as data). - RPC-CONTRACT: new 'Erasure coding — raid5f' section (syndrome contract, -EAGAIN semantics, verify-at-seal is EXPLICIT: 0018 completes raid5f processes UNVERIFIED by design, INV-EC-5 seals via syndrome verify). - Dockerfile: honest module comments (no raid6/raid10 anywhere in SPDK; raid5f = XOR single parity, not Reed-Solomon) + image label loses the fictional RDMA claim (built without --with-rdma, SF-11/SF-2). - scripts/vec-smoke.sh: single-node pre-flight on file-backed AIO (2+1, strip 64K): full-stripe readback byte-identical; 4K write rejected by write_unit discipline; pristine syndrome 0-divergent; parity-strip corruption detected per stripe; rebuild_ranges repairs; degraded refuses -EAGAIN; 0023 counters visible. ALL GREEN 9/9. Oracle note: spdk_dd exit code pre-0024 was not trustworthy — readback + bdev error message are. Validated: patches.sh check 24/24 on pristine clone; docker build (builder stage) green; vec-smoke ALL GREEN in the built image. --- docs/RPC-CONTRACT.md | 68 ++++- images/spdk/Dockerfile | 8 +- patches/0022-raid-verify-ranges.patch | 267 ++++++++++++++++-- .../0023-raid5f-degraded-observability.patch | 102 +++++++ patches/0024-dd-propagate-io-errors.patch | 43 +++ patches/README.md | 27 +- scripts/vec-smoke.sh | 263 +++++++++++++++++ 7 files changed, 736 insertions(+), 42 deletions(-) create mode 100644 patches/0023-raid5f-degraded-observability.patch create mode 100644 patches/0024-dd-propagate-io-errors.patch create mode 100755 scripts/vec-smoke.sh diff --git a/docs/RPC-CONTRACT.md b/docs/RPC-CONTRACT.md index d2c9c41..8e94c20 100644 --- a/docs/RPC-CONTRACT.md +++ b/docs/RPC-CONTRACT.md @@ -236,15 +236,65 @@ a split-brain across disks. - **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). - 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)}` 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). + 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) diff --git a/images/spdk/Dockerfile b/images/spdk/Dockerfile index 2f4c04e..90a0f51 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 @@ -239,7 +241,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/patches/0022-raid-verify-ranges.patch b/patches/0022-raid-verify-ranges.patch index 2f293ff..69429ef 100644 --- a/patches/0022-raid-verify-ranges.patch +++ b/patches/0022-raid-verify-ranges.patch @@ -13,23 +13,33 @@ index c90b4a2..d381b3b 100644 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..6ae24bf +index 0000000..cca7fc4 --- /dev/null +++ b/module/bdev/raid/bdev_raid_verify_ranges.c -@@ -0,0 +1,632 @@ +@@ -0,0 +1,857 @@ +/* SPDX-License-Identifier: BSD-3-Clause + * Copyright (C) 2026 Evariops. + * All rights reserved. + */ + -+/* Evariops 0014.12 (spdk-csi RPC-CONTRACT §10b): bdev_raid_verify_ranges — -+ * the EXHAUSTIVE divergence detector (the integrated verify phase, §10a, is -+ * the probabilistic one). Compares the first configured leg (implicit -+ * arbiter) against every other configured leg of a raid1, chunk by chunk, -+ * each chunk 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 (R >= 3 vote, -+ * or DivergenceUnarbitrated at R == 2 — T-D2). ++/* 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 @@ -79,10 +89,19 @@ index 0000000..6ae24bf + void *buf_leg; + struct iovec iov_arb; + struct iovec iov_leg; -+ /* Leg currently being compared for the in-flight chunk (slot walk). */ ++ /* 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; @@ -148,6 +167,8 @@ index 0000000..6ae24bf + 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 { @@ -325,6 +346,150 @@ index 0000000..6ae24bf + } +} + ++/* ── 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) +{ @@ -336,8 +501,13 @@ index 0000000..6ae24bf + return; + } + c->locked = true; -+ c->leg_slot = 0; -+ raid_verify_compare_leg(c); ++ if (c->syndrome) { ++ c->member_off = 0; ++ raid_verify_syndrome_subchunk(c); ++ } else { ++ c->leg_slot = 0; ++ raid_verify_compare_leg(c); ++ } +} + +static int @@ -379,7 +549,7 @@ index 0000000..6ae24bf +raid_verify_next(struct raid_verify_ctx *c) +{ + struct raid_verify_range *range; -+ uint64_t remaining; ++ uint64_t remaining, pace_bytes; + int rc; + + if (c->removed) { @@ -397,9 +567,20 @@ index 0000000..6ae24bf + } + + c->io_lba = range->start_lba + c->cur_off; -+ c->io_blocks = spdk_min(remaining, (uint64_t)RAID_VERIFY_CHUNK_BLOCKS); ++ 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, c->io_blocks * c->raid_bdev->bdev.blocklen)) { ++ if (!raid_verify_pace_admit(c, pace_bytes)) { + return; /* poller re-enters raid_verify_next */ + } + @@ -489,7 +670,8 @@ index 0000000..6ae24bf + 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; ++ 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; @@ -516,9 +698,19 @@ index 0000000..6ae24bf + goto cleanup; + } + -+ if (raid_bdev->level != RAID1 || raid_bdev->state != RAID_BDEV_STATE_ONLINE) { ++ 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 bdev"); ++ "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) { @@ -536,7 +728,24 @@ index 0000000..6ae24bf + configured++; + } + } -+ if (configured < 2) { ++ 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; @@ -574,6 +783,18 @@ index 0000000..6ae24bf + "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)); @@ -633,12 +854,16 @@ index 0000000..6ae24bf + 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", req.name, req.num_ranges); ++ 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 */ 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 9892dcd..0069bf1 100644 --- a/patches/README.md +++ b/patches/README.md @@ -35,7 +35,9 @@ IS the contract; do not rely on any other ordering. Order matters: | 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) | bdev_raid (adds `bdev_raid_verify_ranges.c`) | 0008, 0014, 0015, 0021 | +| 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 @@ -90,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/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 From f90fb6229075726bf0b241d21b6a48d3f052fab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Thu, 16 Jul 2026 16:16:38 +0200 Subject: [PATCH 10/10] =?UTF-8?q?feat(debug-image):=20ship=20spdk=5Fdd=20?= =?UTF-8?q?=E2=80=94=20the=20V-EC=20bench=20full-stripe=20writer=20(SPEC-7?= =?UTF-8?q?5G=20G1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit raid5f only accepts full-stripe writes (EC-3) and remote initiators are MDTS-bounded (AM-5), so the bench writer must run INSIDE the target pod. Patch 0024 makes spdk_dd's exit code honest, which the bench oracles rely on. --- images/spdk/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/images/spdk/Dockerfile b/images/spdk/Dockerfile index 90a0f51..6cff4ea 100644 --- a/images/spdk/Dockerfile +++ b/images/spdk/Dockerfile @@ -212,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/