Skip to content

sqlite: backup() rejects with "not an error" when its first step returns SQLITE_BUSY or SQLITE_LOCKED #66760

Open

Description

@EminUZUN

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):

  1. A step result of SQLITE_BUSY or SQLITE_LOCKED is not treated as an error at first.
  2. 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 returns SQLITE_BUSY or SQLITE_LOCKED before updating it.
  3. So when the first step returns SQLITE_BUSY or SQLITE_LOCKED, the job is not rescheduled. Because the status is not SQLITE_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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions