Skip to content

[fence 2/9] libsql-server: fence model, durable state and fail-closed recovery - #42

Closed
tszymczyszyn-shopify wants to merge 5 commits into
namespace-fence/0-contractfrom
namespace-fence/1-model-state
Closed

tszymczyszyn-shopify wants to merge 5 commits into
namespace-fence/0-contractfrom
namespace-fence/1-model-state

Conversation

@tszymczyszyn-shopify

@tszymczyszyn-shopify tszymczyszyn-shopify commented Oct 5, 2026 •

Copy link
Copy Markdown

Parked (2026-10-14). We are not pursuing this stack for now, so please do not review or merge it. The existing per-namespace block_writes and block_reads settings, together with operator checks, cover what we need today. The branches stay up for reference.

If we need more server support later, the likely follow-ups are small, separate changes: refusing replication and /dump while reads are blocked, a conditional POST /config, and block flags at namespace creation.


Adds the namespace::fence module with the fence types, stable outcome codes and the pure transition function (replay and fingerprint checks before owner, role, revision and transition checks), plus the namespace_fence.proto record encoding.

Persists fences in the metastore: additive tables, a compare-and-swap on a restart-stable revision inside BEGIN IMMEDIATE, command receipts with retention, the on-disk marker, and the config-write interlock. Makes metastore recovery fail closed when a fence may be involved: non-creating MetaStore::lookup, a fallible MetaStore::handle, marker-aware filesystem recovery, and destroy_on_error renaming the metastore aside instead of deleting it.

Fence activation and table creation are behind --enable-namespace-fence (default off), while existing durable fences remain enforced with the flag off. The defensive metastore plumbing and config-write ordering in this piece apply generally, as called out below.

Review focus

Metastore changes that apply to everyone, not only fenced namespaces: config writes now use BEGIN IMMEDIATE, a config whose persist failed is no longer published in memory, and MetaStore::handle can now fail. The fence module carries a transitional #![allow(dead_code)] until its callers land later in the stack.

Since the first version, from review: a definite failure before the commit of CreateTargetQuarantined removes the new target's marker again (so the name is absent, not unavailable), and MetaStore::handle reads the marker of an unknown name on the blocking pool, outside the configs lock.

Commits

  • libsql-server: add namespace fence types and transition logic
  • libsql-server: persist namespace fences in the metastore
  • libsql-server: fail closed on ambiguous metastore recovery
  • libsql-server: remove a new target's marker when its creation fails before the commit (review)
  • libsql-server: read the fence marker of an unknown name on the blocking pool (review)

Stack

Part 2 of 9, based on namespace-fence/0-contract. The stack sits on #53 (tszymczyszyn/atomic-namespace-create), which sits on #51: review and land #51 → #53 → #41 → … → #49 bottom-up, restacking after each squash or rebase merge.

The first version of these PRs carried #35's fence diff unchanged on v0.9.30-shopify-patches. On 2026-10-13 the stack was rebased onto #53 (how the conflicts were resolved is summarised in a comment on #41) and gained the commits marked above.

shopify-river and others added 5 commits October 13, 2026 22:49
Add the I/O-free core of the namespace fence described in
docs/NAMESPACE_FENCE.md:

- state.rs: roles, fence states (including the non-durable UNFENCED,
  ABSENT and derived UNKNOWN_UNAVAILABLE), operation classes and the
  permission matrix.
- outcome.rs: stable outcome codes, their admin HTTP, user HTTP, Hrana,
  gRPC and proxy mappings (fence denials are never 5xx, 429 or gRPC
  UNAVAILABLE), bounded detail reasons and FenceError.
- command.rs: fence commands and requests, and the canonical SHA-256
  request fingerprint over everything except command_id.
- record.rs: fence records, command receipts and the on-disk marker,
  with a strict protobuf encoding (proto/namespace_fence.proto) whose
  decoder rejects unknown versions, enum values, malformed ids and
  self-contradictory records instead of defaulting.
