current commit
261025.19 · sqlite: retry busy or locked backup steps
// Flags: --expose-gc
import {
  isWindows,
  platformTimeout,
  skipIfSQLiteMissing,
  spawnPromisified,
} from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import tmpdir from '../common/tmpdir.js';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import { writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
skipIfSQLiteMissing();
const { backup, Database } = await import('node:sqlite');

const isRoot = !isWindows && process.getuid() === 0;

let cnt = 0;

tmpdir.refresh();

function nextDb() {
  return join(tmpdir.path, `database-${cnt++}.db`);
}

function makeSourceDb(dbPath = ':memory:') {
  const database = new Database(dbPath);

  database.exec(`
    CREATE TABLE data(
      key INTEGER PRIMARY KEY,
      value TEXT
    ) STRICT
  `);

  const insert = database.prepare('INSERT INTO data (key, value) VALUES (?, ?)');

  for (let i = 1; i <= 2; i++) {
    insert.run(i, `value-${i}`);
  }

  return database;
}

describe('backup()', () => {
  test('throws if the source database is not provided', (t) => {
    t.assert.throws(() => {
      backup();
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "sourceDb" argument must be an instance of Database.'
    });
  });

  test('throws if the source is not an instance of Database', (t) => {
    const database = makeSourceDb();
    for (const source of [
      {},
      database.prepare('SELECT 1'),
      database.createSession(),
    ]) {
      t.assert.throws(() => {
        backup(source, 'out.db');
      }, {
        code: 'ERR_INVALID_ARG_TYPE',
        message: 'The "sourceDb" argument must be an instance of Database.'
      });
    }
  });

  test('throws if path is not a string, URL, or Buffer', (t) => {
    const database = makeSourceDb();

    t.assert.throws(() => {
      backup(database);
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
    });

    t.assert.throws(() => {
      backup(database, {});
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
    });
  });

  test('throws if the database path contains null bytes', (t) => {
    const database = makeSourceDb();

    t.assert.throws(() => {
      backup(database, Buffer.from('l\0cation'));
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
    });

    t.assert.throws(() => {
      backup(database, 'l\0cation');
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
    });
  });

  test('throws if options is not an object', (t) => {
    const database = makeSourceDb();

    t.assert.throws(() => {
      backup(database, 'hello.db', 'invalid');
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options" argument must be an object.'
    });
  });

  test('throws if any of provided options is invalid', (t) => {
    const database = makeSourceDb();

    t.assert.throws(() => {
      backup(database, 'hello.db', {
        source: 42
      });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options.source" argument must be a string.'
    });

    t.assert.throws(() => {
      backup(database, 'hello.db', {
        target: 42
      });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options.target" argument must be a string.'
    });

    t.assert.throws(() => {
      backup(database, 'hello.db', {
        rate: 'invalid'
      });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options.rate" argument must be an integer.'
    });

    for (const rate of [0, -1]) {
      t.assert.throws(() => {
        backup(database, 'hello.db', { rate });
      }, {
        code: 'ERR_OUT_OF_RANGE',
        message: 'The "options.rate" argument must be a positive integer.'
      });
    }

    t.assert.throws(() => {
      backup(database, 'hello.db', {
        progress: 'invalid'
      });
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: 'The "options.progress" argument must be a function.'
    });
  });
});

test('database backup', async (t) => {
  const progressFn = t.mock.fn();
  const database = makeSourceDb();
  const destDb = nextDb();

  await backup(database, destDb, {
    rate: 1,
    progress: progressFn,
  });

  const backupDb = new Database(destDb);
  const rows = backupDb.prepare('SELECT * FROM data').all();

  // The source database has two pages - using the default page size -,
  // so the progress function should be called once (the last call is not made since
  // the promise resolves)
  t.assert.strictEqual(progressFn.mock.calls.length, 1);
  t.assert.deepStrictEqual(progressFn.mock.calls[0].arguments, [{ totalPages: 2, remainingPages: 1 }]);
  t.assert.deepStrictEqual(rows, [
    { __proto__: null, key: 1, value: 'value-1' },
    { __proto__: null, key: 2, value: 'value-2' },
  ]);

  t.after(() => {
    database.close();
    backupDb.close();
  });
});

test('backup database using location as URL', async (t) => {
  const database = makeSourceDb();
  const destDb = pathToFileURL(nextDb());

  t.after(() => { database.close(); });

  await backup(database, destDb);

  const backupDb = new Database(destDb);

  t.after(() => { backupDb.close(); });

  const rows = backupDb.prepare('SELECT * FROM data').all();

  t.assert.deepStrictEqual(rows, [
    { __proto__: null, key: 1, value: 'value-1' },
    { __proto__: null, key: 2, value: 'value-2' },
  ]);
});

test('backup database using location as Buffer', async (t) => {
  const database = makeSourceDb();
  const destDb = Buffer.from(nextDb());

  t.after(() => { database.close(); });

  await backup(database, destDb);

  const backupDb = new Database(destDb);

  t.after(() => { backupDb.close(); });

  const rows = backupDb.prepare('SELECT * FROM data').all();

  t.assert.deepStrictEqual(rows, [
    { __proto__: null, key: 1, value: 'value-1' },
    { __proto__: null, key: 2, value: 'value-2' },
  ]);
});

test('database backup in a single call', async (t) => {
  const progressFn = t.mock.fn();
  const database = makeSourceDb();
  const destDb = nextDb();

  // Let rate to be default (100) to backup in a single call
  await backup(database, destDb, {
    progress: progressFn,
  });

  const backupDb = new Database(destDb);
  const rows = backupDb.prepare('SELECT * FROM data').all();

  t.assert.strictEqual(progressFn.mock.calls.length, 0);
  t.assert.deepStrictEqual(rows, [
    { __proto__: null, key: 1, value: 'value-1' },
    { __proto__: null, key: 2, value: 'value-2' },
  ]);

  t.after(() => {
    database.close();
    backupDb.close();
  });
});

test('throws exception when trying to start backup from a closed database', (t) => {
  t.assert.throws(() => {
    const database = new Database(':memory:');

    database.close();

    backup(database, 'backup.db');
  }, {
    code: 'ERR_INVALID_STATE',
    message: 'database is not open'
  });
});

test('throws if URL is not file: scheme', (t) => {
  const database = new Database(':memory:');

  t.after(() => { database.close(); });

  t.assert.throws(() => {
    backup(database, new URL('http://example.com/backup.db'));
  }, {
    code: 'ERR_INVALID_URL_SCHEME',
    message: 'The URL must be of scheme file:',
  });
});

test('throws if the URL-like path has an unparsable href', (t) => {
  const database = new Database(':memory:');

  t.after(() => { database.close(); });

  t.assert.throws(() => {
    backup(database, { href: 'not a url' });
  }, { code: 'ERR_INVALID_URL' });
});

test('database backup fails when dest file is not writable', { skip: isRoot }, async (t) => {
  const readonlyDestDb = nextDb();
  writeFileSync(readonlyDestDb, '', { mode: 0o444 });

  const database = makeSourceDb();

  await t.assert.rejects(async () => {
    await backup(database, readonlyDestDb);
  }, {
    code: 'ERR_SQLITE_ERROR',
    message: 'attempt to write a readonly database'
  });
});

test('backup fails when progress function throws', async (t) => {
  const database = makeSourceDb();
  const destDb = nextDb();

  const progressFn = t.mock.fn(() => {
    throw new Error('progress error');
  });

  await t.assert.rejects(async () => {
    await backup(database, destDb, {
      rate: 1,
      progress: progressFn,
    });
  }, {
    message: 'progress error'
  });
});

test('backup fails when source db is invalid', async (t) => {
  const database = makeSourceDb();
  const destDb = nextDb();

  await t.assert.rejects(async () => {
    await backup(database, destDb, {
      rate: 1,
      source: 'invalid',
    });
  }, {
    message: 'unknown database invalid'
  });
});

test('backup fails when path cannot be opened', async (t) => {
  const database = makeSourceDb();

  await t.assert.rejects(async () => {
    await backup(database, `${tmpdir.path}/invalid/backup.db`);
  }, {
    message: 'unable to open database file'
  });
});

test('backup waits for a write transaction on the source to end', async (t) => {
  const database = makeSourceDb();
  const destDb = nextDb();

  t.after(() => { database.close(); });

  database.exec('BEGIN IMMEDIATE');
  database.exec("INSERT INTO data (key, value) VALUES (3, 'value-3')");
  const p = backup(database, destDb);
  setTimeout(() => database.exec('COMMIT'), platformTimeout(100));

  await p;

  const backupDb = new Database(destDb);

  t.after(() => { backupDb.close(); });

  const rows = backupDb.prepare('SELECT * FROM data').all();

  t.assert.deepStrictEqual(rows, [
    { __proto__: null, key: 1, value: 'value-1' },
    { __proto__: null, key: 2, value: 'value-2' },
    { __proto__: null, key: 3, value: 'value-3' },
  ]);
});

test('backup has correct name and length', (t) => {
  t.assert.strictEqual(backup.name, 'backup');
  t.assert.strictEqual(backup.length, 2);
});

test('source database is kept alive while a backup is in flight', async (t) => {
  // Regression test: previously, BackupJob stored a raw Database* and the
  // source could be garbage-collected while the backup was still running,
  // leading to a use-after-free when BackupJob::Finalize() dereferenced the
  // stale pointer via source_->RemoveBackup(this).
  const destDb = nextDb();

  let database = makeSourceDb();
  // Insert enough rows to ensure the backup takes multiple steps.
  const insert = database.prepare('INSERT INTO data (key, value) VALUES (?, ?)');
  for (let i = 3; i <= 500; i++) {
    insert.run(i, 'A'.repeat(1024) + i);
  }

  const p = backup(database, destDb, {
    rate: 1,
    progress() {},
  });
  // Drop the last strong JS reference to the source database. With the bug,
  // the Database could be collected here and the in-flight backup would
  // later crash while accessing the freed source.
  database = null;

  // Nudge the GC aggressively, but the backup must keep the source alive
  // regardless. Without the fix, the source Database would be collected
  // and BackupJob::Finalize() would crash the process.
  for (let i = 0; i < 5; i++) {
    global.gc();
    await new Promise((resolve) => setImmediate(resolve));
  }

  const totalPages = await p;
  t.assert.ok(totalPages > 0);

  const backupDb = new Database(destDb);
  t.after(() => { backupDb.close(); });
  const rows = backupDb.prepare('SELECT COUNT(*) AS n FROM data').get();
  t.assert.strictEqual(rows.n, 500);
});

test('backup promise settles when the backup is the last active request', async (t) => {
  const { code, signal, stderr } = await spawnPromisified(process.execPath, [
    fixtures.path('sqlite', 'backup-last-request.mjs'),
    nextDb(),
  ]);

  t.assert.strictEqual(signal, null);
  t.assert.strictEqual(code, 0, stderr);
});