Repository navigation
Recover realms from interrupted writes and out-of-band disk edits - #7010
Closed
Conversation
Contributor
Preview deploymentsHost Test Results 1 files 1 suites 1h 23m 20s ⏱️ Results for commit ec7b5ce. For more details on these errors, see this check. Realm Server Test Results 1 files 368 suites 2h 14m 13s ⏱️ Results for commit ec7b5ce. For more details on these errors, see this check. |
lukemelia
force-pushed
the
cs-14362-recover-realms-from-interrupted-writes-and-out-of-band-disk
branch
from
October 28, 2026 16:42
c8e6cc1 to
63f8112
Compare
A realm that records history now checks itself against its history at mount, before the boot index decision, and on an hourly schedule: - writes journaled pending whose answer was lost are settled by asking deckd for their outcome, which fences an absent one; - revisions recorded but not all on disk, or never indexed, are brought up to HEAD through the realm's own commit, which announces and indexes them; - whatever disk still holds that HEAD does not is recorded as one system revision: "Unrecorded changes (recording was disabled)" when the realm last ran without recording, "Unattributed external changes" otherwise, with an alert. When recovery fails the realm goes read-only: writes answer 423 realm-read-only, reads and readiness keep working, and an operator clears it through /_grafana-clear-realm-read-only (see the runbook). A write to a path whose revision has not reached disk is refused until recovery completes it. deckd's new /tree and /read routes read a revision back; CI builds deckd at that commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnysKbB8Y5FgNf4nQuAd6M
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnysKbB8Y5FgNf4nQuAd6M
A write whose deckd answer was lost stays pending until recovery asks for its outcome after a grace period. A later write to the same files staged from disk, which lacks the lost write's change, so if that write had committed, the later one recorded its files back as they were before it. A write now asks for the outcome of any pending write touching its paths before staging. Asking fences one that never committed; one that did is a revision disk lacks, which the stale-disk guard holds the write back for while recovery completes it. If the outcome can't be had, the write is refused with 503 and nothing is recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnysKbB8Y5FgNf4nQuAd6M
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnysKbB8Y5FgNf4nQuAd6M
…parents A retry that found its revision already committed, and recovery settling a write whose answer was lost, journaled the revision with the HEAD the retry asked against as its parent and a sequence number after revisions that history put on top of it. materializedRevision() ordered by that sequence, so it could name the older revision as the newest. deckd now answers /commit and /outcome with the revision's own parent, which is what the journal records. materializedRevision() walks the parent chain back from the revision no other names as its parent, until it has passed every revision not yet on disk, so the order rows were journaled in no longer matters. A replay or outcome for an operation that changed nothing is treated as unchanged rather than journaled as a revision it did not write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnysKbB8Y5FgNf4nQuAd6M
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnysKbB8Y5FgNf4nQuAd6M
lukemelia
force-pushed
the
cs-14362-recover-realms-from-interrupted-writes-and-out-of-band-disk
branch
from
October 28, 2026 16:57
65f8e12 to
ec7b5ce
Compare
Contributor
Author
|
[Claude Code 🤖] Closed without merging. The revision-history design has moved away from recording a revision inside each write. A separate service will now record changes after they land on disk, so this approach is superseded. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #6991, which records realm writes as revisions through a history-first write transaction. Uses cardstack/deck#7 (deckd
/treeand/read, and a revision'sparentin/commitand/outcome);DECKD_REFinci.yamlpins that PR's commit.What this does
A realm that records history checks itself against its history at mount (before the boot index decision), on an hourly schedule, and whenever a write finds disk behind HEAD. Each run:
pendingbefore deckd is asked to record it, in a statement of its own. A row still pending after five minutes has its outcome asked of deckd, which fences an absent operation: it becomesaborted, and a retry under that ID gets 409operation-fenced./treeblob IDs against a git blob hash of each file. Whatever still differs is re-checked under its file lock, then recorded as one system revision:kind = gap,Boxel-Gap: recording-disabled) when the realm last ran without recording;kind = drift) otherwise, with an alert.If recovery fails, the realm goes read-only:
realm-read-onlywithX-Boxel-Realm-Read-Only;_readiness-checkanswers 200 with the same header;/_grafana-clear-realm-read-only, which re-runs recovery first (force=trueskips it).The runbook is
docs/realm-history-recovery-runbook.md.Key decisions
realm-recoveringuntil recovery completes it. A writer holds its file locks until its bytes are on disk, so such a revision's writer is gone. Staging an append from older bytes would drop that revision's content.materializedRevision()walks back from the revision no other row names as its parent, until it has passed every revision not yet on disk. A replay or outcome for an operation that changed nothing is answered as unchanged, not journaled.realm-history-write-testtherefore expects fixture files as modified rather than added, and one more revision.realm_history_statetable, offrealm_metadata, so the archived filters never have to tell a read-only realm from an archived one.POST <realm>/_history-recovery, as the realm's owner, because only that process holds the realm's files, history and broadcaster./_atomic, source DELETE, create-realm and publish don't record yet, so on a recording realm their changes are taken in as drift and alert. That's accepted while the flag is test-only, and the runbook says so.Schema
1793129747681_realm-history-recovery.js(additive):realm_history_revisions:state,paths,kindandpost_committed_at, withrevisionandparentmade nullable for pending rows, and an index on(realm_url, parent);realm_history_statetable.Both tables stay out of the host's SQLite schema, whose file is renamed to the new migration's timestamp with its content unchanged.
Telemetry
One
boxel:operationsline per recovery (kind: "realm-recovery"). It carries the outcome (clean | recovered | read-only | leased), resolved operations, completed revisions, drift and gap paths and revisions, and the scan's file count, bytes and timings.realm-history-group.jsonalerts onread-onlyand ondriftPaths > 0.Testing
Run with a deckd built from cardstack/deck#7. The four newest recovery tests haven't run locally yet, so CI is their first run:
TEST_FILES=realm-history-recovery-test pnpm test(19 tests)TEST_FILES=realm-history-write-test pnpm test(17 tests)The recovery tests remount one directory and database to cover:
The deckd and revision-reading test helpers moved to
tests/helpers/realm-history.ts, so both files share them.Drift-detection cost was measured locally and recorded on the ticket for Gate 2. On a warm local SSD it's 56 ms for a copy of experiments-realm (621 files, 27 MB), and 0.44 s and 2.7 s for 10k and 50k synthetic files.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AnysKbB8Y5FgNf4nQuAd6M