mirexa.
Technical handbook · checked against source 2026-10-17

How Mirexa works

Mirexa is a time-series database for financial candles and ticks, written in Go. Clients read and write over a binary TCP protocol, and every acknowledged write is fsynced first. It runs on one node or as a cluster.

Language
Go, one binary
Data
OHLCV candles, ticks
Partitions
4,096 by default
Data port
:8080 TCP
Consensus
Raft, metadata only

Three parts: Storage for what a single node does, Clustering for how nodes share the work, and The client for programs that talk to it.

Each claim on this page names the test that pins it. Numbers carry the date they were measured.

Part I

Storage

What one node does with a candle: log it, hold it, compress it, find it again. Everything here is true with clustering switched off, which is the default.

Chapter 01

The shape of it

Two paths, a write path and a read path, meet at one object: the dispatcher. It is the only part of the engine that knows about both.

Network edge TCP or TLS · handshake · frame parsing Dispatcher one writer per stream · owns the WAL WRITE READ 1 · WAL record, checksum, fsync 2 · Hot cache columnar ring per stream Flusher classify gaps · compress Chunks on disk chunks/ stream.idx gap.idx Planner cache first, then index Merge + gap overlay ascending, gaps inline reads cache reads chunks
Fig 1.1 · The spine. Orange marks the durability boundary; blue marks durable, verified storage.
The invariant

WAL first, cache second. A candle never reaches the hot cache, and from there a chunk, without first appearing in the log. Crash recovery follows from that order.

✓TestCrash_KillDashNinekill -9 mid-ingest, restart, every acked candle present
Chapter 02

Streams and records

A stream is one instrument at one timeframe, and every record in it is fixed width.

A stream is named by three strings: exchange, symbol and timeframe, for example XNAS / AAPL / 1m. The names travel in the payload of every request and become directory names on disk, so each part is limited to letters, digits, . _ -, 1 to 64 bytes, and never . or ... That check is the security boundary for the file layout.

A candle is a timestamp and five 8-byte numbers: open, high, low, close, volume. On the wire it is 49 bytes with a flag byte; in the WAL it is 48. A tick is 24 bytes on the wire and grows to 32 in the WAL, because each entry repeats its stream's hash so it can be recovered alone.

On disk a stream's files sit under its partition's directory. What a partition is, and why all timeframes of one symbol share one, belongs to clustering: see Partitions.

Chapter 03

Closures and losses

Most time-series stores see a hole as a hole. Mirexa records why it is there, because a weekend and an outage call for opposite actions.

Fig 3.1 · One week, hourly. Hatching: the stream's calendar says the exchange was shut. Orange: the exchange was open and nothing arrived. Candles synthetic.

Each stream can carry a trading calendar in its configuration. When the flusher writes a chunk, it classifies every missing interval in that chunk against the calendar and files the result in the stream's gap index. A hole is classified once, at flush time, and never revisited. Correcting a calendar later changes how future holes are filed and leaves history alone.

Market closed

statusPERMANENT
reasonOFF_TIME
fillablenever

The node refuses a fill into it. Any candle written there would be an invented price, stored permanently. Real candles that simply arrive during "closed" hours are kept, because then the calendar is what was wrong.

Data missing

statusOPEN
reasonGAP
fillableyes

A backfill can repair it. Once the fill is durable the gap is marked CLOSED, and the record is kept: knowing a range was repaired is provenance.

A LOSS CAN BE REPAIRED OPEN · GAPfillable fill written,durable first CLOSED · GAPkept: this range was repaired no longer countsas missing time A CLOSURE CANNOT BE FILLED PERMANENT · OFF_TIME fill request refused by the node before anything is written
Fig 3.2 · A gap closes only after its fill is on disk; marking it first would report a repair a crash could undo.
✓TestFlusher_ClassifiesGapsWithMetadata
✓TestCorrection_RefusesToFillAMarketClosurealso proves a real outage in the same chunk still fills
✓TestDashboard_DiscoverGapThenFillItThenItIsGone
Chapter 04

The write path

A batch is checked completely before anything irreversible happens, logged before it is applied, and acknowledged only once it is on disk.

  1. 1

    Parse the frame

    Magic, version, reserved bits and flags are checked, the payload size limit enforced, and a CRC-32C verified over the payload.

  2. 2

    Check permission

    The connection's grant was fixed at the handshake. A write needs WRITE_CANDLES.

  3. 3

    Find the partition's owner

    In a cluster, a node that is not the partition's primary answers with a redirect before dispatching anything. A write carrying an out-of-date epoch is refused the same way.

  4. 4

    Find or build the stream's writer

    The first write to a stream creates its directories, opens its two indexes, finishes any chunk install a crash interrupted, and builds an empty hot cache.

  5. 5

    Admit the batch

    Every candle is checked against the hot cache before anything is logged: range, alignment to the timeframe, width. A batch that will never fit is a client error. A batch that would fit once the cache flushes waits for it: the writer asks for the eviction and waits up to 3 × 10 ms (admission_retries, admission_wait). Only then is it shed with Overloaded, and its retry hint is about one eviction, not the whole flush interval.

    ✓TestIngest_ArenaRejectionLogsNothing
    ✓TestIngest_AFullHotCacheWaitsForTheFlushInsteadOfRefusing
  6. From here the write cannot be taken back
  7. 6

    Log it

    The batch becomes one WAL record in the writer's buffer. A background flusher writes and syncs buffers on a short interval. If the buffer is full (4 MiB by default) the write waits up to 2 s, then is shed having stored nothing.

  8. 7

    Apply it to the cache

    Each candle lands in its slot. Slots it skipped are marked as gaps before the length moves forward, and the length is published last with an atomic store, so a reader never sees a slot half-written.

  9. 8

    Tell readers

    The query cache for the stream is invalidated and live subscribers get the batch.

  10. Durable on disk
  11. 9

    Acknowledge

    If the client asked for an ack, the request waits for the next sync that covers its record. One fsync serves every batch waiting on it. The ack is a durability receipt.

    ✓TestServer_AckedCandleIngestIsInWALBeforeAcksince 2026-09-04
