Skip to content

fix(sqlite): bound the online backup so backup create cannot hang forever - #137543

Closed
shojikumaru wants to merge 5 commits into
openclaw:mainfrom
shojikumaru:fix/134215-backup-create-snapshot-deadline
Closed

shojikumaru wants to merge 5 commits into
openclaw:mainfrom
shojikumaru:fix/134215-backup-create-snapshot-deadline

Conversation

@shojikumaru

@shojikumaru shojikumaru commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #134215

What Problem This Solves

openclaw backup create hangs forever with zero output while the Gateway is running, produces no
archive, and leaves an orphan sqlite-readonly-location.worker process behind. openclaw backup git create --all fails the same way. The documented first-class recovery command is unusable on a live
install — a silent, indefinite non-outcome on a default path.

The cause is not a lock the snapshot waits on. It is a livelock in the stepped online backup.
node:sqlite restarts a stepped backup whenever another connection commits, so a source under
continuous write never converges. Measured on current main, against a live database with one
concurrent writer process:

t+15s steps=8264  restarts=8263  remaining 2262887/2262951 pages
t+30s steps=13305 restarts=13303 remaining 2995670/2995734 pages
t+45s steps=15656 restarts=15654 remaining 3751077/3751141 pages
t+60s steps=17406 restarts=17404 remaining 4467186/4467250 pages
VERDICT: backup never completed - steps=17406, restarts=17404, min remaining=1558759/4467250

17 406 steps, 17 404 of them restarts, and remainingPages never falling below its starting value.
(This probe passed rate: 64 for finer-grained samples; production uses Node's default of 100 pages
per step, so step counts scale accordingly — the page counts and the frozen minimum do not.)
sqlite-snapshot.ts:657 already named the hazard — "Node restarts stepped backups on concurrent
writes"
— but nothing bounded it, and nothing above it was bounded either:

  • sqlite-snapshot.ts:662 and sqlite-readonly-location.ts:445 — sqlite.backup with no bound.
  • sqlite-readonly-location.ts:661 — execFile launching the snapshot worker with only
    { encoding: "utf8" }: no timeout, no killSignal. Its callback fires on child exit, so a
    child stuck inside its own backup strands the parent forever. That is the orphan process in the report.
  • sqlite-readonly-location.ts:686 — spawnSync, same omission.
  • MAX_SNAPSHOT_ATTEMPTS (:19, loops at :483/:563) bounds the retry count only, so one
    unbounded attempt makes the whole loop unbounded.

Why This Change Was Made

The obvious repair — a timeout on the backup — is the wrong one here, and this repo has already
learned that: #122213 and #122235 replaced timeouts with no-progress detection for the archive
stream after legitimately slow work was being false-timeouted, and that watchdog still lives at
backup-create-stream.ts:92-121. A 13.7 MB snapshot is legitimately slow; a livelocked one is not.
node:sqlite's progress callback distinguishes them exactly: a step that copies pages lowers
remainingPages, and a restart does not.

So the bound is on net page progress, not wall clock:

  1. The duplicated "consistent online copy of a live source" policy — open read-only, busy_timeout
    and trusted_schema pragmas, BEGIN, validate, back up, ROLLBACK, close — was copied between
    sqlite-snapshot.ts:648-668 and sqlite-readonly-location.ts:441-451. It now lives in one owner,
    src/infra/sqlite-online-backup.ts, which takes the caller's in-transaction validation as a hook so
    the snapshot path keeps running schema_version, the vec extension load, the integrity check, and
    options.validate inside the same pinned read transaction, in the same order. The consolidation
    also fixes a latent bug: createOnlineReadOnlyBackup previously ran ROLLBACK only on the success
    path, so a mid-backup throw left the read transaction open until close.

  2. That owner bounds the copy by elapsed time since it last reached a new all-time-low remaining
    page count
    , read from the monotonic clock (performance.now(), the repo's own precedent in
    src/cli/daemon-cli/restart-health.ts:385) at the top of the progress callback, plus an unchanged
    plateau guard for a stream that copies nothing at all. Deliberately not a setTimeout: the
    progress callback is the only place production regains control of an in-flight node:sqlite
    backup, so a timer could at best set a flag the next callback reads, and it would make the bound
    untestable without real elapsed time.

    Getting here took three attempts, and the discarded ones are worth stating because they are the
    obvious designs. Counting steps against an all-time minimum is wrong: after a restart Node
    replays remainingPages from the top, so recovery costs pagesCopied / rate steps that look like
    non-progress while the copy runs at full speed — measured at exactly pagesCopied ÷ 100. Counting
    restarts, or wasted work, is also wrong, and that one is only visible with instrumentation:
    both quantities overlap between backups that finish and backups that never do, and both grow with
    database size, so neither can be calibrated. See the table under Evidence.

  3. The failure names the cause and a way forward instead of dead-ending: the source is being written
    concurrently, typically by a running Gateway, and quiescing those writes lets the same snapshot
    complete. The remediation text deliberately does not point at openclaw backup sqlite create,
    because that command reaches the same owner (commands/backup-sqlite.ts:85 ->
    snapshot/local-repository.ts:287 -> createOpenClawSnapshotCopy ->
    createVerifiedSqliteSnapshot) and would hit the same bound.

  4. The worker launches gain no deadline of their own. An earlier revision added a 30-minute
    timeout + killSignal there; review was right that a fixed cap over the whole worker
    regardless of progress is the defect fix(backup): prevent false archive stalls during active traversal #122213 and fix(backup): keep large file reads from false-timeout #122235 removed, and that mapping its expiry to
    "concurrent writes" asserts a cause it never established. With the backup inside the child bounded
    and rearmed by observed progress, the child is already guaranteed to exit.

No new configuration surface, no schema change, no change to MAX_SNAPSHOT_ATTEMPTS semantics, the
#130962 audit-lease flow, restore/archive formats, or the #127855 child-process lock isolation.

User Impact

openclaw backup create and openclaw backup git create now terminate instead of hanging forever.
Under continuous writes they either complete — measured above, 66 s with a live writer — or fail with
an actionable message naming the cause, rather than producing nothing and stranding an orphan worker.
When they can copy the database they behave exactly as before, including on large, slow snapshots.
See the open question below for the one case where the current threshold could still cut a converging
copy short.

Evidence

All measurements are real runs, not estimates.

Reproduction of the defect on current main (d2a616bdf373), Linux 6.8.0-138 x86_64, Node
v24.20.0, SQLite 3.53.4, inside a node:24 Docker container. The exact production chain
(createVerifiedSqliteSnapshot -> withSqliteSnapshotSource -> prepareSqliteReadOnlyLocation) was
driven against a live database that a separate writer process kept committing to:

