Repository navigation
Cache Put Group Commit Plan
Status: implemented. The design below shipped as written: the sequencer
was lifted into felix-storage (commit_order.rs, shared with the broker's
publish path), put_checked/delete_checked follow the stage → commit →
turn-wait → apply flow, compaction is gated on the apply step with nothing
staged behind it, and the tests in crates/felix-storage/src/log_cache/tests.rs
cover the throughput regression (flush-count, revert-verified), ordering,
durability-before-visibility, cancellation, and compaction under load. The
crash-mid-window test is deferred to the felix-log-tool kill harness — the
on-disk format and recovery path are untouched by this change.
Concurrent cache writes do not scale. On the Azure NVMe run, 8 concurrent writers to one cache shard got the same ~230 puts/s as a single writer, just with ~8× the per-put latency (~4 ms → ~33 ms p50 at concurrency 8). The durable stream append path, by contrast, amortises fsync under concurrency to >1 GB/s. Same disk, same fsync — the difference is entirely in how the two paths commit.
This is the OnCommit cache-write counter-example already flagged in
docs-site/.../real-network-performance.md ("the durable cache write path
serialises on it"). It is a throughput bug, not a durability bug: the writes are
correct, they just cannot batch.
crates/felix-storage/src/log_cache/mod.rs:
- Each
CacheShardhas a singlestate: Mutex<ShardState>({ log, index }). -
put_checked/delete_checkedtake that mutex and, while holding it, callwrite(), which calls the atomicDiskLog::append()— append and fsync in one call. - So every write to a shard is fully serial: each holds the lock across a whole
device flush. Throughput is
1 / fsync_time(~230/s on a 3.6 ms Premium SSD), regardless of how many writers are waiting. The lock is also held acrossensure_index, the observernotify, and compaction.
Because the fsyncs happen one-at-a-time under the lock, the DiskLog's group
commit (disk_log/sync.rs — "one fsync serves many waiters") never has more than
one append in flight to batch.
crates/felix-broker/src/broker.rs (publish path) splits the write in two and
lets many run concurrently:
-
durable.begin_append(payloads)→PendingAppend— claims disk offsets under the short segment lock, no fsync. -
commit_sequencer.reserve(first, last+1)→ aturn— claims the commit order immediately, before the durability wait. -
durable.commit(&pending)— waits for the fsync, which is group-committed across every concurrentcommitin the window (DiskLog::append_pending+commit,disk_log/mod.rs). -
turn.wait()— blocks until every lower offset has been applied. - Apply to the in-memory replay ring + fanout, in disk-offset order.
The CommitSequencer (crates/felix-broker/src/commit_order.rs) is the crux:
commits complete out of order, but step 4/5 re-serialise the observable effects
into disk order, so "what the log says" and "what readers/subscribers see" never
disagree — even though the expensive fsyncs ran in parallel.
The cache has the same two halves (durable append, then index + observer update) but runs them under one lock with no sequencer, so it cannot let the fsyncs overlap.
The reason this is not "move the fsync outside the lock" is that today the lock gives the cache several guarantees for free. All must survive:
-
Durability before visibility. A value becomes visible in the index (to
get) and to the watch observer only after it is durable. Committing outside the lock naively would expose a value before its fsync — a reader could see a put that a crash then loses. The stream path avoids this by deferring the in-memory apply until aftercommit+turn.wait(); the cache must do the same. -
Watch order = shard write order.
cache_watchrelies on the observer firing in the shard's write order (the comment input_checked: "Observed while the state lock is still held … that hold is what makes the order watchers see the shard's order"). Watchers deliver offsets; a watcher that saw B before A on a shard whose log reads A,B would look like a lost write. Ordering must be re-established by disk offset (the sequencer), not by lock hold. - Index consistency. For the same key, a later offset must win. Index updates applied out of order would leave a stale value pointing at the wrong offset.
-
Recovery is unchanged. The index is derived from the log and rebuilt on
open; the fix must not change on-disk format or the recovery contract
(
docs/durable-storage.md,docs/storage-format.md). -
Compaction correctness. Compaction currently runs under the same lock
(
should_compact→compact). It rewrites the shard directory and swaps the log; it must be serialised against in-flight appends (it cannot run while anappend_pendingfor the old log is uncommitted). -
StorageApi::putack semantics.putreturns()and logs on failure ("the caller has been told the write succeeded"). The fix must keep the point at which the caller is considered acked = after the value is durable (i.e.,put_checkedstill returns only post-commit).
Give each cache shard the stream path's shape: a per-shard commit sequencer,
append_pending + commit, and an ordered in-memory apply.
Restructure put_checked (and delete_checked) to:
-
Short lock on
ShardState(call it the append lock):-
ensure_index. -
append_pending(op.encode())→PendingAppend(claim offset, no fsync). -
let turn = shard.commit_sequencer.reserve(first, last+1). - Record what the index/observer update will be (key, offset, bytes, value, expiry) — a small owned struct — but do not apply it yet.
- Release the append lock.
-
-
Outside the lock:
commit(&pending)— group-committed fsync, overlaps with every other concurrent writer's commit. -
turn.wait()— block until all lower offsets on this shard have applied. -
Apply lock (may be the same mutex, re-acquired): apply the index mutation
for this offset, then
notifythe observer. Becauseturn.wait()guarantees this runs in offset order, index and watch order match disk order. Release. - Return
Ok(())(post-commit ⇒ the caller's ack is still durability-gated).
New state per CacheShard:
-
commit_sequencer: CommitSequencer— reusefelix-broker's if it can be moved to a shared crate, or lift the sequencer intofelix-storage(preferred: it is storage-ordering, not broker logic). Decide during implementation; a small, self-contained copy infelix-storageis acceptable if a shared crate is churn.
Compaction: gate it on the sequencer being idle (no reserved-but-unapplied
turns), and take the append lock for the swap. Simplest correct version: run
compaction only from the ordered-apply step (step 4) when should_compact, where
we already hold the apply lock and know all lower offsets are applied. This keeps
compaction exactly as serialised as today relative to applied state, while
appends for higher offsets that have not yet applied are naturally behind it in
the sequencer.
Failure/cancel handling: mirror the stream path — the turn must release its
range on every exit path (?, panic, drop mid-await), or a failed commit strands
the shard (later writers wait on a turn that never arrives). The broker solved
this by holding the turn in a guard whose drop releases it; reuse that shape.
- Lift or copy a minimal
CommitSequencerintofelix-storage(per-shard instance onCacheShard). Verify the drop-releases-range guard is preserved. - Add
append_pendingusage toCacheShard::writesplit: astage()that doesappend_pending+ returns the pending + the pending index mutation, and anapply()that mutates the index. Keepwrite()for the compaction path (which is single-writer and does not need the split). - Rewrite
put_checked/delete_checkedto the 5-step flow above. - Move the observer
notifyinto the orderedapply()step. - Re-home compaction into the ordered-apply step (or gate it on sequencer idle).
- Audit
ensure_index,covered_through, andshould_compactfor assumptions that the whole write ran under one lock hold.
-
Throughput (the bug): a concurrency harness — N writers to one shard under
FsyncMode::OnCommit— asserts aggregate puts/s scales with N (not flat). Revert the split to confirm it fails (flat ~1/fsync), the way a regression test should. The felix-loadgencachescenario already exercises this end to end. - Ordering: concurrent writers to the same key; assert the final value and the observer's last-seen offset are the highest offset. Concurrent writers to different keys; assert the observer sees strictly increasing offsets.
-
Durability before visibility: with a fault-injecting fsync (fail the flush),
assert the value is not visible to
getand not delivered to a watcher — i.e., visibility is still gated on a successful commit. - Crash recovery: unchanged on-disk format; existing recovery tests must pass, plus one that kills mid-window (some committed, some not) and checks the rebuilt index matches the committed prefix.
- Compaction under load: writes concurrent with a compaction trigger; assert no lost or duplicated keys and offsets stay monotone.
-
Cancellation: drop a
put_checkedfuture mid-commit; assert the next writer'sturnstill fires (no strand).
- Risk: subtle ordering/visibility regression in durability-critical code. Mitigated by reusing the proven stream-path sequencer rather than inventing a new scheme, and by the fault-injection + ordering tests above.
- Alternative considered — coarse batching timer (accumulate puts for a few ms, one fsync): simpler, but adds latency to the uncontended case and still needs ordering care; the sequencer approach adds no latency when there is no contention and matches the path the codebase already trusts. Rejected.
- Alternative — per-key locks instead of one shard lock: does not help; the bottleneck is the fsync, not key contention, and different keys already share the one log and its one fsync.
Concurrent cache-put throughput rises from the flat ~230/s (1/fsync) toward the group-commit ceiling the stream path already reaches — one fsync serving many writers — while durability, watch order, and index consistency are unchanged. Single-writer latency is unaffected (no batching delay when uncontended).
Generated from docs/cache-put-group-commit-plan.md in the repository. That file is the source of truth; this page may be behind it.
Architecture & Design
- Broker-internal forwarding protocol
- Felix Architecture
- Felix Wire Protocol (v1)
- Felix semantics
- Felix – Comprehensive Design Document
Storage & Replication
- Durable Storage
- Durable Storage Performance
- Felix Durable Segment Format (v2)
- One core log, many semantics
- Plan: group-commit the cache write path
- Shard replication and leader failover
- The cache is a projection over the log
Control Plane & Cluster
- A formal model of one shard
- Control Plane + RAFT Plan (Draft)
- Metadata Raft
- Subscribe routing: redirect, not proxy
- The Postgres the control plane requires
- The local cluster harness
Clients & Configuration
- Auth Design: OIDC Exchange + Felix Tokens
- Broker Config Examples
- Client Config
- Running a client against a cluster
Security
- Bootstrap security
- Panic and abort audit — broker data path
- RBAC Model (Control Plane)
- Soak and resource-leak report — broker
- Threat model: the broker-internal surface
Performance Investigations
- Real-network performance suite
- Sharding investigation: keyed publishes, io_uring, and a ceiling that would not move
- The 0.4.0 performance matrix
- Throughput investigation: why Felix is slower than raw QUIC
Explorations & History