Skip to content

Commit c9c1b97

Browse files
authored
craft: in-memory reference model + client-facing API surface (#166)
Add a HomeStore-free, in-process reference model of the CRAFT replication protocol (the server side) so a client can be built and tested against it, plus the client-facing CRAFT API it exposes. Public API (home_blocks.hpp): per-replica CRAFT free functions over an opaque volume_handle -- login / async_write / async_read / keep_alive -- each carrying a client_hdr {term, commit_lsn, all_committed_lsn}. Addressing is byte-based (addr/len aligned to lba_size, returned by login and enforced server-side); sisl::sg_list is the one buffer type both ways (an empty buffer is a zero write); reads fill the caller's buffer and return a sparse io_extent layout. Commit is piggybacked via client_hdr.commit_lsn -- there is no standalone commit verb -- and keep_alive is its term-fenced carrier. The legacy byte block ops are marked [[deprecated]] (kept as reference). Model (src/lib/craft/memory/): MemCraftReplica implements the internal craft_replica backend (journal, per-LBA index, journal-tail overlay, horizon-clamped sparse reads, term fencing, in-order commit apply); MemTransport is the in-process network + cold path (fixed leader, login orchestration) + fault hooks (replica down/up, sub-quorum). create_memory_volume / make_memory_replica_set wrap each replica behind a volume_handle (one handle == one replica device; no aggregate handle). The model links no HomeStore -- its unit-test binary proves it -- and only the create_memory_volume glue reuses volume (and thus links the engine). Tests: 15 model unit tests (no HomeStore, no iomgr reactor; ops complete inline) + 3 end-to-end tests through the public volume_handle API. Docs: docs/craft/{api,rpcs,README}.md updated to the landed surface. Also removes the obsolete disabled memory_backend stub.
1 parent c389009 commit c9c1b97

28 files changed

Lines changed: 1862 additions & 358 deletions

docs/craft/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,18 @@ synchronization, and recovery bookkeeping. Write data never flows through the RA
4040
| **InternalLogin** | A RAFT log entry type. On apply, stores the new `client_token` and enforces single-writer exclusivity. |
4141
| **Missing** | A dLSN slot that a replica knows about (from a peer or from the RAFT log) but has not yet received data for. |
4242
| **Empty** | A dLSN proven never quorum-durable, declared by the leader; a permanent no-op the commit skips. |
43-
| **all_zeros (zero write)** | A payload-free write naming just an LBA range (the WRITE_ZEROES / discard-to-zero op). Takes a dLSN and merges like any write; allocates nothing and unmaps its range on apply. |
43+
| **zero write (all_zeros)** | A payload-free write naming just a byte range (WRITE_ZEROES / discard-to-zero), signaled by an **empty `sisl::sg_list`** (no `all_zeros` flag). Takes a dLSN and merges like any write; allocates nothing and unmaps its range on apply, reading back as a hole. |
4444
| **hole** | A read sub-range with no data (never written, zero-written, or an all-zero region collapsed at read time). Returned as a marker, read as zeros; **not** the same as `Missing`. |
45+
| **client_hdr** | The session + watermark fields stamped on every client IO: `{term, commit_lsn, all_committed_lsn}` (see the commit note below). |
46+
| **lba_size** | The volume block size in bytes, returned by `login`; the client aligns every byte `addr`/`len` to it and presents the geometry to the filesystem. |
47+
| **io_extent** | One sub-range of a read's sparse layout, in **bytes**: `{addr, len, hole}` — carries no bytes (they are in the caller's buffer). |
4548

4649
## Key design properties
4750

