Skip to content

Commit 2f3df21

Browse files
committed
src: add support for AbortSignal in backup method
This adds support for passing an AbortSignal to the database backup function. If the signal is aborted during the backup process, the operation is interrupted and a rejection is returned with an AbortError. The signal is optionally passed through the options object. Internally, the signal is stored and periodically checked between backup steps to respond quickly to abort requests. This improves integration with modern web APIs and aligns with how abortable operations are handled elsewhere in the Node.js ecosystem. Fixes: #58888
1 parent 9bc923a commit 2f3df21

File tree

4 files changed

+260
-4
lines changed

4 files changed

+260
-4
lines changed

doc/api/sqlite.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,8 @@ changes:
774774
* `rate` {number} Number of pages to be transmitted in each batch of the backup. **Default:** `100`.
775775
* `progress` {Function} Callback function that will be called with the number of pages copied and the total number of
776776
pages.
777+
* `signal` {AbortSignal} An optional AbortSignal that can be used to abort the backup operation. If the signal is
778+
aborted, the backup operation will be cancelled and the Promise will reject with an `AbortError`.
777779
* Returns: {Promise} A promise that resolves when the backup is completed and rejects if an error occurs.
778780

779781
This method makes a database backup. This method abstracts the [`sqlite3_backup_init()`][], [`sqlite3_backup_step()`][]
@@ -813,6 +815,40 @@ const totalPagesTransferred = await backup(sourceDb, 'backup.db', {
813815
console.log('Backup completed', totalPagesTransferred);
814816
```
815817

818+
### Aborting a backup
819+
820+
The backup operation can be cancelled using an `AbortSignal`:
821+
822+
```cjs
823+
const { backup, DatabaseSync } = require('node:sqlite');
824+
825+
(async () => {
826+
const sourceDb = new DatabaseSync('source.db');
827+
const controller = new AbortController();
828+
829+
// Cancel the backup after 5 seconds
830+
setTimeout(() => {
831+
controller.abort('Backup cancelled by user');
832+
}, 5000);
833+
834+
try {
835+
await backup(sourceDb, 'backup.db', {
836+
signal: controller.signal,
837+
progress: ({ totalPages, remainingPages }) => {
838+
console.log('Backup in progress', { totalPages, remainingPages });
839+
},
840+
});
841+
console.log('Backup completed successfully');
842+
} catch (error) {
843+
if (error.name === 'AbortError') {
844+
console.log('Backup was cancelled:', error.message);
845+
} else {
846+
console.error('Backup failed:', error.message);
847+
}
848+
}
849+
})();
850+
```
851+
816852
## `sqlite.constants`
817853

818854
<!-- YAML

lib/sqlite.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
11
'use strict';
2+
const {
3+
PromiseReject,
4+
} = primordials;
5+
const {
6+
AbortError,
7+
codes: {
8+
ERR_INVALID_STATE,
9+
},
10+
} = require('internal/errors');
211
const { emitExperimentalWarning } = require('internal/util');
312

413
emitExperimentalWarning('SQLite');
514

6-
module.exports = internalBinding('sqlite');
15+
const binding = internalBinding('sqlite');
16+
17+
function backup(sourceDb, path, options = {}) {
18+
if (typeof sourceDb !== 'object' || sourceDb === null) {
19+
throw new ERR_INVALID_STATE.TypeError('The "sourceDb" argument must be an object.');
20+
}
21+
22+
if (typeof path !== 'string') {
23+
throw new ERR_INVALID_STATE.TypeError('The "path" argument must be a string.');
24+
}
25+
26+
if (options.signal !== undefined) {
27+
if (typeof options.signal !== 'object' || options.signal === null) {
28+
throw new ERR_INVALID_STATE.TypeError('The "options.signal" argument must be an AbortSignal.');
29+
}
30+
if (options.signal.aborted) {
31+
return PromiseReject(new AbortError(undefined, { cause: options.signal.reason }));
32+
}
33+
}
34+
return binding.backup(sourceDb, path, options);
35+
}
36+
37+
module.exports = {
38+
...binding,
39+
backup,
40+
};

src/node_sqlite.cc

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include "threadpoolwork-inl.h"
1313
#include "util-inl.h"
1414

15+
#include <atomic>
1516
#include <cinttypes>
1617

1718
namespace node {
@@ -433,16 +434,21 @@ class BackupJob : public ThreadPoolWork {
433434
std::string destination_name,
434435
std::string dest_db,
435436
int pages,
436-
Local<Function> progressFunc)
437+
Local<Function> progressFunc,
438+
Local<Object> abort_signal = Local<Object>())
437439
: ThreadPoolWork(env, "node_sqlite3.BackupJob"),
438440
env_(env),
439441
source_(source),
440442
pages_(pages),
441443
source_db_(std::move(source_db)),
442444
destination_name_(std::move(destination_name)),
443-
dest_db_(std::move(dest_db)) {
445+
dest_db_(std::move(dest_db)),
446+
is_aborted_(false) {
444447
resolver_.Reset(env->isolate(), resolver);
445448
progressFunc_.Reset(env->isolate(), progressFunc);
449+
if (!abort_signal.IsEmpty()) {
450+
abort_signal_.Reset(env->isolate(), abort_signal);
451+
}
446452
}
447453

448454
void ScheduleBackup() {
@@ -471,6 +477,10 @@ class BackupJob : public ThreadPoolWork {
471477
}
472478

473479
void DoThreadPoolWork() override {
480+
if (is_aborted_.load()) {
481+
backup_status_ = SQLITE_INTERRUPT;
482+
return;
483+
}
474484
backup_status_ = sqlite3_backup_step(backup_, pages_);
475485
}
476486

@@ -479,6 +489,11 @@ class BackupJob : public ThreadPoolWork {
479489
Local<Promise::Resolver> resolver =
480490
Local<Promise::Resolver>::New(env()->isolate(), resolver_);
481491

492+
if (is_aborted_.load() || backup_status_ == SQLITE_INTERRUPT) {
493+
HandleAbortError(resolver);
494+
return;
495+
}
496+
482497
if (!(backup_status_ == SQLITE_OK || backup_status_ == SQLITE_DONE ||
483498
backup_status_ == SQLITE_BUSY || backup_status_ == SQLITE_LOCKED)) {
484499
HandleBackupError(resolver, backup_status_);
@@ -515,6 +530,10 @@ class BackupJob : public ThreadPoolWork {
515530
return;
516531
}
517532
}
533+
if (CheckAbortSignal()) {
534+
HandleAbortError(resolver);
535+
return;
536+
}
518537

519538
// There's still work to do
520539
this->ScheduleWork();
@@ -548,8 +567,14 @@ class BackupJob : public ThreadPoolWork {
548567
sqlite3_close_v2(dest_);
549568
dest_ = nullptr;
550569
}
570+
571+
if (!abort_signal_.IsEmpty()) {
572+
abort_signal_.Reset();
573+
}
551574
}
552575

576+
void AbortBackup() { is_aborted_.store(true); }
577+
553578
private:
554579
void HandleBackupError(Local<Promise::Resolver> resolver) {
555580
Local<Object> e;
@@ -573,19 +598,62 @@ class BackupJob : public ThreadPoolWork {
573598
resolver->Reject(env()->context(), e).ToChecked();
574599
}
575600

601+
void HandleAbortError(Local<Promise::Resolver> resolver) {
602+
Isolate* isolate = env()->isolate();
603+
Local<String> message =
604+
String::NewFromUtf8(isolate, "The operation was aborted")
605+
.ToLocalChecked();
606+
Local<String> name =
607+
String::NewFromUtf8(isolate, "AbortError").ToLocalChecked();
608+
609+
Local<Object> error = Exception::Error(message).As<Object>();
610+
error
611+
->Set(env()->context(),
612+
String::NewFromUtf8(isolate, "name").ToLocalChecked(),
613+
name)
614+
.ToChecked();
615+
616+
Finalize();
617+
resolver->Reject(env()->context(), error).ToChecked();
618+
}
619+
620+
bool CheckAbortSignal() {
621+
if (abort_signal_.IsEmpty()) {
622+
return false;
623+
}
624+
625+
Isolate* isolate = env()->isolate();
626+
HandleScope scope(isolate);
627+
Local<Object> signal = abort_signal_.Get(isolate);
628+
629+
Local<String> aborted_key =
630+
String::NewFromUtf8(isolate, "aborted").ToLocalChecked();
631+
Local<Value> aborted_value;
632+
if (signal->Get(env()->context(), aborted_key).ToLocal(&aborted_value)) {
633+
if (aborted_value->BooleanValue(isolate)) {
634+
is_aborted_.store(true);
635+
return true;
636+
}
637+
}
638+
639+
return false;
640+
}
641+
576642
Environment* env() const { return env_; }
577643

578644
Environment* env_;
579645
DatabaseSync* source_;
580646
Global<Promise::Resolver> resolver_;
581647
Global<Function> progressFunc_;
648+
Global<Object> abort_signal_;
582649
sqlite3* dest_ = nullptr;
583650
sqlite3_backup* backup_ = nullptr;
584651
int pages_;
585652
int backup_status_ = SQLITE_OK;
586653
std::string source_db_;
587654
std::string destination_name_;
588655
std::string dest_db_;
656+
std::atomic<bool> is_aborted_;
589657
};
590658

591659
UserDefinedFunction::UserDefinedFunction(Environment* env,
@@ -1539,6 +1607,7 @@ void Backup(const FunctionCallbackInfo<Value>& args) {
15391607
std::string source_db = "main";
15401608
std::string dest_db = "main";
15411609
Local<Function> progressFunc = Local<Function>();
1610+
Local<Object> abort_signal = Local<Object>();
15421611

15431612
if (args.Length() > 2) {
15441613
if (!args[2]->IsObject()) {
@@ -1612,6 +1681,23 @@ void Backup(const FunctionCallbackInfo<Value>& args) {
16121681
}
16131682
progressFunc = progress_v.As<Function>();
16141683
}
1684+
1685+
Local<Value> signal_v;
1686+
if (!options->Get(env->context(), env->signal_string())
1687+
.ToLocal(&signal_v)) {
1688+
THROW_ERR_INVALID_ARG_VALUE(env->isolate(), "options.signal");
1689+
return;
1690+
}
1691+
1692+
if (!signal_v->IsUndefined()) {
1693+
if (!signal_v->IsObject()) {
1694+
THROW_ERR_INVALID_ARG_TYPE(
1695+
env->isolate(),
1696+
"The \"options.signal\" argument must be an AbortSignal.");
1697+
return;
1698+
}
1699+
abort_signal = signal_v.As<Object>();
1700+
}
16151701
}
16161702

16171703
Local<Promise::Resolver> resolver;
@@ -1627,7 +1713,8 @@ void Backup(const FunctionCallbackInfo<Value>& args) {
16271713
dest_path.value(),
16281714
std::move(dest_db),
16291715
rate,
1630-
progressFunc);
1716+
progressFunc,
1717+
abort_signal);
16311718
db->AddBackup(job);
16321719
job->ScheduleBackup();
16331720
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use strict';
2+
const { skipIfSQLiteMissing } = require('../common');
3+
skipIfSQLiteMissing();
4+
const tmpdir = require('../common/tmpdir');
5+
const { join } = require('node:path');
6+
const { DatabaseSync, backup } = require('node:sqlite');
7+
const { test } = require('node:test');
8+
const assert = require('node:assert');
9+
let cnt = 0;
10+
11+
tmpdir.refresh();
12+
13+
function nextDb() {
14+
return join(tmpdir.path, `database-${cnt++}.db`);
15+
}
16+
17+
test('backup with AbortSignal - abort during operation', async (t) => {
18+
const sourceDbPath = nextDb();
19+
const destDbPath = nextDb();
20+
21+
const sourceDb = new DatabaseSync(sourceDbPath);
22+
t.after(() => { sourceDb.close(); });
23+
24+
// Create a larger table to make backup take longer
25+
sourceDb.exec(`
26+
CREATE TABLE test_table (id INTEGER PRIMARY KEY, data TEXT);
27+
`);
28+
29+
const stmt = sourceDb.prepare('INSERT INTO test_table (data) VALUES (?)');
30+
for (let i = 0; i < 10000; i++) {
31+
stmt.run(`Test data ${i}`);
32+
}
33+
34+
const controller = new AbortController();
35+
36+
// Abort after a short delay
37+
const abortTimer = setTimeout(() => {
38+
controller.abort('Test cancellation');
39+
}, 10);
40+
41+
try {
42+
await backup(sourceDb, destDbPath, {
43+
signal: controller.signal,
44+
rate: 1 // Very slow to increase chance of abort
45+
});
46+
47+
// If we get here, the backup completed before abort
48+
clearTimeout(abortTimer);
49+
// This is acceptable - the backup might finish before abort triggers
50+
} catch (error) {
51+
clearTimeout(abortTimer);
52+
assert.strictEqual(error.name, 'AbortError');
53+
assert.strictEqual(error.cause, 'Test cancellation');
54+
}
55+
});
56+
57+
test('backup with AbortSignal - pre-aborted signal', async (t) => {
58+
const sourceDbPath = nextDb();
59+
const destDbPath = nextDb();
60+
61+
const sourceDb = new DatabaseSync(sourceDbPath);
62+
t.after(() => { sourceDb.close(); });
63+
64+
sourceDb.exec(`
65+
CREATE TABLE test_table (id INTEGER PRIMARY KEY, data TEXT);
66+
INSERT INTO test_table (data) VALUES ('test');
67+
`);
68+
69+
const controller = new AbortController();
70+
controller.abort('Already aborted');
71+
72+
await assert.rejects(
73+
backup(sourceDb, destDbPath, { signal: controller.signal }),
74+
{
75+
name: 'AbortError',
76+
cause: 'Already aborted'
77+
}
78+
);
79+
});
80+
81+
test('backup with AbortSignal - invalid signal type', (t) => {
82+
const sourceDbPath = nextDb();
83+
const destDbPath = nextDb();
84+
85+
const sourceDb = new DatabaseSync(sourceDbPath);
86+
t.after(() => { sourceDb.close(); });
87+
88+
sourceDb.exec(`
89+
CREATE TABLE test_table (id INTEGER PRIMARY KEY, data TEXT);
90+
`);
91+
92+
assert.throws(
93+
() => backup(sourceDb, destDbPath, { signal: 'not-a-signal' }),
94+
{
95+
name: 'TypeError',
96+
message: /The "options\.signal" argument must be an AbortSignal/
97+
}
98+
);
99+
});

0 commit comments

Comments
 (0)