source writer prepareSqliteReadOnlyLocation createVerifiedSqliteSnapshot
408 MB, rollback journal commit storm OK 4.8 s OK 16.7 s
600 MB, rollback journal commit storm OK 6.1 s OK 59.3 s
13.7 MB, rollback journal held open transaction OK 1.5 s OK 1.8 s
215 MB, rollback journal commit every 25 ms OK 2.3 s OK 4.9 s
200 MB, WAL commit storm OK 7.4 s hang, >60 s, zero output
200 MB, WAL (rerun) commit storm OK 7.4 s OK 10.1 s

Reported honestly as a race: the same scenario hung once and completed on a rerun. That is why the
defect is intermittent for operators and hard to reproduce, and it is why the fix is a bound rather
than a change to the copy strategy. The instrumented run quoted above pins the mechanism, and the
control — the identical snapshot with the writer stopped — completes in 733.8 s on the grown database.

Real openclaw backup create on this PR head, built from the branch (pnpm build) and run in a
node:24 container against a 145 MB WAL state database created by openclaw doctor, with a separate
process committing continuously — the reported scenario:

CASE 1: openclaw backup create WITH a concurrent writer
  exit=0 after 66s
  Backup archive: /tmp/out/2026-09-03T22-19-44.884+00-00-openclaw-backup.tar.gz
  Created ... (4,512,378 bytes)
  orphan snapshot workers: (none)

CASE 2: same command, writes quiesced
  exit=0 after 456s
  Created ... (64,869,733 bytes)

On the reported version this command hung with zero output past 180 s and left an orphan
sqlite-readonly-location.worker. On this head it finishes while the writer is still committing,
produces a real archive, and leaves no orphan process.

Two honest notes. First, case 1 completed rather than tripping the new bound, so this run proves the
hang is gone and the healthy path works under contention — it does not exercise the bounded-failure
message end-to-end through the CLI; that path is covered by the unit and owner-boundary tests below,
because forcing it through the CLI means driving the 30-minute deadline. Second, case 1 printed
[sqlite/transaction] SQLite transaction lock wait failed and Warning: the backup outcome could not be recorded: database is locked — the archive was still produced; that is a separate contended-write
path recording the outcome, not the snapshot, and this PR does not change it.

Calibration. Twelve instrumented runs against real WAL sources with a real node:sqlite backup
and an in-process writer at a fixed cadence:

pages writer every outcome peak restarts w/o new low discarded, cumulative worst gap w/o a new low
21,108 55 ms completed 0.7 s 2 7.97 copies —
21,108 55 ms completed 2.0 s 14 30.02 copies 768 ms
63,320 160 ms completed 18.7 s 38 106.92 copies —
63,319 160 ms completed 2.4 s 2 9.75 copies 313 ms
211,088 2,000 ms completed 4.0 s 1 0.81 copies 1,076 ms
211,088 5,000 ms completed 5.0 s 0 0 38 ms
63,322 150 ms capped 200 s at 99.3 % 803 1,186 copies —
63,322 150 ms capped 205 s at 99.2 % 1,228 1,148 copies 190,263 ms rising
63,323 140 ms capped 200 s at 90.8 % 1,122 1,183 copies —
211,094 160 ms capped 400 s at 30.4 % 2,001 606 copies 320,448 ms
211,090 400 ms capped 401 s at 77.4 % 406 675 copies 163,719 ms rising

Read the last three columns across the two groups. Restart counts overlap (completing runs needed
up to 38; a stalled one sat at 406). Discarded work overlaps far worse — a run that finished in
18.7 s
had thrown away 107 full copies, more than two of the stalled runs. Both quantities also grow
with database size, so a constant calibrated at 63 K pages means nothing at 4,467,250. The last column
does not overlap at all: every completing run reached a new low within 38–1,076 ms, every stalled
run sat 120–320 seconds without one. That is the axis the bound uses.

Two-sided proof, Linux Docker. With both bounds set to POSITIVE_INFINITY — the pre-fix
unbounded behaviour — exactly the four non-convergence cases fail and every anti-false-abort case
stays green:

BASE (MAX_MS_WITHOUT_NET_PROGRESS = MAX_STEPS_WITHOUT_COPY_ADVANCE = Infinity)
  Tests  4 failed | 5 passed (9)

HEAD (8 suites)
  Test Files  8 passed (8)
  Tests  223 passed | 2 skipped (225)

The suite covers both directions: shapes that must abort (restart on nearly every step, restart every
few steps without beating the best, a stream that never moves, and headway so slow it costs a whole
pass per page) and shapes that must not (a long strictly-decreasing copy, a single restart followed by
recovery far longer than the bounds, and many restarts each reaching a new all-time low). One case
drives a real, unstubbed node:sqlite backup to confirm that throwing from progress rejects it.

Backward clock correction (added after review). The deadline is read from performance.now(),
so a wall-clock step cannot postpone it. The added test arms the deadline, sets the system clock back
ten minutes, then feeds one-minute livelock cycles, and pins the abort at cycle 31 (thirty one-minute
cycles exhaust the budget). On the previous head, which read Date.now(), the same test fails with the
deadline postponed by exactly the rollback:

previous owner (Date.now):    sqlite-online-backup.test.ts  Tests  1 failed | 9 passed (10)
                              AssertionError: expected 41 to be 31
this head (performance.now):  sqlite-online-backup.test.ts  Tests  10 passed (10)
Linux Docker node:24 (VPS), same two legs:
  previous owner:   sqlite-online-backup.test.ts  Tests  1 failed | 9 passed (10)   expected 41 to be 31
  this head:        4 suites (online-backup, backup-sqlite-snapshot, readonly-location, sqlite-snapshot)
                    Tests  92 passed | 2 skipped (94)

node:sqlite progress contract, rechecked upstream. Node v26.x reference for
sqlite.backup(sourceDb, path[, options])
(added in v23.8.0 / v22.16.0), quoted:

rate <integer> Positive number of pages to be transmitted in each batch of the backup. Default: 100.
progress <Function> An optional callback function that will be called after each backup step. The
argument passed to this callback is an <Object> with remainingPages and totalPages properties,
describing the current progress of the backup operation.
Returns: <Promise> A promise that fulfills with the total number of backed-up pages upon completion,
or rejects if an error occurs.
The backed-up database can be used normally during the backup process. Mutations coming from the same
connection - same <DatabaseSync> - object will be reflected in the backup right away. However,
mutations from other connections will cause the backup process to restart.

