Repository: nodejs/node
Pull request: sqlite: add timeout options to DatabaseSync

Closes #57597

This PR adds the `timeout` option to the DatabaseSync constructor.

Changed files:
filename: doc/api/sqlite.md
patch: @@ -78,6 +78,9 @@ console.log(query.all());
 <!-- YAML
 added: v22.5.0
 changes:
+  - version: REPLACEME
+    pr-url: https://github.com/nodejs/node/pull/57752
+    description: Add `timeout` option.
   - version: v23.10.0
     pr-url: https://github.com/nodejs/node/pull/56991
     description: The `path` argument now supports Buffer and URL objects.
@@ -116,6 +119,9 @@ added: v22.5.0
     and the `loadExtension()` method are enabled.
     You can call `enableLoadExtension(false)` later to disable this feature.
     **Default:** `false`.
+  * `timeout` {number} The [busy timeout][] in milliseconds. This is the maximum amount of
+    time that SQLite will wait for a database lock to be released before
+    returning an error. **Default:** `0`.
 
 Constructs a new `DatabaseSync` instance.
 
@@ -843,6 +849,7 @@ resolution handler passed to [`database.applyChangeset()`][]. See also
 [`sqlite3session_create()`]: https://www.sqlite.org/session/sqlite3session_create.html
 [`sqlite3session_delete()`]: https://www.sqlite.org/session/sqlite3session_delete.html
 [`sqlite3session_patchset()`]: https://www.sqlite.org/session/sqlite3session_patchset.html
+[busy timeout]: https://sqlite.org/c3ref/busy_timeout.html
 [connection]: https://www.sqlite.org/c3ref/sqlite3.html
 [data types]: https://www.sqlite.org/datatype3.html
 [double-quoted string literals]: https://www.sqlite.org/quirks.html#dblquote

filename: src/node_sqlite.cc
patch: @@ -732,6 +732,8 @@ bool DatabaseSync::Open() {
   CHECK_ERROR_OR_THROW(env()->isolate(), this, r, SQLITE_OK, false);
   CHECK_EQ(foreign_keys_enabled, open_config_.get_enable_foreign_keys());
 
+  sqlite3_busy_timeout(connection_, open_config_.get_timeout());
+
   if (allow_load_extension_) {
     if (env()->permission()->enabled()) [[unlikely]] {
       THROW_ERR_LOAD_SQLITE_EXTENSION(env(),
@@ -942,6 +944,23 @@ void DatabaseSync::New(const FunctionCallbackInfo<Value>& args) {
       }
       allow_load_extension = allow_extension_v.As<Boolean>()->Value();
     }
+
+    Local<Value> timeout_v;
+    if (!options->Get(env->context(), env->timeout_string())
+             .ToLocal(&timeout_v)) {
+      return;
+    }
+
+    if (!timeout_v->IsUndefined()) {
+      if (!timeout_v->IsInt32()) {
+        THROW_ERR_INVALID_ARG_TYPE(
+            env->isolate(),
+            "The \"options.timeout\" argument must be an integer.");
+        return;
+      }
+
+      open_config.set_timeout(timeout_v.As<Int32>()->Value());
+    }
   }
 
   new DatabaseSync(

filename: src/node_sqlite.h
patch: @@ -35,11 +35,16 @@ class DatabaseOpenConfiguration {
 
   inline void set_enable_dqs(bool flag) { enable_dqs_ = flag; }
 
+  inline void set_timeout(int timeout) { timeout_ = timeout; }
+
+  inline int get_timeout() { return timeout_; }
+
  private:
   std::string location_;
   bool read_only_ = false;
   bool enable_foreign_keys_ = true;
   bool enable_dqs_ = false;
+  int timeout_ = 0;
 };
 
 class StatementSync;

filename: test/parallel/test-sqlite-database-sync.js
patch: @@ -77,6 +77,15 @@ suite('DatabaseSync() constructor', () => {
     });
   });
 
+  test('throws if options.timeout is provided but is not an integer', (t) => {
+    t.assert.throws(() => {
+      new DatabaseSync('foo', { timeout: .99 });
+    }, {
+      code: 'ERR_INVALID_ARG_TYPE',
+      message: /The "options\.timeout" argument must be an integer/,
+    });
+  });
+
   test('is not read-only by default', (t) => {
     const dbPath = nextDb();
     const db = new DatabaseSync(dbPath);

filename: test/parallel/test-sqlite-timeout.js
patch: @@ -0,0 +1,72 @@
+'use strict';
+require('../common');
+const tmpdir = require('../common/tmpdir');
+const { join } = require('node:path');
+const { DatabaseSync } = require('node:sqlite');
+const { test } = require('node:test');
+const { once } = require('node:events');
+const { Worker } = require('node:worker_threads');
+let cnt = 0;
+
+tmpdir.refresh();
+
+function nextDb() {
+  return join(tmpdir.path, `database-${cnt++}.db`);
+}
+
+test('waits to acquire lock', async (t) => {
+  const DB_PATH = nextDb();
+  const conn = new DatabaseSync(DB_PATH);
+  t.after(() => {
+    try {
+      conn.close();
+    } catch {
+      // Ignore.
+    }
+  });
+
+  conn.exec('CREATE TABLE IF NOT EXISTS data (value TEXT)');
+  conn.exec('BEGIN EXCLUSIVE;');
+  const worker = new Worker(`
+    'use strict';
+    const { DatabaseSync } = require('node:sqlite');
+    const { workerData } = require('node:worker_threads');
+    const conn = new DatabaseSync(workerData.database, { timeout: 30000 });
+    conn.exec('SELECT * FROM data');
+    conn.close();
+  `, {
+    eval: true,
+    workerData: {
+      database: DB_PATH,
+    }
+  });
+  await once(worker, 'online');
+  conn.exec('COMMIT;');
+  await once(worker, 'exit');
+});
+
+test('throws if the lock cannot be acquired before timeout', (t) => {
+  const DB_PATH = nextDb();
+  const conn1 = new DatabaseSync(DB_PATH);
+  t.after(() => {
+    try {
+      conn1.close();
+    } catch {
+      // Ignore.
+    }
+  });
+  const conn2 = new DatabaseSync(DB_PATH, { timeout: 1 });
+  t.after(() => {
+    try {
+      conn2.close();
+    } catch {
+      // Ignore.
+    }
+  });
+
+  conn1.exec('CREATE TABLE IF NOT EXISTS data (value TEXT)');
+  conn1.exec('PRAGMA locking_mode = EXCLUSIVE; BEGIN EXCLUSIVE;');
+  t.assert.throws(() => {
+    conn2.exec('SELECT * FROM data');
+  }, /database is locked/);
+});