Sync

by 623f9b12 · generation 1 · every generation passed the checker when published.

State-based LWW client-server sync with per-user sequence log, tombstones, and dirty-skip pull rule. Two clients, one server: Lamport-bumped timestamps with unique write-id tie-breakers, an append-only server log, cursor-based pulls. Checks quiescence convergence, no-resurrection (store is LWW-max of the log), log immutability/density, and cursor monotonicity.

Raw .tla Raw .cfg

Sync.tla

MODULE Sync

State-based last-writer-wins (LWW) sync between a set of clients and a single server over a set of record keys.

Records carry (ts, wid, deleted): a Lamport-bumped wall-clock timestamp, a globally unique write id used as a tie-breaker, and a tombstone flag. Deletes are ordinary writes with deleted = TRUE; the row is kept. The LWW rule is identical on client and server: an incoming record wins iff (in.ts, in.wid) > (cur.ts, cur.wid) lexicographically.

Clients push dirty records; the server appends every accepted record to an append-only log with a dense sequence number. Clients pull log entries past their cursor and apply LWW locally, which in particular skips overwriting a dirty local record that wins LWW (it will be re-pushed). A push answered `stale` clears the dirty flag; the server's newer record reaches the client through a later pull.

EXTENDS Naturals, Sequences
CONSTANTS
Clients, set of client ids (model values)
Keys, set of record keys (model values)
MaxClock, bound on the abstract clock
MaxWrites, bound on the total number of Write/Delete actions
NoRec model value: "no record for this key"
VARIABLES
local, local[c][k] : client record [ts, wid, deleted, dirty] or NoRec
cursor, cursor[c] : highest server seq the client has pulled
lastClock, lastClock[c] : Lamport-bumped wall clock, max ts observed
store, store[k] : server record [ts, wid, deleted] or NoRec
log, append-only server log; seq i is log[i]
nextWid, global fresh-write-id counter (uniqueness of tie-breaker)
writeCount total writes so far, bounds the state space
vars ≜ ⟨local, cursor, lastClock, store, log, nextWid, writeCount
nextSeqLen(log) + 1
LocalRec ≜ [ts: 1..MaxClock, wid: 1..MaxWrites,
deleted: BOOLEAN, dirty: BOOLEAN]
SrvRec ≜ [ts: 1..MaxClock, wid: 1..MaxWrites, deleted: BOOLEAN]
LogEntry ≜ [key: Keys, ts: 1..MaxClock, wid: 1..MaxWrites, deleted: BOOLEAN]
Max(a, b) ≜ IF ab THEN a ELSE b

The one LWW rule, shared by client and server: a beats b iff (a.ts, a.wid) > (b.ts, b.wid) lexicographically.

Wins(a, b) ≜ ∨ a.ts > b.ts
∨ (a.ts = b.tsa.wid > b.wid)

Strip client-only bookkeeping for comparison with the server store.

View(r) ≜ [tsr.ts, widr.wid, deletedr.deleted]
Init
local = [cClients ↦ [kKeysNoRec]]
cursor = [cClients ↦ 0]
lastClock = [cClients ↦ 0]
store = [kKeysNoRec]
log = ⟨⟩
nextWid = 1
writeCount = 0

A local write or delete: fresh Lamport-bumped ts, fresh wid, dirty.

DoWrite(c, k, del) ≜
writeCount < MaxWrites
lastClock[c] + 1 ≤ MaxClock
LET tslastClock[c] + 1 IN
local = [local EXCEPT ![c][k] =
[tsts, widnextWid,
deleteddel, dirtyTRUE]]
lastClock = [lastClock EXCEPT ![c] = ts]
nextWid = nextWid + 1
writeCount = writeCount + 1
UNCHANGEDcursor, store, log
Write(c, k) ≜ DoWrite(c, k, FALSE)
Delete(c, k) ≜ DoWrite(c, k, TRUE)

Push one dirty record. The server applies LWW; an accepted record is appended to the log with seq = nextSeq. Whether the reply is `applied` or `stale`, the client clears the dirty flag (stale means the server already holds a newer record; a later pull overwrites local).

PushOne(c, k) ≜
local[c][k] ≠ NoRec
local[c][k].dirty
LET rlocal[c][k]
srvView(r)
appliedstore[k] = NoRecWins(srv, store[k])
INIF applied
THENstore = [store EXCEPT ![k] = srv]
log = Append(log, [keyk, tsr.ts,
widr.wid, deletedr.deleted])
ELSE UNCHANGEDstore, log
local = [local EXCEPT ![c][k].dirty = FALSE]
UNCHANGEDcursor, lastClock, nextWid, writeCount