And the same contract exercised against the real API, no OpenClaw code involved (Node v26.5.0,
SQLite 3.53.3, 16-page WAL source):

progress payload keys = ["totalPages","remainingPages"]
progress sequence (rate: 4): [{"totalPages":16,"remainingPages":12},{"totalPages":16,"remainingPages":8},{"totalPages":16,"remainingPages":4}]
throw from progress -> rejected with the thrown error | progress calls before reject = 1
remainingPages with one foreign commit after step 2 (rate: 2): [14,12,15,13,11,9,7,5,3,1]

The last line is the restart the docs describe: a commit from another connection sends remainingPages
back up (12 → 15), which is exactly the signal the bound keys on. The docs do not promise that a throw
from progress rejects the backup; that is observed behaviour, and the unit test rejects when a real node:sqlite progress callback throws pins it on every CI run (Node 24) so a change in Node would show
up there rather than in production.

Data-model compatibility. There is no DDL in this diff, no schema-version bump, no new table, column,
or index; the review tool's "SQLite table change" flag matched the PRAGMA schema_version and
PRAGMA trusted_schema reads moving from sqlite-snapshot.ts / sqlite-readonly-location.ts into
the owner, unchanged in meaning. Copy fidelity, measured through backupSqliteOnline on a WAL source
with two STRICT tables, an index, a view, a trigger, user_version = 41 and 500 rows:

sqlite_master rows in source: 7
copy sqlite_master == source sqlite_master: true
copy pragmas: user_version 41, page_size 4096, integrity_check ok, sessions 500
source untouched by backup: true

