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.
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.
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.
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.
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.
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.
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
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
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.
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
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
Check permission
The connection's grant was fixed at the handshake. A write needs
WRITE_CANDLES. - 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
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
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 withOverloaded, and its retry hint is about one eviction, not the whole flush interval.✓TestIngest_ArenaRejectionLogsNothing✓TestIngest_AFullHotCacheWaitsForTheFlushInsteadOfRefusing - From here the write cannot be taken back
- 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.
- 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.
- 8
Tell readers
The query cache for the stream is invalidated and live subscribers get the batch.
- Durable on disk
- 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
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.
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.
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
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
Classify
Every missing interval in the batch is filed as closure or loss against the stream's calendar.
- 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
Install
The chunk is written as
.pending, fsynced, renamed, and only then entered instream.idx, all under an install lock that garbage collection respects. An index entry never names a file that is not there.✓TestPersist_IndexEntryNeverNamesAMissingChunk - 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
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.
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.
| File | Entry | What it answers |
|---|---|---|
| stream.idx | 38 B | Which 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.idx | 18 B | Start, end, status, reason for every hole in flushed data. |
| tick_index.bin | 24 B | The tick chunks of a stream. Rebuilt by scan if needed. |
| *.log | varies | WAL 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.
How the WAL stays bounded
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.
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.
- 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
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
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
Overlay gaps
Holes from
gap.idxare joined in, so a weekend in a flushed chunk comes back marked as a closure.✓TestQueryCandles_GapClassificationSurvivesToTheResult - 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
A graceful move
- 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
Final seed and tail
The new primary pulls until it covers the frozen durable position, then checks coverage.
- One Raft entry: the commit point
- 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.
- 4
Promote
The new primary replays its replica log and starts serving. Clients follow redirects.
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.
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.
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.
- 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
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
The leader fences it
A node silent for T is marked offline. Other nodes stop redirecting to it and answer
PartitionUnavailableinstead. - 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
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
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.
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 wrong | How it is found | What happens |
|---|---|---|
| A chunk rots on the primary's disk | Checksums verified at both ends of every seed, and by the scrubber | Never sent to a copy; repaired on the primary from an in-sync copy |
| A chunk rots on a copy | The copy's own scrubber | Repaired 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 checksum | Copy leaves the in-sync set, is moved to quarantine/…-diverged, seeded from nothing |
| The primary's retention dropped old chunks | Where the primary's oldest base begins, sent with the reference | Copy retires the same entries, only after its history was accepted |
| A gap fill or correction on the primary | Patch chunks and the gap list travel in every seed | Copy installs the patch; its gap list is replaced, not merged |
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.
| Metric | Meaning |
|---|---|
| mirexa_reconcile_rotten_chunks | Gauge to alert on. A chunk that stays here has no copy holding it whole. |
| …_scrub_bytes_total · …_scrub_cycles_total | The scrubber is running and finishing passes. |
| …_repairs_total | Chunks rewritten from another node. |
| …_divergences_total | Copies found holding another history. |
| …_retired_chunks_total | Chunks a copy dropped to mirror its primary's retention. |
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.
The client
What a program talking to Mirexa needs to know: the SDK, what each failure means, live subscriptions, and the protocol underneath.
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.
| Type | Use it for |
|---|---|
| Client | One connection: ingest, query (single, multi, row iterator), subscribe, list streams and gaps. |
| Batcher | A live feed. Flushes at MaxRecords or MaxDelay from the oldest buffered record, whichever is first. Close is not optional. |
| ResilientClient | Redials on its own, retries what is safe, refuses to guess about what is not. |
| ResumingSubscriber | A subscription that survives a dead connection, resuming one nanosecond past the last record it delivered. |
| Observer | Your metrics hook: retries, reconnects, refusals the server never saw. |
| clienttest | Test 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.
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.
| Mode | Returns when | Promise |
|---|---|---|
| DurabilityAcked | INGEST_ACK arrives | In the WAL and fsynced on the node. The zero value, so the safe choice is the default. |
| DurabilityFireAndForget | Bytes reach the socket | Nothing. A refused batch is dropped and you are never told. |
| IngestHistoryCandles | Chunks are installed | Stronger than a live ack: the data is already in compressed chunks. |
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.
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.
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.
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.
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.
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.
| Code | Frame | Purpose |
|---|---|---|
| 0x01 / 0x02 | INIT / INIT_ACK | Handshake and permission grant |
| 0x12 · 0x10 | INGEST_CANDLE_BATCH · INGEST_TICK_BATCH | Writes; history back-fill is a flag on the same frames |
| 0x11 | INGEST_ACK | Durability receipt, only when requested |
| 0x20 | REDIRECT_COMMAND | Server to client: partition, epoch, node, address of the owner |
| 0x30–0x32 | SUBSCRIBE · UNSUB · STREAM_DATA | Live push, optionally preceded by history |
| 0x40 / 0x41 | QUERY_STREAM / RESPONSE | Range read, ascending or newest-first, gaps optional |
| 0x42 / 0x43 | QUERY_MULTI / RESPONSE | Many independent sub-queries in one round trip |
| 0x44 / 0x45 | LIST_STREAMS / RESPONSE | Stream discovery, with its own permission bit |
| 0x46 / 0x47 | LIST_GAPS / RESPONSE | A stream's holes as ranges, with fillability |
| 0xF0 | ERROR | Code, 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.
Appendix
What is not built yet.
Known limits
What the engine does not do yet, stated so nobody builds on it by accident.
| Limit | Consequence |
|---|---|
| Flushed tick chunks do not replicate | A partition carrying ticks cannot be moved or failed over. Three refusals keep this loud rather than silent. |
| Authentication is off by default | Turning 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 budget | Each WAL writer's buffer is bounded; many streams together are not. |
| A migrated partition's files stay on the source | They are moved to quarantine/ and never served, but the disk is not freed. |
| Idle connections slow shutdown | A node with connected clients waits its full shutdown timeout (30 s) before stopping. |
| Tick replay dedupes by timestamp | Two 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 partition | Nothing 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 gap | A pause longer than the cache keeps but shorter than its arena loses its oldest holes from gap.idx. |