Skip to content

fix(dr): fail closed on unrestorable D1 backups - #1225

Merged
kody-bot merged 5 commits into
mainfrom
cursor/dr-backup-fail-closed-2cdf
Aug 4, 2026
Merged

kody-bot merged 5 commits into
mainfrom
cursor/dr-backup-fail-closed-2cdf

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Aug 4, 2026 •

Copy link
Copy Markdown
Owner

Intent

Prevent D1 exports containing statements above Cloudflare's import limit from being published, sealed, or restored as healthy disaster-recovery media. Fixes threat T4 in #1223.

Summary

  • persist SQL statement stats before manifest finalization and fail the backup Workflow retryably when oversized statements exist
  • gate full-day sealing (including already-sealed historical days), freshness, restore drills, and production restore on SQL restorability
  • show D1 restorability as yes/no/unknown in the control-plane dashboard, preserving verified-manifest status when stats lookup fails
  • preserve pre-stats retained media compatibility before 2026-07-28, while failing closed for newer missing, corrupt, contradictory, or currently unsafe stats
  • document the operator paging signal, catch-up behavior, and historical immutable-media exception

Testing

  • targeted backup/stat regression tests: 48 passed
  • npm run typecheck: passed
  • npm run backup:build: passed
  • npm run validate: passed (555 files, 1,866 tests)
  • independent DR review: no remaining actionable findings after fixes
  • final-head PR CI and AI reviewers: passed; all valid feedback addressed
  • merged-main validation: passed after rerunning an unrelated transient workerd E2E crash
  • production deploy: passed, including DR backup control-plane deploy

Conductor report

Status: done

Squash-merged as 1256e8732626be3ad9f35d35041aed6f9c2ca0b0. All Track 4 gates are live, merged-main validation is green, and the DR backup control-plane deployment succeeded. Nothing remains for this track.

System changes

System recap — extends an existing primitive (medium risk)

Mode: recap · Base: main @ 63616db6 · Head: d29a8b2e

Classification: extends — changes the backup control plane's publication, health, sealing, and restore-safety contracts without adding a new system primitive.

Primitives touched

Primitive Group Impact
backup-control-plane storage extends — SQL statement stats now gate manifest publication, sealing, freshness, restores, and dashboard health

System map

D1 export statistics now form a fail-closed gate from immutable SQL capture through every path that can label or consume backup media.

Legend: green = composes (wiring only) · amber = extended by this PR · red = new primitive · gray = context (unchanged, included only when an edge crosses it).

flowchart LR
	d1Export["backup-control-plane<br/>Production backup control plane"]:::extended
	manifest["Signed day manifest"]:::untouched
	seal["Sealed full backup"]:::untouched
	restore["Drill and production restore"]:::untouched
	dashboard["Operator dashboard"]:::untouched
	d1Export -->|"stats must show zero oversized statements"| manifest
	manifest -->|"stats gate before sealing"| seal
	manifest -->|"stats gate before import"| restore
	manifest -->|"yes / no / unknown restorability"| dashboard
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Invariants

  • Post-cutover unrestorable SQL never receives a canonical day manifest.
  • New backup days cannot seal or restore without valid stats; retained pre-stats days remain usable with an explicit warning.
  • Historical immutable objects are not modified and are blocked at health and restore gates.
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Added SQL restorability tracking for D1 backups.
    • Backups with oversized SQL statements are now rejected and marked non-restorable.
    • Restore, sealing, freshness checks, and restore drills validate SQL restorability before proceeding.
    • Dashboard statuses now show whether daily backups are restorable, with warnings for missing, corrupt, legacy, or oversized statistics.
  • Documentation

    • Updated recovery and backup guidance for unrestorable exports and legacy statistics handling.

cursoragent and others added 2 commits August 4, 2026 20:59
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds versioned SQL statistics, validates D1 backup restorability across backup and restore workflows, and reports restorability in sealing, freshness checks, and the dashboard. Oversized, missing, conflicting, and corrupt statistics now produce explicit outcomes.

Changes

D1 SQL restorability

