Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e539f7e
test(blob): a thin cluster reads zeroes where no write landed (0044, …
rducom Sep 30, 2026
e94d862
fix(blob): a thin cluster is cleared before use (0044)
rducom Sep 30, 2026
d42771c
test(raid): a seeded rebuild copies its ranges, and only under the wi…
rducom Oct 1, 2026
604f3ef
fix(raid): a seeded rebuild copies its ranges, and only under the win…
rducom Oct 1, 2026
42b0e9c
feat(raid): a seeded rebuild reads its ranges from the CBT epoch (0046)
rducom Oct 1, 2026
f82970b
test(raid1): a rebuild copies its window in parts, in flight together…
rducom Oct 1, 2026
3ec19ee
fix(raid1): a rebuild copies its window in parts, in flight together …
rducom Oct 1, 2026
e961ea2
test(raid1): a rebuild writes a zero part as write-zeroes (0048, test…
rducom Oct 1, 2026
8ed1d2a
fix(raid1): a rebuild writes a zero part as write-zeroes (0048)
rducom Oct 1, 2026
fe0903e
test(blob): write-zeroes leaves an unallocated thin cluster unallocat…
rducom Oct 1, 2026
f8ba4e7
fix(blob): write-zeroes leaves an unallocated thin cluster unallocate…
rducom Oct 1, 2026
a55abb1
test(nvmf): a reservation held by all registrants outlives its acquir…
rducom Oct 1, 2026
d7b99d8
fix(nvmf): a reservation held by all registrants outlives its acquire…
rducom Oct 1, 2026
f74e756
test(bdev-nvme): an I/O passthrough needs a connected qpair (0051, te…
rducom Oct 1, 2026
8a3f3f1
fix(bdev-nvme): an I/O passthrough needs a connected qpair (0051)
rducom Oct 1, 2026
350d9cf
test(raid): a raid1 ejection opens its epoch on the raid's cbt (0052,…
rducom Oct 2, 2026
eac15e9
fix(raid): a raid1 ejection opens its epoch on the raid's cbt (0052)
rducom Oct 2, 2026
34a5163
feat(cbt): keep the live bitmap in rotating windows, so an ejection's…
rducom Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions module/bdev/cbt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ The module cannot converge on its own when the write rate approaches rebuild ban

Clearing is reset-driven: there is no automatic clear. Once the backend is re-added and all backends are synchronized, the orchestrator MUST call `bdev_cbt_reset`, or the bitmap grows monotonically and "partial" rebuilds degrade toward full-surface copies.

A reset needs a moment when every backend is known in sync, and a member that leaves without notice gives none: the epoch the raid opens at its ejection (`vbdev_cbt_auto_epoch_open`) cannot clear anything, since the writes the member just missed are in the live bitmap. So the live bitmap is also kept in windows: `bdev_cbt_rotate`, called periodically while no epoch is live, moves it to a previous-window bitmap and starts it again empty, and the epoch at ejection takes the previous window back before it opens. Its delta then holds one to two windows of writes before the departure, and everything after, instead of every write since the last reset. Two rotations are at least `CBT_ROTATE_MIN_INTERVAL_US` (10 s) apart whoever asks, so a write missed a few milliseconds before the ejection is always in one of the two bitmaps. A reset clears both.

## RAID integration

The companion patch (`patches/0001-raid-add-skip_rebuild-parameter.patch`) adds a `skip_rebuild` boolean to `bdev_raid_add_base_bdev`. When true, the RAID module skips its full surface rebuild and instead quiesces the raid, opens `base_channel[slot]` on every existing I/O channel for the re-added bdev — without this, existing channels would never write to the backend — then unquiesces and writes the superblock.
Expand Down
142 changes: 127 additions & 15 deletions module/bdev/cbt/vbdev_cbt.c
Original file line number Diff line number Diff line change
Expand Up @@ -503,44 +503,70 @@ cbt_epoch_state_name(enum cbt_epoch_state state)
}
}

/* Epoch-at-ejection — see vbdev_cbt_query.h. Refuses (-EEXIST) when an OPEN
* epoch already bounds the round: 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 cbt bdev stacked on the bdev named base_bdev_name, if any. */
static struct vbdev_cbt *
cbt_find_by_base_name(const char *base_bdev_name)
{
struct vbdev_cbt *node;

TAILQ_FOREACH(node, &g_cbt_nodes, link) {
if (node->base_bdev != NULL &&
strcmp(spdk_bdev_get_name(node->base_bdev), base_bdev_name) == 0) {
return node;
}
}
return NULL;
}

/* Epoch-at-ejection — see vbdev_cbt_query.h. Refuses (-EEXIST) while the cbt
* carries a live epoch. An open one bounds a round the controller started, and
* taking it over would steal its nonce. A frozen or rebuilding one may have taken
* out of the live bitmap, at its freeze, writes the member missed just before it
* left: an epoch opened now would start short of them. */
int
vbdev_cbt_auto_epoch_open(const char *bdev_name, const char *stale_backend_id)
vbdev_cbt_auto_epoch_open(const char *base_bdev_name, const char *member_id)
{
struct vbdev_cbt *cbt;
struct cbt_epoch *ep;
char epoch_id[CBT_EPOCH_ID_MAX];
char nonce[CBT_NONCE_MAX];
const char *cbt_name;
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);
cbt = cbt_find_by_base_name(base_bdev_name);
if (cbt == NULL) {
return -ENODEV;
}
if (cbt_any_epoch_open(cbt)) {
return -EEXIST;
}

TAILQ_FOREACH(ep, &cbt->epochs, link) {
if (ep->state == CBT_EPOCH_OPEN) {
return -EEXIST;
}
if (ep->generation > max_gen) {
max_gen = ep->generation;
}
}

/* The member may have missed writes from just before a rotation: those moved
* to the previous window, and come back into the delta here. With no live
* epoch, no rotation runs until this epoch closes. */
for (uint64_t i = 0; i < cbt->bitmap_size_bytes; i++) {
if (cbt->bitmap_prev[i] != 0) {
__atomic_fetch_or(&cbt->bitmap[i], cbt->bitmap_prev[i], __ATOMIC_ACQ_REL);
}
}

cbt_name = spdk_bdev_get_name(&cbt->cbt_bdev);
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);
SPDK_NOTICELOG("CBT: auto epoch '%s' (nonce %s) on '%s' — member '%s' left '%s'\n",
epoch_id, nonce, cbt_name, member_id, base_bdev_name);