Client AClient BDispatcherWAL bufferDisk candle batch · ack admit append · seq 41 candle batch · ack append · seq 42 write + fsync, once synced through 42 41, 42 durable INGEST_ACK INGEST_ACK WAL flush interval 500 µssync_policy: always
Fig 4.1 · Group commit. Concurrent acked writers share one fsync instead of queueing for one each.

Which failures a client may safely retry is covered in Writing safely.

Ticks take a shorter path: a WAL record, then straight to tick storage. They never enter the hot cache, which is organised by a fixed interval that ticks do not have.

Chapter 05

Hot cache and chunks

Recent candles live in memory in a fixed-interval ring. Every few seconds the oldest part is compressed into an immutable chunk file.

Fig 5.1 · A slot's timestamp is base + index × interval, so the cache stores no timestamp column and finds any slot by arithmetic.

The cache keeps five parallel columns (open, high, low, close, volume) plus a gap bitmap. A live candle stream holds up to 50,000 slots and keeps 25,000 after the flusher compacts it. Overwriting a slot already published goes through a small version overlay, so readers that hold no lock never see a torn value.

A cache's starting timestamp lives only in memory. After a restart, a floor at the stream's newest flushed chunk stands in for it, so a write cannot land on top of data already in a chunk. Every live batch holds the cache from its admission check to its last candle, and compaction waits for it.

From cache to chunk

  1. 1

    Compact

    On the flush interval (5 s for live candles) or when the arena fills, the flusher cuts the oldest slots out of the cache.

  2. 2

    Classify

    Every missing interval in the batch is filed as closure or loss against the stream's calendar.

  3. 3

    Compress

    Each column gets its own codec. The default, fixed-point delta-of-delta, measured 48.9× on flat data and 3.0× on realistic data, and never expands its input.

    ✓TestCompressionRatio_Regression
  4. 4

    Install

    The chunk is written as .pending, fsynced, renamed, and only then entered in stream.idx, all under an install lock that garbage collection respects. An index entry never names a file that is not there.

    ✓TestPersist_IndexEntryNeverNamesAMissingChunk
  5. 5

    Release

    The candles leave memory only after the chunk is installed, so reads see them in one place or the other at every moment.

    ✓TestCompact_EvictedCandlesStayReadableUntilRelease
writechk_007.pending fsyncthe bytes rename→ chk_007.bin fsyncthe directory index entrystream.idx releasefrom cache install lock · garbage collection waits a crash anywhere here leaves a file no index entry names GC ignores .pending files; the next open of the stream reconciles what a crash left from here readers find it
Fig 5.2 · The index changes last. A file can exist with no entry; an entry never exists without its file.
chk_007.bin · MAGIC "MIRX" · LITTLE-ENDIAN 80 B6 × 16 Bcompressed columns, each with its own codec32 B headercount, range pointersoffset, codec time open high low close volume footer2 checksums data checksum: header + pointers + columns footer checksum No gap section: holes live in gap.idx, beside the chunk.
Fig 5.3 · One chunk file. Both CRC-32C checksums are verified on every read, at both ends of replication, and by the scrubber.

A correction to a bar that is already in a chunk is written as a patch chunk that names its base. A later correction folds the earlier one in, so a read is always the base plus one patch. Each chunk carries CRC-32C checksums over its data and footer, checked on every read.

Chapter 06

On disk

Everything lives under one data directory, grouped by partition, then by stream. Every file starts with a magic number and a version, and an older version is refused rather than guessed at.