The copy's schema_version cookie is written by SQLite's own backup API (sqlite3_backup_finish sets
the destination's previous cookie + 1) and the same sqlite.backup call did that on main, so nothing
about how a copy is read back or upgraded has changed.

Contention classification across the snapshot worker (added after review). A rollback-journal
source is snapshotted inside the isolated read-only worker, which used to serialize only an error
message; the parent rebuilt a plain Error, so a contended copy on that path reached the backup
command without its typed cause and lost the --only-config remedy. The worker now reports a closed
failure code (failure: "contention" | "error") next to the message, the parent validates the closed
shape and rebuilds the typed cause through one small owner factory (the error class stays private),
on both the execFile and spawnSync routes since they share the same adopter. Regression
preserves online-backup contention across the isolated worker boundary drives the public
prepareSqliteReadOnlyLocation in a child process on a rollback-journal source, with a
NODE_OPTIONS --import preload that replaces node:sqlite's backup with a wedged progress stream so
the real owner's plateau guard throws a real contention error inside the real grandchild worker; the
child reports whether isSqliteBackupContentionError holds in the parent:

previous head (message only):  preserves online-backup contention across the isolated worker boundary
                               AssertionError: expected false to be true
this head (typed cause):       4 suites  Tests  92 passed | 3 skipped (95)
Linux Docker node:24 (VPS), same two legs:
  previous head:   sqlite-readonly-location.test.ts  Tests  1 failed | 50 passed (51)   expected false to be true
  this head:       4 suites  Tests  93 passed | 2 skipped (95)

Suites. On Linux (Docker node:24), sqlite-online-backup, sqlite-readonly-location,
sqlite-snapshot, backup-create, backup-create.legacy-audit-lease, backup-archive-publication,
and state-migrations.source-snapshot all pass: 218 passed, 2 skipped, 7 files, including the
existing concurrent-writer coverage at sqlite-readonly-location.test.ts:652, :725, and :768,
unchanged. node scripts/check-changed.mjs passes for every changed file.

One test, backup-create.test.ts > sanitizes every in-state symlink and hardlink alias of a canonical agent SQLite DB, times out at 120 s on macOS. It fails identically on the unmodified base commit on
the same machine, and passes on Linux, so it is pre-existing local slowness rather than a regression
from this change.

Production LOC delta. git diff --numstat against the base:

 91  0  src/infra/backup-sqlite-snapshot.test.ts
  8  1  src/infra/backup-sqlite-snapshot.ts
296  0  src/infra/sqlite-online-backup.test.ts
118  0  src/infra/sqlite-online-backup.ts
115  0  src/infra/sqlite-readonly-location.test.ts
 35 42  src/infra/sqlite-readonly-location.ts
  6  9  src/infra/sqlite-readonly-location.worker.ts
  8 22  src/infra/sqlite-snapshot.ts

Production net +101 (new owner +118, backup boundary +7, sqlite-readonly-location.ts −7,
worker −3, sqlite-snapshot.ts −14); tests +502. Roughly half the owner's lines are the comment recording
what the constant was calibrated against; the rest is the bound, the typed error and its predicate.
Absorbing the duplicated backup policy into the owner is the net-negative half of the diff and is what
keeps the production delta this small.

Open design question for maintainers

The 30 minutes is a product decision, not a measurement, and it is yours to set.

An online backup of a continuously written database has no guaranteed finish time — node:sqlite
discards the whole pass every time another connection commits — so the only real question is how long
an operator should wait before being told to quiesce writes. The measurements above rule out every
alternative axis: restart counts and wasted work both overlap between the populations and grow with
size. Elapsed time since the copy last beat its own best is the one quantity that separates them.

A caveat I want to put in front of you rather than bury. Independent review ran the experiment my
own table lacks — holding contention constant relative to pass duration instead of at a fixed writer
interval — and found the gap without a new low is flat at 0.20–0.24 pass-durations across
20 K/60 K/200 K pages while the absolute figure grows 65 → 138 → 526 ms. Uncontended pass duration is
linear in size. So the size-invariant quantity is the gap measured in pass-durations, and an
absolute millisecond threshold silently re-introduces a size dependence. Real completing backups in
that experiment needed up to 11.6 pass-durations; against the 733.8 s control for the reported
4,467,250-page source, 30 minutes buys only 2.45. My table cannot refute this — its cadence was
raised with size (55 ms at 21 K, 160 ms at 63 K, 2 s at 211 K), so it contains no high-contention
large-database point that completed.

Concretely: at the reported database size this constant could abort a copy that would have
finished.
The suggested fix is to derive the budget from observed pass cost inside the callback
(totalPages is already in the payload) and keep 30 minutes as a ceiling rather than the whole rule.
I have not done that here because it changes the policy surface and I would rather you chose it.

The other disclosed cost: the reported livelock now fails after 30 minutes rather than in ~1.2 s.
A smaller number buys a faster failure there and increases the false-abort risk above. If you want the
fast abort back, the honest options are a shorter deadline or a second, explicitly-owned cap — not a
return to counting restarts, which the data refutes.

Known gaps I would fix on request, none of which affect the hang itself: the copy is still silent
while it runs, so the reported "zero output" becomes "silent for up to the deadline, then a good
error" — the repo's own backup-create-stream.ts:92-121 progress reporting is the precedent; the
retry paths (MAX_SNAPSHOT_ATTEMPTS, withSqliteSnapshotSource) each restart the window rather than
sharing one deadline; and on the prepared-copy branch the message names the
staging path rather than the operator's database.

One more result worth your attention on its own: at 211 K pages under a 160 ms writer, nothing
completed at all
within 400 s, and at 400 ms it was still at 77 % after 401 s. That suggests a large,
continuously written database may simply have no finishing time for an online backup, and that saying
so promptly is the useful behaviour rather than a failure of this bound.

CI status

Head d82ca04eb9 is the branch rebased onto d5992278b3 (main as of 2026-09-05) so that #137999's
errcode suffix in the snapshot worker message and this PR's failure tag coexist; the five commits are
unchanged in content, and the expectation in the upstream worker diagnostics test (added by #137999) now
includes the failure tag. Earlier checks below were taken on the pre-rebase heads and re-run on this one.

Rebased onto 8c6653a78d to pick up the createBackupResourceInventory signature change
(configPath/oauthDir → configPaths/oauthDirs), which my new test had to follow;
pnpm check:test-types is green.

The remaining red checks — checks-node-compact-small-49, -small-55, -large-2 and the
openclaw/ci-gate that aggregates them — are already red on main and unrelated to this change.
Scoped proof, same machine, same command:

this branch:                 test/scripts/full-release-candidate-reuse.test.ts  Tests  2 failed | 28 passed (30)
unmodified main @ 8c6653a78d: test/scripts/full-release-candidate-reuse.test.ts  Tests  2 failed | 28 passed (30)

Both failures are full release candidate discovery CLI > marks bounded candidate evaluation unavailable ... and ... marks a selected candidate with a missing constituent unavailable, in
release-candidate tooling that this PR does not touch. Per AGENTS.md I have not tried to fix them
here. security-fast also failed on an earlier push and is likewise red on main
(run 33813389736); it is green on this head.

Notes

  • clawsweeper:no-new-fix-pr on the issue reads as "does not recommend queueing a new automated
    fix PR"
    , and its review asked for a live reproduction before an automated patch. That reproduction
    is above and was posted to the issue before this PR was opened; this is a human-authored PR, not a
    queued automated one. Happy to stand down if that reading is wrong.
  • The design question of whether the bound belongs in one extracted owner or only at the
    createVerifiedSqliteSnapshot boundary is open on the issue; this PR implements the extracted-owner
    version and can be reshaped.
  • Maintainer edits are enabled on this branch.

Human + AI disclosure: the reproduction, analysis, implementation, and review were produced by me
working with AI coding agents, across four independent read-only review passes plus a second round
against the settled commit. Two of those passes returned NO-GO on the first implementation and are
the reason the bound was rebuilt and the wall-clock timeout dropped; their measurements are quoted
above. Every number in this PR comes from the runs shown.

@clawsweeper

clawsweeper Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

ClawSweeper review complete

ClawSweeper finished reviewing this revision. The review result is being finalized.

View the workflow run.

@clawsweeper clawsweeper Bot added P1 High-priority user-facing bug, regression, or broken workflow. merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Sep 3, 2026
@clawsweeper

clawsweeper Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Codex review: blocked before merge. Reviewed September 5, 2026, 1:45 AM ET / 05:45 UTC.

ClawSweeper review

What this changes

Centralizes SQLite copying behind progress limits and preserves contention errors across worker processes so backup commands can explain recovery options.

Warning

SQLite table change

This PR modifies persisted SQLite tables in src/infra/sqlite-online-backup.ts, src/infra/sqlite-readonly-location.ts, src/infra/sqlite-snapshot.ts. Prefer a design that avoids changing persisted SQLite tables. If the change is necessary, verify migration and upgrade compatibility against an existing database before merge.

Merge readiness

⛔ Blocked before merge - 6 items remain

The previous diagnostic-assertion defect is resolved, and current main still lacks the backup bound. No remaining discrete patch defect was established; the explicitly deferred stall-policy decision remains.

Priority: P1
Reviewed head: d82ca04eb974d889ee0ccd582287a76eac62d392
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) A coherent shared-owner repair with real CLI evidence and resolved prior findings; the remaining operational tradeoff is explicit.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): Recorded built-CLI runs exercise the shared snapshot path against a real WAL database, producing archives with concurrent and quiesced writes and no orphan worker in the contended case. They establish after-fix operation, not end-to-end expiry of the 30-minute limit; boundary tests supplement that coverage, and omitted body text remains unknown.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): Recorded built-CLI runs exercise the shared snapshot path against a real WAL database, producing archives with concurrent and quiesced writes and no orphan worker in the contended case. They establish after-fix operation, not end-to-end expiry of the 30-minute limit; boundary tests supplement that coverage, and omitted body text remains unknown.
Evidence reviewed 13 items Introduced scope and merge result: The local introduced delta matches the nine supplied paths. The test-merge raw parents are the pinned base followed by the exact PR head; its comparison against that base contains the same paths, excluding unrelated endpoint drift.
Still necessary on main: Fetched main and the pinned PR base still await sqlite.backup without progress options. The read-only preparation path likewise remains unbounded at src/infra/sqlite-readonly-location.ts:445.
Latest release retains the unbounded call: The supplied v2026.9.1 release commit also calls sqlite.backup without progress options; this change is not established as shipped.
Findings None None.
Security None None.

How this fits together

OpenClaw snapshots live SQLite databases before creating archive, SQLite, and Git backups. The shared copy operation also supports read-only state inspection, producing a private database copy or an actionable failure.

flowchart TD
  A[Backup commands] --> C[Snapshot preparation]
  B[Read-only state inspection] --> C
  C --> D[Shared online copy]
  D --> E{Progress within limits?}
  E -->|Yes| F[Verified private snapshot]
  E -->|No| G[Typed failure and recovery guidance]
Loading

Decision needed

Question Recommendation
Should all shared online-copy callers use the fixed 30-minute no-net-progress limit, accepting that sufficiently slow replaying copies can fail? Accept the fixed limit: Retain the conservative 30-minute policy and record acceptance of its large-copy cutoff and same-build worker assumption.

Why: The contributor explicitly defers this operational tradeoff; tests demonstrate the chosen behavior but cannot establish the owner's tolerance for rejecting large converging copies.