4851
- **Single writer**: only one client at a time owns a partition (enforced by `InternalLogin` RAFT entry).
4952
- **Leaderless data path**: after login, the RAFT leader has no special role for writes or reads.
50-
- **Client drives commit**: replicas apply to the index only when told (via `commit` / `keep_alive`), strictly in dLSN order at the contiguous frontier. Readability is per-write and does not wait: appended entries are served from the journal-tail overlay (no index write on the read path).
53+
- **Client drives commit (piggybacked, no standalone verb)**: the client stamps its `commit_lsn` on every write / read / keep_alive (`client_hdr`); replicas apply to the index strictly in dLSN order at the contiguous frontier. `keep_alive` is the dedicated carrier (commit + watchdog reset, term-fenced). Readability is per-write and does not wait: appended entries are served from the journal-tail overlay (no index write on the read path).
54+
- **Byte-based, one buffer type**: `addr`/`len` are byte offsets/lengths (block-aligned to `lba_size`); `sisl::sg_list` is the single caller-owned buffer both ways (an **empty** write buffer is a zero write). A read fills the caller's buffer in place and returns a sparse `io_extent` layout (data vs holes).
5155
- **Client-routed reads**: reads are unicast, chosen by LBA-overlap against the client's per-replica Missing map (plus `Synced ≥ L`, the login dLSN). The read path never fetches from a peer; fetch is resync-only.
5256
- **Merge key, not serialization**: overlapping writes need no ordering; highest dLSN per LBA wins on every replica.
5357
- **Thin from the start**: a write may be `all_zeros` (WRITE_ZEROES) with no payload; reads return sparse results (data extents + holes), and the server collapses all-zero reads to holes, so reads and resync stay thin. A hole is not `Missing`.

docs/craft/api.md

Lines changed: 125 additions & 162 deletions
Large diffs are not rendered by default.

docs/craft/rpcs.md

Lines changed: 88 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
# CRAFT RPCs
22

3-
8 RPCs total. 4 client↔server, 4 server↔server (2 via RAFT, 2 non-RAFT).
3+
8 RPCs total. 4 client↔server (Login, Write, Read, KeepAlive), 4 server↔server (2 via RAFT, 2 non-RAFT).
44
RAFT internal RPCs (heartbeat, vote, membership) are not listed here.
55