<data_dir>/ ├── streams.meta which streams exist; maps tick hashes back to names ├── raft/ consensus log, cluster mode only ├── wal/ │ ├── 000000001.log segments, header "MRXW" │ ├── shard_<n>/ tick WAL shards (16 by default) │ └── quarantine.floors how far each log reached when a partition was set aside ├── quarantine/ partitions moved aside, never deleted automatically └── partition_<id>/ ├── replica.wal/ only while this node is a copy: the primary's log, verbatim └── <symbol>_<timeframe>/ ├── stream.idx chunk catalog, "SIDX" v5 ├── gap.idx classified holes, "GIDX" v1 ├── chunks/ │ ├── chk_001.bin "MIRX": header, 6 column pointers, columns, footer │ └── chk_002_patch.bin a correction of an earlier chunk └── ticks/ ├── tick_index.bin "TIDX" v1 └── tick_chunk_000001.bin
FileEntryWhat it answers
stream.idx38 BWhich chunks cover which time range. Entries are ordered by time; the stable key is a sequence number, because positions shift when a back-fill lands below older chunks. The header records the highest sequence number ever issued, so none is reused after compaction.
gap.idx18 BStart, end, status, reason for every hole in flushed data.
tick_index.bin24 BThe tick chunks of a stream. Rebuilt by scan if needed.
*.logvariesWAL records: checksum, length, sequence, timestamp, type, payload. The length is read before the checksum that covers it, and bounds are checked first.

An index write is always a full rewrite: temporary file, fsync, rename, then an fsync of the parent directory. Without the last step a rename can be lost on XFS or btrfs while looking fine on ext4. A build-time test fails if a rename appears anywhere without its directory sync.

✓TestDurability_EveryRenameIsFollowedByADirectorySync

How the WAL stays bounded

wal/ · EVERY 15 MIN THE CHECKPOINT RUNS 000000001 000000002 000000003 000000004 000000005 000000006active AAPL 1m flushed through IBM 1m flushed through MSFT 1h flushed through safe point: the lowest stream ← reclaimed: every record is in a chunk
Fig 6.1 · A segment is deleted only when every stream has flushed past all of it. A stream whose chunk failed to install holds the point back.
✓TestCheckpoint_ReclaimsSegmentsAndKeepsEveryCandle
✓TestCheckpoint_DoesNotAdvancePastAFailedPersist
Rule

Only one open handle may write a stream's stream.idx. Each handle rewrites the whole file from its own memory and issues sequence numbers from its own counter, so two handles erase each other's entries. Every writer gets the pair from the dispatcher.

Chapter 07

The read path

A query reads the hot cache first and the chunk catalog second, merges the two into one ascending series, and stamps every hole with its classification.

QUERY RANGE chunk 1 chunk 2 gap · OPEN chunk 3 hot cache cache wins from here chunks clipped to the cache's span
Fig 7.1 · Segments never overlap: the planner clips chunk segments where the cache's span begins, so the merge needs no de-duplication.
  1. 1

    Note the cache generation

    Before reading anything, the query notes the stream's invalidation counter. If a write lands mid-query, the answer is not stored in the query cache.

  2. 2

    Read the hot cache, then the index

    In that order. A flush finishing between the two reads leaves its batch in the cache view or the index, never neither.

    ✓TestQueryCandles_AFlushDuringPlanningCannotHideItsBatch
  3. 3

    Merge

    A k-way merge over the cache segment and each chunk segment. A chunk is read with a plain file read and decoded only as far as the range needs, into reused buffers. Decoded chunks are kept in a 256 MiB cache (storage.decoded_chunk_cache_bytes), keyed by the file’s identity, so a rewritten chunk is never served stale.

    ✓TestChunkRead_AOneDayRangeDecodesOnlyWhatItNeeds
    ✓TestDecodedChunkCache_AReplacedChunkIsNeverServedStale
  4. 4

    Overlay gaps

    Holes from gap.idx are joined in, so a weekend in a flushed chunk comes back marked as a closure.

    ✓TestQueryCandles_GapClassificationSurvivesToTheResult
  5. 5

    Clip and send

    A query with a limit reads only the end of the range it keeps: newest first for a descending query, so “latest 100” touches the newest chunk, or the cache alone. Candles and ticks work the same way. Results stop at the limit or one frame. The response is one socket write; a client that stops reading is disconnected after the write timeout.

    ✓TestServer_DescendingWithMaxResultsReturnsTheNewest
    ✓TestQueryCandlesLimited_AgreesWithTheFullQuery
    ✓TestTickStorage_LatestNOpensOnlyTheChunksItReturns
Measured 2026-10-16 and 2026-10-17

A query over flushed chunks costs about 15 µs in-process (BenchmarkQueryCandles_FlushedChunks), down from about 60 µs while each chunk was memory-mapped: mmap and munmap take the address-space lock, so every query waited on every other. One node now serves 61–106K reads per second while repairing a chunk, and make perf-check fails below 50K. Over the network, in the benchmark, a day of one stream (1,440 bars) comes back in about 0.6 ms.

✓TestPerfCheck_APrimaryRepairsWhileServing50KReadsPerSecond
Part II

Clustering

How many nodes share 4,096 partitions: who owns what, how data follows ownership, and how the cluster notices and repairs what went wrong.

Chapter 08

Partitions

A partition is the unit everything in a cluster is decided about: who owns it, who copies it, where it moves. Streams map to partitions by a hash that never changes.

XNAS / AAPL / 1m XNAS / AAPL / 1h XNAS / AAPL / 1d XNYS / IBM / 1m key = XNAS·AAPL hash % 4096 key = XNYS·IBM hash % 4096 partition 1,207 all three timeframes, one node partition 3,018 another symbol
Fig 8.1 · The partition key is exchange and symbol only. Partition numbers here are illustrative.