Layer / File(s) Summary
SQL statistics contract and classification
packages/shared/src/backup-sql-stats.ts, packages/shared/src/backup-sql-stats.node.test.ts, packages/backup-control-plane/sql-statement-stats.ts, packages/backup-control-plane/backup-control-plane-test-support.ts
Defines versioned SQL statistics, strict parsing, required-date handling, restorability states, enforcement errors, and test fixtures.
Backup runtime enforcement
packages/backup-control-plane/backup-runtime.ts, packages/backup-control-plane/backup-control-plane-test-support.ts, packages/backup-control-plane/backup-runtime.node.test.ts, packages/backup-control-plane/backup-types.ts, packages/backup-control-plane/readme.md
Persists immutable statistics before manifest creation. Retryable failures now prevent canonical manifests for oversized, missing, or conflicting statistics.
Sealing and restore validation
packages/backup-control-plane/seal-full-backup.ts, packages/backup-control-plane/seal-full-backup.node.test.ts, packages/backup-control-plane/production-restore.ts, packages/backup-control-plane/production-restore.node.test.ts, packages/backup-control-plane/restore-drill.ts, packages/backup-control-plane/restore-drill.node.test.ts
Validates SQL restorability before sealing or importing. Tests cover oversized, missing, invalid, and previously sealed backup days.
Freshness and dashboard reporting
packages/backup-control-plane/freshness-check.ts, packages/backup-control-plane/freshness-check.node.test.ts, packages/backup-control-plane/control-plane-ui.ts, packages/backup-control-plane/control-plane-ui.node.test.ts, docs/contributing/disaster-recovery.md
Marks unrestorable or invalid statistics stale, emits related events, and displays D1 restorability as yes, no, or unknown. Documentation describes unrestorable exports and re-export handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BackupWorkflow
  participant SqlStatsStorage
  participant ManifestStorage
  participant RestoreWorkflow
  BackupWorkflow->>SqlStatsStorage: persist validated SQL statistics
  SqlStatsStorage-->>BackupWorkflow: statistics accepted or error
  BackupWorkflow->>ManifestStorage: publish canonical manifest
  RestoreWorkflow->>SqlStatsStorage: validate referenced SQL statistics
  SqlStatsStorage-->>RestoreWorkflow: restorable or BackupError
  RestoreWorkflow->>ManifestStorage: continue restore only when valid
Loading

Possibly related PRs

  • kentcdodds/kody#986: Modifies backup SQL statement statistics and restorability enforcement in related backup-control-plane flows.
  • kentcdodds/kody#1216: Updates the LogRecord.event union in backup-types.ts.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: fail-closed handling for unrestorable D1 backups.
Description check ✅ Passed The description includes intent, summary, testing, and system changes with sufficient implementation and validation details.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/dr-backup-fail-closed-2cdf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@github-actions

github-actions Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-1225.kody-a99.workers.dev

Worker: kody-pr-1225
D1: kody-pr-1225-db
KV: kody-pr-1225-oauth-kv

Mocks:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/backup-control-plane/control-plane-ui.ts (1)

183-223: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Separate the stats read failure from the manifest read failure.

readSqlRestorability now runs inside the same try as readManifest. If the stats read or the JSON body read fails, the catch at line 220 resets d1Verified to null and reports 'D1 manifest unreadable'. The manifest was already read and verified at that point, so the dashboard shows a verified day as unverified and names the wrong cause. Wrap the restorability read in its own try so the failure maps to d1Restorable and to a stats-specific warning.