Before merge

  • Resolve merge risk (P1) - An existing large backup can now fail after 30 minutes without a new minimum remaining-page count, even while replaying pages toward completion; recovery may require stopping writes.
  • Resolve merge risk (P1) - During an in-place update, a retained parent and replaced worker can disagree on the strict failure envelope and lose the specific diagnostic; the contributor explicitly identifies this same-build protocol tradeoff.
  • Resolve merge risk (P1) - Native callback-cancellation implementation and the omitted body remainder could not be independently inspected because network retrieval failed.
  • Complete next step (P2) - Have the backup owner accept or revise the 30-minute no-net-progress policy and record the large-copy and same-build worker tradeoffs.
  • Resolve maintainer decision - Resolve the maintainer decision shown above before merge.
  • Add data-model compatibility proof - Confirm migration or upgrade compatibility proof before merge.
Agent review details

Security

None.

PR surface

Source +110, Tests +504. Total +614 across 9 files.

View PR surface stats
Area Files Added Removed Net
Source 5 177 67 +110
Tests 4 505 1 +504
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 9 682 68 +614

Review metrics

Metric Value Why it matters
Production versus tests Production net +110; tests net +504 Production growth adds a shared progress monitor and typed worker handoff after removing duplicate copy lifecycles.
Failure-policy surface 2 internal bounds added; 0 configuration options added The elapsed-progress and plateau limits affect both online-copy callers without introducing operator settings.

Stored data model

Persistent data-model change detected: database schema: src/infra/sqlite-readonly-location.ts, serialized state: src/infra/sqlite-readonly-location.worker.ts, unknown-data-model-change: src/infra/sqlite-online-backup.ts, unknown-data-model-change: src/infra/sqlite-readonly-location.ts, unknown-data-model-change: src/infra/sqlite-snapshot.ts. Confirm migration or upgrade compatibility proof before merge.

Merge-risk options

Maintainer options:

  1. Accept the bounded-copy tradeoff (recommended)
    Record owner acceptance that slow replaying copies may require quiesced writes and that worker error transport assumes one build.
  2. Resolve large-copy policy first
    Pause landing if rejecting converging copies is unacceptable, then specify the intended behavior before changing the algorithm.

Technical review

Best possible solution:

Keep one shared copy owner with an accepted stall policy, pinned validation, and typed recovery guidance; the new monitor and worker handoff justify growth beyond the removed duplicate lifecycle code.

Do we have a high-confidence way to reproduce the issue?

Unclear for a deterministic current-main reproduction: the contributor records an intermittent WAL commit-storm hang, while this read-only review confirms unbounded calls without rerunning that scenario.

Is this the best way to solve the issue?

Yes as a bounded mitigation at the shared owner: it consolidates lifecycle handling and preserves existing safeguards. A whole-worker deadline or restart-count limit would also penalize progressing copies; the elapsed cutoff still needs explicit acceptance.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 336b75a11d13.

Labels

Label changes:

  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): Recorded built-CLI runs exercise the shared snapshot path against a real WAL database, producing archives with concurrent and quiesced writes and no orphan worker in the contended case. They establish after-fix operation, not end-to-end expiry of the 30-minute limit; boundary tests supplement that coverage, and omitted body text remains unknown.
  • remove rating: 🦐 gold shrimp: Current PR rating is rating: 🐚 platinum hermit, so this older rating label is no longer current.
  • remove merge-risk: 🚨 availability: Current PR review merge-risk labels are merge-risk: 🚨 compatibility.
  • remove status: ⏳ waiting on author: Current PR status label is status: 👀 ready for maintainer look.

Label justifications:

  • P1: The linked report establishes a broken full-backup workflow with silent hangs and no archive.
  • merge-risk: 🚨 compatibility: The new default cutoff can reject previously completing slow copies, and worker error transport assumes matching builds.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): Recorded built-CLI runs exercise the shared snapshot path against a real WAL database, producing archives with concurrent and quiesced writes and no orphan worker in the contended case. They establish after-fix operation, not end-to-end expiry of the 30-minute limit; boundary tests supplement that coverage, and omitted body text remains unknown.
  • proof: sufficient: Contributor real behavior proof is sufficient. Recorded built-CLI runs exercise the shared snapshot path against a real WAL database, producing archives with concurrent and quiesced writes and no orphan worker in the contended case. They establish after-fix operation, not end-to-end expiry of the 30-minute limit; boundary tests supplement that coverage, and omitted body text remains unknown.

Evidence

What I checked:

  • Introduced scope and merge result: The local introduced delta matches the nine supplied paths. The test-merge raw parents are the pinned base followed by the exact PR head; its comparison against that base contains the same paths, excluding unrelated endpoint drift. (d456ccdad849)
  • Still necessary on main: Fetched main and the pinned PR base still await sqlite.backup without progress options. The read-only preparation path likewise remains unbounded at src/infra/sqlite-readonly-location.ts:445. (src/infra/sqlite-snapshot.ts:662, 336b75a11d13)
  • Latest release retains the unbounded call: The supplied v2026.9.1 release commit also calls sqlite.backup without progress options; this change is not established as shipped. (src/infra/sqlite-snapshot.ts:662, ad6fe23aecb9)
  • Shared owner and intentional cutoff: Both former online-copy implementations now use one owner. It preserves transaction setup and final cleanup, checks monotonic elapsed time since the lowest remaining-page count, and separately bounds consecutive non-advancing callbacks. (src/infra/sqlite-online-backup.ts:71, d82ca04eb974)
  • Caller and sibling coverage: Archive creation reaches the snapshot planner through src/infra/backup-create.ts:398. SQLite and Git backups share src/snapshot/openclaw-snapshot-copy.ts:58; async state inspection and ownership admission also reach read-only preparation. The synchronous raw-copy strategy is unchanged. (src/snapshot/openclaw-snapshot-copy.ts:58, d82ca04eb974)
  • Prior finding resolved: The worker diagnostic helper now expects the tagged error envelope while retaining the existing message and sensitive-field cases. The monotonic clock and typed worker-cause repairs are also present. The earlier reviewed SHA was unavailable for a direct comparison; no late-finding attribution is made. (src/infra/sqlite-readonly-location.worker.test.ts:33, d82ca04eb974)