The partition count is fixed once a cluster agrees on it. Routing is hash % count, so changing the count would move every stream at once, with reads finding nothing in the meantime. The engine refuses the change in its replicated state machine rather than trusting operators not to try. Partitions move between nodes through a directory, one level up, which is why consistent hashing was not needed.

✓TestAssignPartition_DistributionIsUniform40,000 keys, χ² 3750.6 vs critical 4243.9
✓TestAssignPartition_IsStableAcrossBuildsgolden values, so a hash change fails the build
streamXNAS/AAPL/1m hash % 4096 partition 1,207fixed forever directory P1207 → primary: node A copies: B, C in sync: A, B epoch: 1180 NEVER CHANGESCHANGES WHEN DATA MOVES The count never changes; moving a partition edits one directory entry.
Fig 8.2 · The hash decides the partition; the directory decides the node. Values illustrative.

When a cluster first forms, the leader places every partition in one Raft entry, spreading them by count across the online nodes. A node that joins a fully placed cluster gets nothing at first; moving data to it is a rebalance, which is a separate and opt-in decision (Copies and moves).

Each directory entry names one primary, a list of copies, the subset of copies that are in sync, and an epoch: the Raft log index of the entry that installed the current primary. The epoch only moves when the primary changes, so adding a copy never disturbs writes to a partition.

✓TestAssignPartition_DistributionIsUniform40,000 keys, χ² 3750.6 vs critical 4243.9
✓TestAssignPartition_IsStableAcrossBuildsgolden values, so a hash change fails the build
✓TestState_TheEpochDoesNotMoveWhenThePrimaryDoesNot
Chapter 09

Two planes

The control plane decides who owns each partition. The data plane stores and serves the data. The two are deliberately detached: no candle or tick ever passes through consensus.

CONTROL PLANE · RAFT Node registrywho is a member Partition directoryprimary, copies, epoch Settingscount, codec, T heartbeats, leases Node Avoter P7 · primary P12 · copy Node Bvoter P7 · copy · in sync P12 · primary Node Cvoter P7 · copy · syncing pull
Fig 9.1 · Copies dial their primary and pull. The cursor lives with the party that must survive losing it.
CONTROL PLANE · RAFT LOG Decides, slowly and agreed · node registry, lifecycle · partition directory + epochs · settings: count, codec, T · migrations in progress a few entries per minute; every one costs a quorum commit 3 voters; the rest non-voters DATA PLANE · WAL AND CHUNKS Serves, fast and local · candles and ticks · WAL, hot cache, chunks, gaps · primary → copy replication · reconciliation between copies hundreds of thousands of records a second; acked on one node's fsync directory, readlocally once applied heartbeats leases candles, ticksnever Losing quorum stops ownership changes. A primary that cannot renew its lease stops serving within ¾ T, because it can no longer rule out having been replaced. Nothing on the write or read path waits on Raft.
Fig 9.2 · What crosses between the planes, and what never does.

Three reasons keep data out of consensus. A quorum commit per batch would add a network round trip and a second fsync to an ingest path that acks in well under a millisecond. The same bytes would be written durably twice, once in the Raft log and again in the WAL and chunks. And candle ingest does not need the total order consensus provides, because it is idempotent by stream and timestamp.

The price is fencing. In a Raft-replicated design an old leader simply cannot commit. Here a directory entry naming a new primary does nothing to the old one by itself, which is why the engine has epochs on writes and leases on primaries. And because copies are asynchronous, a primary that crashes can lose writes it acked after its copies' last pull.

Every node runs the same binary. A new node joins through a seed list, is admitted to the Raft group as a non-voter, and replicates the directory locally, so routing a request is a map lookup on the node itself. The leader promotes non-voters toward a fixed number of voters, three by default, never to an even count.

Raft holds three things only: the node registry, the partition directory and a few cluster-wide settings (partition count, codec, liveness timeout). A heartbeat does not write to the log; only the decision it justifies does, such as fencing a node that went silent.

✓TestDegradedMode_TheDataPlaneSurvivesLosingControlPlaneQuorumtwo of three voters stopped; ingest, checkpoint and reads go on
✓TestServerBinary_APrimaryStopsServingWhenItsControlPlaneIsGone
Chapter 10

Membership

Nodes join through a seed list, become consensus members before they build anything, and leave by draining. The leader, never the node itself, changes who votes.

JOIN ask a seedany member non-voteradmitted to Raft catch upread settings build storagecluster's values ONLINEcan be placed the leader promotes toward 3 voters (odd only) if this node's log is within 256 entries LEAVE DrainNodeLEAVING primariesmove first copiesnone dropped decommissionedrecord kept removedfrom Raft exit 0 A decommissioned id is refused registration and re-admission until an operator deregisters it.
Fig 10.1 · Joining and leaving. Every step on both rows is driven by an ordinary replicated entry, so a crash midway resumes rather than restarts.

