Documentation
¶
Overview ¶
Package spool is a disk-backed FIFO byte queue. Records are appended durably to segment files and consumed in order, surviving process restarts and power loss.
It exists for store-and-forward: a producer that outruns a remote endpoint, or outlives its outage, spools records to disk (bounded by a byte cap) instead of holding them in memory or pinning its own input. Delivery is at-least-once, with an explicit commit, so a crash re-delivers rather than drops.
Durability contract ¶
Append fsyncs the frame before returning. Once it returns nil the record is on the physical medium, so a producer that has observed a successful Append may safely advance its own checkpoint and forget its copy.
Producers with many SMALL records can group-commit instead: AppendNoSync enqueues without forcing durability, and Sync makes everything since the last durable point safe with ONE fsync. The checkpoint rule moves to Sync ("advance only after Sync returns"); it never silently weakens. On a Sync failure the whole unsynced group is rolled back and must be re-appended, which Sync reports as ErrGroupLost. Duplicates are possible if some frames were popped meanwhile — within at-least-once.
Pop does not remove. It returns the record with a commit function, and nothing is retired until commit runs, so a crash in between re-delivers.
On-disk format ¶
A directory of segment files (<seq>.seg, zero-padded, ascending) plus a cursor file:
segment: "KSPOOL" | version u16be | frame* frame: len u32be | xxhash64(len‖payload) u64be | payload cursor: seq u64be | offset u64be | fnv64a u64be (rewritten in place)
Checksumming the length alongside the payload means a flipped length byte is caught instead of mis-framing the rest of the segment, and a damaged record is dropped and reported rather than handed onward as plausible-looking data.
A segment whose header is absent or names a version this build does not know is discarded at open: the spool is a transient buffer, so carrying a reader for every past or future format would not pay for itself. The version is per segment rather than per spool, so when a new format is added (bump formatVersion, extend knownVersions and the frame read/write paths) segments of both versions coexist in one directory, each read with the framing it was written in. The magic reads KSPOOL for historical reasons — this package was extracted from an agent named kubescrape — and is kept so existing spool directories stay readable.
Recovery and what corruption costs ¶
On restart the newest segment's torn tail — a frame a crash left incomplete — is truncated away and counted in Discarded. A frame that is whole but fails its checksum is left in place and dropped by Pop when it is reached, so one damaged byte inside a PAYLOAD costs one record.
That bound does not extend to the four bytes of the frame length. A length damaged upward overruns the segment and a length damaged downward desynchronizes the walk; either way the frame boundaries from that point on are unrecoverable, so Pop skips the whole segment. One bad byte there costs up to SegmentBytes. The checksum makes this detected rather than silent, which is the guarantee — not that the blast radius is always one record.
A torn cursor fails its checksum on the next load and redelivers from the oldest surviving segment (duplicates, within at-least-once).
Every loss path increments Stats().LostBytes or Discarded; none is silent.
Platform notes ¶
Durability rests on os.File.Sync. On Linux that is fsync(2); on macOS the Go runtime issues fcntl(F_FULLFSYNC), which does flush the drive write cache. Neither is trustworthy on a network filesystem — do not put a spool on NFS, SMB or a similar remote mount, where an fsync may be acknowledged by a client cache.
A failed fsync must not be retried on the same descriptor: on Linux the kernel may have already marked the pages clean, so a second fsync can report success for data that was never written. This package therefore rolls the tail back to its last durable size rather than retrying.
One process per directory; see Open.
Example ¶
package main
import (
"fmt"
"os"
"github.com/JohanLindvall/spool"
)
func main() {
dir, _ := os.MkdirTemp("", "spool")
defer func() { _ = os.RemoveAll(dir) }()
q, err := spool.Open(dir, spool.Options{MaxBytes: 64 << 20})
if err != nil {
panic(err)
}
defer func() { _ = q.Close() }()
// Append is durable: once it returns, the record survives a crash.
if err := q.Append([]byte("batch-1")); err != nil {
panic(err)
}
// Pop hands back the record with a commit function; commit only after the
// record is truly handled (e.g. the collector acked it) — a crash before
// commit re-delivers it.
data, commit, ok, err := q.Pop()
fmt.Printf("%q ok=%v err=%v\n", data, ok, err)
commit()
}
Output: "batch-1" ok=true err=<nil>
Example (ConsumerLoop) ¶
A consumer loop that blocks instead of polling, stays cancellable, and does not spin on the error returns. Pop's errors are advancing — the queue has already moved past the damage — so they must not be retried in a tight loop without also waiting.
package main
import (
"errors"
"fmt"
"os"
"github.com/JohanLindvall/spool"
)
func main() {
dir, _ := os.MkdirTemp("", "spool")
defer func() { _ = os.RemoveAll(dir) }()
q, err := spool.Open(dir, spool.Options{MaxBytes: 64 << 20})
if err != nil {
panic(err)
}
defer func() { _ = q.Close() }()
_ = q.Append([]byte("only"))
done := make(chan struct{})
close(done) // stand-in for ctx.Done(); this example stops after one record
for {
data, commit, ok, err := q.Pop()
switch {
case errors.Is(err, spool.ErrClosed):
return
case err != nil:
// Corruption was dropped and the queue advanced. Count it and go
// round again; Signal fires for these too, so the wait below is
// still correct if this was the last thing in the queue.
fmt.Println("read error:", err)
case !ok:
select {
case <-q.Signal():
case <-done:
fmt.Println("stopped with", q.Bytes(), "bytes queued")
return
}
default:
fmt.Printf("send %q\n", data)
commit() // only after the send succeeded
}
}
}
Output: send "only" stopped with 0 bytes queued
Example (ErrFull) ¶
ErrFull is backpressure and clears as the consumer drains. ErrExceedsCap wraps it but never clears: the record cannot fit under this cap at all.
package main
import (
"errors"
"fmt"
"os"
"github.com/JohanLindvall/spool"
)
func main() {
dir, _ := os.MkdirTemp("", "spool")
defer func() { _ = os.RemoveAll(dir) }()
q, err := spool.Open(dir, spool.Options{MaxBytes: 64})
if err != nil {
panic(err)
}
defer func() { _ = q.Close() }()
oversized := q.Append(make([]byte, 64))
fmt.Println("oversized permanent:", errors.Is(oversized, spool.ErrExceedsCap))
// A record that fits: at most MaxBytes-FrameOverhead bytes.
fmt.Println("largest that fits:", q.Cap()-spool.FrameOverhead)
}
Output: oversized permanent: true largest that fits: 52
Example (GroupCommit) ¶
Group commit trades one fsync per record for one per group. The checkpoint rule moves with it: advance only after Sync returns, never after AppendNoSync. This is the API whose misuse loses data.
package main
import (
"errors"
"fmt"
"os"
"github.com/JohanLindvall/spool"
)
func main() {
dir, _ := os.MkdirTemp("", "spool")
defer func() { _ = os.RemoveAll(dir) }()
q, err := spool.Open(dir, spool.Options{MaxBytes: 64 << 20})
if err != nil {
panic(err)
}
defer func() { _ = q.Close() }()
batch := [][]byte{[]byte("a"), []byte("b"), []byte("c")}
for _, rec := range batch {
// NOT durable yet. A crash here loses the whole group.
if err := q.AppendNoSync(rec); err != nil {
panic(err)
}
}
if err := q.Sync(); err != nil {
// The group was rolled back and must be re-appended. Do NOT advance
// any upstream checkpoint.
if errors.Is(err, spool.ErrGroupLost) {
fmt.Println("group lost; re-append")
}
return
}
// Only now may the producer forget its copy of a, b and c.
fmt.Println("durable:", q.Stats().Appended, "records")
}
Output: durable: 3 records
Index ¶
- Constants
- Variables
- type Options
- type Spool
- func (s *Spool) Append(data []byte) error
- func (s *Spool) AppendNoSync(data []byte) error
- func (s *Spool) Bytes() int64
- func (s *Spool) Cap() int64
- func (s *Spool) Close() error
- func (s *Spool) CursorErr() error
- func (s *Spool) Discarded() int64
- func (s *Spool) Pop() (data []byte, commit func(), ok bool, err error)
- func (s *Spool) Requeue(data []byte, commit func()) (rotated bool, err error)
- func (s *Spool) Segments() int
- func (s *Spool) Signal() <-chan struct{}
- func (s *Spool) Stats() Stats
- func (s *Spool) Sync() error
- type Stats
Examples ¶
Constants ¶
const ( // DefaultSegmentBytes is the segment size used when Options.SegmentBytes is // zero. It also bounds the work repairTail does at Open, and the redelivery // window when the cursor cannot be persisted. DefaultSegmentBytes = 8 << 20 // DefaultMaxBytes is the backlog cap used when Options.MaxBytes is zero. // Deliberately a modest placeholder and not a sizing recommendation: a disk // buffer whose unconfigured state is "grow until the volume dies" fills the // disk its own process runs on, and the failure lands on whatever else // shares the mount. Size it for your outage budget. DefaultMaxBytes = 64 << 20 // Unbounded disables the backlog cap. It must be requested explicitly. Unbounded = -1 )
const FrameOverhead = 12
FrameOverhead is the per-record framing cost in bytes for the current format, so callers can size backlog comparisons against record lengths. A record fits under MaxBytes iff len(record) <= MaxBytes-FrameOverhead.
const MaxRecordBytes = frameLenMax
MaxRecordBytes is the largest record this frame format can represent: the length field is a big-endian uint32. Append rejects anything larger with ErrRecordTooLarge rather than truncating the length silently.
Variables ¶
var ErrClosed = errors.New("spool: closed")
ErrClosed is returned by Append, AppendNoSync, Requeue, Sync and Pop after Close. Commit functions from earlier Pops become no-ops: call the last commit before Close.
var ErrCorrupt = errors.New("spool: corrupt")
ErrCorrupt is returned by Pop when the head frame fails its integrity check: a checksum mismatch, a length overrunning the segment, or a segment tail too short to hold a frame header. The damaged data is dropped — the frame alone when the framing is still trustworthy, otherwise the whole segment — and the queue advances, so corruption degrades to reported loss rather than to corrupt telemetry or a wedged queue. Stats().LostBytes carries the magnitude the error itself cannot.
var ErrExceedsCap = fmt.Errorf("%w: record exceeds MaxBytes", ErrFull)
ErrExceedsCap is returned when a single record can never fit under MaxBytes: the check fails on an empty queue and will fail identically forever. It wraps ErrFull so existing errors.Is(err, ErrFull) callers keep working, but unlike plain ErrFull it will not clear with time — drop the record or split it.
var ErrFull = errors.New("spool: full")
ErrFull is returned by Append when the queue is at its size cap. It is transient: it clears as the consumer drains.
var ErrGroupLost = errors.New("spool: unsynced records lost; re-append the group")
ErrGroupLost reports that records accepted by AppendNoSync were invalidated before any Sync could cover them. The records were never persisted: re-append the group. Returned by Sync and joined into Close's error.
var ErrRecordTooLarge = errors.New("spool: record exceeds MaxRecordBytes")
ErrRecordTooLarge is returned for a record the frame format cannot represent (see MaxRecordBytes). Unlike ErrExceedsCap it is not a capacity condition and does not wrap ErrFull: no cap setting makes it succeed.
Functions ¶
This section is empty.
Types ¶
type Options ¶
type Options struct {
// SegmentBytes caps one segment file (0 = DefaultSegmentBytes). A single
// record may exceed it; segments rotate lazily once non-empty.
SegmentBytes int64
// MaxBytes caps the undelivered BACKLOG — the sum of every segment's bytes
// minus the consumed prefix of the read head, which is what Bytes reports.
// It is not the on-disk footprint: a delivered but unretired prefix keeps
// its segment alive, so physical usage can exceed this by up to one
// segment, and every segment's 8-byte header is charged except the
// consumed part of the head's. Stats().DiskBytes reports the real
// footprint.
//
// Append returns ErrFull past the cap, leaving the queue unchanged.
// 0 = DefaultMaxBytes; Unbounded (-1) disables the cap entirely.
MaxBytes int64
// CommitSync makes every commit fsync the read cursor.
//
// The cursor is a progress hint, not a durability record: a stale, torn or
// missing cursor causes only REDELIVERY, which the at-least-once contract
// already permits, bounded by one segment. Syncing it narrows the duplicate
// window after a power cut, and costs one fsync per commit — on this
// package's own benchmarks, the difference between roughly 900 and 300,000
// commits per second.
//
// The default (false) does not sync. Set it when duplicates cost more than
// consumer throughput.
CommitSync bool
}
Options configure a Spool.
type Spool ¶
type Spool struct {
// contains filtered or unexported fields
}
Spool is a durable FIFO byte queue.
Any number of goroutines may call the Append family, Sync, Pop, Close and the accessors concurrently; all state is mutex-guarded. The real constraint is that Pop carries no per-consumer position: two Pops without an intervening commit return the SAME record, so the consumer must be serialized. One process per directory — see Open.
func Open ¶
Open opens or creates the spool rooted at dir.
One process per directory. Nothing enforces this: two processes on one spool each repair the tail, retire the other's segments and overwrite the other's cursor, which destroys records with no error surfacing on either side.
func (*Spool) Append ¶
Append durably enqueues one record. It returns ErrFull when the size cap would be exceeded, leaving the queue unchanged so the caller can apply backpressure, and ErrExceedsCap when the record can never fit.
Once it returns nil the record is on the physical medium, so a producer may advance its own checkpoint on the strength of it.
func (*Spool) AppendNoSync ¶
AppendNoSync enqueues one record WITHOUT forcing it to disk. The record is readable immediately (same-process visibility) but not durable — a crash may lose it — until a Sync (or a later synced Append on the same tail) returns. The group-commit half of the durability contract: producers with many small records call AppendNoSync per record and Sync once per group, paying one fsync per group instead of per record, and advance their checkpoint only after Sync.
func (*Spool) Cap ¶
Cap is the configured MaxBytes (0 = uncapped). Exposed so a monitor can report utilisation rather than a bare byte count: "the buffer is at 70% and climbing" is the signal that a collector is degrading, and it is only expressible against the cap.
func (*Spool) Close ¶
Close flushes any unsynced group (a clean shutdown must not lose records AppendNoSync accepted) and releases the file handles. It does not delete queued data; a flush failure is returned so the producer knows the group never became durable. Close is idempotent.
func (*Spool) CursorErr ¶ added in v0.1.0
CursorErr reports the last failure to persist the read cursor, sticky until a later persist succeeds. Non-nil means commits are advancing in memory only: a restart redelivers every record back to the last cursor that reached disk, bounded by one segment. It is a disk fault rather than a queue state, so it is reported here instead of being returned from commit.
func (*Spool) Discarded ¶
Discarded is the number of bytes a damaged or torn tail cost, so the caller can log and count it. It is almost always fixed at Open, but can also grow at runtime when a failed append reopens and re-repairs the tail. A crash mid-append leaves one incomplete frame (small and expected); anything larger means fsynced records were destroyed by corruption, which must never be invisible.
func (*Spool) Pop ¶
Pop returns the next record and a commit function that removes it. ok false with a nil error means the queue is empty; a non-nil error means the head frame could not be read — the caller should surface it and retry. ErrCorrupt means damaged data was dropped, fs.ErrNotExist that the head segment's file was gone, and ErrClosed that the spool is closed; in the first two cases the queue has advanced past the problem, so the next Pop makes progress.
Signal also fires whenever Pop advances past unreadable data, so a consumer may use one wait path for both ok == false cases.
The record is not removed until commit is called, so a crash before commit re-delivers it (at-least-once). commit is idempotent, and becomes a no-op after Close — call the last commit before closing.
func (*Spool) Requeue ¶ added in v0.1.0
Requeue moves the record just popped to the back of the queue and commits it at the head, so one undeliverable record cannot block everything behind it. Pass the data and the commit function that Pop returned.
It ignores MaxBytes because the rotation is size-neutral — an equal-or-larger head frame is committed immediately after — and the cap must not be able to wedge a full spool whose head is undeliverable. It returns rotated=false without writing when the record is the only one queued, since rewriting it would be pointless churn.
It is not atomic: the append is fsynced before the commit runs, so a crash between them re-delivers the record — within at-least-once.
func (*Spool) Segments ¶
Segments is the number of segment files currently on disk. Physical footprint can exceed the backlog by up to one segment (a delivered but unreclaimed prefix), so this is the number to watch for disk pressure.
func (*Spool) Signal ¶
func (s *Spool) Signal() <-chan struct{}
Signal fires (non-blocking) after each Append, and whenever Pop advances past unreadable data, so a consumer can wait for work without polling.
func (*Spool) Stats ¶ added in v0.1.0
Stats returns a snapshot of the queue's gauges and this process's counters.
func (*Spool) Sync ¶
Sync makes every record accepted by AppendNoSync durable — the group-commit barrier. A producer using AppendNoSync may advance its checkpoint only after Sync returns nil; on error the whole unsynced group was rolled back (or was already lost with a damaged tail) and must be re-appended. Duplicates are possible when frames of the group were popped before the failure — within at-least-once, exactly like the synced-append failure paths.
type Stats ¶ added in v0.1.0
type Stats struct {
// Gauges.
BacklogBytes int64 // undelivered bytes — what MaxBytes caps (same as Bytes)
MaxBytes int64 // configured cap; 0 when Unbounded was requested
DiskBytes int64 // sum of segment file sizes: the number that fills the volume
Segments int
UnsyncedBytes int64 // accepted by AppendNoSync, not yet fsynced: what a crash loses now
// Counters since Open.
Appended uint64 // records accepted by any Append form
Popped uint64 // records handed out, including redeliveries
Committed uint64 // records retired by commit
Full uint64 // appends refused with ErrFull or ErrExceedsCap
LostBytes uint64 // destroyed by corruption; a LOWER BOUND
LostSegments uint64
ForeignSegments uint64 // dropped for a format version this build cannot read
ForeignBytes uint64
CursorErrors uint64 // failed cursor persists (see CursorErr)
DiscardedBytes int64 // same value as Discarded
}
Stats is a point-in-time snapshot. Gauges describe the queue now; counters are monotonic for the life of this process and are not persisted. Fields may be added — construct it only with keyed literals.