Skip to content

Ingest protocol specification (TLA+)

Ingest.tla models the BES ingest protocol for one invocation: a Bazel-like client that resends from its last acknowledged event after every lost connection, up to two nodes each running at most one worker per invocation (load the row, absorb in order, batch, commit through the compare-and-set fence on last_event_seq), acknowledgements that only travel over the open connection, takeovers, idle exits, and the two lifecycle notifications landing on any node. Chaos is bounded: connection drops and one commit that fails for a non-fence reason (a database hiccup after the retries).

Properties checked by TLC:

Property Meaning
Durability every sequence the client saw acknowledged is committed
KnownCommitted no worker believes more is committed than the row holds
Completes (liveness) with bounded chaos, the stream finishes and the lifecycle is recorded

Three configurations, all with two nodes, three events plus the marker, two drops and one failed commit (about 630k distinct states, a few seconds each):

Config Knobs Result
Ingest_fixed.cfg the protocol as of the dedup fix (2026-09-23) all properties hold
Ingest_current.cfg the dedup rule the code shipped with: a resend is acknowledged at once when it is below the sequence the worker expects Durability violated in 7 steps: absorb 1, drop, reconnect to the same node, resend 1, acknowledged while its batch is still in flight; a failed commit then loses it for good, and Bazel never resends what it believes stored
Ingest_pre_m10.cfg the attempt-started notification starts a worker; a fenced finish is not recorded Completes violated: the finish lands on the stray worker's node, is fenced and never recorded (the builds the AWS trial left in_progress)

The fix for the first row is in Conveyor.Ingest.Worker: a resend is acknowledged immediately only when seq <= committed_seq; between committed_seq and expected_seq the new connection is registered as a waiter on the batch that carries the event, so the ack follows the commit (worker_test.exs, "a resend of an absorbed, uncommitted event").

Blob lifecycle (Blobs.tla)

One digest of one project: upload (object plus a row with a TTL), attach (exists check, pin, reference insert), TTL expiry and orphan pruning after the grace period. Property ReferencedIntact: a reference always points at a blob whose row and object exist.

Config Knobs Result
Blobs_current.cfg pin and reference as two statements, pin ignores a missing row; deletion removes the object, then the row violated in 8 steps: the attacher pins, pruning passes its orphan check and deletes the object, the reference is inserted
Blobs_fixed.cfg pin and reference in one transaction and the pin must hit the row; deletion re-checks the pin and the references under the row lock and drops the row before the object holds

Fixed in Conveyor.Blobs (pin/2 returns {:error, :blob_gone} on a missing row, delete_blob/2 locks, re-checks and drops the row first; the explicit delete/2 forces) and Conveyor.Artifacts.attach/4 (one transaction, {:ok, artifact} | {:error, :blob_gone}; a vanished profile blob makes the fetch job retry, an upload answers 503).

Round two (found through Archive.tla, 2026-09-26): the model's upload was atomic and waited for a deletion in flight, which hid a re-upload of the same content. Uploads now write the object, then upsert the row, at any time. Blobs_reupload.cfg (the 0.2.2 code: the row dropped under the lock, the object after the commit) violates ReferencedIntact in 11 steps: a re-upload writes the object, the deleter drops the row, the re-upload's row lands, an attacher checks and pins it, and the deleter's object delete (after its commit) removes the bytes under a fresh reference. ObjectUnderLock (in Blobs_fixed.cfg): delete_blob/2 deletes the object while it holds the row lock and drops the row after, and pin/2 checks that the object exists while it holds the lock ({:error, :blob_gone} otherwise). A deleter that dies after the object delete leaves a row without bytes; the pin check refuses it and the next prune removes it.

Raw archive write-behind (Archive.tla)

One finished build, its daily segment partition, two nodes running ArchiveRaw, orphan pruning, build retention and the partition drop. The archiver reads the segments, puts one blob (object, then row; the same content always gets the same digest) and records the reference with UPDATE … WHERE raw_status = 'segments'; the loser of that update deletes its blob through the checked delete. Chaos: archivers crash between any two steps, a deleter's transaction dies after the object delete, the job does not run for days (a store outage), a late finish notification touches the row, retention deletes the build. Properties: Stored (every event is in the segments or in the referenced blob) and ReferencedIntact. RETENTION_RAW_DAYS = 2, archive delay one day (the target defaults).

Config Knobs Result
Archive_fixed.cfg drop guard, pinned reference, object deleted under the row lock holds (11k states)
Archive_current.cfg the design as first written (HANDOFF §7): date-only partition drop, plain conditional UPDATE, 0.2.2 blob deletion Stored violated in 7 steps: the archive has not run (store outage, or a build that crossed midnight gets one hour of slack) when the partition's date passes
Archive_noguard.cfg everything but the drop guard Stored violated the same way: no delay/retention arithmetic survives an outage
Archive_plainref.cfg the reference without pinning the row ReferencedIntact violated in 13 steps: put, the node stalls past the pruning grace, prune deletes the orphan, the reference lands on nothing (a later partition drop then loses the events)
Archive_reput.cfg 0.2.2 blob deletion (object after commit) ReferencedIntact violated in 16 steps: a crashed archiver's orphan is pruned, the next run re-puts the same digest between prune's commit and its object delete, pins the fresh row and references bytes that are then deleted