6+
> **Commit is not a separate RPC.** The commit watermark rides on `client_hdr.commit_lsn`, piggybacked
7+
> on Write / Read / KeepAlive; KeepAlive is its dedicated carrier (commit + watchdog reset). Readability
8+
> is per-write and does not wait for it (journal-tail overlay reads).
9+
610
> **Canonical design:** the [**CRAFT Design**](https://github.com/eBay/HomeBlocks/wiki/CRAFT-Design)
711
> wiki page is the source of truth for the protocol, and
812
> [**CRAFT on HomeBlocks**](https://github.com/eBay/HomeBlocks/wiki/CRAFT-on-HomeBlocks) for the
9-
> implementation binding; this file is the RPC wire-format reference.
13+
> implementation binding; this file is the RPC wire-format reference. The C++ API is in [api.md](api.md).
14+
15+
---
16+
17+
## Common header
18+
19+
Every client IO (Write, Read, KeepAlive) carries a `client_hdr`:
20+
21+
```
22+
client_hdr: { term: uint64, commit_lsn: int64, all_committed_lsn: int64 }
23+
```
24+
25+
- `term` — fences a stale writer (rejected `ETERM`); checked on **every** IO including KeepAlive, so a
26+
deposed client cannot even reset the liveness watchdog.
27+
- `commit_lsn` — advance the contiguous commit frontier toward this, best-effort, in dLSN order
28+
(`-1` = don't advance). This is CRAFT's commit, piggybacked.
29+
- `all_committed_lsn` — set-wide min commit_lsn; floors journal reclaim (`-1` = unknown).
30+
31+
**Addressing is byte-based:** `addr` / `len` are byte offset/length, block-aligned to the volume's
32+
`lba_size` (from Login), else `EINVAL`.
1033

1134
---
1235

@@ -16,15 +39,14 @@ RAFT internal RPCs (heartbeat, vote, membership) are not listed here.
1639

1740
```
1841
Request: { client_token: uint64 }
19-
Response: { members: [endpoint], dLSN: int64, term: uint64 }
42+
Response: { members: [endpoint], dLSN: int64, term: uint64, lba_size: uint32 }
2043
```
2144

22-
Client sends to the RAFT leader. Leader runs the full login orchestration sequence
23-
(GetRSCommitLSN → optional FetchData → SyncRSCommitLSN RAFT → InternalLogin RAFT)
24-
and responds once both RAFT entries commit.
25-
26-
`client_token` is an opaque 64-bit identity token. `dLSN` is the per-partition LSN, the only
27-
LSN CRAFT carries.
45+
Client sends to the RAFT leader (a follower replies `NOT_LEADER` + leader endpoint). Leader runs the
46+
full login orchestration (GetRSCommitLSN → optional FetchData → SyncRSCommitLSN RAFT → InternalLogin
47+
RAFT) and responds once both RAFT entries commit. `dLSN` is the starting per-partition LSN. `lba_size`
48+
is the volume block size in bytes — the client aligns every addr/len to it and presents the geometry to
49+
the filesystem.
2850

2951
HomeBlocks handler: `CraftReplDev::login()`
3052

@@ -33,20 +55,18 @@ HomeBlocks handler: `CraftReplDev::login()`
3355
### 2. Write (Broadcast to all replicas)
3456

3557
```
36-
Request: { term: uint64, lsn: int64, lba: uint64, len: uint32, all_zeros: bool, data: bytes }
58+
Request: { hdr: client_hdr, dlsn: int64, addr: uint64, len: uint64, data: bytes }
3759
Response: { status: Status }
3860
```
3961

40-
Client sends to every replica in the set in parallel. Each replica appends the entry to its
41-
data journal at slot `lsn` and ACKs immediately. Write is durable once quorum ACKs, and
62+
Client sends to every replica in the set in parallel at the client-assigned `dlsn`. Each replica
63+
appends the entry to its data journal at slot `dlsn` and ACKs immediately. Durable once quorum ACKs,
4264
readable once Appended (served from the journal-tail overlay until applied).
4365

44-
Two forms. A **data write** (`all_zeros=false`) carries `data`; zero-copy is required (`data`
45-
must not be copied during journal append). A **zero write** (`all_zeros=true`, the WRITE_ZEROES
46-
/ discard-to-zero op) omits `data`: the slot is metadata-only, allocates no blocks, and on apply
47-
unmaps its range. Both take a `dLSN` and merge by highest-`dLSN`-per-LBA. The client sets
48-
`all_zeros` when a client-side scan finds an all-zero buffer; the server does **not** re-scan a
49-
data write.
66+
**Empty `data` (length 0) is a zero write** (WRITE_ZEROES / discard-to-zero): the slot is metadata-only,
67+
allocates no blocks, and on apply unmaps its range (`len` is the range). Non-empty `data` is a data
68+
write of exactly `len` bytes. There is **no `all_zeros` flag** — the empty buffer is the signal. Both
69+
take a `dLSN` and merge by highest-`dLSN`-per-LBA. `hdr.commit_lsn` piggybacks a frontier advance.
5070

5171
HomeBlocks handler: `CraftReplDev::write()`
5272

@@ -55,102 +75,77 @@ HomeBlocks handler: `CraftReplDev::write()`
5575
### 3. Read (Unicast to chosen replica)
5676

5777
```
58-
Request: { term: uint64, readLSN: int64, lba: uint64, len: uint32 }
59-
Response: { status: Status, extents: [{ lba: uint64, len: uint32, hole: bool, data: bytes }] }
78+
Request: { hdr: client_hdr, read_lsn: int64, addr: uint64, len: uint64 }
79+
Response: { status: Status, layout: [{ addr: uint64, len: uint64, hole: bool }], data: bytes }
6080
```
6181

62-
`readLSN` is the read horizon `H` (the client's contiguous quorum-acked prefix); the read
63-
reflects writes ≤ `H`. The client picks an *eligible* replica: one filled to the login dLSN
64-
`L` (`Synced ≥ L`) whose Missing set has no slot ≤ `H` overlapping `[lba, lba+len)`. The
65-
replica returns the latest version ≤ `H` for the range, from the LBA index if applied or
66-
straight from the journal-tail overlay if only appended (an **overlay read**; no index write
67-
on the read path). A replica **ignores any write above `H` even if it holds it** (the
68-
sub-quorum tail), which is the server-side half of read safety. Because the client only routes
69-
to a servable
70-
replica, **the read path never fetches from a peer**; large multi-LBA reads may be split
71-
across replicas.
82+
`read_lsn` is the read horizon `H`. The client picks an *eligible* replica (one filled to the login
83+
dLSN `L`, with no Missing slot ≤ `H` overlapping the range). The replica returns the latest version ≤
84+
`H` for `[addr, addr+len)`, from the LBA index if applied or straight from the journal-tail overlay if
85+
only Appended (an **overlay read**; no index write). It **ignores any write above `H` even if it holds
86+
it** (the sub-quorum tail). The read path **never fetches from a peer**; large reads may be split across
87+
replicas.
7288

73-
The response is **sparse**: data extents interleaved with **holes** (`hole=true`, no `data`). A
74-
hole is an LBA sub-range that is never-written, zero-written (`all_zeros`), or an all-zero region
75-
the replica collapsed by a read-time scan; the client reads it as zeros. A hole is **not**
76-
`Missing` (the client routes around Missing slots, never around holes).
89+
The response is **sparse**: `layout` marks which byte sub-ranges are **data** vs **holes**
90+
(`hole=true`: never-written, zero-written, or an all-zero region collapsed by a read-time scan — read
91+
as zeros, **not** `Missing`); `data` carries the bytes for the data extents. The client places them
92+
into its caller-owned (iomgr) destination buffer per the layout (holes → zeros). `hdr.commit_lsn`
93+
piggybacks a frontier advance.
7794

7895
HomeBlocks handler: `CraftReplDev::read()`
7996

8097
---
8198

82-
### 4. Commit (Broadcast to all replicas)
83-
84-
```
85-
Request: { term: uint64, lsn: int64 }
86-
Response: { status: Status, commit_lsn: int64, last_append_lsn: int64 }
87-
```
88-
89-
Tells replicas to advance `commit_lsn` to `lsn`. May be piggybacked on the next
90-
Write or KeepAlive instead of sent as a standalone RPC. The response carries the **achieved**
91-
`{commit_lsn, last_append_lsn}` (best-effort: `commit_lsn` stalls below the first Missing hole).
92-
93-
HomeBlocks handler: `CraftReplDev::commit()`
94-
95-
---
96-
97-
### 5. KeepAlive (Broadcast to all replicas)
99+
### 4. KeepAlive (Broadcast to all replicas)
98100

99101
```
100-
Request: { commit_lsn: int64, all_committed_lsn: int64 }
102+
Request: { hdr: client_hdr }
101103
Response: { status: Status, commit_lsn: int64, last_append_lsn: int64 }
102104
```
103105

104-
Advances `commit_lsn` (in-order apply) and resets the per-replica client-timeout watchdog.
105-
The response carries `{commit_lsn, last_append_lsn}` (feeds the client's Missing map and the
106-
reconfig promotion gate); the request carries `all_committed_lsn`, the set-wide min the
107-
client computed from those responses, letting each replica reclaim journal opportunistically
108-
below it. Sent periodically during idle periods and after every quorum-acknowledged write.
106+
The dedicated commit carrier: advances `commit_lsn` (in-order apply, skipping Empty, reclaiming
107+
superseded blocks) toward `hdr.commit_lsn` **and resets the per-replica client-timeout watchdog** (which
108+
is why it is term-fenced — a stale client must not keep the session alive). The response carries the
109+
**achieved** `{commit_lsn, last_append_lsn}` (best-effort: stalls below the first Missing hole), which
110+
feeds the client's Missing map and the reconfig promotion gate. `hdr.all_committed_lsn` lets the replica
111+
reclaim journal below it. Sent periodically during idle periods and after quorum-acknowledged writes.
109112

110113
HomeBlocks handler: `CraftReplDev::keep_alive()`
111114

112115
---
113116

114117
## Server → Server (non-RAFT)
115118

116-
### 6. GetRSCommitLSN (Broadcast, initiated by leader)
119+
### 5. GetRSCommitLSN (Broadcast, initiated by leader)
117120

118121
```
119122
Request: { term: uint64, my_commit_lsn: int64, my_last_append_lsn: int64, is_login: bool }
120123
Response: { term: uint64, commit_lsn: int64, last_append_lsn: int64 }
121124
```
122125

123-
Leader sends to all peers to collect their current LSN state before a `SyncRSCommitLSN`
124-
RAFT proposal. Used during login and on timeout. `is_login=true` (the login poll) makes the
125-
peer **quiesce prior-session writes** before reporting `last_append` (the fencing barrier);
126-
watchdog / periodic polls carry `is_login=false` and never quiesce (they ride the live tail).
127-
Only `last_append_lsn` feeds the recovery watermark; `commit_lsn` is a contiguity
128-
certificate that bounds the leader's hole-resolution window, seeds `all_committed_lsn`
129-
(journal reclaim) when no client is attached, and carries the reconfig promotion gate.
126+
Leader polls peers for their LSN state before a `SyncRSCommitLSN` proposal (login and on timeout).
127+
`is_login=true` makes the peer **quiesce prior-session writes** before reporting `last_append` (the
128+
fencing barrier); watchdog/periodic polls carry `is_login=false` and never quiesce. Only `last_append`
129+
feeds the recovery watermark; `commit_lsn` is a contiguity certificate that bounds the leader's
130+
hole-resolution window, seeds `all_committed_lsn`, and carries the reconfig promotion gate.
130131

131132
HomeBlocks handler: `CraftReplDev::get_rs_commit_lsn()` / `get_lsns()`
132133
Dispatched by: `CraftConnector` (inter-node channel, non-RAFT)
133134

134135
---
135136

136-
### 7. FetchData (Unicast, from behind replica to an ahead peer)
137+
### 6. FetchData (Unicast, from behind replica to an ahead peer)
137138

138139
```
139140
Request: { lsns: [int64] }
140141
Response: { slots: [{ lsn: int64, is_empty: bool, all_zeros: bool, lba: uint64, len: uint32, data: bytes }] }
141142
```
142143

143-
Called when a replica discovers it is missing data for certain LSNs after receiving a
144-
`SyncRSCommitLSN` RAFT entry. **Four-way per slot:** an entry with `is_empty=false,
145-
all_zeros=false` carries `data`; `all_zeros=true` is a **zero write** (no `data`, applied as a
146-
range unmap); `is_empty=true` means the peer has **positively** marked that slot `Empty` in a
147-
prior resync; a requested `lsn` **omitted** from `slots` means *not-present-here*. A peer never
148-
returns `is_empty=true` for a slot it merely lacks. The `Empty` **verdict itself is
149-
leader-only**: the leader runs the broadcast-and-accumulate quorum procedure (itself
150-
included; non-responders never count) before proposing `SyncRSCommitLSN`, and distributes
151-
verdicts in that entry (`empty_slots[]`). Lagging replicas fetch present slots and obey the
152-
verdict list; they never declare `Empty` unilaterally (see the wiki's Resync section for the
153-
intersection argument and the Empty-beats-held-data reconciliation rule).
144+
Called when a replica discovers it is missing data for certain LSNs after a `SyncRSCommitLSN` entry.
145+
**Four-way per slot:** `is_empty=false, all_zeros=false` carries `data`; `all_zeros=true` is a zero write
146+
(no data, applied as a range unmap); `is_empty=true` means the peer **positively** marked that slot
147+
`Empty` (leader-only verdict); a requested `lsn` **omitted** means *not-present-here*. A peer never
148+
returns `is_empty=true` for a slot it merely lacks.
154149

155150
HomeBlocks handler: `CraftReplDev::fetch_data()`
156151
Dispatched by: `CraftConnector` (inter-node channel, non-RAFT)
@@ -159,42 +154,40 @@ Dispatched by: `CraftConnector` (inter-node channel, non-RAFT)
159154

160155
## Server → Server (RAFT)
161156

162-
### 8. SyncRSCommitLSN (RAFT proposal, from leader)
157+
### 7. SyncRSCommitLSN (RAFT proposal, from leader)
163158

164159
```
165160
RAFT entry payload: { rs_commit_lsn: int64, client_token: uint64, empty_slots: [int64] }
166161
```
167162

168-
Proposed by the leader via `CraftReplDev::append()`. **Before proposing**, the leader
169-
resolves every unresolved slot ≤ `rs_commit_lsn`: fetch it from any holder, or record an
170-
`Empty` verdict on quorum-lacks evidence; it never proposes past an unresolved slot. On
171-
RAFT commit each replica applies the entry: verify the token against the current session,
172-
mark `empty_slots` as permanent no-op holes (discarding any local data in them), fetch the
173-
remaining missing slots from peers (the leader is guaranteed to hold every non-Empty slot ≤
174-
the watermark), then advance `commit_lsn`. Replicas never declare `Empty` unilaterally.
175-
This is the primary recovery mechanism — it carries no write data, only the watermark and
176-
verdicts.
163+
Proposed by the leader via `CraftReplDev::append()`. **Before proposing**, the leader resolves every
164+
unresolved slot ≤ `rs_commit_lsn`: fetch from any holder, or record an `Empty` verdict on
165+
quorum-lacks evidence; it never proposes past an unresolved slot. On RAFT commit each replica: verify
166+
the token, mark `empty_slots` as permanent no-op holes (discarding any local data there), fetch the
167+
remaining missing slots from peers, then advance `commit_lsn`. Replicas never declare `Empty`
168+
unilaterally. This is the primary recovery mechanism — it carries no write data, only the watermark
169+
and verdicts.
177170

178171
---
179172

180-
### 9. InternalLogin (RAFT proposal, from leader during login)
173+
### 8. InternalLogin (RAFT proposal, from leader during login)
181174

182175
```
183176
RAFT entry payload: { client_token: uint64, term: uint64 }
184177
```
185178

186-
Proposed by the leader after `SyncRSCommitLSN` commits. On apply each replica stores
187-
`client_token` and `term`, rejecting any subsequent IO from a different token. Proposed
188-
immediately after the `SyncRSCommitLSN` entry during the login sequence.
179+
Proposed after `SyncRSCommitLSN` commits. On apply each replica stores `client_token` and `term`,
180+
rejecting any subsequent IO from a different term. Proposed immediately after the `SyncRSCommitLSN`
181+
entry during the login sequence.
189182

190183
---
191184

192185
## RPC Transport
193186

194187
The transport layer for NubloxProto RPCs is decided by the **CRAFT-1 spike** (SDSTOR-22297
195-
dependency). `CraftConnector` is transport-agnostic: it will dispatch via whatever channel
196-
CRAFT-1 selects (likely gRPC or a custom framing over TCP). Server-to-server RPCs (6 and 7)
197-
use the same transport.
188+
dependency). `CraftConnector` is transport-agnostic: it will dispatch via whatever channel CRAFT-1
189+
selects (likely gRPC or a custom framing over TCP). Server-to-server RPCs use the same transport.
198190

199-
During development, before CRAFT-1 lands, `CraftConnector` can use direct C++ function
200-
calls or a stub transport for unit/integration testing.
191+
During development, before CRAFT-1 lands, the calls are exercised in-process via direct function calls:
192+
the [in-memory reference model](../../src/lib/craft/memory/) implements the same per-replica surface
193+
(`craft_replica`) behind a `volume_handle`, with `MemTransport` standing in for the network.

0 commit comments

Comments
 (0)