Skip to content

Commit 1d56fcd

Browse files
JohanLindvallclaude
andcommitted
Share the fsyncs: group commit, AddBatch, AddWait, TryPeek
Per-op durability used to serialize every producer on the disk: two fsyncs per record, under the queue mutex, with readers stalled behind them. The whole change hangs on one new invariant — a STAGED record's bytes are written past the published extent while the header, the cursors and the counts do not move — so a flush leader can release the mutex during its fsyncs and nothing (readers, commits, Stats, a crash) can see a record whose bytes are not yet durable. Publication is a span: data fsync for everyone staged, one header write, one header fsync. Every staged record lives in the active file (cycling, batch, Requeue, Sync and Close quiesce first), so a failed publication discards exactly one contiguous tail. Group commit: an Add that arrives while a flush is in flight stages its record and rides the next span; a solo Add leads a span of one and allocates nothing, so the single-producer path stays 0 allocs/op and within noise of its old cost. Concurrent durable Adds go ~7x faster on this machine, each still individually durable when it returns. The span's failure arms mirror the solo append's exactly — data-fsync latches with nothing published, header-write discards with nothing latched, header-fsync latches with the span real in the page cache — and faults_test.go pins ordering and arms for all three shapes (solo, group, batch) under the same injection points. AddBatch(items) amortizes the same span across a batch from one goroutine (~50x per record here) and reports how many leading items were placed and durable when it stops early. AddWait(ctx, v) is the blocking half of MaxBytes backpressure: it parks on a space signal that every commit-capable consume op raises, and still fails fast on ErrRecordTooLarge. TryPeek inspects the head without consuming — no cursor moves, and a damaged head previews as an UNCOUNTED ErrCorrupt so the consume that follows books the event exactly once. Add and AddWait now marshal BEFORE taking the lock, into pooled buffers, so codecs run concurrently across producers and a slow codec no longer stalls consumers; AddBatch marshals under the lock. The failure arms unreachable without a seam are covered in the faults build (unpublishSpan, discardStaged, rollbackAppend at 100% there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 56024e6 commit 1d56fcd

9 files changed

Lines changed: 1634 additions & 42 deletions

File tree

