A generic, durable, FIFO disk-backed queue for Go — a persistent work queue
that doubles as a write-ahead log — backed by its own small file store using
plain pread/pwrite/fsync (its only dependency is cespare/xxhash/v2 for
per-record checksums).
Items are appended at the back with Add and consumed from the front through a
Reader: a consumer either takes an item (read + commit in one step) or
reserves an item together with its offset and later commits that offset.
Committing advances a persisted read cursor; whole data files are reclaimed once
every record in them has been committed.
The package is designed for high throughput and minimal per-operation heap allocation:
- Serialization reuses an internal scratch buffer, so
Addperforms no heap allocation once warm (given aMarshalFuncthat appends into the supplied buffer). - A
Readercopies each record once into its own reusable scratch buffer before handing it toUnmarshalFunc— no per-read allocation once warm, and the value never aliases the store's reused read buffer.
BenchmarkAddTake-16 4.3 µs/op 0 B/op 0 allocs/op (NoSync; add + take + commit)
The log lives in a directory of numbered files data.00000001, data.00000002,
… each SegmentSize bytes, at most MaxSegments of them at once. Segments are
preallocated — fallocate(2) on Linux, ftruncate elsewhere — so a full
filesystem is reported when a segment is created, while the store is still clean,
instead of arriving as an ENOSPC in the middle of a record; the footprint is
what MaxSegments × SegmentSize says it is, plus any oversized records' segments
(Stats().DiskBytes reports the real number). That guarantee needs real
fallocate: on non-Linux platforms, and on Linux over NFS, 9p, some FUSE and
overlay mounts, it falls back to ftruncate, which reserves nothing — there
ENOSPC still arrives during an Add (which then leaves the queue unchanged),
and Stats().DiskBytes reports apparent size over a sparse file. The directory itself is held open
for the lifetime of the queue and carries an advisory flock, so a second
New on the same directory fails with ErrLocked rather than silently
interleaving writes into the same segments — on platforms whose standard library
has flock; on Windows, Solaris, AIX, plan9 and js the lock is a no-op and
nothing prevents two processes from opening the same directory. Each file
begins with a 64-byte header — a magic number, a format version, the commit
cursor (persisted read position), write cursor (data end), written and committed
record counts, and an xxhash64 over the header itself — followed by records,
each uvarint(len) || payload || xxhash64(payload) (an 8-byte little-endian
checksum trailer). Because the cursors and counts all live in the header,
reopening reads no records at all; the header checksum is verified on open (a
segment that fails it is dropped and counted — see Recovery), and each
record's checksum is verified on read. Records are written with pwrite and read back with
pread into reused buffers; each file's handle is opened on demand and the
least-recently-used handles are closed once MaxOpenFiles are open, so a deep
backlog does not hold an open descriptor per retained file. A file is dropped once
all its records are committed: on the next write that cycles to a new file, and
also at the end of a commit — so a consume-only or producer-stopped workload
frees disk without waiting for another write.
go get github.com/JohanLindvall/diskqueueRequires Go 1.23+ (uses iter.Seq).
package main
import (
"context"
"encoding/binary"
"errors"
"fmt"
"github.com/JohanLindvall/diskqueue"
)
// A zero-allocation codec: marshal appends into the provided buffer, unmarshal
// reads directly from the (zero-copy) slice.
func marshal(dst []byte, v uint64) ([]byte, error) {
return binary.LittleEndian.AppendUint64(dst, v), nil
}
func unmarshal(data []byte) (uint64, error) {
if len(data) != 8 {
return 0, errors.New("bad length")
}
return binary.LittleEndian.Uint64(data), nil
}
func main() {
// Keep at most 8 segment files on disk (default is 32).
w, err := diskqueue.New[uint64]("/tmp/myqueue", marshal, unmarshal, diskqueue.Options{MaxSegments: 8})
if err != nil {
panic(err)
}
defer w.Close()
// Producer.
for i := uint64(0); i < 10; i++ {
if err := w.Add(i); err != nil {
panic(err)
}
}
// Consumer: reads go through a Reader (one per consuming goroutine).
r := w.NewReader()
for {
v, ok, err := r.TryTake() // read + commit in one step
if err != nil {
panic(err)
}
if !ok {
break
}
fmt.Println(v)
}
// Or block until an item arrives.
ctx := context.Background()
v, ok, err := r.Take(ctx)
_ = v
_ = ok
_ = err
}When you need to process an item before acknowledging it, use Reserve/Commit
instead of Take:
r := w.NewReader()
v, ok, offset, err := r.Reserve(ctx)
if ok {
if err := process(v); err == nil {
r.Commit(offset) // acknowledge; everything up to offset is reclaimed
}
// If you don't commit, the item replays after a restart.
}Both iterators consume: each item is committed as it is read (before your
loop body runs), exactly like Take, drawing from the same cursor as
Reserve/Take, so an item is never delivered twice.
r := w.NewReader()
// Drain: drains the items present right now, oldest to newest, then ends.
for v := range r.Drain(ctx) {
process(v) // already committed before this runs
}
// Follow: drains existing items, then blocks and yields new ones as they
// arrive, committing each as it is read, until ctx is cancelled.
for v := range r.Follow(ctx) {
process(v)
}Because the commit happens at read time, an item that fails (or a loop that
stops early via break or ctx cancellation) is not replayed — Drain/Follow
are at-most-once, like Take. If you need to acknowledge only after successful
processing (at-least-once), use Reserve/Commit.
Queue[T] — produce and manage the log:
| Method | Description |
|---|---|
New[T](path, marshal, unmarshal, ...Options) |
Open/create a Queue at path (segment cap via Options.MaxSegments, default 32). |
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. |
NewReader() *Reader[T] |
Create a Reader to consume items (one per consuming goroutine). |
Empty() bool |
Whether anything is available to read. |
Count() int |
Number of items added but not yet committed. |
Size() int64 |
Bytes of uncommitted records (roughly what's retained on disk). |
Sync() error |
fsync the files to stable storage. |
Stats() Stats |
Gauges and lifetime counters, including every loss path. UnsyncedBytes is what a power loss would cost right now; BacklogBytes/MaxBytes is the utilisation to alert on. See Recovery. |
Rewind() (int64, error) |
Return every delivered-but-uncommitted record to the queue. The nack for Reserve. Watch Stats().InFlightBytes to know when it is needed. |
Err() error |
The latched durability failure, or nil. See Failure handling. |
Close() error |
Flush and close (releases the directory lock). |
Reader[T] — consume items (created via NewReader):
| Method | Description |
|---|---|
TryReserve() (T, bool, int64, error) |
Non-blocking read; returns the item and its offset. |
TryTake() (T, bool, error) |
Non-blocking read + commit. |
Reserve(ctx) (T, bool, int64, error) |
Block until an item is available, then read it. |
Take(ctx) (T, bool, error) |
Block until an item is available, then read + commit. |
Commit(offset int64) error |
Mark the entry at offset and all before it consumed; reclaim space. |
Skip() (bool, error) |
Consume and commit the head record without decoding it — discards it. |
Requeue() (bool, error) |
Move the head record to the back without decoding it. The non-destructive way past a poison record: it costs a reordering instead of data loss or a stalled head. |
Drain(ctx) iter.Seq[T] |
Drain items present at call time (commits each). |
Follow(ctx) iter.Seq[T] |
Drain existing then future items until ctx is cancelled (commits each). |
Err() error |
Why the last Drain/Follow stopped; nil if it simply ran out. |
- Offsets.
Reserve/TryReservereturn a monotonically increasing offset. Pass it toCommitto acknowledge that record and everything before it.Takecommits implicitly. Options.MaxSegmentsbounds the number of segment files kept on disk at once, so the footprint is aboutMaxSegments × SegmentSize. When the active segment fills and that many segments are already live,AddreturnsErrFulluntil a whole segment is committed and reclaimed. A record too large for the geometry is not refused — it gets a segment sized to itself;ErrRecordTooLargeis reserved for a record that exceedsOptions.MaxBytes, which no draining can ever admit. The default (a zero value) is 32; a negative value means unbounded.- At-least-once. The read cursor is reset to the persisted commit cursor on
open, so after a restart any items added but not committed are replayed. Use
Reserve/Commitfor explicit acknowledgement. - Durability. Writes go into the page cache via
pwriteand survive a process crash. By default each write and commit isfsync'd for power-loss durability; setNoSyncto skip the per-operation fsync, orSyncEvery: Nto batch one fsync over N operations (optionally with aSyncIntervalwall-clock backstop).Syncflushes on demand andClosealways flushes — including underNoSync, which means "no fsync per operation", not "never". Segment creation and removal are additionally made durable with a directoryfsync; that is a POSIX notion, so it is skipped on Windows, plan9 and js/wasm, and on Unix filesystems that do not implement it — a power loss there can lose a freshly cycled segment's directory entry. - Integrity. Every record and every file header stores an
xxhash64, verified on read and on open. A segment that has lost bytes is corruption too, not a geometry mismatch: since segments are preallocated to a known length, a file of the wrong size is read asErrSegmentSizeMismatchonly when its own header agrees it is complete. See Recovery for what a failed check costs. - Reclamation. Disk is freed a whole file at a time, once every record in it
is committed. It happens on the write that cycles to a new file and at the end
of a commit, so a consumer draining a backlog with the producer stopped still
frees disk — you do not need another
Addto get the space back. A file that will not unlink stays counted (Stats().Unreclaimed), soMaxSegmentskeeps describing what is really on disk, and the next reclamation retries it.
Recovery is not an option to switch on — it is the behaviour. Two rules run through all of it:
Corruption degrades to reported loss, never to corrupt output and never to a
wedged queue. Damaged data is dropped and counted, not handed onward as
plausible-looking records, and there is no failure mode where the consumer waits
forever on bytes that will never parse. A queue that answers ErrCorrupt to
every read until someone edits the directory by hand is not more careful than one
that drops the record — it is just unavailable as well as damaged.
Every loss path is observable. Each event surfaces as exactly one
ErrCorrupt from a read, and Stats() carries the magnitude the error cannot:
LostRecords, LostSegments, LostBytes (a lower bound), DiscardedBytes and
ForeignSegments. increase(LostBytes) > 0 means corruption destroyed data.
| Damage | Cost |
|---|---|
Crash mid-Add (torn record at the tail) |
nothing: the header never published it, and the Add never returned |
Crash between Add and the header fsync |
the record is invisible on reopen — a clean truncation, not an error |
| One flipped byte in a record payload | one record: its length still framed it, so exactly that record is dropped |
| One flipped byte in a record length | up to one segment: the frame boundaries behind it are gone with it |
| Damaged segment header | that segment is dropped at open — wherever it sits — and counted; the rest stay readable |
| Segment truncated by the filesystem | the records still present survive; the cut tail is counted in DiscardedBytes |
| Segment file deleted underneath a running queue | the segment is abandoned, one ErrCorrupt, the queue keeps making progress |
| Zero-length segment (a create interrupted before its header) | removed silently — it never held a record, so it is not a loss event |
| Unknown format version | segment dropped silently, counted in ForeignSegments — a format change is not data loss |
Segment that cannot be read (EACCES, EMFILE, EIO) |
not damage: the open fails and nothing is deleted, because "I could not look" is not evidence |
"One bad byte costs one record" is true of payloads, not of length prefixes. A
damaged length is always detected — the checksum it points at no longer
matches — but it cannot be repaired: damaged upward it overruns the segment,
damaged downward it desynchronizes the walk into the middle of a payload, and
either way the record boundaries from that point on are gone. The rest of that
segment is abandoned, so one bad byte there can cost up to SegmentSize.
Reading is where losses are reported, so a consumer that never reads never learns
of them; Stats() is readable at any time, including after
Close.
for {
v, ok, err := r.TryTake()
switch {
case errors.Is(err, ErrCorrupt):
// Damage was dropped and the queue advanced. Count it and go round again.
log.Warn("diskqueue read", "err", err, "lost", w.Stats().LostBytes)
case err != nil:
return err
case !ok:
return nil
default:
process(v)
}
}Drain and Follow cannot carry an error per item, so they do not stop on
corruption — the damage is already dropped and the queue has moved on, and
stopping would strand every record behind it. They keep iterating and record the
first event in Reader.Err().
An I/O error never panics and never wedges the queue — it comes back as an error, and the queue is left in a state you can reason about.
- A write that fails did not happen.
AddreturningErrFull,ErrRecordTooLargeor a failedpwriteleaves the queue byte-for-byte as it was: the item is not in it, and retrying is well defined. A segment that cannot be created is unlinked again, so a failedAddleaves no stub behind. - A failed
fsyncpoisons the queue. Linux reports a writeback error once and then discards the dirty pages, so a secondfsynccan return success over data that is already gone. Rather than claim a durability it cannot deliver, the queue latches the first such failure:Add,CommitandSyncall return an error wrappingErrIOfrom then on (the original errno is wrapped too, soerrors.Is(err, syscall.ENOSPC)still works), andQueue.Err()reports it without performing an operation. Reads keep working, so a consumer can drain what is there; to write again,Closeand reopen — whatever was durable is still on disk and uncommitted records replay. - Commits report failure.
Take/TryTakereturn the item and a non-nil error when the read succeeded but its commit did not reach disk: the item is yours to process, and it will be delivered again after a reopen.Commitreturns the same error directly. - Iterators report through
Err(). Aniter.Seqcannot carry an error, soDrain/Followstop and record it: checkr.Err()after the loop. Nil means the iteration ended because the queue was drained, the context was cancelled, or the queue was closed. - A record that could not be delivered stays put. If
UnmarshalFuncreturns an error the read cursor is rewound, exactly as for a corrupt record, so a transient decode failure cannot silently consume an item. A record the codec will never accept is therefore offered again on every read — callSkipto step over it deliberately. Commitis bounded by the read cursor. An offset past what has been read returnsErrInvalidOffsetinstead of reclaiming (and deleting) records nobody has seen.- Recovery never destroys what it could not read. A segment is dropped only
when it is readable and damaged. One that merely could not be opened —
EACCES,EMFILE, a device error — fails the open instead, so a transient permission blip cannot unlink good data. A segment that has disappeared is corruption, and is abandoned like any other. - One writer per directory.
Newtakes an advisoryflockon the queue directory (Unix; a no-op where the standard library has noflock). A second open of the same directory fails withErrLocked. - Value lifetime. A
Readercopies each record into its own reusable scratch buffer before callingUnmarshalFunc, so the value (and anything inTthat aliases it) never aliases the store's reused read buffer. It is valid until the next read on that sameReader; copy it insideUnmarshalFuncif you need it to outlive that. The copy reuses the buffer, so it allocates nothing once warm. - Concurrency. A
Queueis safe for concurrent use. A singleReaderis not — use oneReaderper consuming goroutine. Multiple Readers share the one read/commit cursor and cooperate to consume the stream (each item delivered once).Take/TryTakeand theDrain/Followiterators read and commit atomically under the lock, in cursor order, so they are safe for concurrent cooperating readers.Reserve/Commitis the only deferred path: it advances the shared prefix cursor after an unlocked processing window, so its commits must be issued in offset order (use a single consumer, or coordinate the commits) — otherwise one consumer committing out of order reclaims another's in-flight record. The blockingReserve/Takeand theFollowiterator wait for new data and honour their context;Drain/Followrelease the lock between yields, so other methods may be called from inside the iteration.
diskqueue.New[T](path, marshal, unmarshal, diskqueue.Options{
MaxSegments: 0, // 0 = 32 default; N>0 = cap live files (ErrFull); <0 = unbounded
MaxBytes: 0, // 0 = no byte cap; N>0 = cap the uncommitted backlog in bytes (ErrFull)
NoSync: true, // skip fsync per write/commit (faster, no power-loss durability)
SyncEvery: 0, // 0/1 = fsync every op; N>1 = batch the fsync over N ops
SyncInterval: 0, // >0 = background flush every interval (backstop for SyncEvery)
SegmentSize: 0, // 0 = 8 MiB default; floored and rounded up to 4 KiB
MaxOpenFiles: 0, // 0 = keep every touched segment open; N = cap open files (LRU), min 3
})The two caps compose and answer different questions. MaxSegments bounds the
file count, so the footprint that follows from it is MaxSegments × SegmentSize — a ceiling on disk rather than a budget on the backlog, and a queue
holding one record per segment can be nowhere near its byte cap while at its
segment cap. MaxBytes bounds the uncommitted backlog in bytes, which is what
an outage budget is actually sized in; Stats().BacklogBytes / Stats().MaxBytes is
the utilisation ratio to alert on. Whichever binds first returns ErrFull, and the
queue is left untouched, so the caller chooses: block, drop, or shed load upstream.
A record larger than MaxBytes can never be accepted on an empty queue either, so
it is refused with ErrRecordTooLarge — permanent, where ErrFull is transient
and clears as the consumer drains.
SyncEvery trades a bounded power-loss window for throughput: with SyncEvery: N
the fsync cost is amortized over N writes/commits, so up to the last N unsynced
operations can be lost on power loss (they still survive a process crash, and a
torn tail is caught by the per-record checksum). Close and Sync always flush.
Stats().UnsyncedBytes reports that window as a number — zero under the default
per-op policy, climbing under NoSync or SyncEvery > 1 until a flush. If it
keeps climbing, the SyncInterval backstop is not keeping up.
The speed-up is large — on a laptop NVMe, durable Add goes from ~1.6 ms/op at
SyncEvery: 1 to ~9 µs at SyncEvery: 100 and ~1.1 µs at SyncEvery: 1000.
SegmentSize is fixed when the store is created, floored at 4 KiB and rounded up
to a multiple of 4 KiB (a fixed constant, not the host's page size — otherwise a
store created on a 4 KiB-page machine would not reopen on a 64 KiB-page one).
Reopening an existing store with a different (post-rounding) SegmentSize is
rejected with ErrSegmentSizeMismatch rather than truncating the files and
discarding committed records.
It is not, however, a ceiling on record size. Records never span segments — that is
the invariant — but a record too large for the configured geometry gets a segment
sized to exactly itself, marked as such in that segment's header so a reopen can
tell it apart from a store built at a different SegmentSize. Such a segment holds
that one record and nothing else, and Stats().DiskBytes reports the real
footprint rather than segments × SegmentSize.
MIT