A pure-Go library for reading, writing, and rewriting
Tarantool journal files — write-ahead logs
(.xlog), snapshots (.snap), and vinyl logs (.vylog) — without linking the
Tarantool C runtime.
import "github.com/tarantool/go-xlog/reader"
r, err := reader.Open("00000000000000000000.xlog")
if err != nil {
log.Fatal(err)
}
defer r.Close()
for tx, err := range r.Txs() {
if err != nil {
log.Fatal(err)
}
for _, row := range tx.Rows {
fmt.Printf("lsn=%d type=%d\n", row.LSN, row.Type)
}
}Tarantool stores its WAL and snapshots in a self-describing binary format: a
text header followed by a chain of CRC-checked, optionally zstd-compressed
transaction blocks. The only complete reference is the Tarantool C source.
go-xlog reimplements that codec in Go so you can build backup/restore tooling,
replication shims, log inspectors, and migration scripts against
.xlog/.snap/.vylog files directly — no cgo, no running instance.
The library is format-faithful to Tarantool 3.x (format version 0.13) and
reads legacy 0.12 files with an opt-in flag. The on-disk layout, CRC algorithm
(CRC32C / Castagnoli), msgpack row encoding, and compression threshold all
mirror the C implementation byte-for-byte, and the test suite validates against
a frozen corpus of real files produced by Tarantool 2.11 through 3.8.
go get github.com/tarantool/go-xlogRequires Go 1.24+ (the reader exposes iter.Seq2 range-over-func iterators).
A journal file is a text Meta header plus a sequence of
transaction blocks. Each block is one logical transaction containing one or
more rows (XRow). Rows carry an LSN (log sequence number) and, for
multi-statement transactions, a shared TSN (transaction sequence number); the
terminating row sets a commit flag.
Files within a directory form a chain: each is named after the signature
of its vector clock (VClock, the per-replica LSN map), and each file's
PrevVClock equals the previous file's VClock. This chaining lets you locate
which file contains a given LSN or vclock without scanning every file.
The packages are layered so you can drop in at the level you need:
| Package | Role |
|---|---|
format |
Pure byte-level codec — Meta, XRow, VClock, encode/decode. No I/O. |
reader |
Single-file forward cursor: row- and transaction-level iteration. |
writer |
Single-file write-once cursor with atomic finalize. |
rotate |
Directory-aware writer that rotates files by size and threads vclocks. |
dir |
Immutable in-memory index of a journal directory; locate files by LSN/vclock. |
follow |
Tail a live file or a rotating directory — block for appended rows, switch files on rotation. |
filter |
Composable row predicates (And/Or/Not, by replica, type, LSN range). |
pipe |
Stream filtered transactions from a reader to a writer. |
tools |
Meta-only rewrites that preserve payload bytes and CRCs. |
reader gives you a forward-only cursor over a single file. Open it depending
on who owns the underlying stream and where the bytes live:
r, err := reader.Open("data.xlog") // Reader owns the file.
r, err := reader.OpenFS(fsys, "data.xlog") // Read from an fs.FS.
r, err := reader.NewReader(readSeeker) // Caller owns the stream.
r, err := reader.OpenMmap("data.xlog") // Memory-mapped, zero-copy.
r, err := reader.NewReaderBytes(buf) // A journal already in memory.Inspect the header via Meta(), then iterate at whichever granularity fits:
r, _ := reader.Open("data.xlog")
defer r.Close()
meta := r.Meta()
fmt.Println(meta.Filetype, meta.Version, meta.VClock)
// Row at a time:
for {
row, err := r.Next()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
// ... use row
}
// Or transaction at a time (rows grouped by TSN):
for {
tx, err := r.NextTx()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
fmt.Printf("tx starting at lsn=%d, %d rows\n", tx.StartLSN, len(tx.Rows))
}Range-over-func equivalents, Rows() and Txs(), surface errors as the second
loop variable:
for row, err := range r.Rows() {
if err != nil { log.Fatal(err) }
// ...
}Reader options:
SkipCorruptTx()— on a CRC mismatch or unknown magic, scan forward to the next valid block instead of erroring. For salvaging damaged files.IgnoreMissingEOF()— treat a missing EOF marker as a clean end of stream. Useful when tailing a file an instance is still writing.AcceptV012()— accept the legacy0.12format-version line in the meta header (the current format is0.13). The legacyServer:alias for theInstance:UUID line is accepted regardless of this option.WithAliasBodies()— letRow().BodyRawalias the read buffer instead of copying it (see the zero-allocation cursor below).
Mid-stream failures map to sentinel errors you can test with errors.Is:
ErrTruncated, ErrCorruptCRC, ErrUnknownMagic, ErrTxTooLarge,
ErrZeroLengthDecode.
For throughput-sensitive consumers, Scan/ScanTx decode rows into
reader-owned scratch instead of allocating per row. Row() (and Tx()) return
the current row(s); check Err() after the loop.
for r.Scan() {
row := r.Row()
// ... use row
}
if err := r.Err(); err != nil { log.Fatal(err) }By default each row's body is copied into a reader arena, so rows stay valid
until you call Recycle() — accumulate a batch, process it, Recycle() to
reclaim the arena, repeat. With WithAliasBodies() the body instead aliases the
read buffer (valid only until the next Scan), for the fastest streaming path
when you fully consume each row before advancing. Paired with
OpenMmap/NewReaderBytes, aliased bodies are zero-copy and stay valid for
the reader's lifetime, since the backing buffer outlives the cursor.
NextBlockRaw() returns the next physical transaction block's on-disk bytes
(fixheader + payload) verbatim, CRC-verified, without decoding any rows — the
read half of the verbatim-copy fast path (see
pipe.CopyRaw).
writer produces one well-formed file. Create writes to <path>.inprogress
and atomically renames it to the final name on Close, so readers never observe
a partial file. NewWriter writes to any io.Writer (e.g. a bytes.Buffer)
without the rename dance.
meta := &format.Meta{
Filetype: format.FiletypeXLOG,
InstanceUUID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
VClock: format.VClock{1: 0},
}
w, err := writer.Create("out.xlog", meta)
if err != nil {
log.Fatal(err)
}
// Accumulate rows into the pending tx, then commit them as one block:
w.WriteRow(format.XRow{Type: iproto.IPROTO_INSERT, LSN: 1, BodyRaw: body1})
w.WriteRow(format.XRow{Type: iproto.IPROTO_INSERT, LSN: 2, BodyRaw: body2})
w.CommitTx()
// Or write a whole transaction in one call:
w.WriteTx([]format.XRow{
{Type: iproto.IPROTO_REPLACE, LSN: 3, BodyRaw: body3},
})
if err := w.Close(); err != nil { // EOF marker, fsync, atomic rename.
log.Fatal(err)
}XRow is passed and returned by value throughout the read/write API; slices
([]format.XRow) keep the read→write pipeline copy-free. The single-row
WriteRow/Next pay only a small struct copy.
Request types, IPROTO header/body keys, and flag bits come from
github.com/tarantool/go-iproto —
XRow.Type is an iproto.Type and XRow.Flags an iproto.Flag, so you write
iproto.IPROTO_INSERT, iproto.IPROTO_FLAG_COMMIT, etc. format.TypeName
renders a type as a short string ("INSERT").
Transaction blocks larger than 2 KiB are zstd-compressed automatically (matching
Tarantool's threshold and level). Use Discard() instead of Close() to
abandon the .inprogress file without promoting it.
Writer options:
WithCompression(format.Compression{...})— set the block compression policy:Disabled(never compress),Level(zstd level, default 3),Threshold(min payload bytes to compress, default 2 KiB).NoCompression()is sugar forDisabled: true.Version(s string)— setMeta.Versionif the caller left it blank.Sync(mode)—SyncNormal(default,fsyncon close),SyncNone(no sync), orSyncDataSync(fdatasyncon Linux). CallSync()explicitly any time to flush mid-stream.
WriteRow/WriteTx emit one logical transaction per call. To pack many
independent transactions into one compressed block — the shape Tarantool's own
xlog uses (e.g. ~50 single-row autocommit txs per ZRowMarker block) — use
WriteBlock (one physical block, rows' own TSN/commit flags delimit the
transactions inside) or the higher-level BatchWriter, which buffers whole
transactions and flushes a block when a MaxTxs/MaxBytes threshold trips:
bw := writer.NewBatchWriter(w, writer.BatchOptions{MaxTxs: 50})
for _, tx := range incoming {
if err := bw.WriteTx(tx.Rows); err != nil { log.Fatal(err) }
}
bw.Close() // Flushes the final block.BatchWriter encodes each transaction eagerly, so it never retains the caller's
rows — you can feed it rows straight from a WithAliasBodies() reader with no
cloning.
pipe.Copy streams transactions from a reader to a writer, applying optional
filter predicates. Filtering is transaction-aware: if any row in a
transaction matches, the whole transaction is written; otherwise it is dropped.
Partial transactions are never emitted.
src, _ := reader.Open("in.xlog")
defer src.Close()
dst, _ := writer.Create("out.xlog", src.Meta().Clone())
defer dst.Close()
// Keep only DML from replica 1 with LSN in [101, +∞):
keep := filter.And(
filter.ReplicaIDs(1),
filter.Types(iproto.IPROTO_INSERT, iproto.IPROTO_REPLACE, iproto.IPROTO_UPDATE, iproto.IPROTO_DELETE),
filter.LSNRange(1, 101, math.MaxInt64),
)
rows, err := pipe.Copy(src, dst, keep)Filter constructors compose with And, Or, Not: FromVClock,
UntilVClock, ReplicaIDs, Types, LSNRange. Predicates are read-only —
they must never mutate the row.
For a pure copy or truncate where rows are not transformed, pipe.CopyRaw
forwards each physical block's on-disk bytes verbatim — no row decode, no
re-encode, no recompression, no second CRC. A compressed block stays compressed
on disk. It is dramatically faster than Copy but cannot filter (filtering
needs row-level decoding).
blocks, err := pipe.CopyRaw(src, dst) // Verbatim block-for-block copy.rotate.RotatingWriter writes a chain of files into a directory, rotating to a
new file when the current one crosses a size threshold (default 64 MiB).
Rotation only ever happens between transactions, and each new file's
PrevVClock is set to the previous file's VClock.
rw, err := rotate.New(
"/var/lib/tarantool/wal",
format.FiletypeXLOG,
instanceUUID,
format.VClock{1: 0}, // Starting vclock.
rotate.MaxFileSize(128<<20),
)
if err != nil {
log.Fatal(err)
}
for _, tx := range incoming {
if err := rw.WriteTx(tx.Rows); err != nil {
log.Fatal(err)
}
}
rw.Close()dir.OpenDir (or OpenDirFS) scans a directory and builds an immutable,
ordered index of its files. Use it to find which file holds a given position:
d, _ := dir.OpenDir("/var/lib/tarantool/wal", format.FiletypeXLOG)
for _, e := range d.Files() {
fmt.Println(e.Path, e.VClock, e.Signature)
}
entry, err := d.LocateLSN(1, 4096) // File containing replica 1's LSN 4096.
entry, err = d.LocateVClock(format.VClock{1: 4096})FileEntry exposes a lazily-opened Reader, so locating a position and reading
from it is cheap.
tools.RewriteMeta rewrites only the text header and byte-copies every
transaction block verbatim, so CRCs and compressed payloads are preserved
exactly. This is the safe way to fix up UUIDs or remap replica IDs in an
existing file.
// Replace the instance UUID, leaving all rows untouched:
err := tools.RewriteMeta("in.xlog", "out.xlog",
tools.ReplaceInstanceUUID(uuid.MustParse("22222222-2222-2222-2222-222222222222")))
// Remap replica IDs in the vclock (old → new):
err = tools.RewriteMeta("in.xlog", "out.xlog",
tools.RemapVClock(map[uint32]uint32{1: 5}))
// Or supply an arbitrary transform:
err = tools.RewriteMeta("in.xlog", "out.xlog", func(m *format.Meta) *format.Meta {
m.PrevVClock = format.VClock{1: 100}
return m
})The library is built for high-throughput reading, repacking, and dumping:
- Zero-allocation row cursor —
Scan/ScanTxdecode into reader-owned scratch; a steady-state loop allocates nothing per row.WithAliasBodiesremoves the per-row body copy entirely. - Zero-copy in-memory readers —
OpenMmap(memory-mapped) andNewReaderBytesslice transaction blocks straight out of the backing buffer, eliminating the per-block copy and read syscalls; uncompressed bodies alias the buffer end-to-end. - Verbatim repack —
pipe.CopyRaw/reader.NextBlockRaw/writer.WriteRawBlockforward whole blocks byte-for-byte, skipping decode, re-encode, recompression, and the second CRC — far faster than a decoding copy for unfiltered copies. - Hardware CRC32C — checksums use the CPU's Castagnoli instructions via the standard library's accelerated path.
- Batched compression —
BatchWriterpacks many transactions into one zstd block; the level/threshold are tunable viaWithCompression. - Zero-clone pipeline — value-semantics
XRowplus eager-encodingBatchWriterlet rows flow from aWithAliasBodiesreader to the writer without any cloning.
The byte-level codec, with no I/O or filesystem concerns. Defines the core data
types — Meta, XRow, VClock, Filetype — and the encode/decode functions
(EncodeMeta/DecodeMeta, EncodeXRow/DecodeXRow,
EncodeTxBlock/DecodeTxBlock), the magic markers
(RowMarker/ZRowMarker/EOFMarker), layout/version constants
(FixheaderSize, CompressThreshold, ZstdLevel, FormatVersion), the
VyKey* vinyl-log body keys, and TypeName. Request types and IPROTO
header/body keys and flags come from github.com/tarantool/go-iproto
(iproto.IPROTO_*). Use it directly if you need to work below the cursor level.
Single-file, forward-only cursor.
Open/OpenFS/NewReader/OpenMmap/NewReaderBytes construct it; Meta,
Next, NextTx, Rows, Txs, and Close drive it.
Scan/ScanTx/Row/Tx/Err/Recycle are the zero-allocation cursor;
NextBlockRaw reads physical blocks verbatim.
Single-file, write-once cursor that produces an atomically-finalized file.
Create/NewWriter construct it; WriteRow, CommitTx, WriteTx, Sync,
Close, and Discard drive it. WriteBlock and BatchWriter pack many
transactions per block; WriteRawBlock writes a pre-framed block verbatim.
Directory-aware writer producing a vclock-threaded chain of files, rotating by size.
Immutable in-memory index of a journal directory, with LocateLSN /
LocateVClock lookups.
Tail journals as they are written — the read-side counterpart to
writer/rotate. follow.File blocks for rows appended to one file and ends
when the file is finalised (its EOF marker appears); follow.Dir reads a
rotation chain and switches to the next file when the current one finalises,
validating chain continuity. Both are iter.Seq2 iterators (with
FileTx/DirTx transaction-level variants) cancellable via context.Context:
for row, err := range follow.Dir(ctx, walDir, format.FiletypeXLOG, follow.WithFromHead()) {
if err != nil { return err }
// ... process row as the writer appends it ...
}A directory follow requires a start position (WithFromHead / WithStartLSN /
WithStartVClock). Growth is observed by polling by default (no extra
dependency, configurable via WithPollInterval); supply your own WithWatcher
to plug in event-based watching. WithReadInprogress tails the active
.inprogress file for lower latency. For checkpointing across restarts, the
Follower type exposes Offset().
Composable, read-only row predicates for use with pipe.Copy.
Copy — transaction-aware streaming from a reader to a writer with optional
filters. CopyRaw — verbatim block-for-block copy (no decode/re-encode), for
unfiltered copy/truncate.
Meta-only rewrites (RewriteMeta, ReplaceInstanceUUID, RemapVClock) that
preserve payload bytes and CRCs.
Full API docs: pkg.go.dev/github.com/tarantool/go-xlog.
- Versions: writes
0.13; reads0.13by default and0.12withAcceptV012(). - File types:
XLOG,SNAP,VYLOG,RUN,INDEX. - Integrity: CRC32C (Castagnoli) per transaction block, validated on read, computed on write.
- Compression: zstd for transaction payloads over 2 KiB; transparent on both read and write.
- Compatibility: validated against a frozen corpus of real files from
Tarantool 2.11–3.8 (see
testdata/README.md).
go test ./... # Unit + round-trip + compat tests.
go test -tags tarantool ./internal/integration/... # Live Tarantool (needs a binary).A Makefile wraps the common tasks (make help lists them all):
make test # Unit/round-trip/compat tests.
make test-race # With the race detector.
make test-integration # Live-Tarantool suite (tarantool build tag).
make lint # golangci-lint.
make bench # Benchmarks (BENCH=<regex> PKG=<pkg> to narrow).
make fuzz # Fuzz every target (FUZZTIME=10s to shorten).
make ci # Lint + tests, the pre-push gate.The compatibility suite (internal/compat) replays the historical corpus; the
integration suite (internal/integration, gated by the tarantool build tag)
round-trips against a live instance. Fixture provenance and regeneration are
documented in testdata/README.md.
examples/ holds two runnable commands built on this library —
cat and play, pure-Go reimplementations of tt cat and tt play that read
and decode journal files without a Tarantool runtime. They live in their own
module so their dependencies stay out of the core graph. See
examples/README.md.
Pre-1.0. The API may still shift before a tagged release. The on-disk format handling is the most thoroughly exercised part of the library; the higher-level directory and rotation helpers are newer.