A seed is a contact point, not a leader address: any member can say who leads. A node configured with a member list bootstraps a new cluster instead, and seeds itself, so there is one registration path for everyone. Once running, a node reaches the cluster through the members its own state knows, leader first, and falls back to its seeds only after them.

Voter membership changes on failure and departure only. A draining voter counts as already gone, so a replacement is promoted before it is removed, and the group never shrinks from 3 to 2. A leader that is being decommissioned hands leadership to another voter before it is removed, so the group is never leaderless.

Heartbeats run every third of the liveness timeout and never write to the log. A node silent for the full timeout is fenced; a fence and a drain are independent facts, so a drain survives the node crashing.

✓BenchmarkJoin_SeedToOnline111 ms from start to ONLINE, 2026-10-07
✓TestServerBinary_ADecommissionedNodeExitsOnItsOwn
✓TestPromotionSweep_ADecommissionedLeaderHandsLeadershipAwayFirst
Chapter 11

Routing

Every node holds the directory, so any node can answer where a partition lives. A node that does not own a partition redirects; it never forwards.

The epoch

Each partition's directory entry carries an epoch: the Raft log index of the entry that installed its current primary. It changes only when the primary changes. Clients send the epoch they believe with every write, and a primary refuses an older one, answering with a redirect that carries the current epoch so the client's cached route corrects itself. The epoch stops stale clients. A primary that is itself out of date is stopped by its lease: see When a node dies.

ClientNode BNode A · primary of P7 INGEST · XNAS/AAPL/1m · no epoch yet P7 is not minenothing dispatched REDIRECT · P7 · epoch 1180 · node A · addr cache: P7 → Aat epoch 1180 INGEST · epoch 1180 · first attempt here owner, epochmatches INGEST_ACK
Fig 11.1 · Redirect, not forward. Node B holds nothing, so following the redirect is safe even for a write. Epoch value illustrative.
✓TestState_TheEpochIsMonotonicAcrossAControlPlaneLeaderChange
✓TestMilestoneA_EveryStreamLandsOnTheNodeTheDirectoryNamesthree nodes, 4,096 partitions, one client given one address
Chapter 12

Copies and moves

A copy is byte-identical to its primary by construction: it receives the primary's own chunk files and its WAL records, verbatim.

Seed

Ships the primary's flushed artifacts: chunk files, index entries, gap lists. Both ends verify each chunk's own checksums, so rot on the primary's disk never reaches a copy. The seed also compares what the copy already holds below its position and refuses another history.

Tail

Streams WAL records after the copy's position into a per-partition replica log. The copy does not re-ingest. On promotion, that log is replayed the way a crash's WAL is.

Primary · node A chunks + stream.idx + gap.idxflushed, immutable WALrecent writes, not yet in chunks Copy · node B chunks + stream.idx + gap.idxbyte-identical replica.wal/A's records, verbatim; no re-ingest Seedverified both ends Tailafter B's position B dials; A streams on promotion: replay replica.wal, then serve
Fig 12.1 · A copy holds the primary's own files. That is what lets a chunk be compared and repaired byte for byte.

A copy proposes itself in sync once it reaches the primary's durable position. It leaves the in-sync set on its own if it stays behind for longer than two liveness timeouts plus two seconds, but only while the primary is still believed alive: when the primary dies, every copy stops keeping up at once, and none of them fell behind.

NONE SYNCING IN SYNC PRIMARY DIVERGENT copy added caught up tothe primary behind > 2T + 2 s flip orfailover moved away: carried on as a copy, in-sync set reset copy removed failover chose another copy quarantined, re-admitted, seeded from nothing
Fig 12.2 · The state is never stored. It is read off the directory's three sets: primary, copies, in-sync. Only an in-sync copy can become primary.

A graceful move

  1. 1

    Freeze

    The old primary stops admitting writes for the partition and waits until every write already admitted has finished. Writes arriving now are shed and the client resends.

  2. 2

    Final seed and tail

    The new primary pulls until it covers the frozen durable position, then checks coverage.

  3. One Raft entry: the commit point
  4. 3

    Flip

    A single directory entry names the new primary and a new epoch. If the answer is ambiguous, the old primary stays frozen; it never thaws under a flip that may have committed.

  5. 4

    Promote

    The new primary replays its replica log and starts serving. Clients follow redirects.

✓TestCutover_UnderConcurrentWritesNoAckedWriteIsLost
✓TestCutover_TheOldPrimaryAcksNoWriteBeforeItHasAppliedTheFlip
Old primary · ANew primary · BRaft frozen:writes shed,clients resend Drain P7 wait out admitted writes frozen at durable position W final seed tail until cursor ≥ W verify coverage propose flip: P7 → B, new epoch COMMIT POINT applied replay replica logserve P7 applied, a moment later redirects P7 to B; never thaws
Fig 12.3 · Before the commit point anything can roll back by thawing A. After it, A stays shut, even if the proposal's answer was lost.

An operator moves a partition with one request; reconcilers carry out the steps. A planner can also suggest moves that even out bytes across nodes, and acting on them without approval is opt-in. Draining a node moves its primaries first, then its copies, never dropping a copy to do it.