🐛 Suggested scope split
 				else {
-					const restorability = await readSqlRestorability(
-						env.BACKUP_BUCKET,
-						day,
-						manifest.payload.sql.objectKey,
-					)
-					switch (restorability.kind) {
+					let restorability
+					try {
+						restorability = await readSqlRestorability(
+							env.BACKUP_BUCKET,
+							day,
+							manifest.payload.sql.objectKey,
+						)
+					} catch {
+						warnings.push('D1 SQL stats unreadable')
+					}
+					switch (restorability?.kind) {
+						case undefined:
+							break
 						case 'restorable':
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backup-control-plane/control-plane-ui.ts` around lines 183 - 223,
Separate the readSqlRestorability call and its handling from the manifest
try/catch in the surrounding control flow. Preserve d1Verified after a
successful readManifest, and handle restorability or JSON-body failures by
setting d1Restorable appropriately and adding a stats-specific warning instead
of resetting d1Verified or reporting “D1 manifest unreadable.”
🧹 Nitpick comments (3)
packages/backup-control-plane/seal-full-backup.ts (1)

385-394: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log the new seal-skip reasons.

These three branches return incomplete without a log record. The blob-missing branch at lines 517-524 emits full-backup-seal-skipped, so alerting depends on that event. Emit the same event here so an unrestorable, missing, or corrupt stats sibling is visible to operators instead of only through the HTTP response.

♻️ Suggested change
 		case 'unrestorable':
+			safeLog({
+				event: 'full-backup-seal-skipped',
+				status: 'failure',
+				day,
+				errorCode: 'backup-unrestorable-statements',
+			})
 			return {
 				kind: 'incomplete',
 				day,
 				reason: 'backup-unrestorable-statements',
 			}
 		case 'missing':
+			safeLog({
+				event: 'full-backup-seal-skipped',
+				status: 'failure',
+				day,
+				errorCode: 'backup-sql-stats-missing',
+			})
 			return { kind: 'incomplete', day, reason: 'backup-sql-stats-missing' }
 		case 'corrupt':
+			safeLog({
+				event: 'full-backup-seal-skipped',
+				status: 'failure',
+				day,
+				errorCode: 'backup-sql-stats-corrupt',
+			})
 			return { kind: 'incomplete', day, reason: 'backup-sql-stats-corrupt' }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backup-control-plane/seal-full-backup.ts` around lines 385 - 394,
Update the switch branches for unrestorable, missing, and corrupt stats to emit
the same full-backup-seal-skipped event used by the blob-missing branch before
returning incomplete. Preserve each branch’s existing day and reason values, and
reuse the established logging mechanism and event payload shape.
packages/shared/src/backup-sql-stats.node.test.ts (1)

15-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the limit from the shared constant and cover the valid oversized case.

The fixtures hardcode 100_000 and 100_001. If d1ImportMaxStatementBytes changes, the valid fixture starts to throw and the failure reason is unclear. Import the constant instead.

The suite also does not assert that a legitimately oversized record parses. That branch gates unrestorable in readSqlRestorability, so add it. A non-record input and a wrong schemaVersion are also uncovered.

💚 Suggested test additions
+import { d1ImportMaxStatementBytes } from './backup-restore-safety.ts'
+
 	const valid = {
 		schemaVersion: backupSqlStatsSchemaVersion,
 		day: '2026-07-31',
 		objectKey: 'daily/d1/database/2026-07-31/backup-bookmark.sql',
 		maxStatementBytes: 50_000,
 		oversizedStatementCount: 0,
-		importStatementLimitBytes: 100_000,
+		importStatementLimitBytes: d1ImportMaxStatementBytes,
 	}
 	assert.deepEqual(parseBackupSqlStats(valid), valid)
+
+	const oversized = {
+		...valid,
+		maxStatementBytes: d1ImportMaxStatementBytes + 1,
+		oversizedStatementCount: 1,
+	}
+	assert.deepEqual(parseBackupSqlStats(oversized), oversized)
 
 	for (const corrupt of [
-		{ ...valid, importStatementLimitBytes: 100_001 },
-		{ ...valid, maxStatementBytes: 100_001 },
+		null,
+		{ ...valid, schemaVersion: backupSqlStatsSchemaVersion + 1 },
+		{ ...valid, importStatementLimitBytes: d1ImportMaxStatementBytes + 1 },
+		{ ...valid, maxStatementBytes: d1ImportMaxStatementBytes + 1 },
 		{ ...valid, maxStatementBytes: 50_000, oversizedStatementCount: 1 },
 	]) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/backup-sql-stats.node.test.ts` around lines 15 - 31,
Update the backup SQL stats tests around parseBackupSqlStats to import and use
the shared d1ImportMaxStatementBytes constant instead of hardcoded 100_000
values. Add a valid fixture with oversizedStatementCount greater than zero and
the corresponding limit so it parses successfully, and add rejection cases for
non-record input and an incorrect schemaVersion.
packages/shared/src/backup-sql-stats.ts (1)

45-47: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Confirm the oversize boundary and avoid hard-pinning importStatementLimitBytes.

maxStatementBytes === d1ImportMaxStatementBytes is treated as restorable, so the producer must only mark a statement oversized when its length is greater than the limit. If that comparison uses >=, exactly the limit can set oversizedStatementCount = 1, making a restorable row report corrupt.

Also avoid requiring importStatementLimitBytes to equal the current constant exactly. If d1ImportMaxStatementBytes changes, all already-persisted stats siblings fail the validation at once. Use the recorded importStatementLimitBytes for the restorability check, or bump schemaVersion with a documented migration.

Also add parentheses around the second operand. Relational operators bind tighter than !==, so the expression is correct, but the intent is not obvious.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/backup-sql-stats.ts` around lines 45 - 47, Update the
producer’s oversized-statement comparison to use a strict greater-than limit so
statements exactly equal to d1ImportMaxStatementBytes remain restorable. In the
validation expression around oversizedStatementCount and
importStatementLimitBytes, compare against the recorded
value.importStatementLimitBytes instead of hard-pinning
d1ImportMaxStatementBytes, and parenthesize the relational operand for clarity.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/contributing/disaster-recovery.md`:
- Around line 373-378: Update the disaster-recovery documentation near the
“unrestorable export” statement to limit the “never receives a canonical day
manifest” guarantee to post-cutover exports. Explicitly note that historical bad
sealed days may already have canonical manifests and are intentionally left
unchanged, while preserving the existing catch-up retry guidance.

---

Outside diff comments:
In `@packages/backup-control-plane/control-plane-ui.ts`:
- Around line 183-223: Separate the readSqlRestorability call and its handling
from the manifest try/catch in the surrounding control flow. Preserve d1Verified
after a successful readManifest, and handle restorability or JSON-body failures
by setting d1Restorable appropriately and adding a stats-specific warning
instead of resetting d1Verified or reporting “D1 manifest unreadable.”

---

Nitpick comments:
In `@packages/backup-control-plane/seal-full-backup.ts`:
- Around line 385-394: Update the switch branches for unrestorable, missing, and
corrupt stats to emit the same full-backup-seal-skipped event used by the
blob-missing branch before returning incomplete. Preserve each branch’s existing
day and reason values, and reuse the established logging mechanism and event
payload shape.

In `@packages/shared/src/backup-sql-stats.node.test.ts`:
- Around line 15-31: Update the backup SQL stats tests around
parseBackupSqlStats to import and use the shared d1ImportMaxStatementBytes
constant instead of hardcoded 100_000 values. Add a valid fixture with
oversizedStatementCount greater than zero and the corresponding limit so it
parses successfully, and add rejection cases for non-record input and an
incorrect schemaVersion.

In `@packages/shared/src/backup-sql-stats.ts`:
- Around line 45-47: Update the producer’s oversized-statement comparison to use
a strict greater-than limit so statements exactly equal to
d1ImportMaxStatementBytes remain restorable. In the validation expression around
oversizedStatementCount and importStatementLimitBytes, compare against the
recorded value.importStatementLimitBytes instead of hard-pinning
d1ImportMaxStatementBytes, and parenthesize the relational operand for clarity.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f4b9a42-1d01-436d-8ada-f5397398a7b7

📥 Commits

Reviewing files that changed from the base of the PR and between 63616db and 1247d8f.

📒 Files selected for processing (19)
  • docs/contributing/disaster-recovery.md
  • packages/backup-control-plane/backup-control-plane-test-support.ts
  • packages/backup-control-plane/backup-runtime.node.test.ts
  • packages/backup-control-plane/backup-runtime.ts
  • packages/backup-control-plane/backup-types.ts
  • packages/backup-control-plane/control-plane-ui.node.test.ts
  • packages/backup-control-plane/control-plane-ui.ts
  • packages/backup-control-plane/freshness-check.node.test.ts
  • packages/backup-control-plane/freshness-check.ts
  • packages/backup-control-plane/production-restore.node.test.ts
  • packages/backup-control-plane/production-restore.ts
  • packages/backup-control-plane/readme.md
  • packages/backup-control-plane/restore-drill.node.test.ts
  • packages/backup-control-plane/restore-drill.ts
  • packages/backup-control-plane/seal-full-backup.node.test.ts
  • packages/backup-control-plane/seal-full-backup.ts
  • packages/backup-control-plane/sql-statement-stats.ts
  • packages/shared/src/backup-sql-stats.node.test.ts
  • packages/shared/src/backup-sql-stats.ts

Comment thread docs/contributing/disaster-recovery.md Outdated
cursoragent and others added 2 commits August 4, 2026 21:21
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kody-bot
kody-bot merged commit 1256e87 into main Aug 4, 2026
10 checks passed
@kody-bot
kody-bot deleted the cursor/dr-backup-fail-closed-2cdf branch August 4, 2026 21:37
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.

3 participants