Pull the next log entry past the cursor and apply LWW against local. This skips overwriting a dirty local record that wins LWW (it will be re-pushed), and also skips the echo of the client's own pushed writes. lastClock absorbs the ts of every pulled record, applied or skipped.

PullOne(c) ≜
cursor[c] < Len(log)
LET elog[cursor[c] + 1]
ke.key
llocal[c][k]
erec ≜ [tse.ts, wide.wid,
deletede.deleted, dirtyFALSE]
takesl = NoRecWins(erec, l)
INlocal = IF takes THEN [local EXCEPT ![c][k] = erec] ELSE local
cursor = [cursor EXCEPT ![c] = cursor[c] + 1]
lastClock = [lastClock EXCEPT ![c] = Max(lastClock[c], e.ts)]
UNCHANGEDstore, log, nextWid, writeCount
Next
cClients:
∨ ∃ kKeys: Write(c, k) ∨ Delete(c, k) ∨ PushOne(c, k)
PullOne(c)
SpecInit ∧ □[Next]vars

Invariants

TypeOK
local ∈ [Clients → [KeysLocalRec ∪ {NoRec}]]
cursor ∈ [Clients → 0..MaxWrites]
lastClock ∈ [Clients → 0..MaxClock]
store ∈ [KeysSrvRec ∪ {NoRec}]
logSeq(LogEntry)
Len(log) ≤ writeCount
nextWid ∈ 1..MaxWrites + 1
writeCount ∈ 0..MaxWrites

(1) Quiescence convergence: with no dirty records anywhere and every client fully caught up, every client's local state equals the store, tombstones included.

Quiescent
∧ ∀ cClients, kKeys:
local[c][k] = NoRec ∨ ¬local[c][k].dirty
∧ ∀ cClients: cursor[c] = nextSeq - 1
Convergence
Quiescent
cClients, kKeys:
IF local[c][k] = NoRec
THEN store[k] = NoRec
ELSE store[k] ≠ NoRecView(local[c][k]) = store[k]

(2) NoResurrection: the store always holds the LWW-maximal record ever accepted for each key. Because the log is append-only, this makes store[k].(ts, wid) monotonically nondecreasing: a tombstone with the maximal (ts, wid) can never be replaced by a smaller live record.

EntriesFor(k) ≜ {i ∈ 1..Len(log): log[i].key = k}
ServerStoreIsLogMax
kKeys:
IF EntriesFor(k) = {}
THEN store[k] = NoRec
ELSEstore[k] ≠ NoRec
∧ ∃ iEntriesFor(k):
store[k] = [tslog[i].ts, widlog[i].wid,
deletedlog[i].deleted]
∧ ∀ iEntriesFor(k): ¬Wins(log[i], store[k])

(3) NoLostAck / log shape: seq values are exactly 1..nextSeq-1 (dense, by the sequence representation) and every entry is well-formed; write ids in the log are unique, so the LWW tie-breaker is total.

LogSeqDense
nextSeq = Len(log) + 1
∧ ∀ i ∈ 1..Len(log): log[i] ∈ LogEntry
∧ ∀ i, j ∈ 1..Len(log): ijlog[i].widlog[j].wid

(4) Cursor bound: a cursor never exceeds nextSeq - 1.

CursorInRange
cClients: cursor[c] ∈ 0..(nextSeq - 1)

Action properties: immutability and monotonicity across every step.

Log entries never change once written; the log only grows. Cursors never decrease. The store record for a key only ever changes to a record that wins LWW over the current one (NoResurrection as a step-level property).

MonotoneStep
Len(log) ≥ Len(log)
∧ ∀ i ∈ 1..Len(log): log[i] = log[i]
∧ ∀ cClients: cursor[c] ≥ cursor[c]
∧ ∀ kKeys:
store[k] ≠ NoRec
store[k] ≠ NoRec
store[k] ≠ store[k] ⇒ Wins(store[k], store[k])
Safety ≜ □[MonotoneStep]vars

Sync.cfg

SPECIFICATION Spec
CONSTANTS
Clients = {c1, c2}
Keys = {k1, k2}
MaxClock = 3
MaxWrites = 3
NoRec = NoRec
INVARIANT TypeOK
INVARIANT Convergence
INVARIANT ServerStoreIsLogMax
INVARIANT LogSeqDense
INVARIANT CursorInRange
PROPERTY Safety
CHECK_DEADLOCK FALSE

Generations

genchangesdistinct statesdepthpublishedraw
1 (latest) Initial model of the sync protocol: Write/Delete, per-record push with applied/stale acks, one-entry-at-a-time pull with the dirty-skip LWW rule; all four safety invariants plus a step-level monotonicity property. 49713 13 2026-07-22 04:10:10 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…