Likely related people:

  • steipete: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • obviyus: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • Vincent Koc: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Record the backup owner's accepted no-net-progress policy and large-copy tradeoff.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (7 earlier review cycles)
  • reviewed 2026-09-03T20:32:16.764Z sha 25ed939a01cbc1a9a5b2b18a5a7d5ef74453d3ba :: needs real behavior proof before merge. :: none
  • reviewed 2026-09-03T21:58:11.797Z sha 25ed939a01cbc1a9a5b2b18a5a7d5ef74453d3ba :: needs real behavior proof before merge. :: none
  • reviewed 2026-09-03T22:42:06.540Z sha 1f0f073ba84c2d7f3696b14f161ac465d00f87c4 :: blocked before merge. :: [P2] Use a monotonic clock for the no-progress deadline
  • reviewed 2026-09-03T23:19:11.924Z sha ac1e9a519a3f32d47199482bfa21f6f726838b1c :: blocked before merge. :: [P2] Use a monotonic clock for the no-progress deadline
  • reviewed 2026-09-04T15:49:13.288Z sha c71e1d05a8741a498bb1f4530f88b67e351390af :: blocked before merge. :: [P2] Preserve contention classification across the snapshot worker
  • reviewed 2026-09-05T04:05:45.497Z sha afac78220d27a48e2b0161c3196b30cd47da45cb :: blocked before merge. :: none
  • reviewed 2026-09-05T04:55:28.215Z sha ebd2fa8e877658dccf13190724f45d99621f9e72 :: blocked before merge. :: [P2] Update worker diagnostic assertions for the tagged failure result

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Sep 3, 2026
@shojikumaru
shojikumaru marked this pull request as ready for review September 3, 2026 22:53
@shojikumaru

Copy link
Copy Markdown
Contributor Author

Thanks — the "real behavior proof" gap was the right call, and it changed the PR. The body is updated
rather than only this comment; summarising what moved.

Post-fix proof through the actual CLI, built from this head (pnpm build) and run in a node:24
container against a 145 MB WAL state database created by openclaw doctor, with a separate process
committing continuously:

CASE 1: openclaw backup create WITH a concurrent writer
  exit=0 after 66s
  Created /tmp/out/2026-09-03T22-19-44.884+00-00-openclaw-backup.tar.gz (4,512,378 bytes)
  orphan snapshot workers: (none)

CASE 2: same command, writes quiesced
  exit=0 after 456s
  Created /tmp/out/2026-09-03T22-21-44.375+00-00-openclaw-backup.tar.gz (64,869,733 bytes)

On the reported version this hung with zero output past 180 s and left an orphan
sqlite-readonly-location.worker. Here it finishes while the writer is still committing, produces a
real archive, and leaves no orphan.

Being straight about what that run does and does not show: case 1 completed rather than tripping
the new bound, so it proves the hang is gone and the healthy path works under contention — it does not
exercise the bounded-failure message end-to-end through the CLI, because forcing that means driving a
30-minute deadline. That path is covered by owner-boundary tests. Case 1 also printed
[sqlite/transaction] SQLite transaction lock wait failed and Warning: the backup outcome could not be recorded: database is locked while still producing the archive; that is a different contended-write
path recording the outcome, not the snapshot, and this PR does not touch it.

On the data-model item: there is no schema change, no schema-version bump, no new table, column,
index, or persisted shape. The only writes are to the destination copy the backup itself creates.
docs/reference/database-schemas.md review-checkpoint criteria are not engaged.

On the main-history item: that one was a DNS failure on the review side, so I cannot close it from
here — happy to answer anything specific about duplication against current main.

Rank-up move applied: redacted terminal output from this head for both the contended and quiesced
scenarios is in the body.

Two things I have deliberately left open for maintainer judgement rather than deciding myself, both in
the body: the 30-minute threshold is a product decision, and a later review measured that stating it in
absolute milliseconds re-introduces a size dependence — at the reported database size it may be too
small. The suggested fix is to derive it from observed pass cost and keep 30 minutes as a ceiling. I did
not ship that because it changes the policy surface.

Human + AI disclosure: reproduction, implementation and review were produced by me working with AI
coding agents across three independent read-only review rounds. Every number above is from the runs
shown.

@shojikumaru
shojikumaru force-pushed the fix/134215-backup-create-snapshot-deadline branch from 1f0f073 to ac1e9a5 Compare September 3, 2026 23:05
@shojikumaru

Copy link
Copy Markdown
Contributor Author

Follow-up on the remaining items. Head is now c71e1d05a8; the body is updated, this is the summary.

Monotonic clock (P2) — done. The no-progress deadline is armed, compared and re-armed from
performance.now() (same precedent as src/cli/daemon-cli/restart-health.ts:385). New regression
holds the no-progress deadline when the wall clock is corrected backward arms the deadline, steps the
system clock back ten minutes, feeds one-minute livelock cycles, and pins the abort at cycle 31. Against
the previous head it fails with expected 41 to be 31 — the deadline had been postponed by exactly the
rollback. Both legs were run on macOS and in a Linux node:24 container:

previous owner (Date.now):    1 failed | 9 passed   AssertionError: expected 41 to be 31
this head (performance.now):  10 passed;  Linux, 4 adjacent suites: 92 passed | 2 skipped

node:sqlite progress contract (P1) — rechecked upstream. The Node v26.x reference for
sqlite.backup() is quoted in the body: progress is called after each step with
{ remainingPages, totalPages }, rate defaults to 100 pages, and "mutations from other connections
will cause the backup process to restart"
. I also drove the real API on Node v26.5.0 / SQLite 3.53.3
with no OpenClaw code: a foreign commit after step 2 sends remainingPages from 12 back to 15, and a
throw from progress rejects the promise with that same error after one call. The docs do not promise
the throw-rejects part; the existing unit test pins it on every CI run so a Node change would surface
there.

Data-model compatibility — proof added. No DDL, no schema-version bump, no new table/column/index;
the flag matched PRAGMA schema_version / PRAGMA trusted_schema reads moving between files. A copy
made through the owner from a WAL source with two STRICT tables, an index, a view, a trigger,
user_version = 41 and 500 rows has byte-identical sqlite_master, integrity_check = ok,
user_version preserved, and leaves the source untouched.

30-minute policy (P1) — yours to set, not mine. Everything measured is in the body: elapsed time
since the last new all-time-low is the one axis that separates finishing from stalled copies, and an
absolute value re-introduces a size dependence at the reported database size. If you want the
scale-aware form (budget derived from observed pass cost inside the callback, 30 minutes kept as the
ceiling), say so and I will ship it as a follow-up commit on this branch; if you prefer the fixed
ceiling, or a shorter one, that is a one-line change and I will make it. I have not picked either
because it changes the policy surface.

Human + AI disclosure: this delta was produced by me working with AI coding agents and passed three
further independent read-only review seats before pushing. Every number above is from the runs shown.

@shojikumaru shojikumaru closed this Sep 4, 2026
@shojikumaru shojikumaru reopened this Sep 4, 2026
@shojikumaru