Known gap · measured 2026-10-10

Under heavy write load a move freezes its partition for as long as the copy lags: 17 s at 133K acked candles/s, 31–32 s at about 290K/s. Nothing is lost, but the partition takes no writes during the freeze. Converging before freezing would fix it and has not been built.

Chapter 13

When a node dies

A primary serves only while it holds a lease from the control plane. A lease ends before the cluster can declare the node dead, so two primaries never serve one partition.

Fig 13.1 · Measured 2026-10-08 with T = 3 s, three runs. Default T is 30 s; every interval scales with it.
  1. 1

    Heartbeats renew the lease

    Each acknowledged heartbeat grants a lease of ¾ T, counted from when it was sent. Missing one heartbeat does not lapse it.

  2. 2

    A cut-off primary stops itself

    With no acknowledgement, its lease runs out and it refuses reads and writes with PartitionUnavailable, a quarter of a timeout before anyone can fence it.

    ✓TestLease_APrimaryCutOffFromTheControlPlaneStopsBeforeItCanBeFenced
  3. 3

    The leader fences it

    A node silent for T is marked offline. Other nodes stop redirecting to it and answer PartitionUnavailable instead.

  4. 4

    Failover

    One more timeout after first seeing the fence, the leader moves each of the dead node's partitions to an in-sync copy, in one conditional Raft entry. Every other copy is marked divergent, since it may hold chunks the new primary never had.

    ✓TestServerBinary_ADeadPrimaryIsFailedOverToItsCopySIGKILL, failed over 3.97–4.97 s after the fence
  5. 5

    The node returns

    Its divergent files are moved to quarantine/, never deleted, and it rejoins as a copy of the new primary, seeded from nothing.

    ✓TestDivergentCopy_ReturnsAsAnInSyncCopyOfTheNewPrimary
BEFORE · P7 · EPOCH 1180 A · primary fenced B · copy · in sync C · copy · syncing one Raft entry, valid only while A is fenced as the leader saw it AFTER · P7 · NEW EPOCH B · primary A · divergent C · divergent Either may hold chunks B never had. Both are quarantined, then re-seeded from B.
Fig 13.2 · The partition reads as under-replicated until the divergent copies rejoin; no copy silently disappears from the count. Epochs illustrative.
Not covered

Replication is asynchronous. A primary that crashes can lose writes it acknowledged after its copies' last pull. The lease prevents a split brain; it does not close this gap.

Chapter 14

Reconciliation

A copy should hold exactly its primary's flushed state. Reconciliation is how the cluster finds out when it does not, and what it does then: refuse, repair, or set aside.

Copies are byte-identical by construction, so the primary is the reference and no vote is needed. The question reconciliation answers is narrower: what can make a copy hold something else, and how would anyone notice?

What goes wrongHow it is foundWhat happens
A chunk rots on the primary's diskChecksums verified at both ends of every seed, and by the scrubberNever sent to a copy; repaired on the primary from an in-sync copy
A chunk rots on a copyThe copy's own scrubberRepaired from the copy's primary, and only from it
A copy holds another history below its position (files kept from an old role, a failover it missed)The seed's reference: per artifact, the primary's index entry and data checksumCopy leaves the in-sync set, is moved to quarantine/…-diverged, seeded from nothing
The primary's retention dropped old chunksWhere the primary's oldest base begins, sent with the referenceCopy retires the same entries, only after its history was accepted
A gap fill or correction on the primaryPatch chunks and the gap list travel in every seedCopy installs the patch; its gap list is replaced, not merged
PRIMARY SENDS, PER STREAM issued-through mark entry + data checksum for every artifact ≤ copy's floor retained-from time copy comparesbefore installing same entry, same sumkeep, seed only what is above copy lacks an entrynot divergence: retention another historydiffers, lost, rotten, orabove the issued mark refuse,leave in-sync,quarantine,seed from zero Retention is mirrored only after the reference is accepted: a primary that lost its index reads as another history, and the copy's data is kept.
Fig 14.1 · The divergence check. It runs on every seed round, on both sides, before a byte is installed.
Primary · A chk_042 · rotten scrub: 8 MiB/s, budgetcapped at 10 s of reads each chunk once per pass Copy · B (in sync) chk_042 · verifies FetchArtifact · chk_042 bytes, verified by B A checks: B's entry is A's own,bytes verify, then rewrite underthe install lock Copy · C a copy asking another copy is refused:that is a version it was never sent
Fig 14.2 · Scrub and repair. The same call runs the other way when a copy's chunk rots: the copy asks its primary.

Rot is found only where something reads it, and a seed never re-reads what a copy already holds. That is why every node also scrubs. At the default rate, 100 GB takes about 3.3 hours per pass (arithmetic, not measured). Nothing deletes a quarantine automatically; it stays as evidence until an operator removes it.

