A crash-consistent block-volume store built on HomeStore -- thin-provisioned volumes with a replicated, checkpointed data path, exposed through a small C++23 coroutine API.
HomeBlocks turns a set of raw devices into named volumes: each is a sparse, block-addressable store with its
own LBA β block index, per-block CRC, and a write-ahead journal, all riding HomeStore's replication, indexing,
and checkpoint machinery. The public surface is one header and a handful of coroutine entry points.
- Volumes -- create / destroy / look up thin-provisioned block volumes; survive process restarts and crashes via journal replay and a destroy-resume path.
- CRAFT I/O -- clients hold one
volume_handleper replica and drive the protocol directly:loginestablishes the session term and starting dLSN,async_write(broadcast to all replicas at a client-assigned dLSN) ACKs on journal durability,async_read(unicast to a chosen eligible replica) returns a sparseio_extentlayout, andkeep_aliveadvances the commit watermark. All return lazy stdexec coroutines (sisl::async::task). No callbacks, no futures. - One error surface -- every fallible call returns
std::expected<T, std::error_condition>; CRAFT failures usecraft_error(STALE_TERM,NOT_LEADER,NO_QUORUM,REPLICA_DOWN), control-plane failures usevolume_error, everything else maps tostd::errc. No exceptions on the I/O path. - Opaque handles + factory init -- consumers never construct an implementation type; they hold a
volume_handleand astd::shared_ptr<home_blocks>produced byinit_homeblocks(). - Minimal surface -- the entire public API is a single installed header,
<homeblks/home_blocks.hpp>. - Replicated & checkpointed -- data, index, and journal flow through HomeStore's repl-dev / index / CP services; HomeBlocks owns volume lifecycle and recovery on top.
- Linux (io_uring-capable kernel)
- Conan 2.x
- CMake 3.22+
- A C++23 compiler (GCC 13+, Clang 17+)
git clone https://github.com/eBay/HomeBlocks
cd HomeBlocks
conan build . -s build_type=Debug --build missingThis configures, builds the homeblocks library, and runs the unit tests. Artifacts land under
build/Debug/.
# Release
conan build . -s build_type=Release --build missing
# Coverage report (build/Coverage/)
conan build . -o "homeblocks/*:coverage=True" --build missing
# Address / thread sanitizer (build/Sanitized/)
conan build . -o "homeblocks/*:sanitize=True" --build missing
# Index layout: fixed (default) vs prefix-compressed btree
conan build . -o "homeblocks/*:fixed_index=False" --build missingHomeBlocks is the volume layer. It owns volume identity and crash recovery. The replication protocol is
CRAFT (Client Assisted RAFT): clients hold one volume_handle per replica and drive the data path
themselves -- broadcasts writes at client-assigned LSNs directly to all replicas (bypassing RAFT on the
data path), each CraftReplDev appends to its data journal and ACKs on durability, and the commit
watermark (piggybacked on writes and keep_alive) advances the LBA index in LSN order. RAFT is used
only for leader election and login/logout synchronization. HomeStore provides the data journal, index, and
checkpoint services; iomgr provides the io_uring reactor model.
graph TD
app["Consumer<br/>(e.g. a CSI / target daemon)"]
subgraph hb["HomeBlocks -- per replica"]
hbi["home_blocks<br/>volume control plane + recovery"]
craft["CraftReplDev<br/>login Β· write Β· read Β· keep_alive"]
end
subgraph hs["HomeStore"]
raft["RAFT<br/>leader election Β· login sync<br/>(control plane -- not the data path)"]
journal["data journal<br/>write-append Β· tail-overlay reads"]
idx["index service<br/>LBA β blk"]
cp["checkpoint (CP)"]
end
iomgr["iomgr<br/>io_uring reactors"]
dev[("Devices")]
app -->|"init_homeblocks / create_volume"| hbi
app -->|"login / logout (β leader)"| craft
app -->|"write + dLSN (broadcast)"| craft
app -->|"read + read_lsn (unicast)"| craft
app -->|"keep_alive (broadcast, commit)"| craft
hbi --> craft
craft -->|"append"| journal
craft -->|"apply on commit"| idx
craft -.->|"SyncRSCommitLSN Β· InternalLogin"| raft
idx --> cp
journal --> iomgr
cp --> iomgr
iomgr --> dev
HomeBlocks/
βββ src/include/homeblks/
β βββ home_blocks.hpp # the entire public API (one installed header)
βββ src/lib/
β βββ homeblks_impl.{hpp,cpp} # home_blocks instance: init, shutdown, recovery, reaper
β βββ volume_mgr.cpp # control plane + CRAFT free-function dispatch
β βββ craft/ # CRAFT protocol: CraftReplDev, craft_replica, CRAFT API free functions
β βββ volume/ # the volume: LBA index tables, chunk selector, io_req
β βββ listener.{hpp,cpp} # HomeStore repl_dev_listener (on_commit / snapshot hooks)
β βββ hb_internal.hpp # internal prelude (LOG* macros, size constants, aliases) -- not installed
β βββ tests/ # gtest unit + I/O tests
βββ src/test/
β βββ craft/ # CRAFT reference client + its ublk driver, and the in-memory replica model
β βββ legacy_ublk/ # homeblk_ublk, over the deprecated block API
βββ conanfile.py
home_blocks-- opaque handle to a running instance, produced byinit_homeblocks(). Owns the volume control plane (create_volume/remove_volume/get_volume/volume_ids/ stats).volume_handle(std::shared_ptr<volume>) -- opaque handle to one replica device; the CRAFT free functions take it. In production the client holds one handle per member.home_blocks_config-- bring-up config (devices, reactor threads, memory budget, and a cold-booton_svc_ididentity hook).login/async_write/async_read/keep_alive-- CRAFT client free functions over avolume_handle.loginorchestrates the full session setup (returnsLoginResultwithterm,dLSN,lba_size, and member list).async_writetakes aclient_hdrand client-assigned dLSN; an emptysg_listis a zero-write (WRITE_ZEROES).async_readtakes a read-horizonread_lsnand returns a sparseio_extentlayout (data vs holes).keep_aliveadvances the commit watermark and resets the watchdog.client_hdr-- session + commit watermark stamped on every CRAFT I/O (term,commit_lsn,all_committed_lsn); CRAFT's commit piggybacks on these calls -- there is no standalone commit verb.result<T>/async_result<T>-- the synchronous and coroutine flavors of the one error surface.
Everything is in one header:
#include <homeblks/home_blocks.hpp>
using namespace homeblocks;auto hb_res = init_homeblocks(home_blocks_config{
.devices = {{"/dev/nvme0n1"}, {"/dev/nvme1n1"}},
.threads = 2,
// Optional: on first boot (no persisted svc id) HomeBlocks calls this to fetch/assign one -- e.g. a gRPC
// to an orchestrator. Resolve the (possibly rotated) client inside the closure.
.on_svc_id = [&](/* */) -> async_result<peer_id_t> { co_return co_await orch.register_node(); },
});
if (!hb_res) { /* hb_res.error().message() -- e.g. OM unreachable */ return; }
std::shared_ptr<home_blocks> hb = *hb_res;Each volume_handle is a handle to one replica. In production, call create_volume (or get_volume)
once per member and hold all handles; the examples below show one replica for clarity.
sisl::async::task<void> demo(std::shared_ptr<home_blocks> hb) {
// create_volume returns a handle to one replica device.
auto vol = co_await hb->create_volume(volume_info{uuid, /*size*/ 1ull << 30, /*page_size*/ 4096, "vol1"});
if (!vol) co_return; // vol.error()
// Login: unicast to the leader -- returns {term, dLSN, lba_size, member list}.
auto lr = co_await login(*vol, client_token);
if (!lr) co_return; // lr.error(): NO_QUORUM, REPLICA_DOWN, ...
client_hdr hdr{.term = lr->term, .commit_lsn = -1};
int64_t next_lsn = lr->dLSN;
// Write 8 KiB at byte offset 0. addr/len are RAW BYTE offsets (block-aligned to lba_size).
// Broadcast this call to every member; ACK fires on journal durability.
sisl::sg_list sgs{.size = 8192, .iovs = {iovec{buf, 8192}}};
auto w = co_await async_write(*vol, hdr, next_lsn++, /*addr=*/0, /*len=*/8192, sgs);
if (!w) { /* w.error(): craft_error::STALE_TERM, std::errc::no_space_on_device, ... */ }
// Zero-write (WRITE_ZEROES / discard-to-zero): empty sg_list is the signal -- no all_zeros flag.
co_await async_write(*vol, hdr, next_lsn++, /*addr=*/8192, /*len=*/4096, {});
// Commit piggybacks on the next write, read, or keep_alive via client_hdr::commit_lsn.
// Here the read carries it -- no separate keep_alive needed.
hdr.commit_lsn = next_lsn - 1;
sisl::sg_list dest{.size = 8192, .iovs = {iovec{rbuf, 8192}}};
auto layout = co_await async_read(*vol, hdr, /*read_lsn=*/next_lsn - 1, 0, 8192, dest);
// layout: [{addr=0, len=8192, hole=false}] for data; hole=true for unmapped / zero-written ranges
}From a non-coroutine context (a gRPC handler, main), drive the lazy task to completion off-reactor with
stdexec::sync_wait(...).
One surface -- std::expected<T, std::error_condition>:
// Control plane
auto v = hb->get_volume(id);
if (!v) {
if (v.error() == volume_error::UNKNOWN_VOLUME) { /* no such volume */ }
else if (v.error() == std::errc::operation_not_supported) { /* restricted mode */ }
return;
}
// CRAFT I/O
auto w = co_await async_write(*vol, hdr, lsn, addr, len, sgs);
if (!w) {
if (w.error() == craft_error::STALE_TERM) { /* deposed -- re-login */ }
if (w.error() == std::errc::no_space_on_device) { /* out of space */ }
}craft_error covers CRAFT-specific failures (STALE_TERM, NOT_LEADER, NO_QUORUM, REPLICA_DOWN);
volume_error covers control-plane failures (UNKNOWN_VOLUME, CRC_MISMATCH, INDEX_ERROR,
INTERNAL_ERROR, OFFLINE); anything with a standard meaning is returned as std::errc::*. All
compare directly against the returned error_condition.
- 4-space indent, 120-column lines,
#pragma once, C++23. - Run
./apply-clang-format.shbefore committing (CI enforces it).
| Element | Convention | Example |
|---|---|---|
Public API types & functions (include/homeblks/) |
lower_snake_case |
home_blocks, volume_handle, async_write() |
| Public factories | init_* / free functions |
init_homeblocks() |
Internal implementation classes (src/lib/) |
PascalCase |
HomeBlocksImpl, VolumeChunkSelector |
| Methods | snake_case |
create_volume(), get_volume() |
| Members | trailing _ |
vol_map_, config_ |
Implementation types (HomeBlocksImpl, the concrete volume, the chunk selector) never appear in the installed
header; consumers only ever see opaque handles produced by the factory functions.
# 1. write code + tests
# 2. format
./apply-clang-format.sh
# 3. build + test
conan build . -s build_type=Debug --build missingGoogle Test, built and run as part of conan build. The suite covers volume lifecycle/recovery, the data I/O
path (with read-back CRC verification), and the chunk selector:
build/Debug/src/lib/volume/tests/test_volume --dev_size_mb=2048 --data_chunk_size_mb=64 --index_chunk_size_mb=64
build/Debug/src/lib/volume/tests/test_volume_io --dev_size_mb=2048 --data_chunk_size_mb=64 --index_chunk_size_mb=64
build/Debug/src/lib/volume/tests/test_volume_chunk_selectorSize test devices so the data/index chunk size fits -- the defaults are large; pass
--data_chunk_size_mb/--index_chunk_size_mbfor small backing files, otherwise the volume's chunk pool comes up empty andcreate_volumefails.
Beyond the gtest suite, src/test/ ships a small adapter that exposes a HomeBlocks volume as a Linux
ublk device (/dev/ublkbN) -- so you can point standard block
tooling (fio, dd, mkfs, mount) straight at the data path. The homeblk_ublk CLI brings up an instance,
creates (or recovers) a volume, and serves its I/O on HomeBlocks' iomgr reactors via
ublkpp. It is built as part of the normal test build (ublkpp is a
test_requires), landing at build/Debug/src/test/legacy_ublk/homeblk_ublk.
Prerequisites: a ublk_drv-capable kernel (β₯ 5.19; sudo modprobe ublk_drv if /dev/ublk-control is
missing) and root -- the control device is root-only.
# Bring up HomeBlocks on a backing device and expose a 1 GiB volume.
# --create_device makes the backing store a file of --dev_size_mb (handy for a quick try); omit it to use an
# existing file or raw block device -- which HomeBlocks will FORMAT, destroying its current contents.
sudo build/Debug/src/test/legacy_ublk/homeblk_ublk \
--device /var/tmp/hb.dev --create_device --dev_size_mb 8192 \
--vol_size_mb 1024 --data_chunk_size_mb 512 --index_chunk_size_mb 256 \
--num_threads 4 -c
# -> prints "homeblocks volume exposed at: /dev/ublkbN" and stays running until Ctrl-C.| Option | Meaning |
|---|---|
--device <path>[,...] |
HomeBlocks backing device(s). Formatted on use -- existing contents are destroyed. |
--create_device / --dev_size_mb |
Create the backing path(s) as files of the given size first. |
--vol_id <uuid> |
Volume to expose -- recovered if it already exists, else created (default: random). |
--vol_size_mb / --page_size |
Volume size and logical block size (default 4096) when creating. |
--num_threads |
HomeBlocks iomgr reactor count. |
--data_chunk_size_mb / --index_chunk_size_mb |
HomeStore chunk sizing (see the note below). |
--device_id <n> |
ublk device id: -1 to assign one, >=0 to recover a kernel-preserved device. |
In another terminal, drive I/O at the printed device. All raw I/O is O_DIRECT with the volume's page size as
the logical block size, so keep it block-aligned (--direct=1 bs=4k):
DEV=/dev/ublkbN
# integrity: write + read-back CRC verify
sudo fio --name=v --filename=$DEV --direct=1 --rw=randwrite --bs=4k --size=512M \
--ioengine=io_uring --iodepth=32 --verify=crc32c --verify_fatal=1
# a real filesystem round-trip
sudo mkfs.ext4 -F $DEV && sudo mount $DEV /mnt && sudo cp -r some/files /mnt && sudo umount /mntTear down by Ctrl-C-ing homeblk_ublk -- but umount any filesystem first: clean shutdown removes the
ublk device, which blocks until it is unmounted. Volume data persists in the backing device; re-expose it later
with the same --vol_id and without --create_device.
Sizing matters for sustained writes. HomeBlocks is copy-on-write, so sustained random overwrite churns HomeStore's free-block allocator and checkpoint. Give the volume many data chunks (a smaller
--data_chunk_size_mbrelative to the volume) and enough--num_threads, or per-chunk contention surfaces as throughput dips and latency spikes. For a disk-free benchmark, back it with a ramdisk (sudo modprobe brd rd_nr=1 rd_size=$((12*1024*1024))β--device /dev/ram0, no--create_device). One fio gotcha:--verifywith--numjobs>1over a shared range reports false mismatches (writers overwrite each other's blocks) -- give each job a disjoint--offset_incrementregion, or use a single writer.
homeblk_ublk drives a volume through the block API. A sibling CLI, craft_ublk
(build/Debug/src/test/craft/craft_ublk), drives one through the CRAFT API instead -- login,
dLSN-stamped broadcast writes, horizon reads -- against an N-replica set that lives entirely in process.
It needs no backing device and no HomeStore, so sudo craft_ublk --replicas 3 is enough to point fio at a
quorum-replicated volume and watch the commit frontier advance over its REST endpoint.
It exists to validate the client and its I/O path, not to measure anything: the in-process replica set stands in
for a network, and its throughput is a property of that stand-in rather than of CRAFT. See
src/test/craft/client/README.md for the driver, the client, and what a real
transport will have to provide.
| Dependency | Version | Role |
|---|---|---|
| HomeStore | ^8.0 | data journal, index & data services, RAFT, checkpoints |
| iomgr | ^13.0 | io_uring reactor model, drive I/O |
| sisl | ^14.6 | async coroutine substrate, logging, metrics, options |
| stdexec | (via sisl) | P2300 sender/receiver; exec::task |
| ublkpp | ^0.34 | ublk device adapter for integration testing |
| GoogleTest | 1.17 | unit / functional tests |
Built and packaged with Conan 2; requires C++23.
- CHANGELOG.md -- version history.
Licensed under the Apache License, Version 2.0. See LICENSE.