The replica protocol
What a replica must do to be kept in sync by this engine, whatever language it
is written in and whatever database it keeps its rows in. The engine’s own
replica (src/replica.rs, on SQLite) is one implementation of it; this
document is the contract that one follows, and any other must.
The messages are in
proto/sync.proto.
Generate code for your language from it; the engine’s own types are generated
from it too, and a test fails while they differ.
The words must, must not and should mean what they mean in RFCs.
Sections 1 to 10 are what a replica does, and the whole of what an implementation needs. What the server does with what a replica sends is in Appendix A: it explains the rules, and no replica has to implement any of it.
1. The parties
- The server owns the truth. It decides which rows each replica should hold, sends the difference, and records what each replica holds.
- A replica holds a copy of some of the server’s tables, and may write tables of its own that go up to the server. It is identified by a UUID the server knows.
- The transport carries messages between them. The engine does not depend on which one, but it requires what section 2 lists.
A replica never decides what it should hold. It applies what it is sent, reports what it could not apply, and reports what it holds. It follows one server installation: two that send it messages each take the other’s for a new epoch, and provision it again and again.
2. What the transport must provide
The server sends one kind of message, ToReplica, which carries a Down, an
EndSnapshot or a Filed. A replica sends one kind, Up, on one or more
lanes (section 7). A transport carries bytes and nothing else: it never needs
to know what is inside. Whatever it is — a broker, HTTP over a queue of your
own, files on a USB stick — it must provide, in both directions:
- Nothing it took is lost. Once the transport accepted a message, it delivers it: while the other end is unreachable, and across a restart of either end or of the transport itself. It stores, then forwards.
- In order. A replica’s messages are delivered in the order the server
sent them, and a replica’s
Ups on one lane reach the server in the order it sent them. - One at a time, down. The next message to a replica is not delivered until the replica acknowledged the previous one (5.1).
- At least once. A message that was not acknowledged is delivered again. Each side therefore treats every message as one it may have seen before (4.1, 7).
- An acknowledgement names its message. It releases that message and no other: one that arrives twice, or late, never releases a message the replica has not applied.
With NATS JetStream, that is a stream on file storage, and per replica a
durable pull consumer with explicit acknowledgements and
max_ack_pending = 1; up, a stream the server consumes per lane in order.
A message the transport carries has a size limit of its own; the server keeps
every message within it, and a replica must keep every Up within it
(section 7).
A transport acknowledgement (section 5.1) is what makes the transport deliver the next message. It is not the same as telling the server what was applied (section 5.2).
A transport that loses a message anyway costs time, not data: the next message names the one before it, so the replica knows it missed one and is provisioned again (4.1); a replica that leaves messages unconfirmed while nothing newer goes to it is provisioned again (A.1), an upload the server did not answer is sent again (section 7), and a digest that disagrees provisions the replica again (section 8). That is the engine’s safety net, not a licence: until it acts, the replica holds less than it should.
3. Rows and keys
Each table travels in its own encoding, agreed between the server and the replica for that table: either a protobuf message with one field per column, or a JSON object of its columns (the column names as keys). The engine’s messages carry those rows as opaque bytes, so this protocol never changes when a table does.
A row’s key is its primary key, as text, in key order:
- a UUID part: lowercase, hyphenated (
0196…-…); - an integer part: decimal;
- the parts joined by
|where a single text is needed (a1b2…|3).
This is the one form keys are compared in, and every key a side writes into a message must be in it. A side reading a key parses each part as its column types it and writes it back in this form before using it (a UUID that came uppercase or without hyphens is the same key); a key that does not parse is malformed.
A table that travels as JSON is a JSON object with one member per column,
named as the column, each value as the column’s type writes it in JSON —
except a 64-bit integer, which is its decimal text ("9007199254740993"): a
JSON number would be rounded by a reader that holds numbers as doubles. A
decimal is its text, a UUID its canonical form, an instant RFC 3339.
4. Applying what the server sends
4.0 The protocol version
Every ToReplica and every Up carries protocol, the version of this
document its sender speaks (now 1).
- A replica must set
protocolon everyUpit sends. - A replica that receives a
ToReplicaof a later protocol than its own must not act on it and must not acknowledge it to the transport: it waits to be updated, and the message is delivered again then. Every message behind it waits too, which is right, because they speak the same version. - The server does the same with an
Upof a later protocol than its own, and drops anUpof protocol 0 as malformed.
A message that is malformed — bytes that do not decode as a ToReplica; one
of protocol 0 (unset), or of the replica’s own protocol or earlier whose body it
does not know, or that has none; numbers no server sends (4.1) — carries nothing
a replica can act on: it must apply none of it, should log it, and
must acknowledge it, so it does not hold back the queue. The digests
(section 8) find whatever it carried, if it mattered.
What a server sends, field by field — anything else is a number no server
sends, and the corpus of malformed messages (tests/golden/malformed.txt)
has a case of each:
protocol: 1 to the replica’s own. Cases:no_protocol.sequence: 1 to 2^63 − 1. Cases:sequence_zero,sequence_too_large.nonce: 1 to 2^63 − 1. Cases:no_nonce,nonce_too_large.previous: belowsequence. Cases:follows_itself,follows_a_later_one.previous_nonce: 0 withprevious0, and otherwise 1 to 2^63 − 1. Cases:follows_without_its_nonce,follows_nothing_with_a_nonce,follows_a_nonce_too_large.epoch: a UUID. Cases:epoch_no_uuid.snapshot_id: a UUID; empty on a message of no snapshot. Cases:snapshot_no_uuid,ends_a_snapshot_of_no_id.mac: 16 bytes. Cases:no_mac,mac_too_short,mac_too_long.keys: one per upsert. Cases:keys_not_one_per_row.tables,deletes,upserts,uploads: no more rows — upserts and deletes together — and no more tables, or uploads, than the replica takes in a message: the server’s limits, or more (500 each by default). Cases:more_rows_than_a_message_carries,more_tables_than_a_message_carries,ends_more_tables_than_a_message_carries,files_more_uploads_than_a_message_carries.
A row’s own fields are the row’s to answer for, not the message’s: a key
that does not parse, a row that does not decode, a table the replica does
not hold — the row is rejected, or its delete ignored (4.1), and the rest
applied (a_delete_of_a_key_that_does_not_parse, a_row_beside_another_key).
What changes the protocol’s number: a new kind of body, or a field whose meaning changes. What does not: a new field. A side must ignore fields it does not know, so a message with a field added means to a side that predates it what it meant before.
4.1 Down: rows to hold, rows to let go
A replica must apply a Down in one transaction: either all of it, or
none of it.
The number comes first. Every Down carries a sequence and an
epoch, a nonce of its own — a number the server draws at random for it —
and previous and previous_nonce: the sequence and nonce of the message
the server sent this replica before this one, in this epoch — both 0 on a
message that begins a snapshot (4.2), which follows nothing. It carries a
mac too, the server’s seal, which the replica keeps as it came and gives
back (5.2). The replica keeps the (epoch, sequence, nonce, mac) of the last
message it applied, and checks, in order:
sequence0 or above 2^63 − 1, anepochthat is not a UUID, apreviousat or pastsequence, anonce0 or above 2^63 − 1, amacof other than 16 bytes, aprevious_nonceabove 2^63 − 1, apreviousof 0 with aprevious_nonce, or anotherpreviouswithout one, or apreviousof 0 on a message that begins no snapshot: no server numbers a message so; nor sends a table whosekeysare not one per upsert. The message is malformed (4.0).- the same
epochand asequenceat or below the last one: the message changes nothing. The replica must not apply it, and must acknowledge it to the transport: it is a redelivery, or the server’s database was restored from an older copy and is numbering again — its nonce then is another, but the replica need not tell: either way it should tell the server the last message it applied again (5.2), and a restored server finds out from that. previous0: the message begins a snapshot, and the replica applies it, whatever it applied before — of this epoch, or of another, which is a server installed again or restored: it keeps that epoch’s numbers from now on — and whatever gap it had. A new epoch always begins with a snapshot.- otherwise the message must follow the last one applied:
previousandprevious_nonceare its sequence and nonce, of the same epoch. If not, the replica missed one — lost on the way, or the replica’s own database restored from an older copy, or the server’s numbering again — and has a gap. Once it has a gap, it applies nothing but a message that begins a snapshot: not even one that follows the last applied. It must not apply the message, must acknowledge it to the transport, and keeps the sequence of the newest message it could not follow, which it says to the server (5.2) until a snapshot begins. The server answers with one. - a message that follows the last applied and names a snapshot other than the one in progress — or none, while one is in progress, or one while none is — is one no server sends: malformed (4.0, 4.2).
Then the replica applies tables in the order given. The server puts
parents’ rows before their children’s, and children’s deletes before their
parents’, so in that order no row ever points at a row the replica lacks.
It keeps a table’s deletes in the message that carries its upserts: the
rows from a table’s first upsert to its last delete are split across
messages only when they do not fit one on their own. So a unique value one
row lets go of is there, in the same message, for another row to take.
For each Table:
A table the replica holds from the server is one the server writes: a catalog, or a table written on both sides. Rows of any other table — a record of the replica’s own, a table it does not know — are rejected, and their deletes ignored.
-
Deletes, first: a value a row lets go of — a unique one — is then free for an upsert of the same part to take. The keys in
deletesare let go of together:- a row that a row staying for the replica points at — a row of its own, through any foreign key: one that would refuse the delete, checked at once or at commit, and one that would cascade or set the pointing row’s columns, which would change a row the server never sent; or a row it retired — is retired instead (4.3), and so, in turn, are the rows a row retired so points at among them. Where the table has nothing to retire it by, the row is kept, and a rejection recorded (section 6);
- every other is deleted, together, so rows that point only at one another — a tree, a row that points at itself — go whatever the order of their keys. One the database refuses to let go — a trigger, or a row of the server’s that stays and points at it — is kept, and a rejection recorded.
-
Upserts. Each entry of
upsertsis one row, encoded as the table’s message;keys[i]is the key ofupserts[i], and there must be one key per upsert. A row whose own key is not the one beside it is no row the server meant: the replica rejects it under the key beside it. For each row:- insert it, or overwrite the stored row with the same key, whatever its version: the messages of an epoch are ordered by their chain (4.1), so the one applied last is the server’s latest, even when its version is older than the stored one — a server restored from a copy, or whose clock was set back, sends what it has;
- columns the replica owns in a table written by both sides are not overwritten;
- a row the replica had retired (4.3) becomes live again;
- a row of its own it deleted, in a table keyed by the replica, whose
delete the server has not filed yet (4.4), is not taken: the
server sent it before it heard of the delete. The replica records it
held back — a rejection marked
held_back(section 6) — so the server does not count it held, and sends it again should it keep the row.
A key both let go of and upserted in one part is held: its upsert comes after. A key upserted twice in one part is the last row beside it.
A row the database refuses is tried again once the rest of its part is in, and once the whole message is — every part of it, the deletes of later parts included — each while that takes one more: it may point at a row after it, or take a unique value a row after it, or a delete after it, gives up. One that a later part lets go of is not tried again: the server let go of it; nor is one a later part sends again: the later row is the one, and the earlier is dropped, neither taken nor rejected. What is still refused then is rejected (section 6). Two rows that swap a unique value cannot be taken one at a time — the database checks each row as it is written — so both are rejected, and keep what they held, until the server sends them otherwise.
A row the replica cannot take, or let go of, does not fail the message.
If a row does not decode, if the replica’s database refuses it for its data
(a constraint, a trigger), or if the replica does not know the table at all,
the replica must leave that row as it was, apply everything else, and
record a rejection (section 6) in the same transaction. Nothing a message
carries fails it — not even a row whose refusal rolls the whole transaction
back (a trigger’s RAISE(ROLLBACK)), nor one a foreign key checked at commit
refuses — because the row it points at is not held, or because rows point
at a value of it the message changed or let go of: a constraint that fails
the commit is the replica finding such a row, never a failure of its
storage. The replica applies the message again, row by row, finds the rows
at fault — by the database’s own check of its keys — and applies it once
more with them rejected before they are tried; and should the database
still refuse it for what no row of it explains, it applies it with every
row of it rejected, so that the rest of what it says stands. Anything else
the database answers of a row — a trigger’s own error included — refuses
that row. Only a failure of the replica’s own storage (the database is
unavailable, the disk is full, the file is damaged) fails the message: then
the replica must not acknowledge it, and it will be delivered again.
4.2 Snapshots
When the server provisions a replica from scratch, it sends everything the
replica should hold in Down messages that carry a snapshot_id, then an
EndSnapshot naming the tables the snapshot covered. A snapshot of nothing
is its EndSnapshot alone.
The snapshot_id is a UUID, as the epoch is; a message naming anything else
is malformed (4.1).
- A snapshot begins with its first message, the one whose
previousis 0 — theEndSnapshotitself, for a snapshot of nothing. It supersedes the snapshot in progress, if any: the replica forgets what that one named. The messages after it follow it, as any do (4.1). - While applying a
Downof the snapshot in progress, the replica must remember the key of every row it carried, per table — the ones it refused as well: a refused row keeps whatever the replica had of it. - On
EndSnapshot, the replica must let go of every row of the named tables that the snapshot did not name (delete, or retire as in 4.3), children before parents: in reverse of the order the tables are listed. A row it refused was named, so it is never swept. The rejections it recorded for those tables before the snapshot began are forgotten — the snapshot sent again what it should hold — and the ones it recorded since stand. Then the snapshot is no longer in progress. - A part of a snapshot’s
Downmay carryrecorded: keys of a setting the server records the replica holding, which no other field of the part concerns. Of those, the replica must note each row it does not hold as its own delete (section 7), so the server lets go of it as it files the delete; one it holds it has noted already, as the snapshot began. - The messages of a snapshot follow one another, and nothing else comes between them: a message that follows the last applied and names a snapshot other than the one in progress — one that ended, or was superseded — or names none while one is in progress, is one no server sends: malformed (4.0).
EndSnapshotcarriessequence,epoch,nonce,previous,previous_nonceandmac, and follows the same rules asDown(4.1). It needs no count of what the snapshot sent: its messages are chained, and one lost is a gap.
4.3 Retired rows
A replica that must let go of a row that one of its own rows still points at (a receipt that names an order) keeps the row and marks it retired: a column the replica owns, set to when it was retired. Retired rows are out of everything the replica offers its users, but stay for what points at them, and only that long: once nothing that stays points at them — a live row or a retired one, of any table — the replica lets go of them, children first. It looks after every message it applies, once that message is applied and apart from it, so a row it cannot let go of never holds a message back; and it says nothing of one it cannot. A retired row holds none of its unique values against a live one. A retired row the server sends again becomes live.
A row of a table written on both sides that the replica edited, and whose edit the server has not filed yet (section 7), is retired too when it is let go of, rather than deleted: the edit still goes up, as the row is. Once the server files it (4.4), the replica lets go of the row. Where the table has nothing to retire it by, the replica refuses to let it go (section 6), and the server sends the delete again later.
4.4 Filed
The server answers each upload (section 7) once it has filed it. On Filed,
the replica must forget the notes those uploads carried — the ones whose
latest send carried that number, and no other: a note sent again since is
forgotten only on the answer to that send, and a row written since the send
goes up again. A number none of its sends carries — meant for another
replica, or for this one before it was restored — names nothing. The answer
may arrive before the replica recorded that the transport took the upload:
it counts all the same.
4.5 Wishes
A setting is a table of the replica’s own whose rows go up as a record’s
with a version do (section 7) — a value the replica holds and acts on: a
module’s configuration, a screen’s brightness — some columns of which the
server may wish other values. Which columns a wish sets, and which column
is the version, is the table’s declaration, agreed between the server and the
replica as the table’s encoding is (section 3). A replica that holds settings
holds the server’s wishes in a table of the engine’s, sync_wishes: a table
written on both sides (4.1), its rows encoded as the Wish message, its key
the wish’s id. A replica that holds none needs none of this section: it
rejects the rows of sync_wishes as those of any table it does not know.
A wish is one row of a setting — table, key — and what the server wants
it to hold: wanted, the row encoded as table’s own, of which the replica
takes the columns a wish sets, and nothing else. One row has one wish at
most; a new one replaces it, under a new version, a number the server draws
at random: only whether an answer is of the wish the replica holds matters,
never which version is the later. The server owns id to note; the replica
owns the answer — answered, the version it answers, outcome, reason and
answered_at — which it must not take from a message: a wish it takes
that it did not hold is one it has not answered.
Once the message that brought a wish is committed — never inside the
transaction that applies a message, since carrying one out may take time and
reach outside the database — and while no snapshot is in progress (4.2: its
end may let go of a wish), the replica carries out every wish it holds, not
retired (4.3), that it has not answered, or answered waiting. It takes them
one at a time, each read again just before, so a wish replaced or
withdrawn meanwhile is carried out as it is then, or not at all:
- It reads
keyastable’s key (section 3) andwantedastable’s row. A table it holds as no setting, or a row it cannot read — a table or an encoding this build does not know — it answerswaiting, and carries out with a build that can. A key that is not the table’s, a row whose key is notkey, or a row of another replica than itself (by the setting’s origin) it answersrefused. - A row it does not hold it answers
waiting(it holds no such row yet), and carries the wish out once it does: it never writes a row a wish names. - With
if_unchanged, the wish is based on the versionwantedcarries: if the replica holds the row at another version, it answerssuperseded, and does not carry it out — the replica’s value stands. Versions are compared as text: an integer decimal; an instant in UTC, RFC 3339, truncated toward the past to the microsecond, always six fractional digits andZ(2026-10-12T10:00:00.123456Zfor.1234567). - Otherwise it carries it out, however the setting takes it, and answers
applied— then, in one transaction, it writes the wished columns onto the row as it holds it then, its version moved past what it was (an integer one more; an instant now, truncated so, or a microsecond past the old one when the clock is behind), and records the answer: a version that cannot move (an integer at its largest), or a write its database refuses, is answeredrefused, with why, and the row left as it was;refused, with why — the row as it was — final for that wish; orwaiting, with why, and carries it out again later.
An answer is recorded only while the wish the replica holds is still the one
it answers (the same version, not retired); a wish replaced meanwhile is
carried out next, and the row keeps what carrying out the last one wrote. A
waiting answer is recorded when it is new — another reason — not every time
it waits again. The answer and the row it wrote are noted like any write of
the replica’s (section 7): the answer as an edit of the columns the replica
owns of a two-way row.
A replica that stops after carrying a wish out and before it records the answer carries it out again: carrying out the same wish twice must leave the setting as once does.
5. Acknowledging
There are two acknowledgements, and a replica gives both.
5.1 To the transport
After the transaction that applied a message commits, the replica acknowledges the message to the transport, which then delivers the next one. Never before the commit: a replica that acknowledged and then failed to commit would never be sent that message again.
5.2 To the server: what was applied
The server counts a row as held by a replica only once the replica says it applied the message that carried it. Until then, the row is “on its way”. That is what makes the server’s progress figures true.
The replica must tell the server the (epoch, sequence, nonce, mac)
of the last message it applied, as that message carried them, in an Up
on its first lane (section 7) that carries them in epoch, sequence,
nonce and mac. It may do so after every message, or once for several
(a replica that applied messages 5, 6 and 7 reports 7). It should do so
within seconds of applying, and again whenever it applied something new,
or was delivered a message at or below the last it applied. sequence 0
says it has applied nothing yet.
A replica with a gap (4.1) must say so: gap, the sequence of the
newest message it could not follow, on every Up of its first lane, with
its last applied, until a snapshot begins; and it must send one at once
whenever that sequence grows. The server answers with a snapshot (A.2).
6. Rejections
A row the replica could not take, or could not let go of (4.1), is a
rejection: the table, the key — required, in the form of section 3:
the one the row came beside, keys[i], or its delete’s — why,
and the (epoch, sequence) of the message that carried the row, or its
delete — for a row the end of a snapshot could not let go of, the
EndSnapshot’s; for a row held retired for the replica’s edits (4.3) that
it could not let go of once they were filed, the message that let go of
it. A row whose key cannot be told is logged and not reported. A row held
back (4.1) is a rejection marked held_back: nothing is at fault, so the
server raises no alarm over it; the rules below hold for it all the same.
- The replica must record a rejection in the same transaction that left the row out, so it cannot be lost.
- It must report each rejection to the server in an
Up(rejected), no later than theUpthat says it applied the message that refused it (5.2): the server counts the rows a message carried as held once it hears the message was applied. - It must keep the rejection until it takes the row (a later message carries the row and it applies) or lets go of it (a delete, or a snapshot that does not name it): a digest (section 8) leaves rejected rows out, as the server leaves out the rows recorded refused. It reports only the rejections of the epoch it follows; a snapshot of a new epoch clears the others — those of the tables it names as it ends, and those of a table it does not hold from the server, which no snapshot names. A rejection of a row it holds no copy of — of a table it does not hold from the server, or a row it never took — leaves nothing out of a digest: it may forget that one once reported, and the engine does, so what it keeps of rejections is bounded by what it holds.
- It keeps what the server said within bounds: a reason may be cut (to 512 bytes), and rows of a table it does not know, named or keyed longer than that, are logged rather than kept — no server sends such a table. So any rejection fits a message up — the backlog (section 7) waiting for the next one when it would leave the first rejection no room — and never holds back the confirmation. Rejections count against a message’s rows as the rows it carries do.
7. Sending rows up
For a table the replica writes and the server mirrors:
- Note every insert, update and delete in the writing transaction: the
table and the key — on an update that changes the key, the old key too,
which is gone. (A trigger is the only way no write path can forget.)
What the replica writes while applying what the server sent is not
noted: it is the server’s already. A row of a table written on both sides
begins on the server; the replica edits only its own columns of it, and
only those edits go up: it must refuse an insert of one, an update
of a column it does not own, and a delete of one but a row keyed by
itself, in a table keyed by the replica. A record without a version is
history, written once. A setting is noted as a record with a version is;
and in the transaction that begins a snapshot (4.2) the replica must
note again every row of its settings, and every answer it gave to a wish
it holds (4.5): the server’s record of them may be behind — it, or the
replica, restored from an older copy — and is set straight by what the
replica says it holds now (A.3). Such a note is re-stated until the
upload that carries it is filed, a later write of the row
notwithstanding: its row goes up in a part of its own,
restatedset. - Upload noted rows, as they are at upload time, in an
Up:- each row in
tables[].upserts, encoded as its table’s message; - a row that is gone by now, as its key in
tables[].deletes, but only for a table keyed by the replica that wrote it: history a replica pruned is not the server’s to delete; - one key per row in
tables[].keys; tables[].restatedset on a part of re-stated rows only: a table’s rows go in two parts when some are re-stated and some are not, and a delete always goes in a part without it;- upserts parents first, in the order the tables point at one another, then deletes children first;
- in
uploads, the number of this upload: a fresh one on every send, resends included, drawn at random from 1 to 2^63 − 1, so an answer meant for another replica, or for this replica before it was restored from an older copy, never names its notes; - within the transport’s size limit: what does not fit waits for the next
upload. A row that can never fit is set aside — kept, counted in the
backlog, never sent until the replica runs again with a build that may
send it. An
Upthe transport refuses for good is sent again with half its rows and rejections, and so on, until what it refuses is alone: that row is set aside, that rejection taken as told (it must not hold back the confirmation of what was applied), and nothing beside them — but only if the transport took some other message of that stream meanwhile: a transport that refuses every message says nothing of any one row, and the replica sets nothing aside, raises the alarm and asks again after a backoff.
- each row in
- Wait for
Filed(4.4) naming that upload, then forget its notes. An upload not answered after a while (minutes) should be sent again; the server files the same rows idempotently.
A replica may say what it wrote and the server has not filed yet, in
Up.backlog: per table, rows not sent yet (unsent), rows sent and not
yet Filed (unfiled), and rows set aside (set_aside). Say it
when it changes — at once when it empties — and not on every write; say it
again now and then while it is not empty, and to a server whose epoch
changed, either of which may have lost it. An Up without backlog leaves
the last one standing (A.3). A replica that never says leaves the server with
nothing to show, and nothing breaks.
A replica may send its writes on several lanes — streams of Ups the
transport keeps apart, each carrying the writes of some of its tables, so a
table that writes often never waits behind the rest. Every table that goes up
is on exactly one lane. Rejections, the last applied message, digests and
the backlog go on the first. The transport keeps each lane in order
(section 2): the backlog an earlier Up carried must not arrive after a
later one’s.
One Up may carry several of these at once: rows, rejections, digests, the
backlog, and the last applied message.
8. Digests
From time to time (hourly is reasonable), a replica should report what it
holds of every table the server sends it, in Up.digests, together with the
(epoch, sequence, nonce, mac) of the last message it applied (5.2). It
must leave out a table it wrote rows of that the server has not filed
(Filed, 4.4); the reason is given at the end of this section.
table: the table;count: how many rows it holds, leaving out rows it retired (4.3) and rows it rejected (6);fingerprint: FNV-1a, 64 bits, over the keys of those rows as text (section 3), sorted (byte order), each followed by a newline (\n):
hash = 0xcbf29ce484222325
for each key, sorted:
for each byte of key + "\n":
hash = hash XOR byte
hash = hash × 0x100000001b3 (mod 2^64)
Test vectors: no keys → 0xcbf29ce484222325; the single key a →
0x089bdc07b544e7b2.
It should report its settings and its answers as well, which the server records as the replica uploads them: a server restored from an older copy records what the replica said before, and no message it sends shows it. Their lines are fingerprinted the same way, and say more than the key:
- a setting: each row it holds, as its key, a tab (
\t), and its version as text (4.5); sync_wishes: each wish it holds, left out as above, as its key, a tab, the version it answered, a tab, and the outcome — each empty for a wish it has not answered.
Test vectors: the setting line a\t1 alone → 0x6f375e81eb6a6700; the
unanswered wish w (w\t\t) → 0xba0c6af6f14e26d2; w applied at version 7
(w\t7\tapplied) → 0x2e00e679fcb6ffe0.
A replica must leave out of its digests every table of which it wrote rows the server has not filed yet (section 7): what the server records of it is about to change, and a comparison now would find them apart for nothing: the server records a row the replica deleted as held until it files the delete, and then records it held no more only where the replica had applied every message that carries the row (Appendix A.3). That holds from when it reads them to when it sends them: a table written in between — a write that may go up in the very message — goes without its digest too.
9. A session, start to finish
server replica
│ (every arrow down is a ToReplica carrying the message named)
│ Down {snapshot s1, seq 101, previous 0, products…}
│ ──▶ begins s1, applies, remembers its keys
│ Down {snapshot s1, seq 102, previous 101, orders…}
│ ──▶ applies
│ EndSnapshot {s1, seq 103, previous 102, [products,orders]}
│ ──▶ sweeps what s1 did not name
│ ◀── Up {epoch E, sequence 103} "applied up to 103"
│ progress: holds 3,200 of 3,200
│
│ (a product is renamed)
│ Down {seq 104, previous 103, products: 1 row}
│ ──▶ applies
│ ◀── Up {epoch E, sequence 104}
│
│ (a row the replica refuses: a check it has, the server lacks)
│ Down {seq 105, previous 104, orders: 2 rows}
│ ──▶ applies one, rejects one
│ ◀── Up {sequence 105, rejected [orders 9f…: "check failed"]}
│ the row is set aside for this replica
│
│ (writes two receipts)
│ ◀── Up {tables [receipts: 2 rows], uploads [57]}
│ filed
│ Filed {uploads [57]} ──▶ forgets the notes upload 57 carried
│
│ ◀── Up {digests […], epoch E, sequence 105} (hourly)
│ matches what the server records: nothing to do
10. Conformance checklist
The engine’s repository holds test vectors, each line a name, what the other side makes of the message, and the message in hex; each file’s header says how to read it.
tests/golden/to_replica.txt: what the server sends one replica, step by step — a provisioning of several messages, the first delivered again, updates, a row the replica’s own check refuses, a delete of a row a row of its own points at, a snapshot cut short and the one that supersedes it, a message lost on the way, the gap after it and what comes during the gap, the answer to an upload, a version older than the one held, a field the replica does not know, a new epoch, a wish of a setting the replica holds — carried through a provisioning — and its withdrawal. Each message says what the replica makes of it (applied,ended,filed,already,gap, orloston the way), and each step is followed by aholdsline: what the replica then holds, as JSON.tests/golden/malformed.txt: messages no server sends — and the few that set the scene between them — each with what a replica makes of it,malformedfor most.tests/golden/to_server.txt: what a replica sends up after the steps ofto_replica.txtbefore its upload is answered — its rows, its rejections and what it applied; its digests; the same with a seal the server never made — each with what the server makes of it.tests/golden/to_server_verdicts.txt: uploads a replica sends after a provisioning, a change and a take-back it applied — a refused delete with what it applied, an edit of a two-way row, a delete of a row of its own, a refused sweep, a row of its own held back, an answer to a wish and to one withdrawn, a setting and an older version of it, a gap, what it applied said again lower, of another epoch, and past anything sent — each with what the server makes of it.
A replica implementation is complete when:
- it reads every message as a
ToReplica, leaves one of a later protocol unacknowledged, and setsprotocolon everyUp; - it applies a
Downin one transaction, tables in the order given; - it ignores a message at or below the last applied in the same epoch, and says again what it applied; acknowledges without applying a malformed message (4.0, 4.1);
- it applies a message only when it follows the last applied — sequence and nonce — or begins a snapshot; one that follows another is a gap, after which it applies nothing until a snapshot begins, and it says the newest message it could not follow on its first lane, again whenever that grows (4.1, 5.2);
- it overwrites a row with what the server sends, whatever its version;
- it never overwrites the columns it owns;
- it retires a row it cannot delete, and revives a retired row sent again;
- it applies a table’s deletes before its upserts, lets go of the deletes together, and tries a refused upsert again once the rest are in (4.1);
- it begins a snapshot with the message that follows nothing — an
EndSnapshotincluded — and sweeps on its end, in reverse table order; - it leaves as it was and records, in the same transaction, a row it cannot take or let go of;
- it reports rejections, each with the message that refused it, before
it says it applied that message, and keeps them until it takes the
row or lets it go — a row of its own whose delete is not filed among
them, marked
held_back(4.1); - it acknowledges the transport only after committing;
- it reports the last applied
(epoch, sequence, nonce, mac)to the server, as the message carried them; - it notes its own writes in the writing transaction, uploads them as they
are within the transport’s limit, gives every send a fresh random
number, and forgets on
Filedonly the notes that send carried, as it carried them; - it reports digests with the fingerprint above (optional, recommended);
- it says its backlog when it changes (optional, recommended);
- if it holds settings: it holds their wishes as a two-way table, takes no answer from the server, carries each out after the message that brought it is committed — superseded, unread or applied as 4.5 says — and records the answer, with the row it wrote, in one transaction; notes every setting and every answer again when a snapshot begins, and sends them re-stated until filed (section 7); deletes each row a snapshot records it holding that it does not (4.2); its digests, if it reports them, include its settings and its answers (section 8).
Appendix A. What the server does with it
Nothing here is a duty of a replica. It is what the server does with what a replica sends, written down so that a replica’s author knows why the rules above are what they are, and what to expect when a replica breaks one. The sections it explains are named at each part.
A.1 What the server guarantees in return
- Every change committed on the server reaches every replica that should hold the row, as the row is at sending time.
- A row is sent with every row it points at that the replica lacks, and is taken back only once nothing the replica keeps points at it.
- Every message carries a number and a random nonce of its own, and the numbers a replica is sent rise within an epoch in the order it is sent them; each names the one before it, number and nonce, so a replica can tell it missed one — even when a restored server numbers a message as one it applied. What a message carries is recorded before it is sent, and a message that the transport did not take is sent again, under the same number and nonce, before anything after it.
- A replica that leaves the last messages it was sent unconfirmed, while nothing newer goes to it, is provisioned again after a while: the transport may have lost them. One that says it missed a message is provisioned again at once.
- A server whose database was restored from an older copy finds out from the first replica that says it applied a message the server sealed and that copy has no record of sending it, and provisions every replica again, under a new epoch. A replica restored from an older copy finds out from the next message it is sent, and says so; on a server with nothing to send it, the server finds out from what it says it applied, which is less than it said before.
- A row the server cannot send to a replica, or that a replica rejected, is set aside and sent again after a fix: it is never silently dropped.
- An upload is answered with
Filedonly after the transaction that filed it committed.
A.2 What the server makes of “what I applied” (section 5.2)
The server checks the seal: one it did not make — a replica that lies, or a bug — is ignored, and resets nothing. It ignores a report that does not parse, and one of another epoch — unless it last heard the replica apply a message of its own epoch: a replica only goes forward, so it follows an epoch this database never had, which was restored from a copy older than that epoch began. The server provisions that replica again, unless a snapshot written for it since is on its way. A sealed one it judges against what it recorded sending the replica:
- a message past the last it recorded sending it — or, having recorded none since it last provisioned it, past every number it drew — or one at the sequence of the last it sent it, or of the last it heard applied, with another nonce: the server sealed it, and has no record of it. Its own database was restored from a copy older than what the replica applied. It raises an alarm — whoever runs it should know the database went back — takes a new epoch, with a new secret, drops what it was about to send, and provisions every replica again;
- a message older than the last it heard the replica applied: the replica was restored from an older copy, and holds less than the server records. The server provisions it again, unless a snapshot written for it since is on its way;
- otherwise the server counts the rows of every message up to it as held.
A replica with a gap (4.1) is provisioned again, unless a snapshot that began after that message is on its way: a snapshot that began before it is one the replica lost the beginning of, or applied before it was restored from an older copy. A gap past every message the server sent the replica is none it could have missed: malformed, and left out.
Neither a gap nor a message older than the last applied carries a seal the server can check, so what a replica says of itself provisions it again at most once a minute, and twice as long between each time while it keeps saying so, up to 32 minutes; one quiet for 64 minutes starts over. One that says it sooner is provisioned again once it may: the server notes it for then.
A.3 How the server files an upload (section 7)
The server files an upload under the replica it came from (the transport
says which), whatever the rows themselves claim: a row of a table keyed by its
replica that names another replica there is malformed, and left out. A row of a
table written on both sides is an edit of the columns the replica owns, filed
only as an update of a row the server has and records the replica holding. One
it does not record the replica holding while it provisions the replica again —
the provisioning pending, or written and not confirmed — is kept, and filed once
it does; otherwise, one of a row the replica no longer holds — the server
deleted it, or is taking it back — is left out: the server’s wins. One of a row
it never sent the replica is malformed, and left out. A row the server cannot
file, or a delete its database refuses, is kept on the server, not lost, and
filed later; so are an upload’s rows whose filing fails as a whole for a reason
a resend cannot cure. Either still counts as filed for Filed. A replica’s
delete of a row of its own is a fact whether the server’s database lets the
row go or refuses to: the server records the replica holding it no more, and
sends it again a row it kept — but for a row that a message the replica had
not applied when it sent the delete carries (the last it says it applied, or
else the last the server heard it apply). That one the server still records
held: the replica takes that message’s version if it hears the delete filed
first, and the server’s own delete of the row, if it let it go, follows;
otherwise the replica holds it back (4.1), and its rejection, which comes no
later than it says it applied the message, records the row refused (A.4). A
delete filed later, from what the server kept, leaves the row held the same
way: a delete of a row a replica does not hold changes nothing there. The server
keeps, of each row, the newest upload of it: a later upload of the row replaces
what it kept, or drops it once filed, so what is filed later never rolls the row
back. A record with a version is filed only over an older version of it, and so is
a setting — a value the replica holds and acts on — but one in a part
restated (section 7), which is filed whatever its version: it is what the
replica holds now, even when a replica restored from an older copy says an older
version; a delete is filed whether its part is restated or not. The server
keeps a row only with its key in keys: one without is malformed, and left
out. What the upload says of the replica — rejections, the last applied
message, a gap, digests, the backlog — is filed apart from its rows, so neither
holds back the other. Deletes of a table not keyed by the replica, and keys that
are not the table’s, are malformed and left out. An upload beyond the server’s
limits — more bytes than a message takes, or more rows and rejections together
than one carries: a rejection counts as a row — is malformed: dropped,
unanswered, so it waits on the replica until a server that takes it runs. A
replica’s limits are the server’s, or less. So is an upload from a sender that
is no replica of the server’s.
The server keeps the last backlog it was told, with the time, and shows the replica as catching up while it is not empty.
A.4 How the server records a rejection (section 6)
On a rejection of the last message that carried the row, the server records the
row refused: still held for every decision that takes rows back — a delete
still goes when the row leaves the replica’s scope, and a refused delete is sent
again — and not held for progress, or for digests. The row is set aside for that
replica, and sent again, or its delete, when the relay restarts or on a timer,
which is after a deploy on either side has had the chance to fix the cause; it
is taken once the replica applies a later message that carries it. A rejection
of a delete re-records the row the server took back. A row a replica held
back (held_back) is recorded the same way and raises no alarm — nothing is at
fault, and a fix is awaited for nothing: the retry sends it again, should the
server keep the row. A rejection of a message
other than the last one that carried the row, or of another epoch, names a
version that no longer matters, or none it was sent, and is left out. The key is
read as its table’s key (section 3) whatever its spelling. A rejection of
anything else — a table the server does not send, no key or one that is not the
table’s, a message never sent the replica — is malformed and left out. Nobody
repairs the row by hand.
A.5 How the server compares digests (section 8)
The server compares a digest only when the replica has applied every message
recorded for it (the same epoch and last sequence, sealed by the server), and
against what it recorded as of that message: when it has sent the replica
more meanwhile, it waits for the next digests. It compares a setting’s
against the rows of it it holds of that replica, and those of sync_wishes
against the answers it keeps — neither while it keeps rows of that table the
replica uploaded and it could not file yet, as the replica leaves out a table
it wrote rows of that are not filed. If they differ, it provisions the replica
again from scratch (4.2): the replica re-states its settings and its answers
as the snapshot begins (section 7), and deletes those the snapshot records it
holding that it does not. Digests that differ again
within a day of the provisioning they caused are a bug no provisioning cures:
the server logs them and does not act on them. A digest of a table the server
neither sends the replica nor holds as a setting — the replica’s records, a
later build’s table, one it no longer mirrors — is skipped. Two digests of one table are malformed: none of that Up’s digests
is compared. The server compares a replica’s digests at most once every ten
minutes: those that come sooner are not compared. A replica that skips
digests loses only this safety net.
A.6 How the server keeps wishes (section 4.5)
The server writes a wish of a setting row when its host asks, in the host’s
transaction, under a version it draws at random — so a server restored from
an older copy wishes under other numbers than it did — and sends it to the
replica the row belongs to (the setting’s origin), as it sends any row: one
withdrawn is deleted, and taken back as a row is. It never sends an answer
down. It files an answer as the edit of a two-way row it is (A.3): only while
it records the replica holding the wish — so an answer to a wish withdrawn is
left out — and keeps it beside the wish; a wish stands as its answer says
only while that answer is of its version: pending until then. A final answer
— applied, refused, superseded — stands against a later upload of
another answer to the same version, which the transport may have kept back,
but for one re-stated (section 7). It keeps the row wished apart from any
encoding, and encodes it as it sends the wish: the setting row it holds, or
the one wished when it holds none, with the wished columns and the version the
wish is based on laid over it. Table names beginning sync_ are the
engine’s: no host table may take one.