MetricMeaning
mirexa_reconcile_rotten_chunksGauge to alert on. A chunk that stays here has no copy holding it whole.
…_scrub_bytes_total · …_scrub_cycles_totalThe scrubber is running and finishing passes.
…_repairs_totalChunks rewritten from another node.
…_divergences_totalCopies found holding another history.
…_retired_chunks_totalChunks a copy dropped to mirror its primary's retention.
✓TestReplicaSupervisor_ACopyHoldingAnotherHistoryIsMovedAsideAndSeedsAgain
✓TestReplicaSupervisor_APrimarysRottenChunkIsRepairedFromItsCopy
✓TestReplicaSupervisor_ACopysRottenChunkIsRepairedFromItsPrimary
✓TestReplica_ACopyDropsWhatItsPrimarysRetentionDropped
✓TestReplica_ACopyCutOffFromItsPrimaryReconcilesWhenTheLinkHealsbyte-identical and back in sync ~0.8 s after the heal, 2026-10-16
✓TestChunkScrubber_FindsEveryInjectedRotWithinOneFullCycle
Chapter 15

A node's life

A node starts in the order its parts depend on each other, and stops in nearly the reverse, so it never serves a partition it cannot vouch for and never stops serving before the cluster stops counting on it.

START configYAML · env observabilitylogs · metrics TLSdials seeds consensusjoin · settings layoutpartition count dispatcherWAL recovery data serverport 8080 control planereconcilers cluster planesregisters ready/ready: yes a primary serves nothing until its lease is granted and its replica logs are replayed STOP not readyLB moves off checkpointloop stops cluster planesstill serving data serverdrain conns controlgRPC off Raft dispatcherfinal drain heartbeats stop first: the cluster stops counting on this node before it stops serving its error aloneis fatal
Fig 15.1 · Both sequences live in one function each, so the order is stated once. A decommissioned node takes the same stop path and exits 0.
✓TestShutdown_TheClusterPlanesStopWhileTheDataPathStillServes
✓TestServerBinary_ADecommissionedNodeExitsOnItsOwn
Part III

The client

What a program talking to Mirexa needs to know: the SDK, what each failure means, live subscriptions, and the protocol underneath.

Chapter 16

The Go SDK

One import, no dependencies beyond the wire protocol and the data model. The SDK owns the parts that are easy to get wrong: retries, ambiguity, subscription accounting.

Your code clientmirexa/client · thin aliases, surface pinned WIRE CLIENT · WHERE THE LOGIC LIVES Client Batcher ResilientClient Observer Subscription ResumingSubscriber RoutingClient wire codecsframes, CRC-32C, INIT token · TCP or TLS to a node :8080 clienttestin-process serverreal codecs dashed: built,not exported yet
Fig 16.1 · Logic lives in the wire client, never in the public package, so the public layer stays a set of names.
TypeUse it for
ClientOne connection: ingest, query (single, multi, row iterator), subscribe, list streams and gaps.
BatcherA live feed. Flushes at MaxRecords or MaxDelay from the oldest buffered record, whichever is first. Close is not optional.
ResilientClientRedials on its own, retries what is safe, refuses to guess about what is not.
ResumingSubscriberA subscription that survives a dead connection, resuming one nanosecond past the last record it delivered.
ObserverYour metrics hook: retries, reconnects, refusals the server never saw.
clienttestTest your code against a server that answers, refuses or disappears on cue.

Besides the SDK there is mirexa-cli, which ingests CSV and queries to CSV over the same client, mirexa-token, which mints signed tokens offline, and the web dashboard, which holds one token for all its users and talks to a node through the same protocol.

✓TestClientPackage_DependencyFootprint
✓TestClientPackage_PublicSurfaceIsPinned
Chapter 17

Writing safely

An ack is a durability receipt. Every other outcome is one of three things: safe to resend, impossible to resend, or unknown. The SDK tells them apart so callers do not have to.

ModeReturns whenPromise
DurabilityAckedINGEST_ACK arrivesIn the WAL and fsynced on the node. The zero value, so the safe choice is the default.
DurabilityFireAndForgetBytes reach the socketNothing. A refused batch is dropped and you are never told.
IngestHistoryCandlesChunks are installedStronger than a live ack: the data is already in compressed chunks.
ShedOverloaded sent refused before the WAL nothing stored resend: first attempt Dead beforeidle connection closed 30 s quiet, server closes refused locally, 0 bytes sent sent fresh: first attempt Dropped duringmid-request sent may be applied no ack arrives ambiguous: read back
Fig 17.1 · Only the bottom case is uncertain, and only for writes. A read is retried in every case.

Retrying a write is safe only if the server provably did not keep it. Overloaded is raised before the WAL, so the batch was never stored and a resend is a first attempt. A timeout or dropped connection mid-request is different: the batch may have landed with only the ack lost. The client reports that as ambiguous and the caller reconciles by reading back. Ingest is also idempotent by stream and timestamp while a bar is in the hot cache, but the retry rule rests on the shed guarantee, not on that.

✓TestServer_ShedIngestAnswersOverloaded
✓TestResilient_AWriteAfterTheServerClosedAnIdleConnectionIsSentFresha write refused before sending is not ambiguous
closedcalls go through openfail fast, no dial probeone call admitted 5 failed dials after 30 s probe connects probe fails One breaker per node address, kept across attempts, so a dead node's failures add up.
Fig 17.2 · The resilient client's breaker, at its defaults. Only connection failures count; a server that answers with an error is up.

