diff --git a/src/env_properties.h b/src/env_properties.h index d9e18cbc4515ac..55116d463bb844 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -71,6 +71,9 @@ V(__dirname_string, "__dirname") \ V(ack_string, "ack") \ V(address_string, "address") \ + V(source_db_string, "sourceDb") \ + V(target_db_string, "targetDb") \ + V(progress_string, "progress") \ V(aliases_string, "aliases") \ V(alpn_callback_string, "ALPNCallback") \ V(args_string, "args") \ diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 0b8f7ef2a21763..c1f4a8e09e141f 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -8,6 +8,7 @@ #include "node_errors.h" #include "node_mem-inl.h" #include "sqlite3.h" +#include "threadpoolwork-inl.h" #include "util-inl.h" #include @@ -29,6 +30,7 @@ using v8::FunctionCallback; using v8::FunctionCallbackInfo; using v8::FunctionTemplate; using v8::Global; +using v8::HandleScope; using v8::Int32; using v8::Integer; using v8::Isolate; @@ -40,6 +42,7 @@ using v8::NewStringType; using v8::Null; using v8::Number; using v8::Object; +using v8::Promise; using v8::SideEffectType; using v8::String; using v8::TryCatch; @@ -81,6 +84,23 @@ inline MaybeLocal CreateSQLiteError(Isolate* isolate, return e; } +inline MaybeLocal CreateSQLiteError(Isolate* isolate, int errcode) { + const char* errstr = sqlite3_errstr(errcode); + Local js_errmsg; + Local e; + Environment* env = Environment::GetCurrent(isolate); + if (!String::NewFromUtf8(isolate, errstr).ToLocal(&js_errmsg) || + !CreateSQLiteError(isolate, errstr).ToLocal(&e) || + e->Set(env->context(), + env->errcode_string(), + Integer::New(isolate, errcode)) + .IsNothing() || + e->Set(env->context(), env->errstr_string(), js_errmsg).IsNothing()) { + return MaybeLocal(); + } + return e; +} + inline MaybeLocal CreateSQLiteError(Isolate* isolate, sqlite3* db) { int errcode = sqlite3_extended_errcode(db); const char* errstr = sqlite3_errstr(errcode); @@ -128,6 +148,180 @@ inline void THROW_ERR_SQLITE_ERROR(Isolate* isolate, int errcode) { isolate->ThrowException(error); } +class BackupJob : public ThreadPoolWork { + public: + explicit BackupJob(Environment* env, + DatabaseSync* source, + Local resolver, + std::string source_db, + std::string destination_name, + std::string dest_db, + int pages, + Local progressFunc) + : ThreadPoolWork(env, "node_sqlite3.BackupJob"), + env_(env), + source_(source), + pages_(pages), + source_db_(source_db), + destination_name_(destination_name), + dest_db_(dest_db) { + resolver_.Reset(env->isolate(), resolver); + progressFunc_.Reset(env->isolate(), progressFunc); + } + + void ScheduleBackup() { + Isolate* isolate = env()->isolate(); + HandleScope handle_scope(isolate); + + backup_status_ = sqlite3_open(destination_name_.c_str(), &pDest_); + + Local resolver = + Local::New(env()->isolate(), resolver_); + + Local e = Local(); + + if (backup_status_ != SQLITE_OK) { + if (!CreateSQLiteError(isolate, pDest_).ToLocal(&e)) { + Finalize(); + + return; + } + + Finalize(); + + resolver->Reject(env()->context(), e).ToChecked(); + + return; + } + + pBackup_ = sqlite3_backup_init( + pDest_, dest_db_.c_str(), source_->Connection(), source_db_.c_str()); + + if (pBackup_ == nullptr) { + if (!CreateSQLiteError(isolate, pDest_).ToLocal(&e)) { + Finalize(); + + return; + } + + Finalize(); + + resolver->Reject(env()->context(), e).ToChecked(); + + return; + } + + this->ScheduleWork(); + } + + void DoThreadPoolWork() override { + backup_status_ = sqlite3_backup_step(pBackup_, pages_); + } + + void AfterThreadPoolWork(int status) override { + HandleScope handle_scope(env()->isolate()); + Local resolver = + Local::New(env()->isolate(), resolver_); + + if (!(backup_status_ == SQLITE_OK || backup_status_ == SQLITE_DONE || + backup_status_ == SQLITE_BUSY || backup_status_ == SQLITE_LOCKED)) { + Local e; + if (!CreateSQLiteError(env()->isolate(), backup_status_).ToLocal(&e)) { + Finalize(); + + return; + } + + Finalize(); + + resolver->Reject(env()->context(), e).ToChecked(); + + return; + } + + int total_pages = sqlite3_backup_pagecount(pBackup_); + int remaining_pages = sqlite3_backup_remaining(pBackup_); + + if (remaining_pages != 0) { + Local fn = + Local::New(env()->isolate(), progressFunc_); + + if (!fn.IsEmpty()) { + Local argv[] = { + Integer::New(env()->isolate(), total_pages), + Integer::New(env()->isolate(), remaining_pages), + }; + + TryCatch try_catch(env()->isolate()); + fn->Call(env()->context(), Null(env()->isolate()), 2, argv) + .FromMaybe(Local()); + + if (try_catch.HasCaught()) { + Finalize(); + + resolver->Reject(env()->context(), try_catch.Exception()).ToChecked(); + + return; + } + } + + // There's still work to do + this->ScheduleWork(); + + return; + } + + Local message = + String::NewFromUtf8( + env()->isolate(), "Backup completed", NewStringType::kNormal) + .ToLocalChecked(); + + Local e = + CreateSQLiteError(env()->isolate(), pDest_).ToLocalChecked(); + + Finalize(); + + if (backup_status_ == SQLITE_OK) { + resolver->Resolve(env()->context(), message).ToChecked(); + } else { + resolver->Reject(env()->context(), e).ToChecked(); + } + } + + void Finalize() { + Cleanup(); + source_->RemoveBackup(this); + } + + void Cleanup() { + if (pBackup_) { + sqlite3_backup_finish(pBackup_); + pBackup_ = nullptr; + } + + if (pDest_) { + backup_status_ = sqlite3_errcode(pDest_); + sqlite3_close(pDest_); + pDest_ = nullptr; + } + } + + private: + Environment* env() const { return env_; } + + Environment* env_; + DatabaseSync* source_; + Global resolver_; + Global progressFunc_; + sqlite3* pDest_ = nullptr; + sqlite3_backup* pBackup_ = nullptr; + int pages_; + int backup_status_; + std::string source_db_; + std::string destination_name_; + std::string dest_db_; +}; + class UserDefinedFunction { public: explicit UserDefinedFunction(Environment* env, @@ -261,6 +455,10 @@ DatabaseSync::DatabaseSync(Environment* env, } } +void DatabaseSync::RemoveBackup(BackupJob* job) { + backups_.erase(job); +} + void DatabaseSync::DeleteSessions() { // all attached sessions need to be deleted before the database is closed // https://www.sqlite.org/session/sqlite3session_create.html @@ -273,6 +471,7 @@ void DatabaseSync::DeleteSessions() { DatabaseSync::~DatabaseSync() { if (IsOpen()) { FinalizeStatements(); + FinalizeBackups(); DeleteSessions(); sqlite3_close_v2(connection_); connection_ = nullptr; @@ -335,6 +534,14 @@ bool DatabaseSync::Open() { return true; } +void DatabaseSync::FinalizeBackups() { + for (auto backup : backups_) { + backup->Cleanup(); + } + + backups_.clear(); +} + void DatabaseSync::FinalizeStatements() { for (auto stmt : statements_) { stmt->Finalize(); @@ -533,6 +740,121 @@ void DatabaseSync::Exec(const FunctionCallbackInfo& args) { CHECK_ERROR_OR_THROW(env->isolate(), db->connection_, r, SQLITE_OK, void()); } +// database.backup(destination, { sourceDb, targetDb, rate, progress: (total, +// remaining) => {} ) +void DatabaseSync::Backup(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + + if (!args[0]->IsString()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), "The \"destination\" argument must be a string."); + return; + } + + int rate = 100; + std::string source_db = "main"; + std::string dest_db = "main"; + + DatabaseSync* db; + ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); + + THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + + Utf8Value destFilename(env->isolate(), args[0].As()); + Local progressFunc = Local(); + + if (args.Length() > 1) { + if (!args[1]->IsObject()) { + THROW_ERR_INVALID_ARG_TYPE(env->isolate(), + "The \"options\" argument must be an object."); + return; + } + + Local options = args[1].As(); + + Local rateValue; + if (!options->Get(env->context(), env->rate_string()).ToLocal(&rateValue)) { + return; + } + + if (!rateValue->IsUndefined()) { + if (!rateValue->IsInt32()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), + "The \"options.rate\" argument must be an integer."); + return; + } + + rate = rateValue.As()->Value(); + } + + Local sourceDbValue; + if (!options->Get(env->context(), env->source_db_string()) + .ToLocal(&sourceDbValue)) { + return; + } + + if (!sourceDbValue->IsUndefined()) { + if (!sourceDbValue->IsString()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), + "The \"options.sourceDb\" argument must be a string."); + return; + } + + source_db = + Utf8Value(env->isolate(), sourceDbValue.As()).ToString(); + } + + Local targetDbValue; + if (!options->Get(env->context(), env->target_db_string()) + .ToLocal(&targetDbValue)) { + return; + } + + if (!targetDbValue->IsUndefined()) { + if (!targetDbValue->IsString()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), + "The \"options.targetDb\" argument must be a string."); + return; + } + + dest_db = + Utf8Value(env->isolate(), targetDbValue.As()).ToString(); + } + + Local progressValue; + if (!options->Get(env->context(), env->progress_string()) + .ToLocal(&progressValue)) { + return; + } + + if (!progressValue->IsUndefined()) { + if (!progressValue->IsFunction()) { + THROW_ERR_INVALID_ARG_TYPE( + env->isolate(), + "The \"options.progress\" argument must be a function."); + return; + } + + progressFunc = progressValue.As(); + } + } + + Local resolver; + if (!Promise::Resolver::New(env->context()).ToLocal(&resolver)) { + return; + } + + args.GetReturnValue().Set(resolver->GetPromise()); + + BackupJob* job = new BackupJob( + env, db, resolver, source_db, *destFilename, dest_db, rate, progressFunc); + db->backups_.insert(job); + job->ScheduleBackup(); +} + void DatabaseSync::CustomFunction(const FunctionCallbackInfo& args) { DatabaseSync* db; ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); @@ -1718,6 +2040,7 @@ static void Initialize(Local target, SetProtoMethod(isolate, db_tmpl, "close", DatabaseSync::Close); SetProtoMethod(isolate, db_tmpl, "prepare", DatabaseSync::Prepare); SetProtoMethod(isolate, db_tmpl, "exec", DatabaseSync::Exec); + SetProtoMethod(isolate, db_tmpl, "backup", DatabaseSync::Backup); SetProtoMethod(isolate, db_tmpl, "function", DatabaseSync::CustomFunction); SetProtoMethod( isolate, db_tmpl, "createSession", DatabaseSync::CreateSession); diff --git a/src/node_sqlite.h b/src/node_sqlite.h index e78aa39abb3ba5..05fe0bdb143d99 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -43,6 +43,7 @@ class DatabaseOpenConfiguration { }; class StatementSync; +class BackupJob; class DatabaseSync : public BaseObject { public: @@ -57,6 +58,7 @@ class DatabaseSync : public BaseObject { static void Close(const v8::FunctionCallbackInfo& args); static void Prepare(const v8::FunctionCallbackInfo& args); static void Exec(const v8::FunctionCallbackInfo& args); + static void Backup(const v8::FunctionCallbackInfo& args); static void CustomFunction(const v8::FunctionCallbackInfo& args); static void CreateSession(const v8::FunctionCallbackInfo& args); static void ApplyChangeset(const v8::FunctionCallbackInfo& args); @@ -64,6 +66,8 @@ class DatabaseSync : public BaseObject { const v8::FunctionCallbackInfo& args); static void LoadExtension(const v8::FunctionCallbackInfo& args); void FinalizeStatements(); + void RemoveBackup(BackupJob* backup); + void FinalizeBackups(); void UntrackStatement(StatementSync* statement); bool IsOpen(); sqlite3* Connection(); @@ -81,6 +85,7 @@ class DatabaseSync : public BaseObject { bool enable_load_extension_; sqlite3* connection_; + std::set backups_; std::set sessions_; std::unordered_set statements_; diff --git a/test/parallel/test-sqlite-backup.mjs b/test/parallel/test-sqlite-backup.mjs new file mode 100644 index 00000000000000..0460f2e5a128d5 --- /dev/null +++ b/test/parallel/test-sqlite-backup.mjs @@ -0,0 +1,130 @@ +import * as common from '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; +import { join } from 'path'; +import { DatabaseSync } from 'node:sqlite'; +import { test } from 'node:test'; +import { writeFileSync } from 'fs'; + +let cnt = 0; + +tmpdir.refresh(); + +function nextDb() { + return join(tmpdir.path, `database-${cnt++}.db`); +} + +function makeSourceDb() { + const database = new DatabaseSync(':memory:'); + + 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; +} + +test('throws exception when trying to start backup from a closed database', async (t) => { + t.assert.rejects(async () => { + const database = new DatabaseSync(':memory:'); + + database.close(); + + await database.backup('backup.db'); + }, common.expectsError({ + code: 'ERR_INVALID_STATE', + message: 'database is not open' + })); +}); + +test('database backup', async (t) => { + const progressFn = t.mock.fn(); + const database = makeSourceDb(); + const destDb = nextDb(); + + await database.backup(destDb, { + rate: 1, + progress: progressFn, + }); + + const backup = new DatabaseSync(destDb); + const rows = backup.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(rows, [ + { __proto__: null, key: 1, value: 'value-1' }, + { __proto__: null, key: 2, value: 'value-2' }, + ]); +}); + +test('database backup fails when dest file is not writable', (t) => { + const readonlyDestDb = nextDb(); + writeFileSync(readonlyDestDb, '', { mode: 0o444 }); + + const database = makeSourceDb(); + + t.assert.rejects(async () => { + await database.backup(readonlyDestDb); + }, common.expectsError({ + 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'); + }); + + t.assert.rejects(async () => { + await database.backup(destDb, { + rate: 1, + progress: progressFn, + }); + }, common.expectsError({ + message: 'progress error' + })); +}); + +test('backup fails source db is invalid', async (t) => { + const database = makeSourceDb(); + const destDb = nextDb(); + + const progressFn = t.mock.fn(() => { + throw new Error('progress error'); + }); + + t.assert.rejects(async () => { + await database.backup(destDb, { + rate: 1, + progress: progressFn, + sourceDb: 'invalid', + }); + }, common.expectsError({ + message: 'unknown database invalid' + })); +}); + +test('backup fails when destination cannot be opened', async (t) => { + const database = makeSourceDb(); + + t.assert.rejects(async () => { + await database.backup('/invalid/path/to/db.sqlite'); + }, common.expectsError({ + message: 'unable to open database file' + })); +});