Copy link
Copy Markdown
Contributor Author

Head is now afac78220d; the body is updated, this is the summary of what moved.

Preserve contention classification across the snapshot worker (P2) — done. The isolated read-only
worker now reports a closed failure code (failure: "contention" | "error") next to its message, the
parent validates the closed shape and rebuilds the typed cause through one small owner factory (the
error class stays private), on both the execFile and spawnSync routes since they share the same
adopter. The backup boundary therefore sees isSqliteBackupContentionError(error) === true for a
rollback-journal source too and appends the --only-config remedy as it already did for the in-process
path.

Regression preserves online-backup contention across the isolated worker boundary drives the
public prepareSqliteReadOnlyLocation in a child process on a rollback-journal source. The only
fault injected is at the dependency boundary: a NODE_OPTIONS --import preload replaces
node:sqlite's backup with a wedged progress stream, so the real owner's plateau guard throws a real
contention error inside the real grandchild worker, and the child reports whether the typed cause
survived in the parent. Two legs, macOS and a Linux node:24 container:

previous head (message only):  AssertionError: expected false to be true
this head (typed cause):       macOS 4 suites 92 passed | 3 skipped;  Linux 4 suites 93 passed | 2 skipped

One accepted tradeoff, named so it is a decision rather than an oversight: the parent validates the
worker payload as a closed shape by key count, so during an in-place upgrade a long-running pre-delta
parent that spawns the post-delta worker file would report exited unsuccessfully instead of the
child's message for that one window. The worker is spawned from the same build in every real path,
so I did not version the protocol.

Production delta for this commit is +11 net (the worker lost its private formatWorkerError, the
validator became a closed-shape check, one factory was exported). Cumulative production is now
+101 — the extra lines over the previous head are the code and the failure tag that make this
path recoverable, which is the availability contract this PR exists for; I have not found a way to
express it with fewer lines that does not reintroduce the message-only bridge.

Still open for you: the 30-minute no-net-progress ceiling. Both options from my previous comment
stand — a scale-aware budget derived from observed pass cost with 30 minutes as the ceiling, or a fixed
(or shorter) constant — and I will ship whichever you pick as a follow-up commit on this branch.

Human + AI disclosure: this delta was produced by me working with AI coding agents and passed three
further independent read-only review seats before pushing. Every number above is from the runs shown.

@shojikumaru shojikumaru closed this Sep 5, 2026
@shojikumaru shojikumaru reopened this Sep 5, 2026
shojikumaru and others added 4 commits September 5, 2026 11:36
Node restarts a stepped SQLite backup whenever another connection commits, so a
source under continuous write never converges: measured 17406 backup steps with
17404 restarts and remainingPages never falling below 1558759 of 4467250.
Nothing bounded that livelock, and nothing above it was bounded either, so
`openclaw backup create` waited forever and printed nothing while leaving an
orphan read-only worker process behind.

The duplicated "consistent online copy of a live source" policy in
sqlite-snapshot.ts and sqlite-readonly-location.ts now lives in one owner that
pins the read transaction, runs the caller's validation inside it, and fails the
backup once it has taken many consecutive steps without reaching a new minimum
remaining-page count. Progress, not wall clock, decides, so a slow backup that
is still copying is never interrupted and the false-timeout regressions fixed in
 openclaw#122213 and openclaw#122235 do not return. The failure names the concurrent writer as
the cause and points at the paths that still work.

The worker launches also gain a generous timeout and kill signal so a wedged
child can no longer strand the CLI with no output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Independent review of the first commit found the bound itself could abort a
backup that was still copying. Its counter only reset when a step reached a new
all-time minimum, but a restart makes Node replay remainingPages from the top,
so every step of the recovering pass looked like non-progress until it beat the
old minimum. At the default hundred pages per step that recovery costs
pagesAlreadyCopied/100 steps, measured linear at 43, 169 and 507 steps for
5279, 21108 and 63319 pages: past roughly four gigabytes a single well-timed
commit killed the whole snapshot, and blamed a writer that may have stopped.

The bound now watches two things that are both independent of database size.
Restarts since the copy last reached a new all-time minimum count wasted
passes, and steps since remainingPages last fell catch a copy that stopped
moving. A backup that is advancing lowers remainingPages every step, so it
pins both counters at zero however large it is and however late a restart
lands, while every non-converging shape still fails quickly.

The worker launches lose the wall-clock timeout added earlier. It was a fixed
deadline over work of unbounded size, the defect openclaw#122213 and openclaw#122235 removed,
and it asserted contention as the cause without establishing it. With the
backup itself bounded the child is already guaranteed to exit.

The failure text no longer points at `openclaw backup sqlite create`, which
requires a repository argument and reaches this same owner through
createOpenClawSnapshotCopy, so it would fail identically. It now names
quiescing writes, and `openclaw backup create --only-config` for configuration.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
node:sqlite restarts its stepped backup whenever another connection
commits, so a continuously written source never converges and `openclaw
backup create` hung forever. The previous commit bounded that with a
restart count and a step count. Measuring the counters against real WAL
sources under a concurrent writer shows a restart count cannot separate
the two populations: backups that completed peaked at 2 to 38 restarts
without a new all-time low here and at 207 to 257 on a slower machine,
while stalled runs reached 803 to 2,001, and the requirement grows with
database size, so 1,000 says nothing at the reported 4,467,250 pages.
Discarded work behaves the same way - completing runs threw away 9.7 to
107 full copies, stalled ones 1,148 - so bounding wasted pages instead
would false-abort the same runs.

Elapsed time since the copy last reached a new all-time low does
separate them, and it is the cost the operator actually pays: completing
runs never went more than 1.1s without a new low at 21k, 63k or 211k
pages, while stalled ones sat 190s and counting, so unlike the counters
this quantity barely moves with size. Thirty minutes is a product
decision on top of that: it is the smallest value that cannot abort a
healthy copy of the reported database part-way through a pass, since one
uncontended full copy of it measured 733.8s. A smaller number fails
faster on the reported livelock at the risk of aborting a slow but
converging copy of a very large database. The plateau guard for a stream
that copies nothing at all stays as it was. Measurements:
.artifacts/delta5-measurements.md.

The failure text also stops naming a backup command. This owner is
reached from read-only state-database opens and ownership checks, so it
now reports cause plus a generic remedy and exports a predicate; the
backup snapshot boundary adds the `--only-config` suggestion when it
recognises that typed failure, and a sibling test asserts an unrelated
failure does not get it.

