sqlite: backup() rejects with "not an error" when its first step returns SQLITE_BUSY or SQLITE_LOCKED #66760
Description
Version
v22.22.1, v24.21.0
Platform
Darwin 27.0.0 arm64
Subsystem
sqlite
What steps will reproduce the bug?
Run this script as node repro.mjs, then as node repro.mjs --no-overlap:
import { DatabaseSync, backup } from "node:sqlite";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-backup-"));
const db = new DatabaseSync(path.join(dir, "src.db"));
db.exec("PRAGMA journal_mode = WAL; CREATE TABLE t (x BLOB)");
const insert = db.prepare("INSERT INTO t VALUES (randomblob(4000))");
for (let i = 0; i < 300; i++) insert.run();
const overlap = process.argv[2] !== "--no-overlap";
const results = {};
for (let i = 0; i < 20; i++) {
let job;
if (overlap) {
// Keep a write transaction open briefly so the backup worker can attempt its first step.
db.exec("BEGIN IMMEDIATE");
insert.run();
job = backup(db, path.join(dir, `copy-${i}.db`));
const until = Date.now() + 30;
while (Date.now() < until);
db.exec("COMMIT");
} else {
job = backup(db, path.join(dir, `copy-${i}.db`));
}
try {
await job;
results.ok = (results.ok ?? 0) + 1;
} catch (e) {
const key = `${e.code} errcode=${e.errcode} message=${JSON.stringify(e.message)}`;
results[key] = (results[key] ?? 0) + 1;
}
}
console.log(process.version, overlap ? "overlap" : "no overlap", results);
db.close();
fs.rmSync(dir, { recursive: true, force: true });How often does it reproduce? Is there a required condition?
In my tests on each version above, all 20 backups failed with overlap enabled, and all 20 succeeded with --no-overlap. The reproducer holds a write transaction open on the same source connection for 30 ms after starting the backup. Reproduction depends on the worker attempting its first step during that interval.
What is the expected behavior? Why is that the expected behavior?
The backup should reschedule a first step that returns SQLITE_BUSY or SQLITE_LOCKED, just as it reschedules later steps when pages remain, allowing it to finish after the write transaction commits. SQLITE_BUSY and SQLITE_LOCKED from sqlite3_backup_step() are retryable according to the SQLite documentation. If Node does reject, the error should carry the real result code (5 or 6), not 0.
What do you see instead?
v22.22.1 overlap { 'ERR_SQLITE_ERROR errcode=0 message="not an error"': 20 }
v22.22.1 no overlap { ok: 20 }
v24.21.0 overlap { 'ERR_SQLITE_ERROR errcode=0 message="not an error"': 20 }
v24.21.0 no overlap { ok: 20 }
The promise rejects with ERR_SQLITE_ERROR, errcode: 0 and the message "not an error".
Additional information
The cause appears to be in BackupJob::AfterThreadPoolWork in src/node_sqlite.cc (the same logic is on main):
- A step result of
SQLITE_BUSYorSQLITE_LOCKEDis not treated as an error at first. - The job is rescheduled only when
sqlite3_backup_remaining()is non-zero. The count starts at 0 and can remain 0 when the first step returnsSQLITE_BUSYorSQLITE_LOCKEDbefore updating it. - So when the first step returns
SQLITE_BUSYorSQLITE_LOCKED, the job is not rescheduled. Because the status is notSQLITE_DONE,HandleBackupError(resolver)runs. It builds the error from the destination connection, which has no error, so the result is errcode 0, "not an error".
A possible fix: reschedule the work whenever the step returned SQLITE_BUSY or SQLITE_LOCKED, whatever the remaining page count. If the final backup_status_ != SQLITE_DONE rejection path is retained, pass backup_status_ to HandleBackupError there, as the earlier fatal-error branch already does.
A possible workaround for this failure is to retry the backup after the overlapping write transaction ends, using a bounded retry policy and checking for both code === 'ERR_SQLITE_ERROR' and errcode === 0.
Activity