A distributed object store, in Rust. Apache-2.0 open source: public, forkable, and open to issues and pull requests from anyone.
Objects are stored across replicated nodes with quorum writes, quorum reads, per-page checksums and background repair.
use matrixobjectstore_rs::{MatrixObjectStore, StoreOptions};
let mut store = MatrixObjectStore::new(StoreOptions::default())?;
store.put_object("photos", "cat.jpg", bytes, "image/jpeg", now_ms, opts)?;
let object = store.get_object("photos", "cat.jpg")?;| tier | default size | what it is |
|---|---|---|
| chunk | 64 MiB | the append-only container objects are allocated from |
| page | 1 MiB | the replicated unit: what a quorum votes on and what repair rebuilds |
| fragment | derived | the pieces an erasure code splits one page into, plus its redundancy piece |
An object becomes one or more extents — a (chunk, offset, length) range,
each at most max_extent_bytes — and an extent maps onto the pages covering
it by page_index = absolute_offset / page_size. So a ranged read fetches
only the pages it needs instead of scanning a chunk, and a repair rebuilds one
page rather than a whole container.
shard is used in the distributed sense only -- metadata partitioned by key range across services. The pieces inside one page are fragments: they never leave the page they came from. The service coordinating a chunk's replicas is the chunk service; the one holding a single replica is the replica service.
Two classes of name are still spelled the old way on purpose, because the text itself is load-bearing rather than cosmetic:
| still named the old way | why it stays |
|---|---|
148 durable format constants (36 contain BLOCK, 3 SEGMENT) |
they are hashed into proofs, so changing the text changes every checksum computed from it |
| 154 control-field and report keys | they are observable output -- someone's dashboard reads them |
Both are a versioned migration rather than a rename, and
every_durable_format_constant_is_accounted_for pins all 148 by name in both
directions: a renamed constant fails it, and so does a new one. The second count
is distinct lower-case literals containing block or segment; counted another
way it differs, which is why the definition is here and not only the figure.
The directories src/services/page and src/services/chunk hold the chunk
service and the replica service respectively, and move in a later change; the
types and fields are already ChunkId, chunk_size, PageId, page_size.
| service | responsibility |
|---|---|
| meta | object metadata, leases, key-range shards, transactions, the change feed |
| page | allocation, placement, the write and read quorums, repair and scrub |
| chunk | the data itself: one replica of each chunk, with its checksum and discard map |
A put either completes or it did not happen. There is no partial object and no stated size below which that holds and above which it does not: the guarantee is the same for a one-page object and a five-page one.
A put is split into pages by max_extent_bytes and written one page at a time, so a
failure part way through is the case that matters. Both shapes are pinned:
| the failure | the guard |
|---|---|
| refused after mutating — the expected-checksum check fires once counters have moved | a_put_that_fails_after_mutating_leaves_the_store_unchanged |
| refused inside the write loop — four pages written, the fifth refused | a_write_that_fails_part_way_through_its_pages_did_not_happen |
Each asserts the store is unchanged in four views: the object is absent, its neighbour is intact, the metadata fingerprint is unchanged, the chunk ledger is byte-identical and the snapshot bytes are identical. One audit key is allowed to fall and never to rise — a rollback clones the receipt maps and a clone allocates exactly, so a restored receipt holds the same text in a tighter allocation.
What the store does not promise: a partial write does not report how far it got. The error names why it was refused, not how many pages had landed.
A superseded object leaves its bytes behind, and the space ratio says so:
space_amplification_ppm is the logical bytes a chunk occupies over the bytes still live
in it. Overwrite one object in ten and it reads 1,100,000 — a tenth of the payload left
behind. Overwrite half and it reads 1,500,000.
compact_all(min_stale_bytes) moves every live object out of the chunks above that
threshold and retires them, which takes the ratio back to 1,000,000 and then finds
nothing to do: a second pass compacts zero chunks. Two things make that true, and both were
wrong before:
- the chunk being drained is out of the allocator for the whole rewrite, or the rewrite lands back in its own source — and the compaction then marks it stale, including the bytes it has just written there;
- a drained chunk is retired, or its logical bytes keep counting against the ratio and each pass makes the figure worse than the last.
A retained version refuses the compaction of its chunk. Compaction rewrites current objects and has no path to move a version, and a version's bytes are never marked stale, so compacting underneath one would declare them stale and offer them to the next writer.
reuse_stale_chunk_space is the other way to get the space back — the next write takes it
instead of growing the chunk — and it holds the ratio at 1,000,000 at any overwrite rate.
It is on by default, because the alternative is to accumulate without bound: nothing
here drives compaction, so a store nobody maintains keeps every superseded copy. Overwrite
one object nine times and the ratio reads 10,000,000.
Set it to false to get the other behaviour — the garbage accumulates where the collector
can see it, and compact_all(min_stale_bytes) takes it back on demand. Either way a
retained version's bytes are never stale, so neither route can take them.
A metadata lease is held over a key prefix by an owner at an epoch. While it is active, a mutation of a key under that prefix must carry the owner and the epoch, and every object door checks it: put, append, copy, delete, tags, retention, legal hold, restore, and all four multipart doors -- create, upload-part, complete and abort.
A multipart upload is fenced on the credentials recorded when it was created, not on anything the caller passes later. An upload created under a lease keeps working for the writer holding it; a second writer is refused. The consequence: an upload created with no credentials cannot be continued or aborted while a lease covers its key, until the lease expires.
A batch or prefix delete does not fail when a lease protects one of its objects. It
reports that item as failed -- deleted=0, failed=1, error_code=PreconditionFailed -- and
leaves the object alone, so one protected key does not abandon the rest of the batch. Read
the report, not the return value.
The same doors also check the metadata shard fence, which refuses a mutation whose shard is draining or owned elsewhere.
A page carries a write sequence on each replica that holds it, and a read takes a majority over those. That compares the replicas with each other, so a replica that missed a committed write and came back is only ever outvoted -- and when it is not outvoted, it wins. With a read quorum of one it won, and served its superseded bytes as current.
So the page service keeps its own record, which the replicas cannot write to: the replicas
it will not believe. A replica goes in when it stops being able to receive writes --
quarantined, frozen, or taken out of the endpoint set -- and comes out only through
check_in_replica, which reads its pages and refuses while any of them trails a replica
that is believed, naming the page and both sequences.
| a believable replica | counts toward the read quorum |
| a replica in the record | still readable, still holds its bytes, still a repair target -- but it does not count |
| a read that cannot reach a quorum of believable replicas | refused, and the refusal says staleness was why |
| a read served without one | reported as degraded |
Three consequences worth stating plainly, because each is a choice:
- A repair is not a check-in. Repairing a page brings the bytes across; it does not establish that the replica holds every page it should, which is a different statement and the one the record is about.
- Nothing is deleted. A stale copy is kept -- it may be the last copy of something -- which is also why a refused check-in can cite the evidence rather than having thrown it away.
- A restart does not forget. The record is in the page service snapshot, at format version 24; a snapshot written before that version has its record reconstructed from the quarantine and endpoint sets it does carry, because a replica that was not receiving writes is not believable whatever wrote the snapshot.
It costs one BTreeSet field on the page service, which is empty on a healthy store and
so allocates nothing: staleness is a property of a replica rather than of a page, so there
is no per-page term at all.
A scrub reads every stored byte of every active replica, verifies it against its checksum
and, when asked, repairs from quorum. It is the one maintenance pass meant to run at a
throttled rate over days -- which means it has to be able to stop and carry on, and
scrub_page_replicas(repair) cannot: it does the whole store in one call.
That is the right shape for a store small enough to finish and the wrong one for a store
large enough to need throttling, so the bounded form is additive:
scrub_page_replicas_bounded(after_page_id, max_chunks, repair) scans at most max_chunks
pages and says where it stopped.
| field | what it carries |
|---|---|
marker |
the cursor the call was given |
next_marker |
the last page the call scanned |
has_more |
whether there is more to do -- this is the field to loop on |
A max_chunks of zero is refused rather than taken to mean "no bound", and all three
fields carry the meanings they already carry on audit_page_replicas, which bounds and
resumes over these same pages. Two calls over one set of pages, taking the same argument
names, should not mean different things -- so a test asserts the pair agree, rather than a
comment claiming they do.
- Quorum writes and reads. A write needs
write_quorumreplicas to accept. A read votes on(write_sequence, checksum, etag)across replicas, so a replica that has fallen behind cannot answer with stale bytes. - A replica can be its own process.
ReplicaPlacementchooses between replicas in this address space -- the default, and what this store has always been -- and replicas in other processes reached over a socket. The second is what makes the replication factor survivable rather than only paid for; see replicas in their own processes for the kill test and for the measured size of what is not remote yet. - Repair.
repair_chunk_from_quorumrebuilds one chunk on one replica from the others, so a diverged replica costs one chunk of recopying. - Erasure coding. A page with no peers keeps a redundancy fragment, so a partial corruption is still recoverable from the single copy it has. The fragment is local to its page and is empty whenever the page has peers, which is why the data term measures exactly 3.00x at three replicas.
- Striped payload, if you ask for it.
stripe_redundancy_unitsspreads a page across its replicas as fixed-size units instead of copying it to each, so the store holdsreplica_count / (replica_count - stripe_redundancy_units)of the payload -- 1.5x against replication's 3.0x at three replicas and one unit of redundancy. Replication is the default. The trade is durability: one loss survived where three-way replication survives two. A striped write needs every replica to accept it, sowrite_quorummust equalreplica_count. Two limits, named rather than implied: a replica of a striped service cannot be quarantined (the guard refuses dropping below the write quorum), andrepair_chunk_replicasreports a striped page with a lost replica as unrepaired instead of rebuilding its units -- reads still serve it by reconstructing on demand, so it is readable and one loss from unreadable at the same time. Footprint has the whole-store figures. - Space reuse. Overwriting an object normally leaves its previous version
resident until collection unless
reuse_stale_chunk_spaceis left on, when the next write takes that space directly, which measures about 9% off resident footprint on an overwrite-heavy workload. - Idempotent mutations. Allocation, write, discard and repair carry receipts, so a retried request is not applied twice.
- Object semantics. Buckets, versioning, multipart upload, object lock and retention, lifecycle policies, tagging, CORS and bucket policies, quotas, and a change feed.
At three replicas with the metadata counted, measured at 26-character keys:
| object size | objects | amplification | data-only figure |
|---|---|---|---|
| 64 KiB | 1,000 | 3.01x | 3.00x |
| 4 KiB | 4,000 | 3.08x | 3.00x |
| 1 KiB | 8,000 | 3.32x | 3.02x |
The data term is exactly 3.00x -- the replication floor for a store that copies every page to every replica -- so everything above it is metadata, which is 290 bytes an object plus 164,232 fixed whatever the object size.
What that 3.00x buys depends on where the replicas are, and the figure cannot tell you.
Three copies measure 3.00x whether they are three BTreeMaps on one heap or three copies in
three processes, and only the second arrangement survives losing one of them. Under the
default ReplicaPlacement::InProcess they are on one
heap, and the three copies are the price of a guarantee that arrangement cannot provide; under
ReplicaPlacement::Remote the same 3.00x is the price of surviving the loss of one replica
process, which tests/a_replica_is_its_own_process.rs demonstrates by killing one and reading
every acknowledged byte back from the survivors. Read any amplification figure in this
section together with the placement it was measured under.
Those bytes are not all in one place. 186 of them are a storage node's own -- the object's entry in its map, and its key in the payload index -- and 104 are the metadata service's, which keeps its own record of every object with its own versioning, deletion markers and change log. This crate models both services in one process, so the audit counts both; a deployment runs them on different machines, where a storage node carries 186 bytes an object rather than 290.
Neither map keys its entries by a copy of what the entry already holds. objects is ordered by
the identity inside each ObjectMetadata and meta.records by the key inside each record, so
an entry is the value and nothing beside it. That is 32 bytes an object on the storage node and
16 on the metadata service, and it is the reason both figures are what they are rather than 218
and 128.
The metadata service's record is a name, two stamps and a digest, and the collection
holding it is ordered by that name rather than keeping a second copy of it. It does not restate the
object: it holds an eight-byte digest of the object's rendered metadata, so it can prove a
record matches the object without carrying a second copy of it. What that costs is a restore
that is no longer self-sufficient -- a snapshot carries the digest, not the characters, so
reproducing a value needs the object.
every_metadata_leg_is_a_line_and_the_intercepts_are_named measures both shares and fails
if either stops being what this file publishes.
footprint_audit() names every resident byte and its breakdown sums to its own
total with no remainder.
Those two numbers are a line, fitted from measurements at three object counts by
the_published_metadata_footprint_line_is_the_one_this_tree_produces, which fails
if this file disagrees with the tree.
A store that stripes moves the data term itself, to 1.5x at three replicas and
one unit of redundancy -- which is the least any layout can hold and still survive a
loss. Every replica holds one unit of each stripe group, so keeping p units of
redundancy over R replicas costs R / (R - p), which is smallest at one unit; with
no redundancy nothing is recoverable, and the only way further down is more replicas,
which is more hardware rather than less space.
one_unit_of_redundancy_is_the_least_a_layout_can_hold sweeps every layout from three
replicas to eight and fails if any of them undercuts that.
Counted with the metadata, a thousand objects of 1 KiB measure
1.97x striped against 3.46x replicated, both read out of footprint_audit() on a
store nothing has read back yet. What separates the total from the 1.5x data term is the
metadata line above, which striping does not change: the same 290 bytes an object either
way. Striping moves the payload and nothing else.
Both numbers are for the same object count, so the comparison is between two stores rather than across two measurements. It is a smaller point than the rows above because measuring both shapes at 8,000 objects takes longer than the whole suite, and a figure only a test nobody runs would check is a figure that goes stale.
cargo test --lib a_store_holds_one_and_a_half_copies_of_its_payload_when_it_stripes -- --nocapture
cargo test --lib the_published_striped_amplification_is_the_one_this_tree_produces -- --nocapture
MEASURE_OBJECTS=8000 MEASURE_SIZE=1024 cargo test --release --lib measure_the_metadata_footprint -- --ignored --nocapture
docs/footprint.md has the per-leg working: which total to
divide by, why lowering replica_count does not reach 1.0x, what is bounded by
a setting rather than by your data, and what the change feeds cost.
Every figure above is the store at rest. A mutation also holds a copy of what it may have to put back, from the moment it starts until it commits or is refused, so peak resident is the store plus that copy — and a copy taken and dropped inside one call is invisible to a figure taken at rest.
Two copies exist, and they are reported separately because they are bounded by different things.
The store's own undo, reported by ObjectMutationRollback::footprint_audit,
at 1 KiB objects:
| stored objects | undo |
|---|---|
| 250 | 1,225 B |
| 1,000 | 1,225 B |
| 2,000 | 1,225 B |
Flat, because a put, a delete, an append and a copy each write one object key, one metadata record and one change record, and the undo carries exactly that -- not the payload index, not the store's containers, and not the bytes of a multipart upload in flight.
The page service's mutation batch holds a second copy, boxed, so the undo above can leave the page service alone. It separates what it copies from what it only names:
| stored objects | copied | named |
|---|---|---|
| 250 | 2,485 B | 768,312 B |
| 1,000 | 2,485 B | 3,072,312 B |
Also flat in the object count, because a chunk's payload is shared rather than copied and so are the receipt maps. The copy is 847 bytes of page service state, 1,398 bytes of replica state, 168 bytes of shared receipt handles, and 24 bytes for each chunk — so what moves it is the chunk count, which the object SIZE drives rather than the object count.
The receipt maps are shared until the batch writes one, which is what keeps them
at the 168 bytes counted above rather than the whole of what the receipt table
reaches at its bound.
the_published_mutation_copy_figures_are_the_ones_this_tree_produces reads this
table and fails if either figure stops being what the tree produces.
cargo test --release --lib measure_what_a_mutation_undo_costs -- --ignored --nocapture
cargo test --release --lib measure_what_a_mutation_batch_snapshot_costs -- --ignored --nocapture
Writing 500 objects of 1 KiB and then deleting every one leaves the store 26.4% larger than when it held them:
| bytes | |
|---|---|
| holding 500 objects | 1,771,625 |
| after deleting all 500 | 2,240,638 |
The payload index is given back in full. What replaces it is 822 bytes of
idempotency receipt per delete across three replicas, bounded only by
receipt_retention, which defaults to 100,000; plus 106 bytes of change feed and
57 of metadata change log per delete, both bounded by their retention constants.
The object's own entry does not go away either — a delete files a delete marker,
so the 210 bytes of its objects entry stay, which is what the change feed and
the versioned paths read.
Those receipts are also the term that makes the mutation batch grow: after N deletes, every subsequent mutation's batch copies 615 bytes per retained receipt, which at the default bound is about 61.5 MB copied per mutation. Issue #20 has the measurement and what narrowing it requires.
So a deployment sizing from the figures above should add the undo and the batch copy to the resting total, and a workload that deletes should expect the receipt retention to dominate both.
An S3-compatible HTTP front door and a binary that serves it are built in, so a store can be reached from another machine rather than only in process.
MATRIXOBJECT_SNAPSHOT_PATH=/var/lib/matrixobject/store.snapshot \
MATRIXOBJECT_BUCKET=mybucket \
cargo run --release --bin object_server
| variable | default | meaning |
|---|---|---|
MATRIXOBJECT_HTTP_ADDR |
127.0.0.1:17200 |
address to bind |
MATRIXOBJECT_BUCKET |
default |
bucket created at startup, idempotently |
MATRIXOBJECT_SNAPSHOT_PATH |
(unset) | durable snapshot file; in memory when unset |
MATRIXOBJECT_MAX_BODY_BYTES |
64 MiB | largest object this door accepts |
MATRIXOBJECT_THREADS |
4 | accept threads |
The verbs, where {bucket} is the first path segment and {key} is everything after it:
| request | does |
|---|---|
PUT /{bucket}/{key} |
write an object; honours If-None-Match: * and If-Match: "etag" |
GET /{bucket}/{key} |
read it; honours Range: bytes= and If-None-Match |
HEAD /{bucket}/{key} |
its headers, and the length the matching GET would send |
DELETE /{bucket}/{key} |
remove it; idempotent, so a retried delete answers 204 |
PUT /{bucket} |
CreateBucket, idempotently; answers a Location and no body |
HEAD /{bucket} |
HeadBucket: 200 if it exists, NoSuchBucket if not |
DELETE /{bucket} |
DeleteBucket; BucketNotEmpty with 409 while it still holds objects |
GET /{bucket}?prefix=&delimiter=&continuation-token=&start-after=&max-keys= |
ListObjectsV2, with the delimiter rolled up into CommonPrefixes |
GET / |
ListBuckets |
POST /{bucket}/{key}?uploads |
CreateMultipartUpload |
PUT /{bucket}/{key}?partNumber=&uploadId= |
UploadPart; answers the ETag the completion must name |
POST /{bucket}/{key}?uploadId= |
CompleteMultipartUpload; the part list in the body is validated against what was uploaded |
DELETE /{bucket}/{key}?uploadId= |
AbortMultipartUpload |
GET /{bucket}/{key}?uploadId= |
ListParts |
GET /{bucket}?uploads |
ListMultipartUploads |
POST /{bucket}?delete |
DeleteObjects; 200 with a per-key report, because one key failing is not the batch failing |
PUT /{bucket}/{key} + x-amz-copy-source |
CopyObject, honouring x-amz-metadata-directive |
GET /{bucket}?location |
GetBucketLocation |
GET /{bucket}?versioning |
GetBucketVersioning |
PUT /{bucket}?versioning |
PutBucketVersioning |
GET /{bucket}?versions |
ListObjectVersions, with delete markers reported as such |
GET / HEAD /{bucket}/{key}?versionId= |
read one named version |
DELETE /{bucket}/{key}?versionId= |
remove one named version |
GET / PUT / DELETE /{bucket}/{key}?tagging |
object tagging |
GET / PUT / DELETE /{bucket}?cors |
bucket CORS |
GET /healthz |
readiness, which takes the store lock rather than only reporting the listener |
A write answers the object's ETag, Content-Type, Last-Modified, its version and length, and
any x-amz-meta-* headers it was given back again. A listing reports <IsTruncated> and, when it
is truncated, the <NextContinuationToken> to resume from -- a client cannot tell a full page from
a truncated one by counting, and a token on a final page would make it ask again for nothing.
Every failure is an <Error> document carrying the <Code> a client branches on, and the codes
are S3's: NoSuchBucket and NoSuchKey are distinguished rather than both reported as a 404 with
prose, because one tells a client to create the bucket and retry and the other tells it to stop.
NoSuchUpload is a third, for the same reason. A V1 listing client that sends marker and no
list-type is served the same body.
CompleteMultipartUpload is the one request whose body is read. The parser recognises exactly that
document and refuses everything else -- including a document type declaration, a part list under
another root element, and trailing or missing elements -- because accepting more of XML buys nothing
a client needs. The part list is then checked against what was actually uploaded, and a mismatch
answers InvalidPart or InvalidPartOrder: the store completes whatever parts it holds and takes
no list, so a mismatch not caught here is not caught at all, and the client would be handed an
object assembled from something other than what it sent.
It is unauthenticated. No signing, no token, no TLS. Anything that can open a socket to the port can read, overwrite and delete every object. That is why the default bind is loopback; moving it off loopback puts the whole store behind whatever else protects that network. The binary says so on startup when the address is not loopback.
Without a snapshot path nothing survives the process. The default store is entirely in memory.
The startup line always reports which of the two you have -- snapshot=none included -- so it is
visible then rather than at the next restart.
Requests are serialised. The store keeps interior mutability for its caches and durable stamps,
so it is Send but not Sync: it can move between threads but cannot be shared by them, and the
server holds it behind one lock. Extra accept threads stop a slow client stalling others; they do
not add parallelism. This door exists so data can reach the store from another machine, not so it
can reach it faster.
An Authorization header is accepted and ignored rather than refused, so a signing
client works against this surface -- it is simply not verified. There is no SigV4.
Not implemented, and answered with NotImplemented or MethodNotAllowed rather than with an empty
success: object ACLs, bucket policies, lifecycle verbs, object lock.
Five places the model is narrower than S3's, each answered as itself rather than approximated:
| this store | why | |
|---|---|---|
| CORS | one flat policy per bucket; a second rule is refused, not flattened | flattening would union the rules, so an origin one rule allowed only GET would come back allowed whatever another permitted -- access the request never asked for. One rule round-trips exactly |
| The 5 MiB minimum for a non-final part | any part size is accepted | the check would only ever refuse an upload this store can serve |
Versioning (Enabled, Suspended, never configured) |
one flag, so GetBucketVersioning omits <Status> for false |
that is what S3 sends for a never-configured bucket, and a client acts on it identically. Telling suspended from never-configured needs the third state |
| Version ids (opaque strings in S3) | a number, reported as x-amz-version-id; versionId=null is refused |
a client must treat an id as opaque, so it cannot tell. Every object here carries a version from the start, so S3's pre-versioning null names no version -- serving the current object would hand back a different one |
GetBucketLocation |
an empty <LocationConstraint/> |
S3's own value for its default region, and the only answer true of a store with no region. Answered at all because SDKs call it before their first request |
GET /healthz is deliberately not S3: it answers plain text, because a container
probe and an operator read it and an <Error> document would help neither.
Anything a caller might reasonably expect and not get answers a status rather than
a plausible-looking wrong answer: chunked framing is refused with 501 rather than
read as a body of the wrong length, a PUT with no Content-Length is refused with
411 rather than stored as empty, and a multi-range request falls back to the whole
object rather than returning one range under a 206 claiming to be all of it.
There is no deduplicating write: an object's etag is derived from its identity --
bucket, key, version, length, content type -- not from a hash of its content, so
comparing etags cannot tell the same bytes from the same shape. The store's real
conditional writes are applied inside the mutation it guards.
use matrixobjectstore_rs::net::{ObjectHttpServer, ServerOptions};
let store = std::sync::Arc::new(std::sync::Mutex::new(store));
let server = ObjectHttpServer::bind("127.0.0.1:17200", store, ServerOptions::default())?;
let handle = server.spawn()?; // returns at once; `local_addr` reports the bound port
// ... later ...
handle.shutdown(); // stops the accept loops and waits for themThe listener is non-blocking so the accept loops can be stopped, which is what makes the server usable as a component and testable in-process; the cost is one poll per idle interval.
It adds no dependencies. The HTTP reading and writing is written on the standard library
alone -- see the comment at the top of src/net/http.rs for why that is a deliberate constraint
for a crate that other people's data sits inside, and for the list of framings it refuses rather
than guesses at.
The front door above lets a client reach the store from another machine. This is the other direction: letting a replica live somewhere other than the coordinator's heap.
Until src/cluster, every replica in this store was an element of
replicas: Vec<MatrixReplicaService>. The fan-out was a for loop over
self.replicas.iter_mut(), and a write quorum was a count of loop iterations that returned
Ok. Three replicas were three BTreeMaps in one address space, which pays the full cost of
three copies and buys none of what three copies are for: one process ending takes all three.
chunkserver is one replica in its own process, reachable over a socket:
MATRIXOBJECT_REPLICA_ADDR=127.0.0.1:17301 cargo run --release --bin chunkserver
| variable | default | meaning |
|---|---|---|
MATRIXOBJECT_REPLICA_ADDR |
127.0.0.1:17300 |
address to bind; port 0 lets the kernel choose |
MATRIXOBJECT_REPLICA_THREADS |
4 |
connections served at once |
MATRIXOBJECT_REPLICA_MAX_PAYLOAD_BYTES |
8388608 |
largest payload one frame may declare |
MATRIXOBJECT_REPLICA_JOURNAL |
(unset) | append-only replica journal file in an existing directory |
MATRIXOBJECT_REPLICA_CAPACITY_BYTES |
268435456 |
total page payload bytes, including zero-filled space before write offsets; positive |
MATRIXOBJECT_REPLICA_MAX_PAGES |
65536 |
maximum page identities, including empty pages; positive |
The payload and page limits are checked before admitting a write and during journal replay. Restart with limits below the saved history is refused without truncating the journal. Overwrites that do not increase either budget remain available at capacity. These are storage admission limits, not a process memory ceiling: indexes, caches, sidecars, and network buffers use additional memory. Discard marks byte ranges and does not release the underlying payload allocation. Zero disables neither limit; it is rejected at startup.
The bound address and the PID are printed on the first line of stdout and flushed before the
accept loop starts, so a supervisor -- or a test standing three of these up on port 0 --
can learn which port the kernel chose.
ReplicaPlacement is the one place that chooses, and its default is the in-process
arrangement, so nothing that exists today behaves differently:
use matrixobjectstore_rs::cluster::{ReplicaClientOptions, ReplicaPlacement};
let placement = ReplicaPlacement::Remote {
endpoints: vec!["127.0.0.1:17301".into(), "127.0.0.1:17302".into(), "127.0.0.1:17303".into()],
write_quorum: 2,
read_quorum: 2,
};
let mut fanout = placement.open(&ReplicaClientOptions::default())?;
fanout.write_chunk(&page_id, 0, &bytes)?; // three processes, one quorumThe network transport uses a six-byte frame header and a payload on the standard library.
The fs2 dependency supplies the exclusive journal file lock. The frame carries a protocol stamp in both directions
so that a rolling restart -- a new coordinator reaching an old replica -- refuses the frame
by name instead of taking its length field from the wrong offset.
Set MATRIXOBJECT_REPLICA_JOURNAL to retain a replica's writes and discards across restarts.
The server locks and replays the file before listening, then syncs each successful mutation
before acknowledging it. Failed persistence blocks all further requests until restart.
Unset keeps the existing in-memory behavior; startup reports storage=journal or
storage=memory. Each node needs its own journal on persistent storage.
Records contain a version marker, bounded length, header checksum, payload checksum, and encoded mutation. Recovery truncates only an incomplete final record and refuses complete records with checksum or decoding failures. The checksums detect accidental corruption; they do not authenticate the file. A lost acknowledgment can leave a durable operation, so blind mutation retries are not idempotent. Linux process-kill tests verify writes, discards, exclusive ownership, and recovery of all three replicas followed by another node loss. Power-loss and non-Linux filesystem behavior have not been tested.
The journal grows with mutations; checkpointing and compaction remain unfinished. Do not replace or remove a journal while its server runs. Recovery restores that node's own history; it does not fetch writes missed while offline, authorize readmission, or replicate the metadata server. Those require additional coordinated recovery work.
Set MATRIXOBJECT_METASERVER_STATE_DIR to an existing directory to enable durable metadata with
a bootstrap snapshot and an incremental binary protobuf outcome journal. Initial roster
bootstrap saves a synced snapshot. Subsequent heartbeats, heartbeat expiry, and placement
commits append decided record changes to metadata.outcomes and sync before acknowledgment.
A directory lock prevents another server from opening the same directory. Startup retains
its storage=snapshot label for the snapshot-plus-journal backend.
Recovery validates the snapshot, restores metadata and placement state, then installs each outcome without rerunning placement decisions or expiry logic. A recovered roster overrides bootstrap environment settings. Interrupted temporary snapshots are ignored; only an incomplete final journal record is truncated. A missing initialized snapshot or outcome journal, a damaged complete record, or a prior-state mismatch prevents startup. Persistence failures stop requests until restart. The journal currently retains its full history; automatic checkpoint compaction is still required for bounded long-running deployments. Older binaries must not open a directory after it starts recording outcomes because they do not replay the journal.
Keep all files in the state directory together and do not modify them while the node runs. Linux process-kill recovery and write failures are tested; power-loss and non-Linux filesystem behavior are not. A lost acknowledgment may still mean a durable mutation. This provides single-node recovery, not a replicated or highly available metadata service.
tests/a_replica_is_its_own_process.rs is the test this arrangement exists to pass. A test
that wrote bytes, killed something and read the bytes back would pass identically against
three replicas on one heap, so the regime is asserted first:
| what is asserted | |
|---|---|
| 1 | the regime -- three distinct live PIDs, none of them the test's, and an ESTABLISHED socket per replica attributed to that replica's PID. Read twice, from ss -tnp and from /proc/<pid>/fd against /proc/net/tcp, and the two readings must agree |
| 2 | 24 pages written, each acknowledged by all three replicas, each replica then reporting all 24 |
| 3 | one replica killed by PID, after confirming from /proc/<pid>/environ that the process carries this run's token |
| 4 | all 24 pages read back from the survivors: exact count, zero missing, zero wrong byte |
| 5 | the negative control -- a page never written reads absent, on the same survivors at the same moment |
| 6 | a write needing all three is now refused, so the count in step 2 was counting replicas and not iterations |
Steps 1 and 5 are why 2-4 mean anything. Without the regime, the test is a statement about one heap; without the control, 24 hits are consistent with a read path that answers for every address it is given.
The data calls cross the socket: write_chunk, read_chunk, head_chunk, discard_chunk.
The bounded page_inventory call below also reads page identities and sequences remotely.
MatrixChunkService -- the service that holds replicas and runs the fan-out described
above -- is unchanged and does not route through ReplicaPlacement.
The size of that stop was measured with the compiler rather than estimated. Giving the
replicas field a type with no methods and asking what broke produced 298 errors:
| what the compiler found | count |
|---|---|
| distinct replica methods called through the field | 82 |
| call sites for them | 198 |
| distinct private fields of the replica read directly through it | 13 |
| sites reading those fields | 35 |
| files involved | 57 |
The 35 field reads are the part that is a redesign and not a port. replica.pages alone is
read at 12 sites, as a borrow of the whole chunk map by callers that walk it; there is no
socket call behind that, only a snapshot transfer with a different name. So the work is two
pieces in order -- turn those reads into methods, deciding for each whether the answer is a
value or a stream, and only then put the 82 methods behind a dispatch. src/cluster/mod.rs
carries the table and the reasoning.
RemoteReplicaClient::page_inventory(after, limit) reads page identities and write sequences
from a replica node, without transferring payloads. Limits are 1–4,096 rows per request;
each row occupies 32 wire bytes. When has_more is true, resume with the last returned
PageId as the exclusive cursor. An empty final batch has has_more = false.
This is a live ordered traversal, not a snapshot. Fence writers before using it to certify that a returning replica is current. The main chunk-service dispatch still uses local replicas; this inventory extends the remote client for that integration. Existing wire verbs keep their codes and layouts; older nodes explicitly refuse the new inventory verb.
cargo build --release
cargo test
Nothing external is required to build or run the tests.
StoreOptions carries the knobs. The ones most worth knowing:
| option | default | meaning |
|---|---|---|
chunk_size |
64 MiB | how large a chunk grows before a new one is opened |
page_size |
1 MiB | the replicated unit a write lands in — see the sizing note below |
max_extent_bytes |
4 MiB | the largest contiguous range one extent may cover |
volume_count |
1 | volumes to spread chunks across |
replica_count |
3 | copies kept of each chunk |
write_quorum |
2 | replicas that must accept a write |
stripe_redundancy_units |
0 | units of redundancy in a stripe group; 0 copies every page to every replica |
stripe_unit_bytes |
64 KiB | bytes in one stripe unit, when striping |
reuse_stale_chunk_space |
true | take the space a superseded object left instead of growing the chunk; set it false to let the collector see the garbage first |
Every default above is StoreOptions::default(), and
every_option_in_the_table_is_a_field_with_that_default reads this table and
fails if a row names a field that does not exist or states a default the tree
does not have.
The control plane is the metaserver: one metadata service, embedded by default.
control_plane_mode takes auto (the default) or metaserver, and both select it --
auto unconditionally, metaserver using whatever metaserver_catalog_endpoints names
and the embedded catalog when it names nothing.
Two earlier modes, globalcatalog and rootserver, selected a separate root catalog
instead. Both are retired: the spellings are refused by name, a root catalog endpoint is
refused at construction, in the environment and on snapshot load, and a snapshot written in
rootserver mode with no endpoint loads as a metaserver, because that is what it already
was. One written with an endpoint is refused rather than silently repointed.
page_size is the one worth setting deliberately. It is the replicated
unit, so an object smaller than a page still occupies a page's worth of
replication, and an object much larger than a page is split across more pages
than a ranged read needs. Sizing page_size near your median object size is
what keeps both from happening.
receipt_retention is not in the options table -- it is configured on a
live store with configure_chunk_receipt_retention, and it bounds what a
mutation's idempotency receipts cost. Set receipt_retention rather than
leaving it at its 100,000 default if your workload deletes heavily.
docs/configuring.md has the sizing table for both, and the measurements behind them.
Anyone can contribute: fork, commit, open a pull request. Issues are open, and
main carries no branch protection, so an outside pull request can land without a
review you cannot obtain. CODEOWNERS requests a reviewer; it
gates nothing.
CONTRIBUTING.md is the guide -- what CI runs and what it does not, the 17 recorded test failures and how to tell yours from them, and the shape a change is expected to take. CODE_OF_CONDUCT.md applies. There is a pull-request template and two issue templates; a security or durability report goes privately through SECURITY.md instead of an issue.
the_repository_carries_what_an_outside_contributor_needs checks those files are
here, that the contributing guide is this project's, and that every path it sends a
reader to exists -- the guide was a sibling library's for a while, and a check on mere
existence scored it present.
Two invariants worth knowing before you start:
- A durable format is a contract. Snapshot and proof encodings are hashed into checksums, so changing the bytes a format emits — including the text of a format constant — changes every checksum computed from it. That is a migration, not a rename.
- A measurement needs its denominator. Performance claims here are expected to say what they are per: per put, per live byte, per stored object. "Faster" without one is not a result.
On pushes to main and on every pull request, four jobs:
-
build & docs --
cargo build --all-targetsandcargo doc, each for the default features and for--all-features, with warnings denied. -
format and rollback guards -- each guard run on its own with
--exact, and its output checked fortest result: ok. 1 passed, becausecargo testexits 0 when a filter matches nothing. The guards over replica processes check their output forrunning 1 testas well: they name a test of an integration target rather than atests::filter, so a rename would leave the filter matching nothing and the1 passedline absent rather than wrong. -
network metadata consensus -- twenty-one named routing, process, recovery, storage, and checkpoint tests with the optional consensus feature enabled.
-
network chunk consensus -- twenty-one named checks of independently durable chunk voters, leader loss, restart recovery, capacity, shutdown, and metadata health. See chunk consensus configuration.
CI does not run the whole library suite. tools/suite_gate.sh does, locally: it
compares the failures by name against the 17 recorded in
tools/known_failures.txt, and fails in both directions -- a failure that is
new, and a recorded one that has started passing and should come off the list.
Apache License 2.0 -- see LICENSE and NOTICE.
Every Rust source opens with the two lines that say so:
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 MatrixArkAI
every_rust_source_carries_the_licence_header walks the tree and fails if a source
is missing either line, if the walk finds fewer than a thousand sources, or if the
LICENSE and NOTICE stop saying what the header claims.
The replica and metadata server max_payload_bytes setting bounds both incoming
and outgoing payloads. It must be between 24 bytes and the four-byte frame length
maximum; the default remains 8 MiB. A read includes 40 bytes of sequence, checksum,
and length fields, so its data must fit within the remaining budget. Oversized
reads are refused before copying data, and oversized roster lists before copying
the roster. Other successful replies are checked before wire encoding. Failure
messages are shortened on UTF-8 boundaries to fit the same budget.
A reply-size refusal leaves the connection usable. Request smaller read ranges or inventory batches when necessary. Roster listing currently has no pagination; configure enough space for the roster. Head replies may require more space as discarded ranges accumulate. Client and server limits are configured separately.
With matrix-raft, MetadataStateMachine applies the eight lease and shard
migration commands carried by ObjectCommand::to_payload. It checks group,
term, and consecutive log indexes. Repeating the latest identical entry returns
its saved response without applying it again; conflicting or older delivery is
refused. A response starts with byte 0 for success, or byte 1 followed by UTF-8
text for a committed application refusal. Business refusals advance the log;
corruption errors stop application until a checkpoint is installed.
The MOMRAFT1 checkpoint contains metadata, group and Raft log identity, the
latest command and its response, and a checksum. It preserves the Raft index
separately from the metadata service commit counter, including across rejected
commands. Checkpoint transfers reject changed identities, invalid offsets,
stale log positions, and more than 64 MiB of accumulated data. Legacy raw
metadata snapshots remain readable through the metadata service API; they are
not Raft checkpoints and cannot recover a Raft replay position.
This section describes the state-machine adapter. The optional
metadata-consensus and chunk-consensus features additionally run independent
durable MatrixRaft voters. Group-aware placement, the distributed HTTP backend,
and large-cluster failure testing remain required before a distributed
production-readiness claim. See chunk consensus configuration.
New chunk journal records, ChunkRaftWriteGroup proposals, and independent
chunk-consensus voters use the same
binary protobuf PageOutcome schema in proto/page_outcome.proto. The outcome
contains decided byte patches, page sequence, lengths, checksums, discard ranges,
and physical counter increments. Recovery installs that state without running a
write command again. A prior-state digest rejects reordered or mismatched changes.
Appending six bytes to a 1 MiB page produces an outcome smaller than 256 bytes.
Payloads are bounded to 8 MiB and materialized pages to 256 MiB on this path.
The local MOR2 journal framing adds header and payload checksums. Older MOR1
records and chunk command log entries remain readable. Older binaries cannot
read the new records. The page codec does not establish network Raft consensus. The group adapter remains an in-process apply harness.
The metaserver journal and metadata MatrixRaft adapter accept the same PlacementOutcome
binary protobuf schema in proto/placement_outcome.proto. Each outcome carries prior and
resulting node usage or object placement values and consecutive placement commit indexes.
All prior-state and shape checks complete before mutation. A heartbeat captures its node and,
for a catalog member, its logical chunk group. An object placement captures the object and its
affected nodes. Heartbeat expiry scans the roster.
The payload is limited to 8 MiB and protected by a checksum.
This covers the network placement API. Other metadata commands still use their existing codec,
and checkpoints retain the metadata snapshot inside a group-aware envelope when a chunk catalog
is configured. With metadata-consensus, network placement requests commit through MatrixRaft.
The optional metadata-consensus feature runs one durable MatrixRaft voter per
metaserver process. See configuration, recovery, and current limits.
A metaserver can load a fixed binary protobuf chunk catalog through
MATRIXOBJECT_METASERVER_CHUNK_GROUPS_FILE. Each replicated group contributes one
placement target and one copy of capacity. Voters report health under separate
member IDs. Metadata clients resolve the persisted membership and verify chunk
configuration before sending data. See catalog configuration and recovery.