Production LOC vs d2a616b: +140/-52 (net +88); tests +406/-0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Date.now() can step backward under an NTP or operator correction, and the
online backup deadline read from it would wait the whole correction on top
of its budget. Read performance.now() instead, as the restart health wait
already does, and cover a ten-minute backward step.
@clawsweeper clawsweeper Bot added merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Sep 5, 2026
@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Sep 5, 2026
@shojikumaru
shojikumaru force-pushed the fix/134215-backup-create-snapshot-deadline branch from afac782 to ebd2fa8 Compare September 5, 2026 04:46
@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Sep 5, 2026
The read-only snapshot worker serialized only an error message, so a
contended online backup that ran in the child came back to the parent as a
plain error and the backup command lost its --only-config remedy. The worker
now reports a closed failure code, and the parent rebuilds the typed cause.
@shojikumaru
shojikumaru force-pushed the fix/134215-backup-create-snapshot-deadline branch from ebd2fa8 to d82ca04 Compare September 5, 2026 05:01
@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. merge-risk: 🚨 availability 🚨 May cause crashes, hangs, restart loops, stalls, or process outages. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Sep 5, 2026
@fuller-stack-dev fuller-stack-dev self-assigned this Sep 10, 2026
@fuller-stack-dev

Copy link
Copy Markdown
Member

Independent follow-up on head d82ca04eb974d889ee0ccd582287a76eac62d392, against main 1baeb74f01730d183e3500163f741ca235e2d6fc. Thanks @shojikumaru for the investigation and for recording the limits of the earlier proof.

I found two gaps that need resolving before preparing this PR.

  1. The current production path does not reproduce the proposed restart mechanism in the controlled test. createVerifiedSqliteSnapshot pins its read transaction with BEGIN and a schema read before starting backup. That was introduced by #113708, and both production callers in this PR retain it. On Node 24.20.0 / SQLite 3.53.4, an 8 MiB WAL fixture with a real foreign commit after every backup step produced:

    Probe Callbacks Restarts Outcome
    Current-main createVerifiedSqliteSnapshot, second connection commits after every step 20 0 Completed, 305 ms
    Native backup with the same read pin, separate writer process after every step 20 0 Completed
    Native negative control without the read pin, same separate writer 80 79 Still repeating at the safety cap

    A healthy slow variant of the current-main API probe also completed in 41.249 seconds with 20 real advancing steps and concurrent writes. Source and snapshot integrity checks passed; the successful control preserved user_version and counter data. This does not disprove the original report, but it means the new guard's premise still needs an executable failing case through the actual pinned production path. The mocked progress streams do not establish that case.

  2. The callback guard does not guarantee worker exit, and main now has a different lifecycle owner. The guard runs only after a step returns. It cannot expire while callbacks have stopped. Main's sqlite-readonly-worker.ts now owns size-based deadlines, SIGKILL cancellation, close/join settlement and parent-owned staging cleanup. The relevant changes landed in #138986, #141412, #141918 and #142179. This PR currently conflicts with main.

I ran the existing current-main read-only location, cancellation and worker suites: 87 passed, 1 Linux-only test skipped on macOS. I also stopped actual snapshot child processes with SIGSTOP, then exercised cancellation and the existing timeout boundary. Both settled only after SIGKILL/close, removed the owned staging root, preserved source bytes and released the writer lock. The timeout probe shortened only the child-process timeout at the test boundary, from the recorded 31,000 ms production value to 250 ms.

The nonduplicative continuation is on this same PR: first retain the read pin in a current-main failing reproduction, then carry only the necessary repair into the existing lifecycle owner. Preserve its cancellation/cleanup guarantees and prove healthy slow progress and a genuinely non-progressing child separately. The 30-minute callback constant alone cannot establish those guarantees.

These are current-main diagnostic receipts, not passing validation of the contributor head. No contributor code was executed on the host, and no contributor-branch changes, replacement PR or merge were made in this review.

@shojikumaru

Copy link
Copy Markdown
Contributor Author

Thank you for the follow-up, and for running the pinned path yourself — you were right, and I want to set the record straight before closing.

What I re-measured (current main 1baeb74f01, macOS Node 26.5.0 / SQLite 3.53.3 and a node:24 Linux container, Node 24.21.0 / SQLite 3.53.4):

  1. The exact production sequence — readOnly open, busy_timeout, trusted_schema OFF, BEGIN, PRAGMA schema_version, then node:sqlite backup() — against a 50 MB WAL source with a separate-process commit storm (≈7,000 commits/s on the Mac):
    • pinned (production): completed in 129 ms, 177 steps, 0 restarts (Linux: 110 ms, 0 restarts)
    • unpinned control: 446,637 steps, 374,429 restarts, remainingPages never below its start, still running at the 60 s budget
      The livelock I described in this PR is real, but it only exists on the unpinned path. The 17,406-step trace in the description came from an instrumented probe that did not carry the BEGIN pin; I should have checked that against sqlite-snapshot.ts before building on it. My apologies for the time that cost you.
  2. The real createVerifiedSqliteSnapshot on current main under the same storm: WAL 200 MB, 5 runs, 3.1–11.4 s, all complete, integrity ok, while the WAL grew from 4 MB to 194 MB behind the pinned reader. Rollback-journal 14 MB (the reporter's shape) and 200 MB, 3 runs each, 1.0–3.7 s. A writer holding a 45 s BEGIN IMMEDIATE transaction in rollback mode: ~10 s, complete. No run hung, none went through a restart.
  3. On the reporter's side: v2026.8.1 already contains the pin (fix(sqlite): prevent snapshot starvation under writes #113708 landed 2026-07-25), so the original hang was not a restart storm either. The one thing that build lacked was any bound on the read-only worker child — execFile with no timeout — and that child was the orphan in the report. Current main bounds it with a 30 s SIGKILL in sqlite-readonly-worker.ts (fix: keep database preflight responsive during gateway startup #138986), so the worst case I can construct today is a bounded, named failure, not a hang.

So: I cannot produce a failing case through the pinned production path, and the lifecycle owner you pointed at already carries the guarantees this PR was reaching for. The right outcome is to close this PR rather than carry a repair whose premise does not hold. If a reproduction of the 8.1 orphan ever turns up that survives the 30 s bound, I would be glad to take it, but it should start from that evidence and land in sqlite-readonly-worker.ts, not here.

Probe scripts and raw logs (pinned/unpinned contrast, the real-function matrix) are kept and I can attach them if useful for #134215.

Investigation by Sho (human) + Alpha (AI agent).

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

Labels

merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P1 High-priority user-facing bug, regression, or broken workflow. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: L status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: openclaw backup create (full archive) hangs / deadlocks while the Gateway is running on 2026.8.1

2 participants