Design changes before any code (Conveyor.Workers.ArchiveRaw, Conveyor.Storage.Partitions, Conveyor.Blobs): the partition drop skips a day that still holds a finished build with raw_status = 'segments' (the oldest-unarchived gauge alerts instead of data being lost); the reference is recorded in one transaction that pins the blob (Blobs.pin/2, which now checks the object under the lock); blob deletion as in the Blobs round two above. Out of the model: builds that never finish are never archived and their segments go with the partition after RETENTION_RAW_DAYS, as before the archive.

Deduplicated input lists (SpawnInputs.tla)

Execution-log input lists are stored once per project and digest (spawn_inputs) and referenced by spawn rows. One list, two stores (upsert the list, insert the spawns, commit, or crash before the commit), retention deleting spawns at any moment, and orphan pruning (scan for old unreferenced lists, lock the row, re-check, delete). Property Intact: a committed spawn always references a list that exists.

Config Knobs Result
SpawnInputs_fixed.cfg the upsert takes the row lock (ON CONFLICT DO UPDATE), pruning re-checks under the lock holds
SpawnInputs_nolock.cfg ON CONFLICT DO NOTHING (no lock) violated in 11 steps: the prune locks and re-checks an orphan, a store commits a new reference, the prune deletes the list
SpawnInputs_norecheck.cfg locked delete that trusts the candidate scan violated: a reference committed between the scan and the lock is deleted from under

Implemented in Conveyor.ExecLog.store!/2 (lists upserted with DO UPDATE in the same transaction as the spawns) and prune_orphan_inputs/2 (FOR UPDATE, re-check, delete); spawn_inputs_test.exs pins the Postgres locking behaviour the fixed configuration relies on with two connections (DO UPDATE blocks a FOR UPDATE NOWAIT, DO NOTHING does not).

Writer group commit (Writer.tla)

One shard, two invocations, stale submissions, the group commit with its per-batch fallback, tag counts added after the commit. Without chaos every property holds (Writer_fixed.cfg, 686 states): a batch's rows land exactly once and in order, and the counted tags equal the committed batches. Writer_current.cfg adds a lost commit acknowledgement (the transaction committed, the writer saw an error and retried): the retry fences, the fallback fails every batch, the clients re-synchronise through dedup, nothing is lost or duplicated, but the tag counts of that group are never added. Accepted: facet counts are approximate by design and Invocations.rebuild_tag_keys!/1 restores them.

Since 2026-10-01 the implementation's fallback is narrower than the model's: a fenced group fails only the fenced invocations' batches (from the fenced sequence on) and commits the rest again as one group, instead of redoing every batch alone. Every batch the model commits in Fallback is still committed or failed-and-resent by the client; the model's properties (rows land once and in order, counted tags equal committed batches) are unaffected because the change only removes commits from a round. Re-model if the fallback ever starts committing batches the group did not contain.

Rollup staleness (Rollup.tla)

One project-hour, builds changing while the hour is computed. Property Fresh: a row that passes the staleness check reflects every change. Rollup_current.cfg (row stamped when it is written) fails: a build finishing during the compute is newer than the read but older than the stamp, so the hour stays wrong until something else changes. Fixed: the stamp is taken before the read, five seconds early for clock skew (Rollup.roll!/2, Rollup_fixed.cfg holds).

Oban rescue (Oban.tla)

One long job with Lifeline's rescue. OneRun (never two runs at once) holds when the job's timeout is shorter than the rescue window (Oban_fixed.cfg, 10 < 15 minutes as configured) and fails otherwise (Oban_current.cfg); oban_rescue_test.exs pins the configuration to that order.

Retention against a live stream (Retention.tla)

Retention deletes a build that started before the cutoff while its stream is still live or about to resume: the worker's next commit is fenced (row gone), the client retries, the row is created again empty and the resent sequence is ahead of it, so the upload fails for good and an empty in_progress row stays behind (Retention_current.cfg violates NoZombie). Fixed: retention skips in_progress and disconnected builds whose row changed within two idle windows (BuildRetention.delete_before/3, Retention_fixed.cfg).

Running

docs/spec/check.sh                          # every configuration
docs/spec/check.sh Ingest:fixed Blobs:fixed # the ones that must pass

Not modelled: the drain on shutdown beyond what the ingest model's exits cover.

TLC needs Java and tla2tools.jar (TLA_TOOLS=/path/to/tla2tools.jar, default ~/tla/tla2tools.jar from https://github.com/tlaplus/tlaplus/releases). Re-run the model when the worker or writer protocol changes; keep the spec's actions aligned with worker.ex (handle_call({:push, …}), takeover/2, handle_info({:batch_committed…})), writer.ex (fenced_update!/3) and ingest.ex (lifecycle/2).

Not modelled: several invocations, the writer's group commit across invocations (the fence is per row), backpressure, the log cap, retries inside the writer (a DbFail is the exhausted retry), and the contents of events.