‎CLAUDE.md‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ store using plain `pread`/`pwrite`/`fsync` (no mmap).
2424
deliberately *not* split further: pulling `writeHeader`/`flushFile` away from `append` and
2525
`commitTo` would separate the calls whose order is the whole point (see the
2626
data-before-header invariant below).
27-
- [diskqueue.go](diskqueue.go) — the generic `Queue[T]` writer/owner (Add, Empty/Count/Size,
28-
Stats/Rewind/Sync/Err/Close, NewReader) on top of `store`.
29-
- [reader.go](reader.go) — `Reader[T]`: all consume ops (Reserve/Take/Commit/
27+
- [diskqueue.go](diskqueue.go) — the generic `Queue[T]` writer/owner (Add/AddWait/AddBatch,
28+
Empty/Count/Size, Stats/Rewind/Sync/Err/Close, NewReader) on top of `store`,
29+
including the group-commit leader/follower machinery (`addDurableLocked`,
30+
`leadFlushLocked`, the quiesce/space signals).
31+
- [reader.go](reader.go) — `Reader[T]`: all consume ops (TryPeek/Reserve/Take/Commit/
3032
Drain/Follow/Err); copies each record into its own scratch buffer.
3133
- [prealloc_linux.go](prealloc_linux.go) / [prealloc_other.go](prealloc_other.go) — `preallocate`:
3234
`syscall.Fallocate` (stdlib, Linux) with an `ftruncate` fallback everywhere else and on
@@ -350,8 +352,57 @@ mirrors its own `written`/`committed` counts into its header.
350352
reclaims. The arm is unreachable through the public API (both callers pass a
351353
cursor `commitTo` keeps inside a live segment) and cannot be deleted either —
352354
dropping the guard nil-derefs on the next line.
355+
- **Staged records are invisible until their span publishes.** The per-op
356+
append path (Queue-level: `addDurableLocked`/`leadFlushLocked`/
357+
`publishBatchLocked` over the store's `stagePending`/`takeSpan`/
358+
`publishSpan`) writes record bytes past the published extent and tracks them
359+
only in `pendingBytes`/`inFlight`: `df.hdr`, `af.size`, `writeOff` and
360+
`nWritten` move at publication, never at staging. That single rule is what
361+
lets the flush leader release the queue mutex during its fsyncs — readers,
362+
`commitTo`, `Stats` and a crash all see the published extent only, so
363+
nothing can deliver, commit or recover a record whose bytes are not yet
364+
durable. A header write racing the data fsync is the torn state the solo
365+
append's ordering exists to prevent; staging keeps the header out of reach
366+
by construction.
367+
- **Every staged record lives in the active file** — cycling, `AddBatch`,
368+
`Requeue`, `Sync` and `Close` quiesce first (`waitFlushQuiescedLocked`) — so
369+
a failed publication discards exactly one contiguous tail
370+
(`discardStaged`/`unpublishSpan`) and the in-memory view snaps back to the
371+
header that never changed. The span failure arms mirror the solo append's:
372+
data-fsync failure latches with nothing published, header-write failure
373+
discards with nothing latched, header-fsync failure latches with the span
374+
real in the page cache. `faults_test.go` pins the ordering for all three
375+
shapes (solo, group, batch) under the same injection-point names.
376+
- **Group commit shares the fsyncs, not the contract.** A solo Add leads a span
377+
of one and allocates nothing (the `flushGroup` is created only by a follower
378+
that arrives mid-flush), which is what keeps `BenchmarkAddTake` at 0
379+
allocs/op. Followers wait on their group's `done` channel and take the
380+
span's verdict; the leader drains spans until nothing is pending, then
381+
clears `flushing` and wakes the quiesce waiters. After ANY wait that
382+
released the lock (quiesce, space, follower), re-check `closed` — staging
383+
onto a closed store would reopen handles Close just released. `Requeue`
384+
quiesces BEFORE its `takeHead`, because its rotation must be atomic under
385+
one continuous lock hold (its `rewindHead` on failure may not undo another
386+
reader's progress). The leader deliberately leaves `af.dirty` set after its
387+
header fsync — a commit may have rewritten the header while the lock was
388+
away, and clearing it would skip that write's flush.
389+
- **Marshal runs outside the lock for Add/AddWait** (pooled `marshalBuf`, so
390+
codecs run concurrently across producers and a slow codec cannot stall
391+
consumers); `AddBatch` marshals under the lock through `w.scratch`. Both
392+
keep the no-callback rule: `MarshalFunc` must never call back into the
393+
queue.
394+
- **`peekHead` previews, it never books.** `Reader.TryPeek` moves no cursor,
395+
pays down no `pendingCorrupt`, and its `ErrCorrupt` is uncounted — the
396+
consume op that steps past the damage books the event exactly once. Don't
397+
add accounting to the peek path; double-counting a loss is how operators
398+
stop trusting `Corruptions`.
353399
- **Blocking waiters.** `waitLocked`/`signal` use a lazily-created `notify`
354-
channel, nil when nobody waits, so `Add` stays allocation-free.
400+
channel, nil when nobody waits, so `Add` stays allocation-free. The same
401+
pattern runs the other two waits: `spaceNotify` (consume ops wake `AddWait`
402+
producers; every commit-capable Reader op signals it, spurious wakes are
403+
re-checked) and `flushDrained` (the flush leader wakes quiesce waiters).
404+
`Close` closes `notify` AND `spaceNotify`, then quiesces the flush before
405+
`st.close()` so a leader mid-fsync finishes on live handles.
355406
- **`Reader.Drain`/`Follow` consume** via the shared `headOff` (like iterator-
356407
shaped `Take`): read **and `commitTo` under the lock**, then release and yield —
357408
so they commit-on-read (at-most-once) and are safe for concurrent cooperating

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,9 @@ processing (at-least-once), use `Reserve`/`Commit`.
179179
| Method | Description |
180180
| --- | --- |
181181
| `New[T](path, marshal, unmarshal, ...Options)` | Open/create a Queue at `path` (segment cap via `Options.MaxSegments`, default 32). |
182-
| `Add(v T) error` | Append an item. Returns `ErrFull` at `MaxSegments` or `MaxBytes` (transient), `ErrRecordTooLarge` if it can never fit under `MaxBytes` (permanent). A record too big for one segment gets a segment of its own. |
182+
| `Add(v T) error` | Append an item. Returns `ErrFull` at `MaxSegments` or `MaxBytes` (transient), `ErrRecordTooLarge` if it can never fit under `MaxBytes` (permanent). A record too big for one segment gets a segment of its own. Serialization runs before the lock, and under the default policy concurrent `Add`s share their fsyncs (group commit) while each record stays individually durable. |
183+
| `AddWait(ctx, v T) error` | `Add` with backpressure: blocks while the queue is full, until a commit frees capacity or `ctx` is done. `ErrRecordTooLarge` still returns immediately. |
184+
| `AddBatch(items []T) (int, error)` | Append items in order with the fsyncs amortized over the whole batch (per segment: one data fsync, one header write, one header fsync). Returns how many leading items were placed — on error the first `n` are in and durable, the rest are not. |
183185
| `NewReader() *Reader[T]` | Create a Reader to consume items (one per consuming goroutine). |
184186
| `Empty() bool` | Whether anything is available to read. |
185187
| `Count() int` | Number of items added but not yet committed. |
@@ -194,6 +196,7 @@ processing (at-least-once), use `Reserve`/`Commit`.
194196

195197
| Method | Description |
196198
| --- | --- |
199+
| `TryPeek() (T, bool, error)` | Look at the front item without consuming it: no cursor moves, and the next read (by any Reader) returns the same item. A damaged head previews as `ErrCorrupt` with nothing dropped or counted. |
197200
| `TryReserve() (T, bool, int64, error)` | Non-blocking read; returns the item and its offset. |
198201
| `TryTake() (T, bool, error)` | Non-blocking read + commit. |
199202
| `Reserve(ctx) (T, bool, int64, error)` | Block until an item is available, then read it. |

‎bench_test.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,58 @@ func BenchmarkBatchCommit(b *testing.B) {
164164
b.StartTimer()
165165
}
166166
}
167+
168+
// BenchmarkAddBatch measures the amortized durable append against its per-Add
169+
// equivalent: one lock round and (per segment) one data fsync + one header
170+
// write + one header fsync for the whole batch. Must stay 0 allocs/op like
171+
// every other hot path.
172+
func BenchmarkAddBatch(b *testing.B) {
173+
const batch = 100
174+
m, u := bytesCodec()
175+
w, err := New[[]byte](b.TempDir(), m, u, Options{MaxSegments: -1}) // per-op durability
176+
if err != nil {
177+
b.Fatal(err)
178+
}
179+
defer func() { _ = w.Close() }()
180+
r := w.NewReader()
181+
items := make([][]byte, batch)
182+
for i := range items {
183+
items[i] = benchPayload
184+
}
185+
b.ReportAllocs()
186+
b.ResetTimer()
187+
for i := 0; i < b.N; i++ {
188+
if n, err := w.AddBatch(items); n != batch || err != nil {
189+
b.Fatalf("AddBatch: n=%d err=%v", n, err)
190+
}
191+
b.StopTimer()
192+
for j := 0; j < batch; j++ { // drain so the backlog stays flat
193+
if _, ok, err := r.TryTake(); !ok || err != nil {
194+
b.Fatalf("drain: ok=%v err=%v", ok, err)
195+
}
196+
}
197+
b.StartTimer()
198+
}
199+
}
200+
201+
// BenchmarkAddParallel is the group-commit case: many producers on the per-op
202+
// policy, each Add individually durable, the fsyncs shared across whoever
203+
// arrived while the previous span was in flight. Compare against
204+
// BenchmarkAddDurable/SyncEvery1 to see what the sharing buys.
205+
func BenchmarkAddParallel(b *testing.B) {
206+
m, u := bytesCodec()
207+
w, err := New[[]byte](b.TempDir(), m, u, Options{MaxSegments: -1}) // per-op durability
208+
if err != nil {
209+
b.Fatal(err)
210+
}
211+
defer func() { _ = w.Close() }()
212+
b.ReportAllocs()
213+
b.ResetTimer()
214+
b.RunParallel(func(pb *testing.PB) {
215+
for pb.Next() {
216+
if err := w.Add(benchPayload); err != nil {
217+
b.Fatal(err)
218+
}
219+
}
220+
})
221+
}

0 commit comments

Comments
 (0)