Universe
-
soul.demarkus.io:6309
198+ docs · 22+ accepted · 10+ sections · links to 2 worlds
plans/adr/demarkus-library/research/roadmap/architecture/+4 more
-
latebitfederated · sign-in
linked from 1
-
musicfederated · sign-in
linked from 1
universesoul.demarkus.io:6309
198+ docs · 22+ accepted · 10+ sections · links to 2 worlds
plans/adr/demarkus-library/research/roadmap/architecture/+4 more
latebitfederated · sign-in
linked from 1
musicfederated · sign-in
linked from 1
soul.demarkus.io:6309/adr/0036-bucket-store-commit-log.md proposed reader metaStatus: 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.
_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.*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._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.server-error disappears.backend.ReadView and in the SPEC and proven by the conformance suite; handlers do not know which backend serves them.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.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.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.
sealChanges, sealedThrough, the block LRU and the changes/ objects. A slot already holds up to 16 changes.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 counts readers waiting for its next event, so a store can schedule work that only live watchers need; a subscription nobody reads does not count, and callers have nothing to release.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, ends its watches and refuses later writes with backend.ErrClosed.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, run only while a watcher waits on the hub) 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.
Measured on real GCS from GKE on 2026-10-02 unless noted.
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.backend.Store, ChangeSource if it supports WATCH and Follower if replicas share it, passes the conformance suites, and is wired by one constructor.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.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.Hub.CatchUpTo, weighed in review 2026-10-02). It would drop backend.Follower, but hints also keep catalog reads inside the one second bound whether or not WATCH is on, and the hub would then schedule store reads.Decided with the maintainer while building phase 4. It replaces "The sweep epoch is a log entry, which keeps ADR 0008's fencing" and the sweep epoch named among a slot's fields; slots carry no epoch.
ifGenerationMatch); an S3 backend must confirm conditional deletes before it lands.The maintainer deferred content collection after building it, since today's worlds grow by a couple of gigabytes a month. Until it is needed, a compactor that writes a checkpoint deletes the checkpoint objects past the newest three that are more than an hour old, each delete conditioned on the generation it listed, so a cold start's listing stays short. Nothing else is deleted: superseded shards, roots, history blocks, blobs of pruned versions and slots stay, as ADR 0008 accepted. A replica resting on a deleted checkpoint keeps reading, because everything that checkpoint reached is still stored.
The generation fence above is the decided design for when collection is built. The full collector (sweep, mark, the rewrite of reused objects, slot retention, the stale replica reload and the maintenance loop) was written and tested, and was set aside as a patch rather than merged.
Shards and roots are the bulk of what checkpoints leave behind: changing one entry rewrites its whole shard, about 200 KB. Nothing reads one once its checkpoint is dropped, so they are collected after all. History blocks, blobs and slots still are not.
Decided with the maintainer before phase 5 was built. It replaces "reported as partial while behind" in the section index decision above, and the bucket store consequence of ADR 0012, which indexed every body at open and on refresh.
backend.SearchFreshness) for a section index that covers exactly the snapshot its view pinned. Without one in time the store refuses with backend.ErrBodyMatchUnavailable, and the handler answers from the catalog on the same view, echoing match: catalog, which clients already treat as inconclusive. Nothing new goes on the wire. The SPEC now says a server with body match may answer a body request in catalog mode while its index is built or behind, and must not echo match: body for an index that misses part of the state the request reads._demarkus/v1/segments/<label>/<shard hash>/<part>.json, in parts of at most 2 MiB encoded because objects are capped at 4 MiB; part 0 names the part count and is written last. It holds bodies rather than serialized sections: about 1.33 times body bytes against 2.34, measured on the soul corpus, and it never goes stale when the tokenizer is retuned. The root names no segment, so checkpoint bytes stay a function of the log; a missing segment only costs blob reads. A reader uses a body only when it hashes to the document's body hash.Revised the same day after review: the compactor writes segments after the checkpoint object, so optional work can never fail a checkpoint, and a drop collects segments with one sweep of segments/ instead of per shard: a part goes once its shard is in no kept checkpoint and it is past the grace, at its listed generation. The sweep also takes parts a cut-short write or a late repair left behind, so the leak noted above is gone.
At the maintainer's request the grace in the collection amendments above drops from one hour to 15 minutes by default, and a world can set it as bucket.checkpointGrace (Helm passes it through). One value now serves both rules: a checkpoint stays while its successor is younger than the grace, and a shard, root or segment part is deleted only once older than the grace at the drop's start. The age past which a create rewrites stored bytes it reuses is fixed at 7.5 minutes, so replicas configured with different graces stay fenced, and the store refuses a grace under 15 minutes: a compactor that reused bytes just younger than that age needs the rest of the grace to finish its run, which is capped at 5 minutes. A shorter grace also makes each drop cheaper, since it reads fewer kept roots and lists fewer segments.
The maintainer dropped migration: no world outside our own tests uses the bucket store, so existing schema 1 worlds are emptied at rollout and start again from genesis. The in-place conversion, the head replace race, the rollback tool and the federation rebuild at migration are not built. A schema 1 head stays refused at open.
Decided with the maintainer in phase 6. The hub read its backlog only for a cursor within one ring (4096 events) of the head, so the federation deriver, whose saved cursor is up to a minute old, rebuilt a world (about 14 hours at 1,000,000 documents) whenever more than 4096 changes had passed, whatever the slots retained. At 400 writes per second that is every deriver restart.
This replaces "slots stay" in the earlier amendments. History blocks and the blobs of pruned versions still stay.
A review of the whole branch before rollout tightened four points; none changes the format.
commit <world> <sequence>; the head token kept for mixed-version replicas is gone.Decided with the maintainer after the full-feature scan.
segments/<bits>-<label>/<window>/<part>.json, schema 2, no longer by shard hash. A checkpoint writes a rewritten shard's segment at most once per window, carrying bodies from the label's newest segment by body hash (a split shard from its parent's); a later checkpoint in the window leaves it. A builder lists segments once, reads each label's newest, and reads from blobs what it lacks or holds at an older hash, at most about a window's changes. Parts are created if absent: a racer's part serves as well, since every body is verified on read. Checkpoint bytes are unchanged, so racing compactors still agree. A checkpoint sweeps parts a newer window superseded, and those of another layout's bits, once past the grace. At 400 writes per second over a million documents this cuts segment traffic from about 5 GB each way a minute to the same once per window.refreshMu still lets one probe of the log read the bucket at a time; a separate install lock orders installs and is never held across a bucket call, so a commit confirming its slot never waits for a probe. A catch-up a commit overtook starts again from the newer snapshot.op names the operation that made the change on every replica; the allowance to report an append as publish is gone.With no migration and every bucket emptied at rollout, nothing will read an object of the old head-CAS store or of this branch's first segment layout. The marker, slots and checkpoints (logSchema), roots and shards (foldedSchema) and segments (segmentSchema) are all schema 1, as history objects already were. Where the sections above say "schema 2", read schema 1. An old head left in a bucket the wipe missed still fails to open: its fields fail the marker's strict decoding.
Found by the dry run of the local 1,000,000-document scale test, and decided by the maintainer: "do the right fix for the product". It replaces the yield in Group commit above (30 ms of jitter after each win while a peer's slot was seen within 3 s).
backend.Follower carry every sequence a replica learns the store committed through, its own or one it lost to; the wire stays commit <world> <sequence>. Without peer hints writes stay correct, but a replica that keeps losing to a busy peer waits until that peer's queue drains, so the configuration reference says more than one replica needs peers.Revised the same day, after the fake GCS runs: a store remembers its 256 newest slots, not eight. A peer with a short queue pipelines small slots (about 70 a second locally), so a loser's hint often named a slot more than eight back, the winner ignored it, and the backlogged replica got about two slots a second and timed out. With 256 (over a second of slots at any rate), an open loop of 250 writes/s split over two replicas acknowledged all 30,000 writes, 15,000 on each, p99 3.8 s, and a closed loop of 64 writers ran 312 writes/s with no error.
Found by the local 1,000,000-document scale test (journal). Once a checkpoint run outgrew its 5 minute bound, no checkpoint landed for 37 minutes at about 200 writes/s: a run folded a history block for every document changed since the newest checkpoint, kept nothing on timeout, and every retry started over, each present block costing a create, a full read back and, past the freshen age, a rewrite. Replay and in-memory version records grew without bound, against the plan's invariant. Fixes, as the maintainer proposed:
TestSteppedCheckpointsLeaveNoResidue and TestCheckpointedStoreHeapTracksLiveData.Cold start now derives the served snapshot from the loaded checkpoint instead of applying slots into it, and a replay reads slots 256 at a time so a bounded step stops without reading a whole page. Not changed: the step size is a constant, and the compactor's bursts stay unpaced (about 1,000 creates/s against the GCS bucket envelope), both for the GCS gate to size.
Revised the same day, after the rerun's seed: "adopting ends a run" did not keep one compactor. Both replicas install the same slots, so both triggers fire within the 10 s jitter from the same base and, the boundary being deterministic, write the same step at the same time; the recheck between steps never saw the other's checkpoint because a step takes 60 to 300 s. The two leapfrogged for 25 minutes of the seed, then wrote identical steps in lockstep, the loser reading every present shard back; on the fake the doubled uploads stalled slot creates (open loop at 200 writes/s: 93/s acknowledged, 30,558 timeouts). Decided with the maintainer, with no claim object: a store writes a step only when the newest checkpoint is its own, is checkpoint zero, or is older than the step bound (5 minutes, judged by one metadata read of the checkpoint object); otherwise it adopts and ends its run. A checkpoint is a store's own only when its plain create made it, so of two compactors racing one step exactly one goes on. One compactor in steady state; a dead one is replaced within the bound, during which the lag grows by at most the bound's writes. Tests: TestFollowerDefersToAFreshCompactor, TestRacedCheckpointIsNotOwn, and the race in TestStepBoundaryIsDeterministic.
Revised again the same day, with the maintainer: the checkpoint cadence is 8,192 slots or 10 minutes, not 1,024 slots or 60 s. The minute cadence dates from the spike, when a full checkpoint cost 6 to 13 s; with spread writes over a million documents every step rewrites all 4,096 shards, about 800 MB, and every follower reads them back to adopt, so a step a minute at 200 writes/s was about 13 MB/s of uploads per world plus the same per follower (on the fake, a 40 s stall of slot creates per step; open loop at 200 writes/s acknowledged 170/s with 7,798 timeouts, all inside two stalls). A checkpoint exists only to bound replay and in-memory version records, and the slots already are the O(change) deltas: 1,024 slots replay in about 0.9 s on GCS and a version record is about 150 bytes, so ten minutes of writes at 200/s cost about 7 s more cold start and about 18 MB of heap, against a tenfold cut in shard traffic. The step bound and the defer rule are unchanged. A tiered index that merges deltas into shards rarely would make checkpoints O(change) too; that is a format change, left for after the GCS gate measures this cadence.
Also revised 2026-10-04, from PR 538 review: "installs never wait on the bucket" now holds on a reload too. A stale or diverged reload replayed slots under the install lock, so a commit confirming its slot waited for the replay, up to the 5 minute reload bound. The replay now reads outside the lock, buffers the applied slots' events, and installs only if no commit moved the served snapshot past its tip; if one did, it replays on and tries again. Test: TestCommitConfirmsWhileAReloadReplays.
Found by the in-process model of several replicas writing one world (plan), after the GCS gate ran three writers at 94 writes/s with p50 5 s against 365 with one. Branch fix/several-writers.
batchObjects.asWarmup), and the batch that takes it adopts those reads once. A loser's recovery is then the winner's read, one listing and a rebuild in memory: 120 to 175 ms in the model.handoffWait is 300 ms, about twice the measured recovery. 1 s and 300 ms gave the same throughput in the model; 300 ms wastes less on a stale hint, 100 ms and no hold lost more slots or starved a store.TestSeveralWritersModel in committer_test.go: stores on one blob.Memory behind GCS-shaped latency (create 45 ms, get 25 ms, list 30 ms, a tenth of jitter), a 2,000-document seed, 30% creates and 70% blind updates, closed loop 64 writers a store and open loop 300 writes/s, R = 1, 2, 3, each store's slot creates probed. Before the fix R = 3 ran at 91 writes/s closed with one store starved and p50 6.4 s open; after, 211 to 237 writes/s against 219 to 235 alone. The test holds two and three writers within 70% of one, every store within half its share, recovery p50 under 500 ms, the open loop served whole at p50 under 1.5 s, and every acknowledged write in exactly one slot; TestDiscardedBatchesKeepTheirReads holds the read count across a lost slot. Each fails on its mutation. One GCS lane confirms the constants.Confirmed on real GCS the same day (lane report): three replicas served an open loop of 300 writes/s whole (298/s, 0 errors, p50 559 ms) against the gate's 94/s with 8,861 timeouts; closed loop 64 split over three 147/s at p50 387 ms against 58/s; a loser's recovery 46 to 80 ms at p50; 148,540 acknowledged versions read back, 0 bad. The remaining latency tails are compaction steps and adoptions on writing replicas, as the gate had already seen with one writer.
Planned in Refresh as a shared flight from the full-feature scan's "the committer waits on refreshMu while read probes do bucket I/O". Branch fix/refresh-flight.
Store.refresh took a mutex and probed the bucket under it, so every caller that needed the tip waited on whoever held it, with no bound of its own: reads by path, the committer's head refresh, a loser's catch-up after a lost slot, the follower's poll and the stale reload. One bucket call at a GCS tail held them all for its duration, about a second with blob.Hedged.asked rule is unchanged: a missing next slot confirms the tip as of the probe's start, a listing as of its own start, so a caller that asked after that time probes again. The flight is a channel that dies with its probe; no state outlives it.installMu with the existing restart check. If the served snapshot moved past the winner's slot meanwhile, the rest is an ordinary refresh through the flight. A loser's recovery never waits behind a reader's probe, at the cost of a bounded duplicate read next to a concurrent probe.start's own loop, which replays past any slot a concurrent probe or commit installed before it installs, so the mutex it took around that install is gone too.installMu and its rule: a commit confirming its slot never waits on a probe. Nothing above the store; backend.Store and backend.Follower are untouched.refresh_test.go): a read by path fails on its own 200 ms deadline while another caller's probe is stalled on a held Get; a store whose create lost during such a stall creates again with at most four bucket calls between the lost create and the next; two refreshes share one probe when its listing confirmed the tip after the second ask, and the second probes again when a missing slot confirmed it before. The first two fail on the mutex. The replica and change conformance suites and TestSeveralWritersModel are unedited.Revised after review the same day (PR 540): a probe that fails on the bucket answers every refresh waiting on its flight with that error, so a persistent failure costs one probe per burst; only a probe that ended because its starter's context did leaves the waiters to probe again. A loser whose served snapshot already passed the winner's slot, installed by a concurrent probe, rebuilds on that snapshot and never joins the flight; the earlier draft sent it through refresh, which could wait on a stalled probe up to the request timeout and fail the batch.
Planned in The compactor off the writers from the GCS gate's list and the several-writers lane, whose tails were compaction steps and adoptions on writing replicas. Branch feat/compactor-role: steps 1, 2 and 4a of the plan. The deploy topology (step 3) and the GCS lane (step 4b) are still open.
bucketstore.Options.Compaction is Eager (the default and today's behavior: trigger at 8,192 slots or 10 minutes, step, defer to a peer's checkpoint fresher than one step bound, never to its own, keep a base) or Backstop. A backstop runs the same trigger but steps only when the newest checkpoint, whoever wrote it, checkpoint zero and its own included, is older than backstopDelay: the cadence plus two step bounds, 20 minutes, which covers an eager replica's trigger, its step and one more. It keeps no base (start, keepBase and advanceBase skip it), so when it does step it writes the backlog whole, and it adopts peers' checkpoints as before. One compactor in steady state, the first step after genesis written once while an eager replica lives, a dead eager replica replaced within about 20 minutes of writes.compaction.role: eager | backstop at the top level of the knowledge server's YAML, default eager, validated at load; run.go hands it to every world's store. A config field rather than an environment variable because the strict YAML is the server's one configuration surface (nothing else is read from the environment), a typo is refused with the field's name, and a chart renders a second ConfigMap for the compactor Deployment as easily as a second env entry. Not hot-reloaded, like the listeners and peers.installAdoption derives and rebases the served snapshot outside installMu, then takes the lock and installs only if the served snapshot is still the one it rebased; if a commit installed meanwhile it rebases again on the new one, as start replays on. The third attempt rebases under the lock, since a restart repeats the whole rebase and a writer committing faster than one would never finish. Measured: a 32,768-entry rebase onto 32,768 documents takes 34 ms (209 ms under the race detector) against a slot every 50 to 200 ms on a loaded writer, so the first attempt usually lands. Batches built before the adoption still rebase as they install, once each, as before.backend.Store, backend.Follower and everything above the store but the role's wiring.role_test.go): a backstop next to an eager store writes nothing, the first step after genesis included, and still nothing once the eager store's checkpoint is past the step bound; alone, with store.now past the delay, it writes the backlog in one checkpoint; a backstop's own trigger writes nothing; a backstop keeps no base across 50 adoptions of production-shaped writes, memtest.Retained outside the bucket under two agent bodies; a commit confirms its slot while an adoption's rebase is held by a test hook (Store.holdRebase), and the adoption restarts on the snapshot the commit installed. None builds on main; the lock test times out with the rebase under the lock. The replica and change conformance suites and TestSeveralWritersModel are unedited.TestSeveralWritersModelWithACompactor): three writers at 300 writes/s open loop for 20 s over GCS-shaped latency, a checkpointed seed aged past the step bound, a fourth store that takes no writes, checkpoints every 64 slots; writers eager (main's arrangement) against writers backstop, three runs each. Writers' checkpoint creates: 5 to 7 per run eager, 0 backstop. The fourth store's steps: 1 to 3 eager (it defers once a writer's checkpoint lands), 3 backstop, 5.0 to 6.4 s each. Writer heap at the end: 5.2 to 5.3 MB eager, 4.1 to 4.6 MB backstop, the base's share. Write p99 2.0 to 5.3 s eager, 1.9 to 8.6 s backstop; the writers' longest windows without a won slot 1.0 to 2.4 s in both: run-to-run noise larger than any difference. The model's latency is sleep, not CPU, and its adoptions read a few shards, so it cannot show the lane's effect; the p99 claim waits for the GCS lane.Open, for the maintainer:
The near-duplicate gate (near-duplicate gate) judges every PUBLISH and APPEND against the section index and answers from the newest version built rather than waiting for one covering the snapshot. Under the lazy rule above a pod's first write after open went unchecked, and on production with three pods behind the gateway the first three writes to a world passed. So a writable world now starts the build at open, off the commit path as before; a read-only replica keeps the lazy rule, since it never writes. The gate's stale read waits up to two seconds for a cold index (one activated with nothing built yet, at open or after idle eviction), then lets the write through unchecked. Idle eviction stays, with writes counting as use. Startup reads the checkpoint's body segments plus the bodies changed since, the same work the first body search did.
Revised the same day after review: the warm-up belongs to the gate, not to a store option. writepolicy.Enforce warms any store that offers WarmSections (the bucket store does, as a no-op when read-only); the library's own default stays lazy. The cold wait is the existing freshness bound (backend.SearchFreshness, one second), the same loop await uses, not a new number; a warm index answers at once with no timer or waiter accounting.