return bdev_cbt_epoch_open(bdev_name, epoch_id, stale_backend_id,
max_gen + 1, nonce);
return bdev_cbt_epoch_open(cbt_name, epoch_id, member_id, max_gen + 1, nonce);
}

/* Cross-module query — the raid module publishes these facts per member in ITS
Expand Down Expand Up @@ -573,6 +599,53 @@ vbdev_cbt_query_latest_epoch(const char *bdev_name, struct vbdev_cbt_epoch_facts
return 0;
}

/* Cross-module query — the delta of an epoch for the raid module's seeded
* rebuild (see vbdev_cbt_query.h): the ranges bdev_cbt_epoch_get_dirty_ranges
* walks, in the query's own type, refused whole when they do not all fit. */
int
vbdev_cbt_query_epoch_ranges(const char *bdev_name, const char *epoch_id, uint32_t max,
struct vbdev_cbt_range **out, uint32_t *count)
{
struct cbt_dirty_range *ranges = NULL;
struct vbdev_cbt_range *copy = NULL;
uint64_t dirty_chunks, total_chunks;
uint32_t num_ranges = 0, chunk_size_kb, i;
bool truncated = false;
int rc;

assert(spdk_get_thread() == spdk_thread_get_app_thread());

*out = NULL;
*count = 0;

rc = bdev_cbt_epoch_get_dirty_ranges(bdev_name, epoch_id, max, &ranges, &num_ranges,
&dirty_chunks, &total_chunks, &chunk_size_kb, &truncated);
if (rc != 0) {
return rc;
}
if (truncated) {
free(ranges);
return -E2BIG;
}

if (num_ranges > 0) {
copy = calloc(num_ranges, sizeof(*copy));
if (copy == NULL) {
free(ranges);
return -ENOMEM;
}
for (i = 0; i < num_ranges; i++) {
copy[i].offset_blocks = ranges[i].offset_blocks;
copy[i].num_blocks = ranges[i].length_blocks;
}
}
free(ranges);

*out = copy;
*count = num_ranges;
return 0;
}

