Repository navigation
libsql-server: make namespace creation all-or-nothing (stacked on #51) - #53
tszymczyszyn-shopify wants to merge 3 commits into
Conversation
Reviewer walkthrough
TL;DR
Contract for callers: Primer: the pieces this PR touches (if you don't know
|
| Piece | File | What it is |
|---|---|---|
NamespaceStore |
namespace/store.rs |
The registry of loaded namespaces: a moka cache Name → Arc<RwLock<Option<Namespace>>>, bounded by --max-active-namespaces (default 100). Evicted namespaces are shut down and lazily reloaded from disk on the next request. with(name, f) is how every user request reaches a namespace; create, fork, reset, destroy are the admin operations. |
MetaStore |
namespace/meta_store.rs |
Which namespaces exist and their DatabaseConfig: an in-memory map (configs, what exists() reads) backed by a SQLite table (namespace_configs). Writes go through a FIFO change queue processed on the blocking pool. Gotcha this PR works around: handle(name) inserts the name into the in-memory map as a side effect. |
| Configurators | namespace/configurator/{primary,schema,replica,fork}.rs |
setup(name, RestoreOption) builds a Namespace from dbs/<name>/: directory, .sentinel (dirty-shutdown flag), replication logger (wallog), SQLite connection maker, and — for RestoreOption::Dump — the dump import from #51. cleanup removes a namespace's files. |
| Admin API | http/admin/mod.rs |
POST /v1/namespaces/:ns/create resolves dump_url into a stream and calls NamespaceStore::create. |
Two moka facts matter: (1) try_get_with(key, init) runs init at most once per key and makes concurrent callers wait for it — the base code relies on this to avoid loading a namespace twice; (2) moka 0.12 has no background thread: cache maintenance (including awaiting async eviction listeners) runs inline inside whatever cache operation trips it.
Before this PR, create did: metastore.store(config) (durable) → load_namespace → setup. The order is what this PR inverts.
Before / after
Who owns what
Live, editable board (Shopify-internal): https://drawr.quick.shopify.io/b/GNUB8o_56hhfFGxv
The happy path, with a concurrent lookup
sequenceDiagram
autonumber
participant A as Admin API
participant S as NamespaceStore
participant M as MetaStore
participant C as Configurator
participant FS as Filesystem
participant U as User request (with foo)
A->>S: create(foo, Dump, cfg)
S->>S: Reservation::acquire — get_with entry, write_arc(), reject if Some or exists(foo), pin in in_flight
S->>M: handle(foo); store(cfg, flush=false)
Note over M: in memory only — exists(foo) is now true, nothing on disk
S->>C: discard_incomplete(foo)
Note over C,FS: removes dbs/foo only if it still carries .incomplete (a crashed earlier attempt)
S->>C: setup(foo)
C->>FS: FreshDir::begin — mkdir dbs/foo + .incomplete (sync)
C->>C: logger, WAL, connections, dump import…
U-->>S: with(foo): existing_handle → load_namespace
Note over S,U: in_flight has foo → wait on the pinned entry's read lock (never a lazy load)
C-->>S: Ok(Namespace) (FreshDir.keep())
S->>M: handle.flush()
Note over M: COMMIT POINT — row written; a row now implies a complete namespace
S->>S: publish → install(ns) into the locked entry, unpin, unlock
S->>FS: remove dbs/foo/.incomplete (best effort)
S-->>A: 2xx
S-->>U: read lock acquired → Some(ns) → serve
Failure or cancellation
sequenceDiagram
autonumber
participant A as Admin API
participant S as NamespaceStore
participant M as MetaStore
participant C as Configurator
participant FS as Filesystem
participant U as User request (with foo)
A->>S: create(foo, Dump, cfg)
S->>S: Reservation::acquire (lock + pin)
S->>M: store(cfg, flush=false)
S->>C: setup(foo)
C->>FS: mkdir dbs/foo + .incomplete
U-->>S: with(foo) → waits on the pinned entry
alt import fails
C->>FS: FreshDir::drop — rename dbs/foo → .discarded/foo-<uuid> (sync), delete in background
C-->>S: Err
S->>S: Reservation::abort() — awaited
else client disconnects (future dropped)
Note over C,S: FreshDir and Reservation are dropped during teardown
C->>FS: FreshDir::drop — rename, same as above
S->>S: Reservation::drop — cleanup handed to the blocking pool
end
S->>M: Discard(foo) via the change queue (unconditional, ordered after any queued store)
S->>C: discard_incomplete(foo) — no-op if FreshDir already renamed it
S->>S: unpin, then unlock
S-->>A: error (only after the cleanup, on the Err path)
S-->>U: read lock acquired → None; exists(foo) false → NamespaceDoesntExist
Note over A: a retry now finds a free name and a free path
Lifecycle of a name, including crashes
stateDiagram-v2
[*] --> Absent
Absent --> Reserved: acquire (entry locked + pinned, config in memory)
Reserved --> SettingUp: FreshDir::begin — dbs/foo + .incomplete
SettingUp --> Committed: flush — row written
Committed --> Published: install, unpin, unlock
Published --> Complete: .incomplete removed
Complete --> [*]
Reserved --> Discarding: Err / drop
SettingUp --> Discarding: Err / drop — dir renamed to .discarded/
Committed --> Discarding: drop before publish — Discard deletes the row again
Discarding --> Absent: unpin, unlock
SettingUp --> MarkedOrphan: process dies
Committed --> Published: process dies here — row + marker; serves after restart, marker dropped on load
MarkedOrphan --> Absent: sweep at startup, or next create of the name (discard_incomplete)
Published --> Complete: process dies before unmark — stale marker removed on next load
note right of Discarding
Order matters: config → directory → unpin → unlock.
The name is unobservable until it is clean.
end note
note right of MarkedOrphan
No row, so exists(foo) is false: invisible to clients.
The marker is what tells it apart from a real namespace.
end note
Eviction during an operation (commit 3)
sequenceDiagram
autonumber
participant S as NamespaceStore (create foo)
participant K as moka cache
participant L as eviction listener
participant U as with(foo)
S->>K: get_with(foo) → entry E; write_arc(E); pin E in in_flight
Note over S: import running for minutes…
K->>K: capacity pressure — evicts E
K->>L: notify(foo, E)
alt foo is pinned
L->>L: spawn the shutdown (don't await E's lock inline — moka awaits listeners from arbitrary cache ops)
else not pinned (a normal eviction)
L->>L: await E.write() and shut down inline (serialises with a reload of the same name)
end
U->>S: load_namespace(foo): in_flight has foo → wait on E (not on the cache)
S->>K: install: store.get(foo) ≠ E and exists(foo) → insert fresh entry F with Some(ns)
Note over S: E stays empty — the spawned listener finds nothing to shut down
S->>S: unpin, unlock
U->>S: E is None → exists(foo) true → try_get_with → F → serve
If foo was destroyed meanwhile instead of evicted, install sees exists(foo) == false and hands the namespace to E, where destroy is waiting for it ("created, then destroyed").
Suggested reading order
1. The on-disk protocol (small, self-contained)
namespace/incomplete.rs:INCOMPLETE_MARKER,create_marked_dir/mark_incomplete(fsynced),discard_dir(rename into.discarded/, background delete),discard_if_incomplete,sweep— with unit tests at the bottom (tombstone with an open file, sweep, FS recovery skipping marked dirs).FreshDir: the configurator's guard; synchronousbegin,Drop⇒discard_dir. Used by primary and schema setup; fork marks its temp dir.- Startup sweep call in
lib.rs.
2. The reservation (the heart of the change)
Reservation:acquire(prefers a pinned entry; invalidates the empty entry it created when rejecting),publish,abort(awaited on error paths),Drop(cancellation only),take_cleanupwith the config → directory → unpin → unlock order.create/create_reservedandfork/fork_reserved: same shape;has_shutdownre-checked before the commit; publish before unmark.MetaStore:ChangeMsg::{Update, Discard};discardis unconditional (unlikeremove, whose linked-DB guards would leave a ghost);existing_handleis the non-registering lookup; the flush branch ofprocessno longer holdsconfigsacrosstry_process(lock order vsremove).
3. Eviction safety
InFlightpin map +unpin, pin-aware eviction listener,install,load_namespace(pinned entry first; empty-and-unlocked entry treated as a miss),destroy(prefers the pinned entry),shutdown(skips pinned entries).
4. Tests
- Store unit tests (
store.rs::test): a realNamespaceStorewith a channel-fed streaming import — lookup waits across an eviction; lookup sees failure across an eviction; destroy during creation; retry the instant the error returns (no polling); abort mid-import (JoinHandle::abort);discardvsremove's guards. - Integration (
tests/namespaces/lifecycle.rs,dumps.rscancel tests): failed create leaves no trace (both importers + schema configurator), cancelled create (cancel fires only oncedbs/foo/.incompleteexists), concurrent requests wait, crash remnant discarded, unmarked dir untouched, marker on a published namespace ignored (creates before any lookup after a restart), single-namespace dump create rejected. - 9 of the original 11 integration tests fail on the unfixed code; the two that pass pin behaviour that must hold before and after.
Behaviour changes visible to clients
- Failed create ⇒
GET …/configis 404 and users get "doesn't exist" (was: 200 / "no such table"). - Single-namespace mode:
create defaultfrom a dump while it exists ⇒already exists(was: 200, dump silently skipped). Plaincreate defaultstays a config upsert. <db-path>/.discarded/appears; it holds renamed directories until the background delete or the startup sweep removes them.
Where reviewer judgment is most valuable
- The cleanup order in
Reservation::take_cleanup— is there any interleaving where the name becomes observable before it is clean? install's three-way decision (same entry / destroyed / evicted) andload_namespace's empty-entry healing: do they cover every way an entry can go stale?MetaStore::discardbypassingremove's linked-DB guards — correct for an unpublished namespace; is there any path where it runs for a published one? (It only runs from aReservation, whoseacquirerejects existing names.)- The pin-aware eviction listener: inline for normal evictions, spawned for pinned — any entry that is long-locked but not pinned?
- Whether the tombstone directory and startup sweep are acceptable operationally (disk use is bounded by the background delete; sweep logs each leftover).
Explicitly out of scope
- Bottomless: not used in our deployment; this change is untested with it.
- Two accepted residues (design doc §9): an empty cache entry after an abort (never served; healed on the next lookup or evicted), and a metastore rebuilt from the filesystem in the milliseconds between a namespace's flush and its unmark.
- An import timeout; idempotency keys / async create jobs.
Full rationale, failure matrix and the review table: docs/ATOMIC_NAMESPACE_CREATE_DESIGN.md.
`NamespaceStore::create` persisted the namespace config before setting the namespace up, and nothing undid it: a dump that failed to import left a "ghost" namespace (config without data) whose name could not be reused and which the next user request lazily turned into an empty database. A request cancelled mid-import ran no cleanup at all, since the error branch of the future is never reached when it is dropped, and the schema configurator had no cleanup even for errors. The metastore write is now the commit point and everything before it is reversible. Contract for callers: 2xx means complete; anything else, including a lost connection, means retry; `already exists` on the retry means the earlier attempt did complete. Reservation (store). `create` and `fork` write-lock the name's cache entry for the duration and keep the config in memory only. `publish` installs the finished namespace; `abort` (awaited on error paths) and `Drop` (the creating future was dropped, i.e. the request was cancelled) undo the attempt — config, then directory, then unpin — and only then release the lock, so waiters observe a namespace that does not exist, never a half-built one, and a retry issued the moment the error arrives neither races the cleanup nor finds leftovers. Replaces fork's ad-hoc guard, which blocked in place (panics on a current-thread runtime) and published before flushing. Marker and tombstones (namespace/incomplete.rs). A new namespace directory carries `.incomplete` (fsynced, so it is durable before anything else in the directory can be) until the config is persisted; the store removes it after publishing. A creation that finds a marked directory for a name the metastore does not know discards it, so a crash mid-import never blocks a retry; a startup sweep does the same and deletes leftover tombstones; the metastore's filesystem recovery skips marked directories. Directories are never deleted in place: `discard_dir` renames them into `<base>/.discarded/` (instant; frees the path; a still-running import executor writes into the tombstone) and deletes in the background. `FreshDir` (primary and schema configurators) creates the marked directory synchronously, guard first, and discards it on drop. Fork marks its temporary directory, and only removes its destination on failure if it carries the marker. Metastore. `discard` (via the change queue, so it is ordered after a config store still queued when the request was cancelled) forgets a namespace unconditionally — `remove`'s linked-DB guards would otherwise leave a ghost when a linked-DB creation's flush fails. `existing_handle` looks a namespace up without registering it; `with()` and the fork source use it, and the lazy init re-checks existence, so a lookup racing the end of a failed creation cannot resurrect the name. `process`'s flush branch no longer holds `configs` across `try_process` (lock-order inversion against `remove`). Cache eviction. The reservation is the entry's write lock, which moka knows nothing about. If the cache evicted that entry during a long operation (capacity pressure): a concurrent lookup lazily opened the directory being written; the eviction listener awaited the lock and moka awaits async listeners inline from whichever cache op runs maintenance, so that op hung for the whole import and, once the write-op channel filled, so did every namespace load; an operation touching the cache while holding its own entry (fork) could deadlock. Now the locked entry is pinned in `in_flight`; `load_namespace` and `destroy` consult it first and wait on it; the listener spawns its shutdown only for pinned entries; `install` publishes into a fresh entry when the locked one was evicted, or hands the namespace to the old entry when the name was destroyed meanwhile (destroy is waiting on it). `shutdown` skips pinned entries; `create`/`fork` re-check `has_shutdown` before the commit. Empty cache entries. A rejected `acquire` (`already exists` for a namespace that is not loaded, e.g. a retry after a restart) left behind the empty entry its `get_with` inserted, and `try_get_with` returned that entry forever instead of loading the namespace. `acquire` removes the entry it created; `load_namespace` treats an empty, unlocked entry as a miss; `checkpoint` no longer inserts one for an unloaded namespace (the pre-existing source). Single-namespace mode: `create(default, Latest)` stays a config upsert; creating the default namespace from a dump while it exists is rejected instead of silently skipping the import. Needs async-lock 3 for `RwLock::write_arc` (already in the lockfile; the bump removes the duplicate 2.x). Dump tests set `tcp_capacity(4096)`: the fsync makes setup's following blocking fs ops slower in real time, turmoil advances simulated time while a host waits on them, and the dump store writes 1-byte chunks past turmoil's default 64-segment socket buffer. Tests. Store unit tests build a real `NamespaceStore` with a channel-fed streaming import: lookup waits across an eviction and sees the data, or "doesn't exist" when the import fails; destroy during creation; retry the instant the error returns; abort mid-import; discard vs remove's guards. Unit tests for the marker protocol, tombstones, sweep and metastore recovery. Integration (turmoil): failed creation leaves no trace (both importers, schema configurator), cancelled creation (cancel fires once the marker exists), concurrent requests wait, crash remnants discarded, unmarked directories untouched, marker on a published namespace ignored (create before any lookup after a restart), single-namespace dump create rejected.
Adds docs/ATOMIC_NAMESPACE_CREATE_DESIGN.md (design, failure matrix, concurrency analysis, the eviction mechanism, and what the adversarial review changed), states the all-or-nothing creation contract and the single-namespace-mode behaviour in ADMIN_API.md, and marks the lifecycle items of the streaming dump import design (section 13) as fixed.
Exported from the Drawr board (https://drawr.quick.shopify.io/b/GNUB8o_56hhfFGxv) and embedded in the design doc.
afa6361 to
bb44625
Compare


Summary
Stacked on #51 (targets
tszymczyszyn/streaming-dump-importer; will be retargeted tov0.9.30-shopify-patchesonce #51 merges — only the last three commits are this PR:f51aca96bacode and tests,1f688cb9cadesign doc and admin API,bb446259f6diagrams).Makes
POST /v1/namespaces/:ns/createall-or-nothing. Fixes the pre-existing lifecycle gap called out in #51's design doc §13: a dump that failed to import left a ghost namespace (config without data, name unusable, lazily turned into an empty DB on first access); a request cancelled mid-import ran no cleanup at all; the schema configurator had no cleanup even on error.Design:
docs/ATOMIC_NAMESPACE_CREATE_DESIGN.md.Contract (new, documented in
ADMIN_API.md)After a non-2xx or a lost connection the namespace is either absent (no config, no directory, name reusable) or complete — never partial. For callers:
2xx⇒ complete; anything else ⇒ retry;already existson the retry ⇒ the earlier attempt did complete.How
The metastore write becomes the commit point; everything before it is reversible.
Reservationgeneralises the patternforkalready used (write-lock the cache entry, config in memory only, flush on success). Dropping it unpublished — error or cancellation — removes the config on the blocking pool and releases the lock only afterwards, so waiters see "doesn't exist", never a half-built namespace.forknow uses it too; its old guard usedblock_in_place(panics on a current-thread runtime) and published before flushing.FreshDirremoves a brand-new directory on drop unlesskeep()-ed; primary and schema configurators..incompletemarker for crashes: written with the directory, removed after the flush; consulted in exactly one place (create, for a name with no config and nothing loaded) so it can never wipe a published namespace;maybe_recover_from_fsskips marked dirs. Marker-less directories keep today's semantics.fork/reset).async-lock = "3"forRwLock::write_arc— already in the lockfile; the bump removes the duplicate 2.x.Behaviour changes visible to clients
GET …/configis 404 and user requests get "namespace doesn't exist" (was: 200 / "no such table").create defaultfrom a dump while it exists ⇒already exists(was: 200 with the dump silently skipped). The config-upsert behaviour of a plaincreate defaultis unchanged.Tests
FreshDir(incl. drop of the enclosing future),discard_incomplete, and the eviction-during-creation scenarios above.libsql-serversuite under nextest (270 tests): identical results to the base branch (local_sync_with_writesand the wall-clock-ratioreplica_no_resync_on_restartfail under parallel load on both; both pass alone).What the adversarial review changed (now folded into the code commit)
Four reviewer agents plus a manual pass; the full table is design doc §9. The notable ones:
forkcould deadlock on its own entry. Fixed with thein_flightpin, a pin-aware listener, andinstallpublishing into a fresh entry when the locked one is gone (or handing over todestroyif that's why it's gone)..discarded/+ background delete + startup sweep; error paths await the cleanup,Dropis for cancellation only.MetaStore::remove's guards could leave the in-memory config behind on abort (ghost) → unconditionaldiscardthrough the metastore's own queue.FreshDir::beginsynchronous and fsynced.acquireleft an empty cache entry thattry_get_withthen returned forever instead of loading the namespace — a retry after a restart made the namespace unreachable until eviction. Found while writing the tests;checkpointhad the same latent bug. Both fixed.shutdownno longer waits on an in-flight import;with()uses a non-registering lookup;publishcancellation-safe; log levels; doc fixes.Full
libsql-serversuite under nextest: identical results to the base branch (local_sync_with_writesand the wall-clock-ratioreplica_no_resync_on_restartfail under parallel load on both; both pass alone).Accepted residues (design doc §9): the empty cache entry left after an abort (never served; healed on the next lookup or evicted), and a metastore rebuilt from the filesystem in the milliseconds between a namespace's flush and its unmark. Bottomless is not used in our deployment; this change is untested with it.