soul.demarkus.io:6309/adr/0036-bucket-store-commit-log.md/v1 proposed reader meta

ADR 0036: The bucket store commits through a create-only log

Status: proposed (2026-10-02). Plan: Bucket store commit log. Evidence: phase 0 spike on real GCS. Amends ADR 0008 and ADR 0015; supersedes ADR 0031. Part of the decisions hub.

Context

  • ADR 0008 commits every mutation by replacing _demarkus/v1/head.json, the world's only mutable object, and assumed a read-heavy world under one mutation per second. GCS allows about one update per second to one object name, so the bucket store paces head attempts at 1.5 s. The product now needs many users writing one world at once.
  • Measured, the head is the ceiling: 0.67 commits/s paced on real GCS, and 1.2 commits/s from three unpaced writers with 552 of 692 attempts throttled. On kind, one writer took 1.51 s per publish and a four-writer burst lost 49 of 200 publishes to the 10 s request deadline.
  • The cap belongs to any design where every commit mutates one object name (CAS, lock, lease, pointer). Creating a new name with create-if-absent has no per-name cap.
  • The second wall is bytes. A commit rewrites the document's history block and manifest, one of 256 shards and the root. A shard holds a 256th of the world at about 850 bytes per document: about 3.3 MB at 1,000,000 documents, so 250 writes/s would upload, and every replica would decode, about 830 MB/s.
  • The knowledge server reaches past the backend contract. Its world manager holds the concrete *bucketstore.Store, compares peer hints with its head sequence, schedules its polls, creates the world's genesis and calls its constructor inline, so a second backend could not replace it without changes above the store.

Decision

Commit point

  • A commit creates _demarkus/v1/log/<first sequence as 16 hex digits>.json with blob.Store.Create (create-if-absent: ifGenerationMatch=0 on GCS, If-None-Match: * on S3). The successful create is the commit, and a slot is never rewritten. This replaces ADR 0008's head replacement as the commit point.
  • A slot holds one batch of mutations. Per mutation it carries the receipt, the new index entry (path, current version, archive state, body hash, modified time, catalog record, history block references), the new version record and any retention prune range. It also carries the schema, world ID, first sequence, the writing store's ID, the sweep epoch and the hash of the previous slot, so the log is a verifiable chain of contiguous sequences.
  • A commit writes the content-addressed document blobs and one slot, nothing else: about 1.1 objects and the body plus about 1.2 KB per mutation, whatever the world's size. No root, shard, manifest, history block, change block or head is written on the commit path.
  • The manifest layer is folded into the index entry, which carries its history block references; there is no per-document manifest object.
  • Every writer racing from one tip targets the same slot name. A loser reads the winning slot, rebuilds only the members whose path, ancestors, descendants or the world's publish policy that slot touched, and tries the next name. Expected-version, topology, policy and archive semantics are unchanged.
  • An ambiguous create is decided by reading the slot: absent, retry the identical create; present with this batch's operation IDs, committed; present with others, lost. ADR 0008's evicted-receipt server-error disappears.

Group commit

  • One committer per world in each store takes whatever is queued, up to a batch limit of 16 that may grow to 32 while the queue stays deep, with no timer. Members build in queue order, each against the tip plus the members before it; a member whose precondition fails gets its normal error alone.
  • Up to two further batches are built and staged while one slot create is in flight, and slot k+1 is created only after slot k is confirmed. Every member is acknowledged only after its slot is confirmed.
  • Every replica commits for every world it serves. A store that has seen another store's ID in a slot within the last 3 s waits up to 30 ms of jitter after each win; a store alone in a world never waits.

Reads and freshness

  • A replica's snapshot is the latest checkpoint it loaded plus every slot applied since. Slots carry full entries, so applying one needs no further reads, and a replica installs its own commits as they succeed.
  • Reads by path (FETCH, VERSIONS, chain verification) are exact: they see every commit acknowledged on any replica before the request. They check the next slot name first, one metadata read of 11 to 14 ms.
  • Catalog reads (LOOKUP, LIST, directory checks and hash lookup) may trail another replica's commit by up to one second: a replica serves them from a snapshot confirmed current within the last second, which costs at most one tip check per second per world. This relaxes ADR 0008's immediate LOOKUP and hash freshness. Write preconditions are always evaluated at the tip, so the lag never affects write correctness.
  • One request still reads one snapshot. A view pins its snapshot at its first read: a read by path pins the tip, a catalog read may pin a snapshot up to one second old.
  • The bound is a property of the backend contract. It is documented on backend.ReadView and in the SPEC and proven by the conformance suite; handlers do not know which backend serves them.