static int
vbdev_cbt_dump_info_json(void *ctx, struct spdk_json_write_ctx *w)
{
Expand Down Expand Up @@ -629,6 +702,7 @@ _cbt_device_unregister_cb(void *io_device)
struct vbdev_cbt *cbt_node = io_device;

free(cbt_node->bitmap);
free(cbt_node->bitmap_prev);
free(cbt_node->cbt_bdev.name);

struct cbt_epoch *ep;
Expand Down Expand Up @@ -831,14 +905,18 @@ vbdev_cbt_register(const char *bdev_name)
cbt_node->total_blocks = bdev->blockcnt;

cbt_node->bitmap = calloc(1, cbt_node->bitmap_size_bytes);
if (!cbt_node->bitmap) {
SPDK_ERRLOG("CBT: bitmap allocation failed (%lu bytes)\n",
cbt_node->bitmap_prev = calloc(1, cbt_node->bitmap_size_bytes);
if (!cbt_node->bitmap || !cbt_node->bitmap_prev) {
SPDK_ERRLOG("CBT: bitmap allocation failed (2 x %lu bytes)\n",
(unsigned long)cbt_node->bitmap_size_bytes);
spdk_bdev_close(cbt_node->base_desc);
free(cbt_node->bitmap);
free(cbt_node->bitmap_prev);
free(cbt_node->cbt_bdev.name);
free(cbt_node);
return -ENOMEM;
}
cbt_node->rotated_at = spdk_get_ticks();

/* ── Copy geometry from base bdev ── */
cbt_node->cbt_bdev.write_cache = bdev->write_cache;
Expand Down Expand Up @@ -886,6 +964,7 @@ vbdev_cbt_register(const char *bdev_name)
TAILQ_REMOVE(&g_cbt_nodes, cbt_node, link);
spdk_io_device_unregister(cbt_node, NULL);
free(cbt_node->bitmap);
free(cbt_node->bitmap_prev);
free(cbt_node->cbt_bdev.name);
free(cbt_node);
return rc;
Expand All @@ -899,6 +978,7 @@ vbdev_cbt_register(const char *bdev_name)
TAILQ_REMOVE(&g_cbt_nodes, cbt_node, link);
spdk_io_device_unregister(cbt_node, NULL);
free(cbt_node->bitmap);
free(cbt_node->bitmap_prev);
free(cbt_node->cbt_bdev.name);
free(cbt_node);
return rc;
Expand Down Expand Up @@ -2478,6 +2558,38 @@ bdev_cbt_reset(const char *cbt_name)

__atomic_thread_fence(__ATOMIC_ACQUIRE);
memset(cbt->bitmap, 0, cbt->bitmap_size_bytes);
/* The caller vouches every backend in sync through now: the history window
* goes with the rest. */
memset(cbt->bitmap_prev, 0, cbt->bitmap_size_bytes);
return 0;
}

int
bdev_cbt_rotate(const char *cbt_name)
{
struct vbdev_cbt *cbt = cbt_find_by_name(cbt_name);
uint64_t now = spdk_get_ticks();

assert(spdk_get_thread() == spdk_thread_get_app_thread());

if (!cbt) {
return -ENODEV;
}
/* A live epoch reads the live bitmap whole: moving bits out of it would take
* them out of that epoch's delta. */
if (cbt_any_epoch_open(cbt)) {
return -EBUSY;
}
if (now - cbt->rotated_at < CBT_ROTATE_MIN_INTERVAL_US * spdk_get_ticks_hz() / SPDK_SEC_TO_USEC) {
return -EAGAIN;
}

/* Per-byte exchange, as at freeze: an I/O thread OR-ing a bit between a copy
* and a clear would lose it. */
for (uint64_t i = 0; i < cbt->bitmap_size_bytes; i++) {
cbt->bitmap_prev[i] = __atomic_exchange_n(&cbt->bitmap[i], 0, __ATOMIC_ACQ_REL);
}
cbt->rotated_at = now;
return 0;
}

Expand Down
19 changes: 19 additions & 0 deletions module/bdev/cbt/vbdev_cbt.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ extern "C" {
#define CBT_EPOCH_ID_MAX 64
#define CBT_BACKEND_ID_MAX 128
#define CBT_NONCE_MAX 32 /* control-plane epoch nonce */
/* Two rotations of the history window are at least this far apart, whoever asks:
* a write a member missed just before it left must still be in one of the two
* bitmaps when the epoch for that member opens, a few milliseconds later. */
#define CBT_ROTATE_MIN_INTERVAL_US 10000000
#define CBT_REBUILD_TOKEN_MAX 192 /* consumed-close proof token */
#define CBT_REBUILD_DEFAULT_QD 16
#define CBT_REBUILD_MAX_QD 128
Expand Down Expand Up @@ -260,6 +264,21 @@ int bdev_cbt_epoch_get_dirty_ranges(const char *cbt_name, const char *epoch_id,
uint32_t *out_chunk_size_kb,
bool *out_truncated);

/**
* Start a new history window: the live bitmap moves to the previous-window bitmap
* (dropping the window before it) and starts again empty. Without it, the live
* bitmap holds every write since the last reset, and the epoch opened when a
* member leaves (vbdev_cbt_auto_epoch_open) would copy all of them, on a volume
* that went days without an epoch the whole device. That epoch takes the previous
* window back, so it covers one to two windows before the departure and
* everything after.
*
* \return 0; -EBUSY while an epoch is open, frozen or rebuilding (it reads the
* live bitmap whole); -EAGAIN less than CBT_ROTATE_MIN_INTERVAL_US after
* the previous rotation; -ENODEV if \c cbt_name is not a cbt bdev.
*/
int bdev_cbt_rotate(const char *cbt_name);

/* ── Legacy aliases (deprecated, will be removed in v2) ────────────── */

int bdev_cbt_start_tracking(const char *cbt_name);
Expand Down
9 changes: 9 additions & 0 deletions module/bdev/cbt/vbdev_cbt_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ struct vbdev_cbt {
* bdev_cbt_reset, refused while any epoch is active. Nothing inside the
* target can know that every distributed backend is in sync. */

/* ── History window (bdev_cbt_rotate) ── */
/* The writes of the previous window, rotated out of the live bitmap while no
* epoch was active. An epoch opened when a member leaves takes them back:
* the writes the member missed just before it left are in one of the two. */
uint8_t *bitmap_prev;
/* Ticks of the last rotation; rotations are at least
* CBT_ROTATE_MIN_INTERVAL_US apart. */
uint64_t rotated_at;

/* ── Epoch management ── */
TAILQ_HEAD(, cbt_epoch) epochs;
uint64_t epoch_count;
Expand Down
45 changes: 36 additions & 9 deletions module/bdev/cbt/vbdev_cbt_query.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,43 @@ struct vbdev_cbt_epoch_facts {
int vbdev_cbt_query_latest_epoch(const char *bdev_name, struct vbdev_cbt_epoch_facts *out);

/**
* Open a delta epoch bounding an unplanned member loss. The raid module calls
* this on each surviving member's cbt when a member leaves, so the missing
* writes stay a delta instead of degrading to a full rebuild. The generated
* epoch id and nonce are reported through get_bdevs, which is how the
* control-plane adopts the round.
* Open a delta epoch bounding an unplanned member loss, on the cbt bdev stacked
* on the bdev named \c base_bdev_name. The raid module calls this when a current
* member leaves a raid1, with the raid's name and the member's UUID as
* \c member_id: from then on the cbt records every write the member misses, so
* its return is a delta instead of a full rebuild. The generated epoch id and
* nonce are reported through get_bdevs, 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 epoch machinery. App thread only.
* \return 0 on success; -EEXIST if the cbt carries a live epoch (open, frozen or
* rebuilding): never take a round over, nor start one short of writes a
* freeze took out of the live bitmap; -ENODEV if no cbt bdev sits on
* \c base_bdev_name; other negative errno from the epoch machinery. App
* thread only.
*/
int vbdev_cbt_auto_epoch_open(const char *bdev_name, const char *stale_backend_id);
int vbdev_cbt_auto_epoch_open(const char *base_bdev_name, const char *member_id);

/* One dirty range of an epoch, in blocks of the cbt bdev: the raid's own, since
* a cbt bdev passes its LBAs through. */
struct vbdev_cbt_range {
uint64_t offset_blocks;
uint64_t num_blocks;
};

/**
* The dirty ranges of a frozen (or rebuilding) epoch of the cbt bdev named
* \c bdev_name, sorted and non-overlapping, for the raid module to seed a
* rebuild with in-process. A JSON-RPC request carries ~200 ranges at most
* (1024 parsed values, a 32 KiB receive buffer), and folding a scattered delta
* down to that many recopies every clean block in between.
*
* \param max the most ranges the caller takes. A delta with more is -E2BIG:
* truncated, it would not be a smaller delta but a wrong one.
* \return 0 with \c *out allocated (the caller frees it; NULL when \c *count is
* 0); -ENODEV, -ENOENT or -EINVAL as bdev_cbt_epoch_get_dirty_ranges;
* -E2BIG; -ENOMEM. App thread only.
*/
int vbdev_cbt_query_epoch_ranges(const char *bdev_name, const char *epoch_id, uint32_t max,
struct vbdev_cbt_range **out, uint32_t *count);

#endif /* SPDK_VBDEV_CBT_QUERY_H */
32 changes: 32 additions & 0 deletions module/bdev/cbt/vbdev_cbt_rpc.c
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,38 @@ rpc_bdev_cbt_reset(struct spdk_jsonrpc_request *request,
}
SPDK_RPC_REGISTER("bdev_cbt_reset", rpc_bdev_cbt_reset, SPDK_RPC_RUNTIME)

/* bdev_cbt_rotate: start a new history window (see bdev_cbt_rotate). Answers the
* errno as the JSON-RPC error code, as bdev_cbt_reset does: -EBUSY while an epoch
* is live, -EAGAIN when called again too soon. */
static void
rpc_bdev_cbt_rotate(struct spdk_jsonrpc_request *request,
const struct spdk_json_val *params)
{
struct rpc_cbt_name_only req = {NULL};
int rc;

if (spdk_json_decode_object(params, rpc_cbt_name_only_decoders,
SPDK_COUNTOF(rpc_cbt_name_only_decoders), &req)) {
spdk_jsonrpc_send_error_response(request, SPDK_JSONRPC_ERROR_INTERNAL_ERROR,
"Failed to decode parameters");
goto cleanup;
}

rc = bdev_cbt_rotate(req.name);
if (rc != 0) {
spdk_jsonrpc_send_error_response(request, rc, spdk_strerror(-rc));
goto cleanup;
}

struct spdk_json_write_ctx *w = spdk_jsonrpc_begin_result(request);
spdk_json_write_bool(w, true);
spdk_jsonrpc_end_result(request, w);

cleanup:
free_rpc_cbt_name_only(&req);
}
SPDK_RPC_REGISTER("bdev_cbt_rotate", rpc_bdev_cbt_rotate, SPDK_RPC_RUNTIME)

/* ================================================================== */
/* bdev_cbt_partial_rebuild (async — deferred RPC response) */
/* ================================================================== */
Expand Down
Loading
Loading