- transition.rs: the pure apply() and complete_drain() functions.
  Replay and fingerprint conflicts are decided before owner, role,
  state and revision; TARGET_WRITABLE has no reverse transition;
  adoption changes only the owner.

Nothing outside the module uses it yet; the metastore, controller and
protocol layers follow.

Co-authored-by: Tomasz Szymczyszyn <tomasz.szymczyszyn@shopify.com>
Add the additive `namespace_fences` and `namespace_fence_receipts`
tables (created with --enable-namespace-fence, loaded and enforced
whenever they exist) and run every fence command as a compare-and-swap
in one BEGIN IMMEDIATE metastore transaction: read the record, the
command's receipt and the config row, decide with the pure transition
function, then commit the record, the receipt and the legacy block_*
mirror together. The revision is stored, so it survives restart.

Stored state is read strictly: an unknown format version, an
undecodable payload, a revision column that disagrees with its payload,
or a per-namespace `.fence` marker that is ahead of the metastore makes
the namespace UNKNOWN_UNAVAILABLE instead of guessing. The marker is
written after each commit (before it, for target creation) and
rewritten on load when it fell behind.

Ordinary config writes and deletes now take BEGIN IMMEDIATE and refuse
while the fence denies lifecycle operations, and a config write that
failed to persist is no longer published to the in-memory config.
Receipts of finished operations are pruned after a retention period.

Co-authored-by: Tomasz Szymczyszyn <tomasz.szymczyszyn@shopify.com>
When namespace fences are in use (the flag is on, the fence tables exist,
or a namespace directory holds a fence marker), a namespace whose state
startup cannot establish is registered UNKNOWN_UNAVAILABLE instead of
being skipped or given a default config:

- an undecodable config row, fence row or marker;
- a directory with a marker that the metastore has no trustworthy record
  for: filesystem recovery, a metastore rebuilt by destroy_on_error, a
  metastore restored from an older backup or without fence tables, and a
  target whose creation was interrupted.

Such a namespace is refused by lookups, config writes and deletes with
FENCE_STATE_UNAVAILABLE, is reported by InspectFence, and is settled only
by a fence command that commits for it.

MetaStore::handle() no longer default-creates on read paths: the new
non-creating MetaStore::lookup serves NamespaceStore::with, fork's source
and ATTACH authorisation. handle(), used only by creating paths, refuses
unavailable names and names without a config whose directory holds a
marker. Filesystem recovery skips marked directories, destroy_on_error
moves the broken metastore aside instead of deleting it, and a fence row
for a namespace name that cannot be decoded stops startup.

Servers that never used fences recover as before.

Co-authored-by: Tomasz Szymczyszyn <tomasz.szymczyszyn@shopify.com>
…efore the commit

CreateTargetQuarantined writes the namespace's marker before the
transaction that stores its config row, record and receipt. A statement
failing in that transaction (SQLITE_FULL, an I/O error) rolled the SQL
back but left the marker, so the name stayed UNKNOWN_UNAVAILABLE until
the caller replayed the same command, which a caller told of a definite
failure has no reason to do. Remove the marker again on any failure
before the commit is attempted. A commit that reports a failure keeps
it, as does a creation that was already interrupted before.
…ng pool

MetaStore::handle read the namespace directory's fence marker with
blocking file I/O while it held the configs lock, which every namespace
lookup and creation takes. Read it on the blocking pool, before taking
the lock.
@tszymczyszyn-shopify
tszymczyszyn-shopify force-pushed the namespace-fence/1-model-state branch from 2e91ab3 to e87d1d1 Compare October 13, 2026 20:51
@tszymczyszyn-shopify

Copy link
Copy Markdown
Author

Closing. This stack is parked; the note at the top of the description explains why. The branch is kept at e87d1d1105, so this PR can be reopened if an automated mover or concurrent moves make an operation-owned fence necessary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants