A distributed, time-aware serving engine, written in Rust. One durable store for the read paths that sit in front of a model or a request: ranked context, long sequences, aggregated features, frequency caps, and control state. Apache-2.0 · self-hostable · no vector database · temporalstore.ai
📊 Benchmarks & docs — temporalstore.ai/benchmarks.html
Keys are time-ordered, writes are append-structured, and every workload below is answered from one temporal index rather than from several stores that each keep their own idea of time. It runs locally with one Docker command and scales out to a replicated, shared-storage cluster.
Every figure on this block is measured, and carries its denominator and its conditions — store
size, routing range, payload size, node count. Each one names what reproduces it, and all of them
were re-measured on main for this document except the one that says it was not. Where a limit is
real it is stated plainly, and What this store is bad at is a section
rather than a footnote.
| You are building | This store | Read on |
|---|---|---|
| Event sequences per entity that grow without bound — clickstreams, session histories, interaction logs — read as a recent window | Keeps one sorted array per key, so a window read is a binary search plus the points in the window and does not grow with the tail. Retains by count (10,000 points a series by default), trimmed oldest-first on every write. No per-point age policy. | §1 |
| Many millions of distinct entities, each with few values | 39.4 B of resident index per key at 20,000 keys in one shard, falling from 86.3 B/key at 2,000 as per-bucket rows amortise. A 64 MiB per-shard resident bound is reached at roughly 1.7 M keys. | §2 |
| Rollups over a window — counts, sums, min/max/avg | Exact, recomputed from the points on each read; an exact incremental ladder at 1 s / 1 m / 1 h / 1 d is available for sum-family counters. No decayed scores, no top-k, no percentiles. | §3 |
| Plain online feature serving — point reads at serving latency | Addresses from a DRAM-resident index, bytes from a memory cache tier, disk only on a miss. Reads are cheap and local; writes pay a consensus round trip. Measured 21x apart at p50 on one box. Ships as a Feast online store. | §4 |
| Context for an LLM application under a token budget | Retrieve, rank, and pack to max_prompt_tokens; the prompt is charged first and blocks are taken greedily in ranked order, with what did not fit recorded. Ranking is lexical plan score plus cosine over per-node vectors. No ANN index — scoring is linear in the candidate set the caller names. |
§5 |
| Frequency caps, quotas, pacing, blobs, stream ingestion | Single-key atomic counters with windowed sums, a multipart blob path with range reads, and batch/stream ingestion with a dead-letter export. | §6 |
Underneath all six: an append-structured block store, no GC pauses, crash-safe reload from its own persistence, and a format stamp that refuses an image it cannot read rather than guessing.
A series is a sorted owned array per key, not a per-key tree: a one-point series costs one
allocation, and both ends of a window are located by binary search, so a read costs
O(log n) plus the points inside the window. The cost of a long tail falls on memory and
retention, not on the read.
Windowing. Every series read names a window. FeatureQuery / SequenceQuery take
start_ms, end_ms and a count; SequenceBatchQuery carries several windows in one request;
FeatureQueryFiltered adds AND-ed predicates over four fixed row fields.
Retention is by count, and it is enforced on the write path. feature_max_size defaults to
10,000 points a series, trimmed oldest-first on every append, and it is also the default and
the maximum read bound — a count above it is clamped, so a long window cannot return more than
the retained length. One append is hard-capped at 100,000 points. Whole-key TTL
(CommonExpire / CommonTtl) is evaluated lazily on read and by a background sweep bounded at
128 in-memory buckets and 8 on-store buckets a round, so expiry never becomes an unbounded scan.
What the tail costs. The term that grows with the shard is the resident index. Measured on the
shipped routing range 0..1023, one shard, single-block records:
| keys in the shard | resident index | per key |
|---|---|---|
| 2,000 | 172,672 B | 86.3 B |
| 8,000 | 404,992 B | 50.6 B |
| 20,000 | 788,224 B | 39.4 B |
data_node::tests::evict_live::what_a_default_eviction_threshold_has_to_clear.
Limits. Retention is by count or by whole-key TTL — there is no per-point age policy and no
per-point deadline. Row filters are evaluated after decoding each candidate row, so a wide
filtered window pays for every row in it. The only destructive series verbs are a window
overwrite (FeatureReplace) and a whole-key delete (FeatureDelete).
This is the case where per-key overhead decides whether the design is affordable, so the figure is given rather than an adjective.
Resident index per key: 39.4 B at 20,000 keys in one shard (shipped routing range 0..1023,
single-block string records). It falls with key count — 86.3 B/key at 2,000, 50.6 at 8,000 —
because the per-bucket rows are shared by every key that lands in the bucket. Quoting a per-key
figure without its key count therefore means nothing here.
The resident bound. index_resident_limit_bytes ships at 64 MiB a shard. At 39.4 B/key
that bound is reached at roughly 1.7 M keys in one shard, which is the scale it is a bound at.
What the routing range buys. A block's bucket is start + hash(object_key) % (end - start + 1),
so the range sets the modulus and therefore how many blocks share a bucket. On the shipped
0..1023, per routed record, incompressible values, one shard — two allocator columns, because
the rounding is exactly what a change of container shape moves:
| routed records | 0..1023 asked / charged |
whole keyspace asked / charged | blocks a bucket holds (p50 / max) |
|---|---|---|---|
| 4,000 | 94.8 / 107.0 B/rec | 187.3 / 197.1 B/rec | 4 / 8 |
| 40,000 | 52.8 / 54.0 B/rec | 186.5 / 198.1 B/rec | 39 / 50 |
engine::tests::routing_range_default::both_byte_columns_and_the_allocations_a_record_at_every_candidate_range,
854 s. "Asked" is what the allocator was asked for; "charged" reads malloc_usable_size. The
whole-keyspace column is the arm where every key lands alone in its own bucket by construction.
At a matched denominator — same corpus, same 256 B values, one shard, weighing the whole
resident shard state rather than the index alone — the shipped range costs 281.2 B/key at 8,000
keys (marginal 238.0 B/key) against 447.4 B/key at 2,000 keys (marginal 438.7 B/key) on the
whole keyspace. (where_the_resident_index_bytes_go_on_both_routing_ranges.)
The per-key figure is a sawtooth, and that is a property of the measurement. The hash table
behind a per-key map is a step function of the key count, so dividing by keys gives a sawtooth that
doubles the moment the table does: the string table measures 74.3 B/key at 1,792 keys and 147.9
B/key at 1,800 — 1.99x for eight more keys — while the whole resident index steps 362.0 → 435.9
B/key (1.204x), diluted by the per-bucket rows that do not move.
(the_per_key_index_figure_is_a_sawtooth.)
Limits. A store records the routing range it was built on and refuses to load under a different one, and refuses an unstamped store rather than guessing; the range is a decision taken before the first ingest, not afterwards. Only new stores take the shipped default.
FeatureAggQuery takes one aggregator over a window: sum, avg, min, max, first, last,
count. Values are i64 throughout, so avg is an integer division and there are no float
aggregates. The same shapes exist for control-state counters, where "at most N in the last H hours" is a single round trip.
By default an aggregate is recomputed from the points in the window on every read — the window is ranged, each point decoded, and the aggregator folded over the result. Exact, and linear in the window.
Kept current without recomputing: the rollup ladder. For sum-family counters an optional index maintains exact per-bucket sums at 1 s → 1 m → 1 h → 1 d, folded forward on each counter write, so a wide window reads a few coarse cells plus the sub-second edges instead of every raw bucket. A fully covered window is bit-identical to the raw scan; partial edges fall back to the authoritative series; the index is derived, never serialized, rebuilt whenever its recorded length disagrees with the series, and dropped on delete, expiry or reconcile.
Batching amortises only the fixed part of a record, and the fixed part is small. An index-log
delta record measures 63.0 B for one item, 55.1 B/item at 8 and 54.3 B/item at 32, with a marginal
54.00 B/item over 1..8 and 54.08 B/item over 8..32 — three sizes, because two points fit any
line. About 9 B a record is shareable; the rest travels with the item.
(index_log::tests::what_an_index_item_costs_per_append_at_three_batch_sizes.)
Limits, and they matter. The ladder is off by default, and on the feature path it serves
only a literal sum with no count. There is no decayed score anywhere in the engine — no
time-decay function and no decay aggregator; recency enters retrieval as a tiebreak, not as a
curve. There is no top-k, percentile, histogram or distinct aggregator on the feature surface, no
multi-aggregate request, and an unknown aggregator name returns the point count rather than an
error. On the feature path the ladder's first read after an append re-derives its numeric view
from the whole series, so an alternating append-and-aggregate workload pays a full-series decode
per aggregate.
The tiers. A point read resolves its address in the DRAM-resident index — in-process, no
I/O, no cache call — then fetches bytes from the memory cache tier, falling through to a local
block slab (or to the log, for a block still resident there) only on a miss. The resident index
holds addresses, never values. A disk tier exists but is only switched on where it spans a real
distance: with shared storage the durable copy is on another machine, so a local disk tier closes
milliseconds; with consensus replication the durable copy is this node's own disk, where the tier
would be a third copy of bytes the operating system already serves from the same device, so it is
off. The backend is resolved before the engine is built, and publishes the reason it chose what it
chose. The budget is TS_CACHE_MEMORY_BYTES (16 MiB default), and it is the only one: the
resident index named above takes no cache call, so there is no second cache to bound.
Blocks are packed, so one window read of adjacent instants typically decodes one block, and a multi-point read memoizes each block for the duration of the query.
The read/write asymmetry, measured. A three-node cluster, consensus write path against replica reads served from local state, 504 proposals and 44 sampled reads in a 2,018 ms run, one process, one box (release build, WSL Ubuntu 22.04):
| p50 | p95 | p99 | max | |
|---|---|---|---|---|
| write, through consensus (504 samples) | 2,652 µs | 8,144 µs | 12,004 µs | 20,200 µs |
| read, from a replica's local state (44 samples) | 126 µs | 537 µs | 1,025 µs | 1,291 µs |
21x apart at p50, and the reason is structural rather than tunable: a write is replicated to and
committed on a majority before it is acknowledged, while a read takes a lock and applies the command
against the state the node already holds. The same run reported replication_healthy, a maximum
replica lag of 0, a full error budget, one failover and two membership changes exercised
during the workload, and all three voters converged on commit index 504.
(scale_harness --nodes 3 --string-ops 400 --hash-ops 100 --sequence-keys 4 --sequence-len 100 --read-sample-every 10.)
What one append's envelope costs. Over incompressible payloads from 0 to 8,192 B under a 7-byte key, a framed write-ahead record fits
one local WAL record = 1.0004 * payload + 36.4 B worst residual 3.4 B
so the marginal cost of a payload byte is a payload byte, and the fixed cost is 36.4 B. What that means per write depends entirely on the payload:
| payload | user bytes | framed bytes | framed / user |
|---|---|---|---|
| 0 | 7 | 33 | 4.71x |
| 128 | 135 | 167 | 1.24x |
| 512 | 519 | 551 | 1.06x |
| 8,192 | 8,199 | 8,231 | 1.00x |
(wal::tests::one_record_envelope_terms::what_one_local_wal_record_costs. The regime is asserted
at every point: a control sweep over repeated bytes fits a slope of −0.0021 instead of 1.0004,
which is the compressor and not the log, so any envelope figure taken on compressible values is
measuring zstd.)
Write amplification by payload size. Amplification here is dominated by the fixed per-record envelope, so it dilutes as the payload grows. Measured end to end — every durable byte a write causes, over the bytes it carries — 4,000 records a rung, genuinely incompressible payloads, one shard:
| value | user bytes | blocks | index | total written |
|---|---|---|---|---|
| 64 B | 356,000 | 0.85x | 3.39x | 4.25x |
| 256 B | 1,124,000 | 0.95x | 1.79x | 2.74x |
| 1,024 B | 4,196,000 | 0.99x | 1.17x | 2.16x |
| 4,096 B | 16,484,000 | 1.00x | 1.06x | 2.06x |
The payload itself is written about once at every rung — the block column stays between 0.85x and
1.00x — so the whole excess is envelope: the record's framing plus the index log that every write
appends to. It is worst exactly where the payload is smallest. Once maintenance has reclaimed the
logs the retained index settles at roughly 740–780 KB at every rung whatever the payload, which
is a smaller and more flattering number for the same workload; write amplification conventionally
means the first.
(engine::tests::part4::what_a_write_costs_across_every_subsystem.)
None of that is replication, and replication is the other cost. Every amplification figure this
engine publishes is per node: replication is a mode here (consensus or shared storage), not a
factor, so no replica count appears in any of them. Three voters means three physical copies of
every committed byte — a deliberate cost that buys durability, and arithmetic rather than a
measurement. The two are quoted apart so neither is mistaken for the other. On the per-node side,
the index log costs 1.528x its own image — log written over index image — at 2,000 keys × 256 B
in one shard. (the_amplification_set_in_the_published_format.)
And a warning about one of this engine's own exported ratios. Rewriting the same 2,000 keys ×
256 B five times, the block-granular tally reads 5.000x write and 5.000x space
amplification — one extra copy a rewrite, which is correct — while the space_amplification figure
the engine publishes reads 1.000x throughout, because its denominator is slab-granular and
cannot see a rewrite that lands inside a slab. Space does not come back quickly either: a compacted
slab waits out a 60-minute quarantine before release, so space amplification cannot fall within the
quarantine age of the rewrite that caused it. Do not read a published 1.000x as evidence about a
rewriting workload. (the_amplification_set_under_rewrite, 22.5 s.)
Maintenance is nine stages in a fixed order, run as one round by a scheduler: prepare,
reclaim_wal, expire, evict, reclaim_memory, reclaim_page, reclaim_index,
compact_pages, reap_metrics. The order is load-bearing — a blanket cache invalidation ahead of
eviction zeroes the number eviction's own gate reads — and a test asserts the stages that ran did
so in that order. Some are pressure-gated and legitimately skip a round.
By ablation — timing the round, then timing it again with one stage disabled at a time — a whole round measured 685 ms on a 5,000-object shard and 2,269 ms on a 20,000-object one (release build, one shard, 96-byte values). Two stages dominate at both sizes: reclaiming the write-ahead log (427 ms of the 685, then 1,419 ms of the 2,269) and reclaiming the index log (418 ms, then 1,208 ms).
Read the per-stage column as an order of magnitude, not a constant. A repeat of the same
measurement on the same box put the round at 602 ms and 1,863 ms and moved individual stages by up
to 50%, and in each run at least one stage ablated to a negative cost — meaning its own cost sits
under this instrument's run-to-run noise. The two dominant stages are the same in both runs, and
that is the part of the table worth planning against. The ablation also covers eight of the nine
stages: eviction is never switched off, so the round has no per-stage figure for it.
(data_node::tests::part3::what_each_maintenance_stage_costs, about 290 s a run.)
The resident bound is the other half of maintenance: index_resident_limit_bytes caps the
resident index at 64 MiB a shard, which at the 39.4 B/key of §2
is reached at roughly 1.7 M keys in one shard. A shard under the bound declines to evict, and that
is correct — a cap firing at a few percent of its limit would be a thrash rather than a bound.
TemporalStore as a Feast online store. Feast is the
open-source feature store. It resolves its online store through a plugin class named in
feature_store.yaml, and this repository ships two of them under
integrations/feast/, because the engine exposes two surfaces with
different trade-offs. Pick by surface:
| package | surface | pick it when |
|---|---|---|
feast_temporalstore |
RESP, port 6399 | you already run the proxy, or you want Feast's Redis tooling and mental model |
feast_temporalstore_command |
binary batch writes and reads on the data node | you want bounded round trips, one process instead of three, readable hash fields, and optionally a queryable feature history behind each served value |
Both store values identically (ValueProto.SerializeToString()) but do not share a key space:
rows written through one are not readable through the other, so choose one per project rather than
switching under a populated store.
# feature_store.yaml, command-surface plugin
project: my_project
provider: local
registry: data/registry.db
entity_key_serialization_version: 3
online_store:
type: feast_temporalstore_command.TemporalStoreCommandOnlineStore
host: 127.0.0.1
port: 17102 # served by `docker compose -f docker-compose.single-node.yml up`
shard_id: 1Which Feast slot this is. The online store only. It is not an offline store and not a
materialization engine: the stock engines write into it by calling online_write_batch, so
feast materialize works unchanged and TemporalStore becomes a materialization target as a
consequence of being the online store.
Why there are two, and what the RESP one works around. Pointing Feast's stock Redis store at
the RESP port does not fail; it returns wrong answers, for two server behaviours the
feast_temporalstore plugin routes around. Keys and hash fields are decoded lossily, and Feast
names a field with four arbitrary bytes (a murmur3 digest): 91.08% of 4-byte names do not
survive as UTF-8, and two distinct names then collide 1 time in 1,042 against 1 in
4,294,967,296 for a correct store. That puts a 50-feature view at a 69.1% chance of at least one
silent collision and a 100-feature view at 99.1%. Separately, SCAN cannot see another
connection's keys, so Feast's teardown finds nothing to remove and reports success. The plugin
encodes keys and fields to survive the first and keeps its own row registry for the second; the
command-surface plugin needs neither. Both findings and the deletion condition for the encoding are
in integrations/feast/README.md.
Measured through Feast itself. bench_feature_serving.py drives each store through Feast, so
the only difference between two rows is the online_store block. Conditions: Feast 0.67.0, 5,000 entities, hot
set 512, 1,500 reads per cell, one client thread, 20 B of feature payload per row (ten int64
features), a 16-core shared host at 1-minute load 3 with no compiler running, TemporalStore a
release build on a freshly created empty shard. Every arm's rows were read back and compared
byte for byte before any clock started, and the SQLite control re-run held within tolerance (drift
17.3% and 13.0% against a 25% limit).
| store | durability | cold p50 | warm p50, batch 1 | warm p50, batch 10 | warm rows/s, batch 10 |
|---|---|---|---|---|---|
| SQLite | rollback journal, in-process (control) | 72 µs | 73 µs | 452 µs | 20,053 |
| Valkey 8.1.10 | no persistence | 479 µs | 477 µs | 925 µs | 10,486 |
| PostgreSQL 16.15 | synchronous_commit=on, tmpfs |
703 µs | 825 µs | 1,192 µs | 7,076 |
| TemporalStore | async commit | 396 µs | 355 µs | 970 µs | 10,130 |
| TemporalStore | durable commit | 656 µs | 410 µs | 933 µs | 10,204 |
At this payload the async configuration answered a single-entity read faster than Valkey in every percentile measured: cold p50 17% lower (396 vs 479 µs), warm p50 26% lower (355 vs 477 µs). At batch width 10 the two are level (warm p50 970 vs 925 µs, throughput 3% lower). Durable commit costs 37% on the cold p50 against its own async configuration, which is the price of the fsync and is why durability is a column rather than a footnote.
And the result that goes the other way. At 1,035 B per row (four int64 features and a 1 KiB
blob) and batch width 10, the async configuration measured 1,614 µs warm p50 against Valkey's
861 µs, and 5,923 against 11,381 rows/s: 88% behind on p50 and 48% behind on throughput. At batch
width 1 the two stay level (445 vs 455 µs). That table was taken over the JSON /batch_execute
surface, whose response is a decimal array of about 3.8 characters per byte. The benchmark's own
follow-up found the number of field slots fetched to be a first-order cost and payload width a
second-order one: 110 slots holding 376 B cost more than 60 slots holding 10,490 B, and a
ten-command batch that returns nothing at all costs 1,026.6 µs.
The command-surface plugin has since moved to MessagePack in both directions. For 4,096 commands carrying 12,296-byte values that is 24.99% fewer request bytes (50,605,338 against 67,460,469) and a client encode 4.93x faster (55.41 against 273.01 ms), and 25.03% fewer response bytes. Those are wire and client-encoding figures; no cross-store table has been taken on the binary path yet, so the 1 KiB comparison above has not been re-measured on it.
On that binary path, with 4,096 entities each holding one 12,296-byte sequence, batch 10, 500 measured read calls and eight concurrent readers (release build, isolated tmpfs, storage maintenance disabled so the figures price the command API and codec rather than a maintenance cycle):
| mode | write rows/s | warm rows/s, batch 10 | concurrent rows/s, 8 workers | concurrent p50 | concurrent p99 |
|---|---|---|---|---|---|
| async commit | 4,985 | 12,289 | 4,585 | 14,500 µs | 36,781 µs |
| durable commit | 3,437 | 6,204 | 9,344 | 8,086 µs | 15,885 µs |
Durable commit served more concurrent rows than async in the last column, the opposite of what the
write and warm-read columns show, and the benchmark document does not explain it; treat the
commit-mode ordering of that one column as unresolved rather than as a property of the modes. With
write_feature_history on, a separate run on a release standalone node with durable commit
sustained about 15,000 history events/s and 1,482 to 1,552 aggregate queries/s across two
valid runs (p50 4,566 to 4,848 µs, p99 12,737 to 13,106 µs) on 1,024 entities with 64 events each,
and one accepted run served 4,207 aggregate queries/s through the bounded batch API at 64
queries per call. Those are single-node figures, not a comparison with another store.
(Method, the checks the harness refuses to run without, and every table:
docs/feast_online_store_benchmark.md. The offline suites,
standard library only: python integrations/feast/run_tests.py.)
What to know before relying on it.
- The cross-store tables are single-client latencies, driven from one thread because Feast's serving path is synchronous. None is a saturation throughput. A concurrency sweep under async commit peaked near 8,900 rows/s from one client process at concurrency 4 for the 20 B payload, and p99 grew 12x between concurrency 4 and 16.
- Exact
teardownneedstrack_keys_for_teardown, which is off by default, because the command surface has no keyspace scan and cannot enumerate what to delete otherwise. Turning it on adds one write per row, spread over 1,024 tracking sets: ten times the rows now costs 8.3 times the write time where a single shared set cost 152 times. Combined with async commit it lost 17,500 of 20,000 rows before the WAL container lookup was fixed; the same workload now retains 20,000 of 20,000, and the regression script istools/repro_async_commit_row_loss.py. - A delete batch costs what the shard holds, not what the batch deletes. The same 2,000 deletes measured 2.24 ms per key on a shard holding only those keys and 69.17 ms per key beside 20,000 filler keys, 31x for identical work. The benchmark document records the measurement and not a fix, so tearing down a view on a large store should be planned as slow.
/healthreports process liveness, not shard serviceability. It answered 200 in 1.0 ms while every data operation on the same node was blocked behind a hot-key write storm, so an orchestrator watching it will neither restart that node nor take it out of rotation.- No offline store and no materialization engine ship here. Historical point-in-time queries for training stay with whichever offline store the project already uses.
This is the workload the project was built around, and it is the one with the most surface.
Ingest → extract → retrieve → pack. Retrieval takes a tenant, a time window, a set of candidate
node hashes and a query, and returns ranked ContextBlocks — {uri, tier, node_hash, event_time_ms, text, estimated_tokens, source_ref} across three tiers (L0 a short form, L1 a
compact pointer, L2 the event text).
Packing to a budget. max_prompt_tokens (2,048 by default) is the budget. The caller's own
prompt is charged against it first; blocks are then taken greedily in ranked order, and a block
that does not fit is recorded in blocked_blocks while the fill continues, so a small
lower-ranked block can still land after a large one was rejected. Both lists, with the token counts,
are written durably as a ContextPackAudit — what went in and what was held back is a record, not
a log line.
Recency and relevance. Candidate nodes are scored two ways and merged: a lexical query plan
(topic phrases, stemmed term groups with synonyms, adjacent bigrams, and intent bonuses for
"latest"-shaped and timeline-shaped questions) and cosine similarity over vectors stored inline on
the node record, batched 1,000 nodes a command. A lexical match saturates deliberately below a
perfect cosine, so in a mixed store a strong semantic match outranks a purely lexical one while an
un-embedded node stays rankable instead of collapsing to recency order. The final block order is
lexical relevance, then tier (L0 before L1 before L2), then recency, then URI, de-duplicated
by source_ref. Freshness is a tiebreak — again, there is no decay curve.
Keeping a working set resident. The nodes a caller names are resolved from the resident index and their blocks from the memory cache tier, on the same path as §4; a cold sweep deliberately does not fill the cache, so bulk maintenance does not evict a live working set.
Embedding safety is enforced, not documented. Vectors of differing width are declined, and
same-width vectors written by a different encoder are declined too, counted separately — two
widths means a provider outage seeded fallback vectors, two encoders at one width means a model
swap: same symptom, different cause, different fix. Embeddings are supplied, over an
OpenAI-compatible HTTP endpoint; the built-in alternative is a deterministic 16-dimension hash for
tests, and MATRIXARK_REQUIRE_MODEL_EMBEDDINGS turns using it into a hard error rather than a
quiet downgrade.
What it saves, on a recorded run. On a full local agent corpus of 12,384 events / 97 sessions
/ 1,698,940 tokens, a token-budgeted pack of ~1,333 tokens scored 8.30 against 6.59 for
replaying the whole local context, on 6 of 6 queries, with qwen2.5:7b as both reader and judge
(2026-08-09). Method, arms and the judge's known failure mode are in
docs/benchmarks; that run needs a local model and was not re-run
for this document.
Limits. There is no ANN index — scoring is a linear cosine over the candidate nodes the
caller supplies, and node_hashes is mandatory: there is no "search the whole tenant" entry point
in this engine. The secondary-index commands exist and are queryable, but retrieval does not use
them to select candidates; entity records are written and queryable but are not packed. Switching
embedding model means re-embedding the corpus.
- Control state — quotas, pacing, suppression and selection as single-key atomic updates, with windowed sums, UUID-keyed idempotency over a 5-minute dedup window, and an optional bounded distinct sketch (off by default, so distinct stays exact).
- A RESP surface — strings, hashes, sets, sorted sets and lists, plus native verbs for the
feature, sequence and control-state models. Unsupported commands return a deterministic error
rather than a fake success,
SELECTaccepts database 0 only, and the TTL semantics of every verb are written down. Lists and sorted sets are storage-backed through encoded string values; native list and sorted-set models remain future work. - Blobs and large content — a multipart upload (begin / append / commit, content-hashed and fsynced before the rename), a single-shot put, range reads, and a sweep that collects blobs no record points at. Large raw content offloads to content-addressed storage while metadata stays in the index.
- Batch and stream ingestion — batch requests with per-record results, a dead-letter path with an export report, and offset-ledger and checkpoint-handshake surfaces for streaming sources.
- Namespaces → tables → shards, with per-table and per-tenant read and write QPS limits, per-shard rate and burst limits, a read-only shard mode, and topology-aware placement that spreads replicas widest-first across location labels.
- Crash safety you can check — the shard index carries a format stamp and refuses an image older than the one it understands; expiry replay is clock-aware, so a key live at leader time is not silently dropped on recovery; fault-injection hooks and a single-barrier crash harness are in the tree.
- Failure detection with guardrails — an adaptive φ-accrual detector, conviction ratio thresholds, a safe mode, and refusals for orphaning shards or self-clearing a conviction.
- A Rust SDK with both an in-process client and an HTTP client, and a C ABI for non-Rust
callers. It lives in
sdk/rust/temporalstoreand is deliberately outside the workspace.
A block that only lists what works is less useful than one that does not.
- Writes scale sublinearly with concurrency, and the cost comes back as latency. On the durable
shared-storage write path, 400 writes a run on one box: 471 writes/s at 2 concurrent writers,
842 at 4, 1,320 at 8 — four times the writers for 2.80x the throughput — while write p50
climbs 3.77 → 4.36 → 4.73 ms and the read p50 behind them climbs 76 → 119 → 247 µs. Concurrency
does buy something; it does not buy proportionally, and what it costs is the tail. During each
concurrent burst a replica fell as far as 400 entries behind the primary and converged back to
0 after replay. (
scale_harness --nodes 3 --compare-shared-store true --shared-store-ops 400 --shared-store-flush-every 25 --shared-store-concurrency N, release build, one box; all four points come from the same arm of the same harness.) A separate single-box report records a throughput plateau beyond eight cores on the HTTP path: docs/ENTERPRISE_INGESTION_RETRIEVAL_PERF.md. - Small payloads pay a large fixed envelope. 36.4 B a record before the payload: 4.71x framed over user bytes on an empty value, 1.24x at 128 B, and only at a few kilobytes does it round to 1.00x. End to end it is worse, because the index log is per record too: total write amplification is 4.25x at 64-byte values against 2.06x at 4 KiB. A workload of tiny writes is paying mostly for framing.
- The cluster figures here are from three nodes in one process on one box. That is enough to measure the consensus/local-read asymmetry and to exercise a failover and two membership changes, and it is not a multi-machine result. The multi-process harnesses are in the tree and listed under Build & test; run them on real nodes before sizing a deployment.
- A per-key figure is a sawtooth. The table behind a per-key map steps at powers of two, so the per-key cost can move 1.99x between two key counts eight apart. Any capacity plan built on a single per-key number is built on a phase.
- Aggregates are linear in the window by default. The incremental ladder that avoids that is off by default and sum-family only, and on the feature path it serves one literal aggregator.
- No decay, no top-k, no percentiles, no ANN index. Ranking recency is a tiebreak; retrieval scoring is linear in the candidate set the caller names.
- Retention has no age dimension. Series retention is by count, TTL is per key, and neither can express "drop points older than a week".
- Authentication is thin. Two shared-secret environment tokens, no users, no roles, no ACLs and no per-key authorization. Consensus transport has TLS; the service edge does not terminate it.
- Stored formats can still change. See below.
docker run --rm -p 6379:6379 -p 8080:8080 \
-v temporalstore-data:/var/lib/temporalstore \
ghcr.io/matrixarkai/temporalstore:latestThen talk to it with any RESP client:
# write: key "hello" = bytes for "world"
redis-cli -p 6379 SET hello world
# read it back
redis-cli -p 6379 GET helloThe RESP surface, the HTTP routes, and the control verbs are documented under
docs/. Deploy manuals: Windows Docker ·
Linux · macOS.
This repository is the Rust engine only. The Python context tooling — ingestion, retrieval, feedback, the agent hooks and the MCP server — lives in matrixarkai/matrixark, also Apache-2.0.
Use this repository when you want the engine: embed the crate, run the binaries, or talk to a node
over RESP or HTTP. Use matrixark when you want the Python client, the extraction and retrieval
pipeline, or an agent integration.
The exception is the Feast online store: it is a client of the engine's own surfaces, so it lives here under integrations/feast/; see §4.
- Local single node — one Docker command (above); durable storage in a local volume, no metaserver dependency.
- Distributed — replicate through MatrixRaft consensus; the storage backend auto-resolves object store → shared filesystem → replicated local disk, and every resolution publishes the reason it chose what it chose.
- Serving modes, budgets, startup and recovery, storage resolution are documented in docs/benchmarks and the deploy manuals above.
Clients ── RESP · HTTP · proxy · matrixark (Python)
│
Engine ─ TemporalStore: temporal index · serving paths · append-structured block store
│
Foundation ─ MatrixCache (multi-layer cache) · MatrixRaft (Rust Raft consensus)
│
Storage backend (auto) ─ object store → shared filesystem → local + Raft
Five Apache-2.0 repositories, all public and open to anyone in the world to use, fork and contribute to: TemporalStore (engine) · MatrixCache (cache) · MatrixRaft (consensus) · MatrixObject (object store) · matrixark (Python tooling).
cargo check -p temporalstore-rust --all-targets
cargo test -p temporalstore-rust --lib --tests -- --test-threads=1Focused harnesses:
cargo run -p temporalstore-rust --bin readiness_gate -- --service-reports
cargo run -p temporalstore-rust --bin context_workflow_harness
cargo run -p temporalstore-rust --bin storage_modes_harness
cargo run -p temporalstore-rust --bin raft_secondary_replication_harnessReproducing the figures on this block: the byte and allocation measurements need the counting
allocator, which is a Cargo feature and test-only, and they are #[ignore]d because they seed
stores and weigh them — so they run by name:
cargo test --release --features alloc-probe -p temporalstore-rust --lib \
<measurement name> -- --include-ignored --nocapture --test-threads=1Formatting:
cargo fmt --all -- --checkStored formats and wire shapes may still change between releases. The index carries a format stamp and refuses an image it cannot read rather than guessing, so an upgrade fails loudly instead of silently mis-reading a store. Pin a version for anything you care about keeping.
Apache-2.0. Read the project's production-readiness claims from passing reports rather than from this README:
- Benchmarks: token & quality against full local replay
- Context management on TemporalStore
- Deploy: Windows Docker · Linux · macOS
The engine is Rust-native end to end: the temporal index, the serving paths and the block store are all Rust, with no foreign-language bridge anywhere in a request. Out of scope unless separately re-added: alternate wire-protocol compatibility (no brpc/thrift layer, clients speak RESP or HTTP), and live external object-store integration.
Anyone is welcome to contribute, and you do not need to ask first. If something is wrong, unclear or missing, open an issue; if you would rather fix it yourself, send a pull request. You need no connection to this project and no permission: the licence is Apache-2.0 and the fork button works for everyone.
See CONTRIBUTING.md and SECURITY.md. Contributions
target main, this repository's default branch and the branch every change merges to. The
older rust-main name is retired and no branch by that name exists here, so a pull request
opened against it has nowhere to land.
Licensed under the Apache License, Version 2.0 (LICENSE, NOTICE).
Third-party dependency licenses and attributions are listed in
THIRD-PARTY-NOTICES.md. Product and crate names are trademarks
of MatrixArkAI; see TRADEMARKS.md.
New local WAL records use binary protobuf. Outcome recording and binary framing are enabled by default; commands remain when needed for safe recovery. Existing JSON records remain readable.
SharedStoreReplicator also defaults to ProtobufAppendBlob, including the
retry-policy constructor and the MatrixObject server durability path. Synchronous
and asynchronous writes use the same encoding; acknowledgement timing is separate.
Readers accept older per-key JSON records alongside the protobuf append log.
A shared-store backend must implement real append_blob semantics for this
default. The base trait returns an unsupported error instead of replacing earlier
log bytes. Existing put-only integrations can explicitly select
SharedStoreWalAppendMode::PerKeyObject during migration; there is no automatic
fallback to JSON after an append failure.