Documentation
¶
Overview ¶
Package diskqueue implements a generic, durable, FIFO disk-backed queue — a persistent work queue that doubles as a write-ahead log.
Items are appended with Queue.Add and consumed through a Reader. The queue survives process crashes and, configurably, power loss; on reopen it resumes from the last committed record. It is backed by its own file store using plain pread/pwrite/fsync (no mmap), and its only dependency is github.com/cespare/xxhash/v2 for per-record checksums.
Getting started ¶
A queue lives in a directory and is parameterized by the element type plus a codec pair:
q, err := diskqueue.New[uint64](dir, marshal, unmarshal)
if err != nil {
return err
}
if err := q.Add(42); err != nil {
return err
}
r := q.NewReader()
v, ok, err := r.TryTake()
// Close flushes and reports a latched durability failure, so its error is
// worth checking rather than deferring into the void.
if err := q.Close(); err != nil {
return err
}
MarshalFunc appends to a caller-supplied buffer rather than allocating, which is what keeps Queue.Add allocation-free. Where it runs differs by method, and the difference matters: Queue.Add and Queue.AddWait marshal BEFORE taking the queue's lock, so a MarshalFunc must be safe for concurrent use — several producers run it at once — while Queue.AddBatch marshals under the lock, as does every UnmarshalFunc. Neither codec may call back into the queue or its readers; the mutex is not reentrant.
Consuming ¶
There are three consumption patterns, and the choice between them is a delivery guarantee, not a matter of taste.
At-most-once, one call. Reader.Take (blocking), Reader.TryTake, and the Reader.Drain / Reader.Follow iterators read and commit under a single lock. If the process dies after the commit and before the work is done, that item is gone. Use these when the work is cheap to lose or is itself idempotent and externally recorded.
At-least-once, two calls. Reader.Reserve / Reader.TryReserve hand back an item and its offset without committing; Reader.Ack retires that one item once the work is durably done, and Reader.Commit retires it along with everything before it. A crash in between replays the item. This is the right default for a work queue, and it is what makes the package usable as a write-ahead log. Ack is the one to reach for when more than one worker consumes the queue — see Concurrency below.
Iteration. Reader.Drain yields the items present when iteration begins; Reader.Follow continues indefinitely, waiting for new ones until the context is cancelled or the queue is closed. Both are iter.Seq, so an error cannot travel with the values — check Reader.Err after the loop, or an I/O failure is indistinguishable from an empty queue.
A record the consumer cannot process would otherwise block the head forever, so there are two ways past it. Reader.Skip discards it without decoding — the sanctioned route past a record the codec will never accept, since a decode failure deliberately leaves the record in place (see ErrCodec). Reader.Requeue moves it to the back instead, so a single poison record costs a reordering rather than either data loss or a stalled queue.
Reader.TryPeek inspects the front item without consuming it: no cursor moves — unlike Reserve, which advances the shared read cursor — and the next read by any Reader returns the same item. A damaged head previews as ErrCorrupt with nothing dropped and nothing counted; the consume op that eventually steps past the damage books it exactly once.
A consumer that wants to know how long each item waited does not need to wrap every record in a timestamp envelope of its own: Options.StampRecords stamps the enqueue time into each record's payload as it is serialized, and the Reader strips it back off and reports the wait as Reader.LastAge — surviving reopens (the stamp is payload, as durable as the record) and accumulating across a Reader.Requeue rotation. The stamp is inside the framed size every byte accounting reports, and reopening a store with a different StampRecords than it was written with is a caller error, exactly as swapping the codec would be; see the option's documentation for what that mistake produces.
Durability ¶
Every Queue.Add writes the record and then, separately, the header that publishes it — data before header, each with its own fsync. A power loss can therefore truncate the log cleanly but can never expose a record whose payload never landed.
Under the default per-op policy those fsyncs are shared, not repeated: an Add that arrives while another Add's fsync is in flight joins the next flush span, so one data fsync and one header fsync cover every record that joined (group commit). Each Add still returns only once its own record is durable — the sharing changes the cost, not the contract. Queue.AddBatch amortizes the same way across a batch from one goroutine, and returns how many leading items were placed, each durable, when it stops early.
How often that fsync happens is the main throughput knob:
- Options.SyncEvery of 0 or 1 (the default) syncs every write and commit. Safe against power loss, and roughly two orders of magnitude slower than the alternatives.
- Options.SyncEvery greater than 1 syncs once every N operations. Up to N operations are exposed to power loss; a torn tail is caught by the per-record checksum on read.
- Options.SyncInterval adds a wall-clock backstop, so an idle queue's last writes become durable on a timer rather than waiting for N more operations.
- Options.NoSync never syncs. Data still survives a process crash through the page cache, but not a power loss.
Queue.Sync flushes on demand and Queue.Close always flushes. A flush's per-file fdatasyncs run WITHOUT the queue's lock held — pinned against eviction and reclamation instead — so the SyncInterval backstop over a deep unsynced backlog does not stall every concurrent Add and read for the duration of the disk write-back. Records written while a flush is in flight are simply not covered by it: they stay counted in Stats.UnsyncedBytes and are taken by the next one. Concurrent Syncs serialize, and Close waits for an in-flight flush before the file handles go away. The SyncEvery boundary is the exception and stays under the lock on purpose: it is a bound on unsynced operations, enforced by the operation that crosses it paying the flush before returning.
A failed fsync is not retriable and is not treated as one. Linux reports a writeback error exactly once and then drops the dirty pages, so a second fsync can report success over data that is already gone. Rather than claim a durability it does not have, the queue latches the failure: every subsequent Add, commit and Sync returns ErrIO wrapping the original errno. Reads keep working, so a poisoned queue can still be drained. Close and reopen to continue.
Crash recovery and corruption ¶
Two rules govern everything the recovery path does:
Corruption degrades to reported loss — never to corrupt output, and never to a wedged queue. There is no strict mode. A queue that answers ErrCorrupt forever is unavailable as well as damaged, and because a stuck cursor also stops reclamation, the disk fills up behind it.
Every loss path is observable. Each event surfaces as exactly one ErrCorrupt from a read and is counted in Stats.
How much is lost depends on how much framing survives. A record whose checksum fails but whose length still frames it inside its segment costs that record alone. A length that is undecodable or overruns the segment takes the rest of that segment with it, because the record boundaries behind it are gone too. A genuine I/O error is not damage at all: nothing is dropped and the cursor stays put, since the bytes may still be there next time.
Reopening reads no records — one 64-byte header pread per segment, and the header is the single source of truth for the write cursor, the resume point and the record count. Two kinds of segment are excepted, both already known to be damaged, so every healthy open keeps the cost model. A segment whose header proves it lost bytes to truncation gets a bounded frame walk, because its recorded count describes records that no longer exist and believing it would promise a backlog no drain could deliver. And a segment whose header checksum fails while its magic and version are intact — the residue of a header rewrite torn by a power cut — is rebuilt from a checksum-verified walk of its records rather than dropped: the header was the only casualty, the records beneath it vouch for themselves, and the one thing that cannot be reconstructed is the commit position, so that segment and everything after it replays (at-least-once). One corruption event is reported for it.
On reopen the read cursor resets to the persisted commit cursor, so uncommitted items replay: the crash guarantee is at-least-once. Queue.Rewind does the same thing without reopening, returning in-flight (reserved but uncommitted) records to the queue — a bulk nack for a consumer that is shutting down or has failed.
Observability ¶
Queue.Stats returns a plain struct — no metrics registry is imposed on the caller, and no callback of theirs runs under the queue's lock. It carries gauges (backlog, in-flight bytes, unsynced bytes, segment count, disk footprint), lifetime counters (added, delivered, committed), and the loss counters.
Stats.UnsyncedBytes is what a power loss would cost right now. It is always zero under the default per-op policy and climbs under NoSync or SyncEvery > 1 until a flush; if it keeps climbing, the Options.SyncInterval backstop is not keeping up.
Stats.Corruptions is the field to alert on: it counts events, each of which was or will be surfaced as one ErrCorrupt. Stats.LostBytes, LostRecords and LostSegments say what those events cost. Stats.Unreclaimed climbing means fully-committed segments will not unlink and disk is not being freed.
Note that Reader.Err and a read's error tell you an event happened; only Stats carries its magnitude.
Sizing and limits ¶
Storage is a directory of numbered, preallocated segment files. Options.SegmentSize sets each one (8 MiB by default) and Options.MaxSegments caps how many exist at once (32 by default). Options.MaxBytes additionally caps the backlog in bytes, which is the budget operators actually reason about; the two compose, and whichever binds first returns ErrFull. Watch Stats.BacklogBytes against Stats.MaxBytes — "70% and climbing" is a signal, a bare byte count is not.
Under a MaxBytes budget the disk footprint is bounded too: segments are preallocated whole and reclaimed whole, so on a healthy queue Stats.DiskBytes never exceeds MaxBytes + 2×SegmentSize + one 64-byte header per segment — up to one segment of committed-but-not-yet-reclaimable records plus up to one segment of preallocated slack in the active file. Size the volume to that bound rather than to MaxBytes alone. Reopening is never a sizing concern: recovery reads one header per segment and no records, sub-millisecond even for a deep backlog.
Records never span segments, but that does not make Options.SegmentSize a ceiling on record size: a record too large for the geometry gets a segment sized to itself, flagged as such in its header so a reopen can tell it apart from a store built at a different SegmentSize. ErrRecordTooLarge is now reserved for a record larger than Options.MaxBytes, which no amount of draining can ever admit — as opposed to ErrFull, which clears as the consumer catches up.
Queue.AddWait is the blocking half of that backpressure: where Add answers ErrFull, AddWait parks until a commit frees capacity (or its context is done) and then retries, so a producer can lean on the queue instead of polling it. ErrRecordTooLarge still returns immediately — waiting cannot fix it.
Segments are preallocated with fallocate where available, so a full filesystem is discovered at segment creation rather than mid-record. Options.MaxOpenFiles bounds open descriptors for deep backlogs by closing least-recently-used handles; it only matters when MaxSegments is unbounded, since the segment cap already bounds the descriptor count.
Reclamation is whole-segment: a file is unlinked once every record in it is committed. Committing is therefore what frees disk, and a consumer that reserves without committing will hit ErrFull with the disk full of retained work.
Concurrency ¶
A Queue is safe for concurrent use. A single Reader is not — create one per consuming goroutine with Queue.NewReader.
Readers share one read/commit cursor and cooperate: each item is delivered to exactly one of them. Take, TryTake, Drain and Follow commit under the lock as they read and are safe for concurrent cooperating readers.
Reserve is the only deferred path, and it has two acknowledgements, because the choice between them is what makes several workers safe. Reader.Commit retires an offset and everything before it, which makes a batch retire cheap — reserve N, commit the last offset once — but with workers finishing in whatever order their work allows, one worker's commit retires another's in-flight record. Use it from a single consumer, or when one goroutine acknowledges a batch it reserved itself. Reader.Ack retires one record and is safe to call in any order, which is what competing workers want. It still reaches disk only across a contiguous run of acknowledged records, so a slow worker delays the retire of everything behind it without ever losing it; see Ack for what that costs.
Reader.Skip acts on the shared head rather than on a record the calling Reader holds, so with cooperating readers it may discard one another reader would have handled. Its retire is per-record, through the same ledger as Reader.Ack: a skip never retires a reservation another consumer still holds, and behind an outstanding reservation it becomes durable only once that reservation acknowledges (until then a crash replays the skipped record). Reader.Requeue retires the rotated original the same way.
The blocking methods honour their context.
Value lifetime ¶
The slice passed to UnmarshalFunc — and anything in T that aliases it — is owned by that Reader and is valid only until the Reader's next read. Copy out of it if you need it longer.
Each Reader copies its record into a private buffer before decoding, which is what makes concurrent readers safe: the store's read buffer is shared by every Reader on the queue, so without the copy one consumer holding its value while another reads would have its bytes rewritten from a different goroutine.
Performance ¶
The hot paths are allocation-free once warm. Add serializes through a pooled buffer BEFORE taking the queue's lock — so codecs run concurrently across producers and never stall a consumer — and writes each record with a single pwrite; reads go through a shared block buffer that holds a run of a segment rather than a single record, so consuming a backlog costs roughly one pread per block instead of two per record. Under per-op durability, concurrent Adds share their fsyncs through group commit, so durable throughput scales with producers instead of serializing on the disk.
On-disk format ¶
Each segment is a 64-byte little-endian header followed by records. The header holds a magic number, the commit cursor, the write cursor, written and committed counts, a format version, the segment's own capacity and the SegmentSize its store was created with (the geometry is decided by these fields, never by file length), and an xxhash64 over its own first 56 bytes. Each record is a uvarint length, the payload, and an 8-byte xxhash64 of the payload, verified on every read.
A directory holds exactly one queue: New takes a non-blocking advisory lock on it and returns ErrLocked if another Queue, in this process or another, already holds it.
Segments written by a future format version are dropped on open and counted in Stats.ForeignSegments rather than failing the open. Reopening with a different Options.SegmentSize is refused with ErrSegmentSizeMismatch, since honouring it would discard data.
Platform support ¶
Every GOOS compiles. Preallocation uses fallocate on Linux and falls back to ftruncate elsewhere and on filesystems that reject it. The directory lock uses flock where the standard library exposes it and is a no-op on Windows, Solaris, AIX, plan9 and js — on those platforms nothing prevents two processes from opening the same directory. Directory fsync is POSIX-only and is likewise a no-op off Unix. All of this uses only the standard library; there is no golang.org/x/sys dependency.
Example ¶
package main
import (
"encoding/binary"
"errors"
"fmt"
"log"
"os"
"github.com/JohanLindvall/diskqueue"
)
// tempDir gives each example its own queue directory. A directory holds one
// queue: New takes an advisory lock on it.
func tempDir() string {
d, err := os.MkdirTemp("", "diskqueue-example")
if err != nil {
log.Fatal(err)
}
return d
}
// A zero-allocation codec. MarshalFunc must APPEND to dst and return the
// extended slice — returning a fresh slice instead works, but costs the
// allocation the reused buffer exists to avoid.
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() {
q, err := diskqueue.New[uint64](tempDir(), marshal, unmarshal)
if err != nil {
log.Fatal(err)
}
defer func() { _ = q.Close() }()
for i := uint64(1); i <= 3; i++ {
if err := q.Add(i); err != nil {
log.Fatal(err)
}
}
r := q.NewReader()
for {
v, ok, err := r.TryTake() // read and commit in one step
if err != nil {
log.Fatal(err)
}
if !ok {
break
}
fmt.Println(v)
}
}
Output: 1 2 3
Index ¶
- Variables
- type MarshalFunc
- type Options
- type Queue
- func (w *Queue[T]) Add(data T) error
- func (w *Queue[T]) AddBatch(items []T) (int, error)
- func (w *Queue[T]) AddSized(data T) (int64, error)
- func (w *Queue[T]) AddWait(ctx context.Context, data T) error
- func (w *Queue[T]) Close() error
- func (w *Queue[T]) Count() int
- func (w *Queue[T]) Empty() bool
- func (w *Queue[T]) Err() error
- func (w *Queue[T]) NewReader() *Reader[T]
- func (w *Queue[T]) Rewind() (int64, error)
- func (w *Queue[T]) Size() int64
- func (w *Queue[T]) Stats() Stats
- func (w *Queue[T]) Sync() error
- type Reader
- func (r *Reader[T]) Ack(offset int64) error
- func (r *Reader[T]) AckBatch(offsets ...int64) error
- func (r *Reader[T]) Commit(offset int64) error
- func (r *Reader[T]) Drain(ctx context.Context) iter.Seq[T]
- func (r *Reader[T]) Err() error
- func (r *Reader[T]) Follow(ctx context.Context) iter.Seq[T]
- func (r *Reader[T]) LastAge() time.Duration
- func (r *Reader[T]) LastBytes() int64
- func (r *Reader[T]) Requeue() (bool, error)
- func (r *Reader[T]) Reserve(ctx context.Context) (T, bool, int64, error)
- func (r *Reader[T]) Skip() (bool, error)
- func (r *Reader[T]) Take(ctx context.Context) (T, bool, error)
- func (r *Reader[T]) TryPeek() (T, bool, error)
- func (r *Reader[T]) TryReserve() (T, bool, int64, error)
- func (r *Reader[T]) TryTake() (T, bool, error)
- type Stats
- type UnmarshalFunc
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrClosed is returned by every operation that would touch the store once the // Queue has been closed. The pure observers are deliberately exempt and keep // reporting the final state: Count, Empty, Size, Stats and Err. ErrClosed = errors.New("diskqueue: closed") // ErrFull is returned by Add when the write cannot be admitted right now: // either a new segment would exceed Options.MaxSegments, or the uncommitted // backlog would exceed Options.MaxBytes. It is the TRANSIENT refusal — it // clears as the consumer commits — where ErrRecordTooLarge is permanent. ErrFull = errors.New("diskqueue: full") // ErrInvalidOffset is returned by Commit for an offset beyond the last record. ErrInvalidOffset = errors.New("diskqueue: invalid offset") // ErrRecordTooLarge is returned by Add for a record that can NEVER be // admitted, whatever the queue does next. Two things earn it: a framed length // beyond Options.MaxBytes, which no amount of draining changes; and — only on a // 32-bit build, and only for a payload near the addressable maximum — a framed // length this platform cannot index, which would otherwise wrap negative and // panic. It is the permanent refusal, where ErrFull is the transient one. A // record merely larger than SegmentSize is not an error — it is stored in a // segment sized to itself. ErrRecordTooLarge = errors.New("diskqueue: record too large") // ErrCorrupt is returned by a read whose data failed its integrity check: a // record whose stored xxhash64 does not match, a length that overruns its // segment, or a segment dropped at open for a damaged header. // // The damaged data is dropped and the queue advances past it — the record // alone when its framing is still trustworthy, otherwise the rest of that // segment — so corruption degrades to reported loss rather than to plausible // looking garbage or a queue that never moves again. The error says one event // happened; Stats().LostBytes and LostRecords carry the magnitude it cannot. ErrCorrupt = errors.New("diskqueue: corrupt") // ErrCodec wraps an error returned by the caller's UnmarshalFunc. The record // is left at the head of the queue — a codec failure is not data loss, and the // same record is offered again — so use Reader.Skip to step over one the codec // will never accept. // // It exists to keep a codec error from impersonating a library sentinel: a // UnmarshalFunc may return anything, and an error that happened to wrap // ErrCorrupt would otherwise tell the caller that data on disk was damaged and // dropped, while nothing was damaged and nothing was dropped. The codec's own // error stays reachable through errors.Unwrap, which means it can still carry // ErrCorrupt — so TEST ErrCodec BEFORE ErrCorrupt. Only ErrCorrupt that is not // also ErrCodec means the queue lost data. ErrCodec = errors.New("diskqueue: unmarshal failed") // ErrSegmentSizeMismatch is returned by New when reopening a store with a // different SegmentSize than it was created with (which would discard data). ErrSegmentSizeMismatch = errors.New("diskqueue: segment size mismatch") // ErrLocked is returned by New when another Queue — in this process or // another — already holds the directory's advisory lock. ErrLocked = errors.New("diskqueue: directory already in use") // ErrIO wraps a durability failure that the queue cannot recover from in // place: an fsync that failed. The kernel reports such an error once and then // drops the dirty pages, so a later fsync may well succeed with the data // already gone — rather than report a durability it does not have, the queue // latches the failure and every subsequent Add, commit and Sync returns it // (wrapping the original errno). Close it and reopen to continue. ErrIO = errors.New("diskqueue: durability failure") )
Errors returned by the package.
Functions ¶
This section is empty.
Types ¶
type MarshalFunc ¶
MarshalFunc serializes v by appending to dst and returning the extended slice (like the builtin append). Appending rather than allocating keeps Add alloc-free.
Add and AddWait call it BEFORE taking the Queue's lock (into a pooled, per-call buffer), so an expensive codec no longer serializes producers or stalls consumers; AddBatch calls it with the lock held. Either way it must not call back into the Queue or any of its Readers — the mutex is not reentrant, and doing so deadlocks on the paths that hold it.
type Options ¶
type Options struct {
// NoSync disables the fsync after every write and commit. This trades
// durability against a power loss for substantially higher throughput; data
// still survives a process crash via the page cache. Default false.
NoSync bool
// SyncEvery batches durability: fsync once every N writes/commits instead of
// after each one, amortizing the fsync cost. 0 or 1 syncs every operation (the
// default). A larger N raises throughput but widens the power-loss window — up
// to the last N unsynced operations can be lost on power loss (they still
// survive a process crash via the page cache, and a torn tail is caught by the
// per-record checksum). Call Sync to flush on demand; Close always flushes.
// Ignored when NoSync is set.
SyncEvery int
// SegmentSize sets each segment file's capacity. Default 8 MiB, floored at
// 4 KiB and rounded up to a multiple of 4 KiB — a fixed constant, deliberately
// NOT the host's page size, so a store created on a 4 KiB-page machine still
// reopens on a 64 KiB-page one. Values above 2^48-4096 are clamped to that
// ceiling, which is what the 6-byte geometry field in each header can hold.
// It is not a ceiling on record size: a
// record too big for the geometry gets a segment sized to exactly itself.
// Fixed at creation: reopening with a different (post-rounding) value is
// rejected with ErrSegmentSizeMismatch.
SegmentSize int64
// MaxSegments caps how many segment files are kept at once; once reached, Add
// returns ErrFull until a segment is committed and reclaimed. The footprint is
// about MaxSegments × SegmentSize bytes — oversized records excepted, since
// their segments are as long as the record; Stats().DiskBytes reports the real
// number. 0 selects the default of 32; a negative value means unbounded.
MaxSegments int
// MaxBytes caps the uncommitted backlog in BYTES — the same number Size and
// Stats().BacklogBytes report. Past it, Add returns ErrFull and the queue is
// left untouched, so the caller chooses: block, drop, or shed load upstream.
// 0 (the default) applies no byte cap, leaving MaxSegments as the only bound.
//
// It is worth setting because MaxSegments bounds the FILE COUNT, and the
// footprint that follows from it (MaxSegments × SegmentSize) is a ceiling on
// disk rather than a budget on the backlog: a queue holding one record per
// segment is nowhere near its byte cap but may be at its segment cap. Sizing an
// outage budget in bytes is the thing operators actually want to do, and
// BacklogBytes/MaxBytes is the utilisation ratio to alert on — "70% and
// climbing" is a signal, a bare byte count is not.
//
// The two caps compose: whichever binds first returns ErrFull. A record larger
// than the cap itself can never be accepted and is refused with
// ErrRecordTooLarge, which is permanent, rather than ErrFull, which is not.
MaxBytes int64
// MaxOpenFiles caps how many segment files are kept open at once. Segments are
// opened on demand and the least-recently-used handles are closed beyond the
// cap, bounding open descriptors for deep backlogs; the active segment is
// always open. 0 means unbounded (keep every touched segment open).
//
// Values are raised to a floor of 3, because the write, read and commit
// cursors can each be in a different segment; a smaller cap evicts the handle
// the next operation needs. Note that the open-file count is already bounded
// by MaxSegments, so this is only worth setting when MaxSegments is unbounded.
MaxOpenFiles int
// SyncInterval, if > 0, runs a background goroutine that flushes to stable
// storage on that period — a wall-clock backstop for SyncEvery batching, so an
// idle queue's last writes become durable within the interval instead of
// waiting for SyncEvery more operations. Ignored when NoSync is set.
SyncInterval time.Duration
// StampRecords, when set, prepends an 8-byte big-endian unix-nano timestamp
// to every record's payload as it is serialized — INSIDE the record, so it is
// covered by the record checksum and counted in the framed size that
// AddSized, Reader.LastBytes, MaxBytes and the byte gauges all agree on. The
// Reader strips it back off before the payload reaches UnmarshalFunc and
// exposes it as Reader.LastAge, so a consumer can report how long each item
// waited without wrapping every record in a timestamp envelope of its own.
//
// It changes what the records CONTAIN, not the on-disk format — the stamp
// lives inside the codec's bytes, exactly where such an envelope would.
// Consistency across reopens is therefore the caller's contract, exactly as
// it already is for the codec itself: reopen a store with the same
// StampRecords it was written with. Flipping it over an existing backlog is
// a caller error — the reader would strip eight payload bytes, or hand the
// codec eight bytes of timestamp — and what it produces is codec-level
// garbage, surfaced as ErrCodec (a stamped record shorter than its stamp
// included; never a panic), with Reader.Skip the way past each record.
//
// Reader.Requeue preserves the original stamp: it moves the record's raw
// payload, stamp and all, so a rotated record keeps its enqueue time and its
// age keeps accumulating across rotations rather than resetting — the age
// answers "how long has this item been waiting", not "how long since its
// last rotation".
StampRecords bool
}
Options tunes the behaviour of a Queue. The zero value is valid and selects sensible defaults.
type Queue ¶
type Queue[T any] struct { // contains filtered or unexported fields }
Queue is a generic persistent FIFO queue of T.
func New ¶
func New[T any](path string, marshal MarshalFunc[T], unmarshal UnmarshalFunc[T], opts ...Options) (*Queue[T], error)
New opens (creating if necessary) a Queue under the directory path. The segment count, durability, and recovery behaviour are tuned via Options (see Options.MaxSegments for the file-count cap, which defaults to 32). The variadic opts exists only to make the whole argument optional: the first value is used and any further ones are ignored.
func (*Queue[T]) Add ¶
Add appends data to the back of the log.
A write that cannot be placed at all (ErrFull, ErrRecordTooLarge, a failed pwrite) leaves the queue untouched: the error means the item is not in it.
A durability failure is the one AMBIGUOUS answer. If the error wraps ErrIO the item may or may not be in the log, and the two arms are not distinguishable from the error: a failed HEADER fsync leaves the record real to everything short of a power loss (its bytes and the header publishing them both reached the page cache), while a failed DATA fsync publishes nothing at all. Either way the queue is poisoned and every later operation repeats the error, so the only recourse is to close and reopen — and a reopen answers the question definitively through Count and Stats. Treat an ErrIO as at-least-once ambiguous rather than as a confirmed placement.
It can block. Besides waiting for its own flush, an Add yields to any concurrent Sync, AddBatch, Requeue or Close that is quiescing the store — without that, a steady producer stream starves those operations for seconds. The wait is bounded by the flush being quiesced, not by the caller.
Serialization happens before the lock, and under the default per-op policy the two fsyncs are SHARED: concurrent Adds that arrive while a flush is in progress ride the next one (group commit), so per-record durability scales with producers instead of serializing on the disk. Every record is still individually durable when its Add returns.
func (*Queue[T]) AddBatch ¶ added in v0.0.6
AddBatch appends items in order, amortizing the lock and — under the per-op policy — the fsyncs across the whole batch: the records' bytes go down first, one data fsync covers them all, then one header write and one header fsync publish them together (per segment crossed). Every published record is durable when AddBatch returns.
It returns how many leading items are in the queue. n < len(items) comes with the error that stopped the batch (ErrFull, a marshal error, an I/O failure); the first n items are placed and durable regardless.
Unlike Add and AddWait, which marshal BEFORE taking the lock, AddBatch calls MarshalFunc with the queue mutex held. A codec that panics there is therefore a store-state hazard as well as a caller-visible one; the panic is passed through unchanged, with the staged-but-unpublished tail discarded first so the queue stays usable and closable. A power loss during AddBatch truncates it cleanly to a published prefix — never a torn record, never a phantom.
func (*Queue[T]) AddSized ¶ added in v0.0.11
AddSized is Add, also reporting the framed size the record occupies on disk — length prefix, payload and checksum trailer, with the Options.StampRecords stamp (when enabled) inside the payload and therefore inside this number. That is the unit MaxBytes and the Stats byte gauges count, so a producer metering its own throughput agrees with the store's accounting (Reader.LastBytes is the consuming side of the same number). The size is 0 when the add failed.
func (*Queue[T]) AddWait ¶ added in v0.0.6
AddWait is Add with backpressure: where Add answers ErrFull, AddWait blocks until a commit frees capacity (or ctx is done) and then retries. Every other error — ErrRecordTooLarge included, which no amount of draining changes — returns immediately, exactly as from Add.
Each refused attempt still counts in Stats().Full, so that counter reads as "attempts refused" rather than "items dropped" when producers wait.
ctx bounds the ErrFull wait, not the whole call: like Add, this yields to a concurrent quiesce and waits on its own flush, and neither of those consults ctx. A deadline can therefore be overshot by the length of a flush. It is a delay, not a hang — every such wait is released by an operation already in flight.
func (*Queue[T]) Empty ¶
Empty reports whether there are no items available to read.
It also stays false while corruption reports are owed — losses from segments dropped at open, which no read of their own will ever fail on. Only a consume op discharges those (TryPeek deliberately does not), so a blocking consumer wakes up, collects each ErrCorrupt, and only then sees an empty queue. That is why Empty can be false with Count, Size and TryPeek all saying nothing is there.
It remains readable after Close and reports the final observed state.
func (*Queue[T]) Err ¶
Err returns the latched durability failure, if any: nil while the queue is healthy, and an error wrapping ErrIO once an fsync has failed. A poisoned queue keeps serving reads but refuses every write, commit and sync, because the kernel reports a writeback error once and then discards the pages — a second fsync would report success over data that is already gone. Close it and reopen to continue; whatever was durable is still there, and uncommitted records replay.
func (*Queue[T]) NewReader ¶
NewReader returns a Reader that consumes from this Queue; all read operations are methods on it.
A Reader copies each record into a private reused buffer before unmarshalling, so the value never aliases the store's reused read buffer and stays valid until the Reader's next read (alloc-free once warm). A Reader is not safe for concurrent use: use one per consuming goroutine. Readers share the read/commit cursor and cooperate (each item delivered once); see the package doc on which ops are safe across concurrent readers.
func (*Queue[T]) Rewind ¶
Rewind returns every delivered-but-uncommitted record to the queue, so the next read starts from the commit cursor again. It reports the bytes made readable, and wakes any blocked reader.
Reserve/Commit is an acknowledgement protocol, and this is its nack. Without it, a consumer that reserved records and then could not process them — a downstream that stayed down, a worker that gave up — left the read cursor ahead of the commit cursor with no way back: Empty reported true, Follow blocked, and the records were unreachable until the process restarted, even though they were still on disk and still uncommitted.
Offsets handed out before a Rewind become invalid with it: Commit rejects anything past the shared read cursor, so a Commit of a pre-Rewind offset answers ErrInvalidOffset. That is the nack doing its job — the record was returned to the queue — but a consumer holding reserved offsets has to expect it.
It moves the *shared* cursor, which is why it is here and not on Reader. With cooperating readers it replays records other readers may still be working on, and those will be delivered a second time; that is within the at-least-once contract, but it means Rewind belongs to whoever owns the consumer group, not to one worker. Records already committed are unaffected — this cannot un-commit anything.
func (*Queue[T]) Size ¶
Size returns the bytes of uncommitted records.
This is payload accounting, not disk usage: segments are preallocated, so what the queue occupies is Stats().DiskBytes, which is a multiple of the segment geometry and never smaller than this.
It remains readable after Close and reports the final observed state.
func (*Queue[T]) Stats ¶
Stats returns a snapshot of the queue's gauges and lifetime counters. It remains readable after Close and reports the final observed state.
func (*Queue[T]) Sync ¶
Sync flushes buffered writes to stable storage.
The per-file fdatasyncs run WITHOUT the queue mutex held, so a large flush — the SyncInterval backstop over a deep unsynced backlog, say — does not stall every concurrent Add and read for the duration of the disk write-back. Everything Sync promises still holds: on a nil return, every byte written before the call is durable (bytes written DURING it may or may not be, and stay reported in Stats().UnsyncedBytes either way).
type Reader ¶
type Reader[T any] struct { // contains filtered or unexported fields }
Reader is a consuming view over a Queue; create it with Queue.NewReader.
func (*Reader[T]) Ack ¶ added in v0.0.10
Ack retires the single record at offset, which Reserve or TryReserve returned. It is the acknowledgement to use when several workers consume the same queue, because it is safe to call in any order — unlike Commit, which retires every record before the offset as well and therefore retires whatever the other workers are still holding.
The durable commit cursor still moves in one direction over a contiguous run, because that is what the on-disk format records. So an Ack takes effect on disk only once every record ahead of it has been acknowledged too: acknowledge the third of three reserved records and nothing is retired yet; acknowledge the first and second and all three retire together. The consequence worth planning for is that one slow worker holds the whole run behind it — the records are acknowledged, but their space is not reclaimed and they replay after a crash. Stats().InFlightBytes is what that looks like from outside.
Acking is idempotent in both directions: an offset already retired, whether by this Ack, a Commit, a Skip or a Requeue, is accepted and does nothing. An offset past the shared read cursor, or one that names no outstanding reservation, returns ErrInvalidOffset — including every offset handed out before a Rewind, which returns them all to the queue.
Mixing Ack with Commit on one queue is allowed and needs no care: a Commit simply retires the reservations it passes, and they leave the ledger.
func (*Reader[T]) AckBatch ¶ added in v0.0.11
AckBatch acknowledges several reservations under one lock acquisition, with one ledger drain and at most one commit for the whole batch — the batched form of Ack, for a consumer retiring a batch of records it processed together. Per offset it keeps Ack's contract: already-retired offsets are accepted and do nothing, and an offset past the read cursor or naming no outstanding reservation makes AckBatch return ErrInvalidOffset — after every resolvable offset in the batch has still been acknowledged and the completed run committed, so one bad offset does not hold good acknowledgements hostage.
func (*Reader[T]) Commit ¶
Commit marks the record at offset, and every record before it, as consumed. Committing an already-committed offset is a no-op.
The offset must be one a read handed out: committing past the shared read cursor returns ErrInvalidOffset rather than reclaiming records nobody has seen (which would delete them, and the segment a reader is positioned in with them). An offset that falls inside a record rather than on a boundary is not an error: the commit stops at the last record ending at or before it — the bias everywhere is to redeliver, never to retire something nobody said was done.
func (*Reader[T]) Drain ¶
Drain iterates the items present when iteration begins, oldest first, committing each as it is read (like Take), so a loop that stops early does not replay the item it stopped on. Use Reserve/Commit to ack after processing. Safe for concurrent cooperating readers.
Example ¶
An iterator cannot carry an error per item, so check Err after the loop: a nil Err means it ended because the queue ran out, not because something failed.
package main
import (
"context"
"encoding/binary"
"errors"
"fmt"
"log"
"os"
"github.com/JohanLindvall/diskqueue"
)
// tempDir gives each example its own queue directory. A directory holds one
// queue: New takes an advisory lock on it.
func tempDir() string {
d, err := os.MkdirTemp("", "diskqueue-example")
if err != nil {
log.Fatal(err)
}
return d
}
// A zero-allocation codec. MarshalFunc must APPEND to dst and return the
// extended slice — returning a fresh slice instead works, but costs the
// allocation the reused buffer exists to avoid.
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() {
q, err := diskqueue.New[uint64](tempDir(), marshal, unmarshal)
if err != nil {
log.Fatal(err)
}
defer func() { _ = q.Close() }()
for i := uint64(1); i <= 3; i++ {
if err := q.Add(i); err != nil {
log.Fatal(err)
}
}
rd := q.NewReader()
sum := uint64(0)
for v := range rd.Drain(context.Background()) {
sum += v
}
if err := rd.Err(); err != nil {
log.Fatal(err)
}
fmt.Println(sum)
}
Output: 6
func (*Reader[T]) Err ¶
Err reports what went wrong during the most recent Drain or Follow: nil when the iteration simply ran out of items, the context was cancelled, or the Queue was closed. An iter.Seq cannot carry an error, so check this after the loop — otherwise a failure is indistinguishable from an empty queue.
A read, decode or commit failure ends the iteration and is reported here. ErrCorrupt does not: the damage is already dropped and the queue has advanced, so iteration continues and the first event is kept here for the loop to find afterwards. Stats().LostBytes and LostRecords say how much was lost.
func (*Reader[T]) Follow ¶
Follow is like Drain but unbounded: after the existing items it waits for and yields new ones until ctx is cancelled or the Queue is closed. Each item is committed as it is read (at-most-once; see Drain). The lock is released across yields, so other methods may be called from within the loop.
func (*Reader[T]) LastAge ¶ added in v0.0.12
LastAge reports how long the record the most recent successful consuming read on this Reader returned had waited in the queue: time.Since the stamp Options.StampRecords laid down when the record was serialized, measured at the read. It is 0 when StampRecords is off — there is no stamp to age — and clamped at 0 against a wall clock that stepped backwards between the write and the read (the stamp is wall time; it has to survive a reopen, so no monotonic reading can travel with it). A record Requeue rotated keeps its original stamp, so its age keeps accumulating rather than resetting. Like LastBytes, meaningful only after such a read, on a Reader that is single-goroutine by contract.
func (*Reader[T]) LastBytes ¶ added in v0.0.11
LastBytes reports the framed on-disk size — length prefix, payload and checksum trailer, the Options.StampRecords stamp (when enabled) included in the payload — of the record the most recent successful consuming read on this Reader returned. It is the unit MaxBytes and the Stats byte gauges count, so a caller metering its own throughput agrees with the store's accounting (AddSized is the producing side of the same number). Meaningful only after such a read; a Reader is single-goroutine by contract, so there is no concurrent overwrite to race.
func (*Reader[T]) Requeue ¶ added in v0.0.2
Requeue moves the record at the head of the queue to the BACK, without decoding it; ok is false when the queue is empty.
It is the answer to a poison record — one the consumer cannot process and that would otherwise block the head forever. Skip is the other answer and it destroys the record; Requeue keeps it and lets everything behind it drain, so a single unprocessable item costs a reordering rather than either data loss or a stalled queue. Watch for it with Stats(): Delivered climbing while Committed stays flat on a non-empty backlog is a head record nobody can retire.
The record is re-appended and only then retired at the head, in that order, because the reverse would lose it outright if the append failed. The retirement goes through the reservation ledger (see Skip), so it never retires a reservation another consumer still holds; behind an outstanding reservation it becomes durable only once that reservation is acknowledged. Both orderings have the same consequence: if the append succeeds and the retirement is not yet durable — a failed commit, or one waiting behind a reservation at a crash — the record exists twice, once at the tail and once still at the head, which is what at-least-once already permits. A failed append moves nothing and leaves the record where it was.
The re-append is EXEMPT from MaxBytes and MaxSegments. The rotation is backlog-neutral — the tail copy is followed immediately by the commit that retires the head original, so the net backlog is unchanged and the overshoot is transiently one record (at worst one segment). Enforcing the caps here inverted the method's purpose: commits are a cursor, so nothing behind an unprocessable head can retire first, and a poison head at a FULL queue — exactly when rotation matters most — could then never be moved, wedging the queue and pinning its disk across restarts.
Two caveats. It BREAKS FIFO order for the record it moves, which is the point; a queue whose ordering is load-bearing should not use it. And like Skip it acts on the SHARED head rather than on a record this Reader is holding, so with several cooperating Readers it moves whatever is at the cursor when it runs — call it from one consumer, or coordinate.
Under Options.StampRecords the rotated record keeps its ORIGINAL stamp — the raw payload is moved, stamp inside it — so its LastAge keeps accumulating across rotations rather than resetting: the age answers "how long has this item been waiting", and a rotation is not an answer to that.
func (*Reader[T]) Reserve ¶
Reserve blocks until an item is available (or ctx is done), returning it and its offset without committing.
Example ¶
Reserve/Commit is the at-least-once path: the record is not retired until you say so, so a crash between the two replays it.
package main
import (
"context"
"encoding/binary"
"errors"
"fmt"
"log"
"os"
"github.com/JohanLindvall/diskqueue"
)
// tempDir gives each example its own queue directory. A directory holds one
// queue: New takes an advisory lock on it.
func tempDir() string {
d, err := os.MkdirTemp("", "diskqueue-example")
if err != nil {
log.Fatal(err)
}
return d
}
// A zero-allocation codec. MarshalFunc must APPEND to dst and return the
// extended slice — returning a fresh slice instead works, but costs the
// allocation the reused buffer exists to avoid.
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() {
q, err := diskqueue.New[uint64](tempDir(), marshal, unmarshal)
if err != nil {
log.Fatal(err)
}
defer func() { _ = q.Close() }()
if err := q.Add(42); err != nil {
log.Fatal(err)
}
r := q.NewReader()
v, ok, offset, err := r.Reserve(context.Background())
if err != nil || !ok {
log.Fatal(err)
}
// ... process v; only acknowledge once it is safely handled.
if err := r.Commit(offset); err != nil {
log.Fatal(err)
}
fmt.Println(v, q.Count())
}
Output: 42 0
func (*Reader[T]) Skip ¶
Skip consumes the record at the head of the queue without decoding it, and retires it; ok is false when the queue is empty.
It is the deliberate way past a record UnmarshalFunc rejects. Because a decode error leaves the record in place — so a codec bug can never silently eat data — a consumer that has decided a record is unprocessable has to say so explicitly.
Skip acts on the SHARED head, not on a record this Reader holds. With several cooperating Readers it discards whatever is at the cursor when it runs, which may be a record another Reader would have handled — so call it from one consumer, or coordinate. It is the one consume operation that destroys a record without reading it, and the loss is not counted as corruption.
The retirement is per-record, through the reservation ledger: skipping never retires a reservation another consumer still holds (a plain commit here would, silently — the exact hazard Ack exists to prevent). A skip behind an outstanding reservation therefore becomes durable only once that reservation is acknowledged; until then a crash — or a Rewind — replays the skipped record, and the consumer that could not process it skips it again. That is the standard at-least-once answer, and it is idempotent.
Like every consume op, Skip can also surface a pending corruption report: ok=false with ErrCorrupt means the queue collected a loss (and may have stepped past damaged data), not that the record Skip was aimed at is gone — call it again to skip the record now at the head.
func (*Reader[T]) Take ¶
Take blocks until an item is available (or ctx is done) and returns + commits it. As with TryTake, a non-nil error alongside ok true means the item was read but its commit did not reach disk, so it replays after a reopen.
func (*Reader[T]) TryPeek ¶ added in v0.0.6
TryPeek returns the front item WITHOUT consuming it: no cursor moves, the item stays exactly where it is, and the next read — by any Reader — returns it again. ok is false when the queue is empty.
It is the inspection the consume ops cannot provide: TryReserve advances the shared read cursor (that is why Empty can be true while Count is not zero), while TryPeek leaves every cursor alone. The value is decoded through this Reader's buffer and is valid until the Reader's next read, like every other delivery.
A damaged head returns ErrCorrupt as a PREVIEW: nothing is dropped, nothing is counted, and Stats does not move — the consume op that eventually steps past the damage books and reports it exactly once. Likewise TryPeek does not surface the corruption reports owed for segments dropped at open; those belong to the consume ops.
func (*Reader[T]) TryReserve ¶
TryReserve returns the front item and its offset without committing; ok is false when empty. Pass the offset to Commit (or call Take) to consume it.
func (*Reader[T]) TryTake ¶
TryTake returns and commits the front item; ok is false when empty.
A non-nil error with ok true means the item was read but its commit could not be persisted: the item is yours, and it will be delivered again after a reopen (the commit, not the read, is what is missing).
Example (Corruption) ¶
Corruption never stops the queue: damage is dropped, counted and reported as one ErrCorrupt, and the next call makes progress. This is the loop to write.
package main
import (
"encoding/binary"
"errors"
"fmt"
"log"
"os"
"github.com/JohanLindvall/diskqueue"
)
// tempDir gives each example its own queue directory. A directory holds one
// queue: New takes an advisory lock on it.
func tempDir() string {
d, err := os.MkdirTemp("", "diskqueue-example")
if err != nil {
log.Fatal(err)
}
return d
}
// A zero-allocation codec. MarshalFunc must APPEND to dst and return the
// extended slice — returning a fresh slice instead works, but costs the
// allocation the reused buffer exists to avoid.
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() {
q, err := diskqueue.New[uint64](tempDir(), marshal, unmarshal)
if err != nil {
log.Fatal(err)
}
defer func() { _ = q.Close() }()
for i := uint64(1); i <= 2; i++ {
if err := q.Add(i); err != nil {
log.Fatal(err)
}
}
rd := q.NewReader()
var lost int
for {
v, ok, err := rd.TryTake()
switch {
case errors.Is(err, diskqueue.ErrCorrupt):
// Already dropped and stepped past; count it and go round again.
lost++
continue
case err != nil:
log.Fatal(err)
case !ok:
fmt.Println("drained, lost", lost, "of", q.Stats().Added)
return
}
_ = v
}
}
Output: drained, lost 0 of 2
type Stats ¶
type Stats struct {
// Gauges.
BacklogBytes int64 // uncommitted bytes: the same number as Size
Backlog int64 // uncommitted records: the same number as Count
// UnsyncedBytes is record bytes that are written but not yet fsync'd: what a
// power loss would cost right now, and how far a deferred sync policy has run
// ahead of the last flush. It is always zero under the default per-op policy,
// which fsyncs before Add returns, and climbs under NoSync or SyncEvery > 1
// until a Sync, a batch flush or Close brings it back to zero.
//
// One window it does not cover, by construction: under group commit a span is
// published — and therefore readable and counted in Backlog — for the duration
// of its header fsync, during which those bytes are not yet durable. The gauge
// stays zero there because the per-op policy's contract is that it is zero; the
// Add that owns the span has not returned yet.
//
// A process crash does not lose these bytes — the kernel owns the pages — so
// this measures exposure to power loss and kernel panic specifically. Watch it
// against SyncInterval: if it keeps climbing, the backstop is not keeping up.
UnsyncedBytes int64
// InFlightBytes is the bytes handed to a reader but not yet committed —
// BacklogBytes minus what is still unread. It is the state Rewind exists to
// undo, and the number behind the documented oddity that Empty can be true
// while Count is not zero. Climbing with a flat Committed means consumers are
// taking work and not acknowledging it.
InFlightBytes int64
Segments int // live segment files
MaxSegments int // the configured segment-count cap; 0 when unbounded
MaxBytes int64 // the configured backlog byte cap; 0 when unbounded
DiskBytes int64 // what the segment files occupy, including preallocated slack
// Counters since New.
Added uint64 // records accepted by Add
// Delivered counts records READ OUT OF THE STORE by a Reader, redeliveries
// included — one step earlier than "processed". A record whose COMMIT then
// failed is counted, because the read is what happened and the record replays.
// A record the codec REJECTED is not: Reader.read puts it back at the head, and
// un-counts the delivery with it, so a permanently-failing UnmarshalFunc does
// not inflate this. Compare against Committed to see work taken and not retired.
Delivered uint64
// Committed counts records retired by a commit. A record retired by the
// corruption quarantine is included even though it reached no consumer, so
// Committed can exceed Delivered on a damaged queue; those records are in the
// Lost* fields too.
Committed uint64
Full uint64 // Adds refused with ErrFull
// Committed can exceed Delivered: a record dropped for a bad checksum is retired
// without ever being handed out, and a quarantined segment retires its whole tail.
// Both are counted in the Lost* fields too.
//
// Unreclaimed counts failed attempts to unlink a fully-committed segment.
// A segment that will not unlink stays in the live set and is retried on the
// next drop, so this climbing means disk is not being freed.
Unreclaimed uint64
// Loss, all since New. LostBytes is a lower bound: for a segment that
// vanished from the directory, only its recorded size is left to count.
LostBytes uint64 // destroyed by corruption
LostRecords uint64 // individually dropped damaged records
LostSegments uint64 // segments abandoned or dropped whole
ForeignSegments uint64 // dropped for a format version this build cannot read
ForeignBytes uint64
DiscardedBytes uint64 // trailing bytes a segment lost to truncation
// Corruptions counts corruption events since New: segments dropped at open,
// records dropped for a bad checksum, and segments abandoned for unusable
// framing. Each one was, or will be, surfaced as exactly one ErrCorrupt from a
// read — this is the number an operator alerts on, and the Lost* fields above
// say how much each event cost.
//
// It counts EVENTS, not damaged regions, and one region can produce more than
// one: a segment the read path quarantines is quarantined again when the commit
// walk later crosses it, so a single unframable record can read as Corruptions=2
// with LostSegments=1. The byte and record figures are booked once; this one
// tracks the reports actually handed to consumers.
Corruptions uint64
}
Stats is a snapshot of a Queue's gauges and lifetime counters, for monitoring. It is a plain struct on purpose: no registry model is imposed on callers, and no callback of theirs runs under the queue's lock.
The loss counters are what make corruption observable. ErrCorrupt says an event happened; LostBytes and LostRecords say how much it cost.
type UnmarshalFunc ¶
UnmarshalFunc decodes a value from data, a Reader-owned buffer valid only until that Reader's next read; copy out of it if you need it longer.
Like MarshalFunc it runs under the Queue's lock and must not call back into the queue. Returning an error leaves the record at the head of the queue rather than consuming it, so the same record is offered again; use Reader.Skip to step over one the codec will never accept.