Checkpoints, world marker and cold start

  • Checkpoints are derived, not committed. Any store's compactor may write one when the newest lags the tip by 1024 slots or 60 s, after a random delay. A checkpoint at sequence S is a deterministic function of the log up to S, so racing compactors write identical bytes through create-if-absent. Readers verify it against the slot chain, and a missing or failed checkpoint costs only replay.
  • A checkpoint keeps ADR 0008's content-addressed, verified object model: a root, shards whose entries carry history block references, history blocks and section segments, each written only when it changed and otherwise referenced by hash. Shard count scales with the world at about 200 KB per shard: 4096 shards at 1,000,000 documents.
  • head.json becomes a frozen world marker in schema 2: world ID and format, created once for a new world or replaced once at migration, never written on the commit path. An old replica refuses it and fails closed.
  • A new world starts with the marker and checkpoint zero, an empty root at sequence 1. Cold start lists checkpoints/ for the newest, loads it, lists log/ from its sequence and applies the slots. blob.Store.List gains a start offset for this, with no GCS types in it.

Section index

The body-search index (ADR 0012) is never built on the commit path or when a slot is applied. A world builds it on its first body search, from checkpoint section segments plus the bodies changed since; until then body search answers as catalog fallback, which LOOKUP already reports as inconclusive. It is kept current in the background, reported as partial while behind, and dropped when body search goes idle.

Change feed

  • Slots are the durable change record (ADR 0028), and the hub's backlog reads them through an LRU of recent slots. Slots are retained at least as long as the hub's ring and the federation deriver's checkpointed cursor age, so a resume inside that window never resyncs.
  • ADR 0031's sealed change blocks are removed: sealChanges, sealedThrough, the block LRU and the changes/ objects. A slot already holds up to 16 changes.

Scope

  • Bucket only. GCS, and later S3, stays the only state; a metadata database stays out (ADR 0010).
  • Ordering per world. WATCH cursors and the change feed stay numbered per world (ADR 0025, ADR 0028).
  • A baseline of 1,000,000 documents per world. Every gate, shard count, checkpoint interval and cold start bound is sized for it, and ADR 0008's supported limit of 100,000 rises tenfold. Write cost depends on the change, never on the document count.
  • Throughput. At least 200 writes per second per world, and p99 at most 250 ms for one store at 200 writes/s.

Store boundary

Everything above is private to bucketstore. The server sees a world's store only through the backend contract in server/internal/backend and constructs it at one wiring point, so swapping bucketstore for filestore, an S3 store or a database changes one constructor.

What the world manager holds:

  • backend.Store: OpenReadView, Publish, Append and SetArchived, with the freshness property above on its views.
  • backend.ChangeSource: the store's change hub, nil without WATCH. The hub gains a subscriber count, so a store can schedule work that only watchers need.
  • backend.Follower, new, for a store several replicas share. Follow(sequence int64) says a peer replica committed through sequence: the store reads ahead of its own schedule unless it already serves that sequence, and a store whose reads are always current ignores it. It never blocks.
  • io.Closer, which stops whatever the store runs in the background.
  • One store factory in the world manager's config. It takes the world's config, a logger and a commit hook, func(sequence int64), which the store calls after each of this replica's own commits. The server's wiring in run.go is the only code that names bucketstore or a blob store.

Peer hints carry only a world ID and a sequence, never a slot name, checkpoint or object key. The world manager hands an incoming hint to that world's Follower and does nothing else with it.