Overloaded is retried for you, three attempts in total by default, honouring the server's hint. Reads are retried across a reconnect. A Batcher moves the receipt: Add returning nil means buffered, a failed flush poisons the batcher, and only Close is guaranteed to report it.

✓TestIngest_RetriedShedBatchesLandExactlyOnce
✓TestResilient_InFlightWriteIsAmbiguousNotRetried
✓TestChaos_NoAckedRecordIsLosta proxy severs connections mid-request; every nil-returning ingest is present
Chapter 18

Subscriptions

A subscription can start with history and then turn live. The server numbers live batches so a dropped one shows up as a hole; history sits outside that numbering.

FRAMES AS THEY ARRIVE backfillseq 0 backfillseq 0 backfillseq 0 liveseq 1 liveseq 2 droppedseq 3 liveseq 4 Event.Live = true Event.Dropped = 1missing 3 … 3 overlap: a record ingested during thebackfill can arrive on both sides History is never a drop. One counter over both once reported 18,446,744,073,709,551,615 lost batches.
Fig 18.1 · Two sequence spaces. The guarantee at the pivot is no gap, not no duplicate; dedupe on stream and timestamp.

A dropped batch is gone, not late. The server drops when a subscriber's queue is full, and after drop_limit drops in total it evicts the subscriber with a SlowConsumer error. Recover with a query or a backfilling re-subscribe; a ResumingSubscriber does the second on its own after a disconnect. One subscription per connection, because the push stream is per connection.

✓TestServer_BackfillStreamsHistoryBeforeLive
✓TestSubscriptionRegistry_DroppedBatchLeavesSequenceGap
✓TestServer_SlowSubscriberIsEvictedWithSlowConsumerError
Chapter 19

The wire protocol

A small binary protocol over TCP. Every frame has a 14-byte header; every request carries a correlation id so responses can be matched on one connection.

Fig 19.1 · Header, big-endian. Reserved bytes and unknown flags are refused, not ignored.

A connection opens with INIT: an 84-byte frame carrying an Ed25519-signed token with tenant, expiry and a permission bitmap. The server clamps the grant to its own ceiling and returns it in INIT_ACK. Expiry is enforced during the session as well: a writer is refused on its next frame, a subscriber is disconnected by a timer.

acceptTCP TLSif enabled CONNECTINGINIT → INIT_ACK READYserve loop CLOSING CLOSED each request: parse → permission → dispatch an error frame keeps the connection transport failure,oversize frame,write timeout over max_connections:closed at once bad or expired token:refused with a reason one deadline for the whole exchange
Fig 19.2 · One TCP connection, accept to close. Subscriptions are dropped with the connection.
CodeFramePurpose
0x01 / 0x02INIT / INIT_ACKHandshake and permission grant
0x12 · 0x10INGEST_CANDLE_BATCH · INGEST_TICK_BATCHWrites; history back-fill is a flag on the same frames
0x11INGEST_ACKDurability receipt, only when requested
0x20REDIRECT_COMMANDServer to client: partition, epoch, node, address of the owner
0x30–0x32SUBSCRIBE · UNSUB · STREAM_DATALive push, optionally preceded by history
0x40 / 0x41QUERY_STREAM / RESPONSERange read, ascending or newest-first, gaps optional
0x42 / 0x43QUERY_MULTI / RESPONSEMany independent sub-queries in one round trip
0x44 / 0x45LIST_STREAMS / RESPONSEStream discovery, with its own permission bit
0x46 / 0x47LIST_GAPS / RESPONSEA stream's holes as ranges, with fillability
0xF0ERRORCode, retry hint in ms, message

Only Overloaded is retryable, and the Go SDK retries it for you. A redirect is not an error; the routing client follows it to the named node, which is a first attempt there because the refusing node dispatched nothing.

Part ·

Appendix

What is not built yet.

Chapter 20

Known limits

What the engine does not do yet, stated so nobody builds on it by accident.

LimitConsequence
Flushed tick chunks do not replicateA partition carrying ticks cannot be moved or failed over. Three refusals keep this loud rather than silent.
Authentication is off by defaultTurning it on refuses older clients. With it off, a client's permissions are self-declared, still clamped by the server's ceiling.
No node-wide memory budgetEach WAL writer's buffer is bounded; many streams together are not.
A migrated partition's files stay on the sourceThey are moved to quarantine/ and never served, but the disk is not freed.
Idle connections slow shutdownA node with connected clients waits its full shutdown timeout (30 s) before stopping.
Tick replay dedupes by timestampTwo trades at one timestamp in different records lose one on crash recovery. Fixed on a development branch, not merged yet.
A cutover under heavy writes freezes its partitionNothing is lost, but the partition takes no write while its copy catches up: 17–32 s at 133–290K acknowledged candles a second.
A compaction that evicts no candle files no gapA pause longer than the cache keeps but shorter than its arena loses its oldest holes from gap.idx.