Private to bucketstore: the blob store and every object key; the log, slots, checkpoints and the world marker; the committer, batching, staging and the yield rule; the compactor and the collector; when to read the bucket, including the backstop poll for watchers (5 s today, gated on the hub's subscriber count) and coalescing of peer hints; creating a new world's genesis inside open, refusing a bucket that holds foreign objects and leaving read-only worlds untouched; migration; building and evicting the section index.

The refactor that sets this boundary lands before any log code, on today's head CAS store, and changes no behavior. Today's leaks are all in server/knowledgeserver/worlds.go: worldEntry.store is a *bucketstore.Store; Hint compares HeadSequence(); pollChanges, changePollInterval and the kick channel schedule Poll; ensureGenesis calls bucketstore.EnsureWorld; and bucketstore.Open is called inline with its options and Committed hook. A world manager test serves a world on a store that is not a bucket store, through the factory.

Consequences

Measured on real GCS from GKE on 2026-10-02 unless noted.

  • Throughput. Batch 16 with two batches staged ahead sustained 352 to 355 writes/s; batch 32 with one ahead, 470 to 512. One batch ahead capped batch 16 at 256 to 291, because the next batch waited on the slowest of its parallel blob creates; three ahead added nothing.
  • Gate. 250 writes/s for 10 minutes on a 1,000,000-document world, with a following replica and a compactor: 150,012 writes, none lost, no gaps or chain breaks, no 429 or 5xx, p50 143 ms, p99 196 ms, p999 319 ms.
  • Latency. Without a queue, one store: p99 200 ms at 200 writes/s and 306 ms at 300. A write waits for batch formation, its blob, the slot in flight and its own slot, about 3.5 creates at p50 and 5 at p99. A small create costs 40 ms at p50 and 50 ms at p99 alone, 45 to 52 ms at p50 under load.
  • Several stores. With 2 or 3 stores racing one world, total throughput stays at the single-store level and about half the slot attempts lose. Without the yield one of three stores got 51 writes/s against another's 151, with p99 6.5 s; with it the stores evened out (95, 99 and 78 writes/s) and the worst p99 fell to 3 s, for about 10% of throughput. Under saturated contention p99 is in seconds and set by queueing.
  • Bucket. A warmed bucket took 1200 creates/s without a 429, and latency doubled from about 1000/s; at about 1.1 objects per write that puts one world's ceiling near 900 writes/s. A cold bucket was not tested.
  • Cold start. At 1,000,000 documents: 7.6 s (10.3 s on a contended node), index heap 1.04 GiB, and 1024 slots replayed in 0.9 s. A full checkpoint (863 MB) took 6 to 13 s, and one a minute showed no clear effect on write latency. At 100,000 documents a fixed 4096 shards took 4.2 s, bound by object count, which is why shard count scales with the world.
  • Freshness. A replica following by reads applied 250 writes/s with apply lag p99 166 ms, and body catch-up with 16 workers stayed under 200 ms at p99, both well inside one second. Catalog reads on another replica may now trail a write by up to a second, and the SPEC says so; requests that reach the replica that committed see it at once.
  • Memory. A first body search at 1,000,000 documents answers as catalog fallback for about 20 s while 3 GB of section segments load, and resident sections are about 3 GB per active world per replica. That catalog and section memory wall is out of scope and needs its own plan before several baseline worlds share a process.
  • Migration. A new replica checkpoints a schema 1 head and replaces it once with the marker. Cursors older than the migration sequence get one resync, which triggers a federation rebuild per written world unless ADR 0031's blocks stay readable until the deriver passes it. Rollback is open: a tool that writes a schema 1 head from a fresh checkpoint, or a one-way migration.
  • Hash lookup stays in the one second class until ADR 0035 is decided; if it is accepted, the body-hash map leaves snapshots and checkpoints.
  • Garbage collection follows reachability from retained checkpoints, retained slots, backup pins and imports. The sweep epoch is a log entry, which keeps ADR 0008's fencing.
  • A new backend implements backend.Store, ChangeSource if it supports WATCH and Follower if replicas share it, passes the conformance suites, and is wired by one constructor.

Alternatives considered

  • Group commit on head.json. Still one replace per batch at 1.5 s, a hot object whose 429s delay the whole batch, and a receipt window that must grow with the batch.
  • Slots that point to a rewritten root and shards. Removes the head cap but keeps a per-commit write that grows with the world: about 3.3 MB per write at 1,000,000 documents.
  • A two-level shard tree rewritten per commit. Tens of KB per write, but an extra object per write and a cold start that reads 65,536 leaves.
  • A per-world writer elected above the store, with writes forwarded to it. Puts the bucket store's concurrency shape into server routing and needs pod-to-pod transport that a database store would not. Rejected as a layering violation.
  • An in-memory head shared between replicas and flushed every 2 s. Loses acknowledged writes on a crash unless acknowledgement waits for the flush, which is group commit on the head; making it safe needs quorum replication.
  • Per-document conditional writes, or per-version create-only objects. No world sequence for WATCH and the feed, topology races between creates, and a second commit point beside the log that a crash can split from it.
  • Section entries carried in slots. A section entry is about as large as the body, and every replica would hold sections for every world whether or not anyone searches it.
  • A metadata database beside the bucket. Out by the bucket-only decision.
  • Libraries. glassdb, objwal, SlateDB and delta-go were surveyed and none fits (alpha, single writer, one rewritten manifest, or no GCS). Delta Lake's log plus checkpoints and SlateDB's put-if-absent commit are design references.
soul.demarkus.io:6309/research/bucket-commit-log-spike.md draft reader meta

Bucket commit log spike: real GCS, phase 0

Phase 0 of the bucket commit log plan: the log protocol simulated against real GCS from GKE pods, measured against the plan's gate. Audience: demarkus maintainers. Run 2026-10-02. Verdict: the gate passes, with two design changes (stage two batches ahead; size the shard count to the world) and a latency gate restated in milliseconds.

Setup

  • Harness: a throwaway Go binary (demarkus-logspike) with setup, run and teardown scripts, built on the benchmarks worktree and deleted after the run; it was never committed. It simulated slots, blobs, checkpoints and replicas with synthetic objects using the same GCS settings as server/blob/gcs (JSON API client, SDK retries off, single-request uploads with CRC32C, DoesNotExist creates). It never ran bucketstore, so member builds and index work cost nothing here.
  • Raw evidence: 49 JSON reports under tools/benchmarks/artifacts/logspike-2026-10-02/ (ignored).
  • Cluster demarkus (zonal, northamerica-northeast2-a), a tainted scratch pool of 2 n2-standard-8 nodes, pods limited to 4 vCPU and 10 GiB. Scratch buckets in northamerica-northeast2, Standard, soft delete off. All removed afterwards.
  • Shapes: 2 KB bodies, 1.2 KB slot entries, 850 byte shard entries, 3 KB of section text per document, 4096 shards. Batches form from whatever is queued, with no timer.
  • To fit the time, the last third of the runs went in three lanes on three separate buckets at once; lanes shared nodes, so cold start and gate timings may carry some CPU contention.

Gate

10 minutes open-loop at 250 writes/s on a 1,000,000-document world, batch limit 16, two batches staged ahead, with a replica following and reading every changed body, and a compactor writing a full 1M checkpoint every minute.

Criterion Result Pass
At least 200 writes/s sustained for 10 min 250/s; slowest 10 s window 245/s; 150,012 writes yes
No lost writes verify walked 14,597 slots: 0 lost, 0 gaps, 0 chain breaks, 0 duplicates yes
429s absorbed by bounded retry no 429 or 5xx on any slot or blob create yes
Write latency p50 143 ms, p99 196 ms, p999 319 ms; median 10 s window p99 178 ms, worst 429 ms yes, at the restated bound below
Cold start within the bound set before the run (15 s at 1M, 3 s at 100k, heap under 1.5 GiB) 1M: 7.6 s after the gate (10.3 s on a contended node), heap 1.04 GiB. 100k: 4.2 s 1M yes; 100k no, see shard count

c and read costs

Operation (low concurrency) p50 p99
Create, 1.2 KB or 4 KB 39.5 ms 50 ms
Get, 1.2 KB 18 ms 37 ms
HEAD or GET of a missing name (tip check) 11 to 14 ms 18 to 24 ms

Under load (16 parallel blob creates plus a slot create) creates run 45 to 52 ms at p50, about 60 ms at p90. A path read pays one tip check, about 14 ms.

Slot chain

Closed loop, one store, 2 minute runs; every run verified with 0 lost writes.

Batch Staging Writers Writes/s p50 p99
16 sequential 64 179 350 ms 573 ms
16 sequential 256 177 1446 ms 1616 ms
16 1 ahead 64 256 247 ms 313 ms
16 1 ahead 256 291 874 ms 1117 ms
16 2 ahead 64 352 179 ms 220 ms
16 2 ahead 256 355 716 ms 984 ms
16 3 ahead 64 330 188 ms 256 ms
32 sequential 64 289 220 ms 288 ms
32 sequential 256 309 824 ms 897 ms
32 1 ahead 64 470 134 ms 192 ms
32 1 ahead 256 512 497 ms 597 ms
  • With one batch staged ahead, the critical path is staging, not the slot: the next batch is ready only when the slowest of its 16 parallel blob creates finishes, about the p94 create latency (~60 ms) against a slot create of ~45 ms. A local dry run with injected latency reproduced this exactly.
  • Two batches ahead takes staging off the critical path: batch 16 reaches about 355/s, close to the batch limit divided by c. Three ahead adds nothing.
  • Closed-loop latency is mostly queueing (writers divided by throughput).

Latency without a queue

Open loop, one store, batch 16, two ahead, 1 minute runs, 0 lost.

Writes/s Mean batch p50 p99 p999
100 3.9 134 ms 173 ms 214 ms
200 8.4 147 ms 200 ms 249 ms
250 (gate) 10.3 143 ms 196 ms 319 ms
300 13.3 155 ms 306 ms 354 ms

A write waits for batch formation, its blob create, the slot already in flight, and its own slot create: about 3.5c at p50 and 5c at p99, not the 1c to 3c the plan projected.

Several stores racing

Closed loop, 64 writers per store, batch 16, one ahead, 1 minute runs; the third store of the 3-store runs shared a pod with the second. Every run verified against every store's acks: 0 lost, 0 gaps, 0 chain breaks.

Stores Yield Total writes/s Per store Per store p99
2 off 290 100, 190 5.5 s, 2.9 s
2 30 ms 255 136, 119 1.6 s, 1.6 s
3 off 294 51, 92, 151 6.5 s, 3.4 s, 2.4 s
3 30 ms 272 95, 99, 78 3.0 s, 2.2 s, 2.5 s
  • Total throughput holds at the single-store level; stores trade slots, they do not add contention cost.
  • Without yield, one store starves the others. A 30 ms yield after a win, while another store's ID was seen in the last 3 s, evens throughput and cuts the worst p99 from 6.5 s to 3 s, for about 10% of throughput.
  • About half of all slot attempts lose with 2 or 3 stores; each loss costs one create and one read.

Bucket rate and the head baseline

  • Ramp of open-loop creates in 30 s steps to 1200/s, on a bucket warmed by a checkpoint write: no 429 and no 5xx at any step. Latency is the signal instead: p50 stays at 38 to 41 ms through 800/s and doubles to about 91 ms at 1000/s and above. A cold bucket may throttle sooner; it was not tested.
  • Checkpoint writes reached 1123 creates/s on a fresh bucket without a 429.
  • Today's head CAS, paced at 1.5 s: exactly 0.67 commits/s. Three unpaced writers on one head: 1.2 commits/s, with 552 of 692 attempts throttled (429) and 68 conflicts.

Checkpoints and cold start

100,000 docs 1,000,000 docs
Checkpoint, shards and root 86 MB 863 MB; 6 to 13 s per write, 10 writes during the gate, 0 failures
Checkpoint with section segments 395 MB in 7.3 s 3.9 GB in 18.4 s
Cold start: list, root, 4096 shards, 1024 slots 4.2 s 7.6 s (10.3 s contended)
Index heap after load 106 MiB 1.04 GiB
Replay of 1024 slots (10,504 entries) 0.9 s
Section segments load 308 MB in 5.7 s 3.08 GB in 20.7 s
  • At 100,000 documents the 4096 shards are about 21 KB each, so cold start is bound by object count (4096 gets, 64 at a time) rather than bytes, and misses the 3 s bound.
  • During the gate, 11 of 60 ten-second windows had a writer p99 above 230 ms; only one of them overlapped a compactor run, so the 60 s compactor shows no clear effect on write latency.

Replica apply and body catch-up

During the gate the follower applied every slot (14,591 slots, 250 entries/s) by reading the next name until not found: slot get p50 20 ms, apply lag p99 166 ms. With 16 workers reading every changed body, body lag p99 was 191 ms with no backlog, well inside the 1 s search bound.

What this changes in the plan

  • c: 40 ms p50 and 50 ms p99 at low concurrency, 45 to 52 ms under load. The plan's 40 ms case holds.
  • Stage two batches ahead. Pipelining with one speculative batch caps batch 16 near 260 to 290/s; two ahead reaches about 355/s.
  • Batch limit: batch 32 with one ahead reaches 470 to 510/s. Letting the limit grow to 32 when the queue is deep is worth it.
  • Latency gate in milliseconds: single store at 200 writes/s, p99 at most 250 ms (measured 200 ms). Under contention between stores at saturation, p99 is in seconds and is set by queueing and fairness, not c.
  • Yield: keep the rule; 30 ms of jitter with a 3 s recency window.
  • Checkpoint interval: 60 s is cheap at 1M (full rewrite in about 10 s).
  • Shard count: scale it with the world (target about 200 KB per shard), or load shards with far more parallelism; a fixed 4096 makes small worlds slow to start.
  • Section index at 1M: a first body search answers from the catalog for about 20 s while 3 GB of segments load, and those sections would stay resident at about 3 GB per world per replica. Section segments need to stay off-heap or be queried in the bucket; this belongs with the catalog memory wall.
  • blob.Store.List needs a start offset for cold start to list the log from a checkpoint; the spike used the SDK directly.

Caveats

  • Synthetic objects: no member builds, topology checks or catalog updates, so real per-batch CPU is not included.
  • Only the gate ran 10 minutes; the matrix ran 2 minutes, lanes 1 minute, the ramp 30 s per step.
  • Contention runs were closed loop only; an open-loop race at a fixed rate was not run.
  • Batch 16 with three ahead at 256 writers was not run.
  • Buckets were warm from earlier runs; a cold bucket's ramp is untested.
trail
  1. soul.demarkus.io:6309 v1
  2. bucket-commit-log-spike