From e75cedbe9790b950f65f9422e7a0c1715b3cb6d9 Mon Sep 17 00:00:00 2001 From: geeksilva97 Date: Thu, 16 Jul 2026 11:24:40 -0700 Subject: [PATCH 1/7] sqlite: expose prepared statement statistics Signed-off-by: geeksilva97 --- doc/api/sqlite.md | 34 ++++++++ src/node_sqlite.cc | 39 +++++++++ src/node_sqlite.h | 19 +++++ test/parallel/test-sqlite-statement-sync.js | 92 +++++++++++++++++++++ 4 files changed, 184 insertions(+) diff --git a/doc/api/sqlite.md b/doc/api/sqlite.md index 97eac5c5a139..2362df213402 100644 --- a/doc/api/sqlite.md +++ b/doc/api/sqlite.md @@ -1322,6 +1322,39 @@ added: REPLACEME Finalizes the prepared statement. If the prepared statement is already finalized, then this is a no-op. +### `statement.stat(counter)` + + + +* `counter` {string} The name of the counter to read. One of: + + * `'fullscanStep'` The number of times SQLite has stepped forward in a table + as part of a full table scan. + * `'sort'` The number of sort operations that have occurred. + * `'autoindex'` The number of rows inserted into transient indices that were + created automatically to help joins run faster. + * `'vmStep'` The number of virtual machine operations executed by the + prepared statement. + * `'reprepare'` The number of times the statement has been automatically + reprepared due to schema changes or changes to bound parameters. + * `'run'` The number of times the statement has run to completion. + * `'filterMiss'` The number of times the Bloom filter returned a result that + required the join step to be processed as normal. + * `'filterHit'` The number of times a join step was bypassed because a Bloom + filter returned not-found. + * `'memused'` The approximate number of bytes of heap memory used to store + the prepared statement. + +* Returns: {number} The current value of the requested counter. + +Returns one of the runtime counters that SQLite tracks for this prepared +statement. This method is a wrapper around [`sqlite3_stmt_status()`][] and does +not reset the counter. Asserting that a statement does not perform a full table +scan (`statement.stat('fullscanStep') === 0`) is a useful check to guard +against degenerate performance. + ## Class: `SQLTagStore` + +Resets every counter reported by [`statement.stat()`][] back to zero. This +method is a wrapper around [`sqlite3_stmt_status()`][] and is useful for +measuring a specific workload without the counts accumulated by earlier +executions of the same prepared statement. + ### `statement.run([namedParameters][, ...anonymousParameters])` -Resets every counter reported by [`statement.stat()`][] back to zero. This +Resets every counter reported by [`statement.stat()`][] back to zero, except +`memused`, which reports current memory usage and cannot be reset. This method is a wrapper around [`sqlite3_stmt_status()`][] and is useful for measuring a specific workload without the counts accumulated by earlier executions of the same prepared statement. @@ -1350,7 +1351,7 @@ added: REPLACEME prepared statement. * `'reprepare'` The number of times the statement has been automatically reprepared due to schema changes or changes to bound parameters. - * `'run'` The number of times the statement has run to completion. + * `'run'` The number of execution cycles started by the prepared statement. * `'filterMiss'` The number of times the Bloom filter returned a result that required the join step to be processed as normal. * `'filterHit'` The number of times a join step was bypassed because a Bloom diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 0a0a328b26f7..506a793986c7 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -2699,6 +2699,7 @@ void StatementSync::Finalize() { void StatementSync::InvalidateColumnNameCache() { cached_column_names_.clear(); + cached_column_names_reprepare_count_ = -1; } inline bool StatementSync::IsFinalized() { @@ -3393,9 +3394,20 @@ void StatementSync::ResetStats(const FunctionCallbackInfo& args) { // sqlite3_stmt_status() resets a single counter per call, so every exposed // counter is visited. The returned value is the pre-reset one and is unused. + // SQLITE_STMTSTATUS_MEMUSED is skipped: it reports current memory usage + // rather than an accumulated counter, and SQLite ignores the reset flag for + // it. for (const auto& info : kStatusMapping) { + if (info.sqlite_status_id == SQLITE_STMTSTATUS_MEMUSED) { + continue; + } sqlite3_stmt_status(stmt->statement_, info.sqlite_status_id, true); } + + // The column name cache is keyed on SQLITE_STMTSTATUS_REPREPARE, which was + // just zeroed. Without invalidating, a later re-prepare can make the counter + // match the cached generation again and the stale names would be reused. + stmt->InvalidateColumnNameCache(); } void StatementSync::SetAllowBareNamedParameters( diff --git a/test/parallel/test-sqlite-statement-sync.js b/test/parallel/test-sqlite-statement-sync.js index a600e1f2febb..14725bda92ba 100644 --- a/test/parallel/test-sqlite-statement-sync.js +++ b/test/parallel/test-sqlite-statement-sync.js @@ -673,6 +673,53 @@ suite('StatementSync.prototype.resetStats()', () => { t.assert.strictEqual(stmt.resetStats(), undefined); }); + // The column name cache is keyed on the reprepare counter, which + // resetStats() zeroes. A later re-prepare must not be able to make the + // counter match the cached generation again and reuse stale names. + test('invalidates cached iterator column names', (t) => { + using db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE data(a); INSERT INTO data VALUES (1)'); + const stmt = db.prepare('SELECT * FROM data'); + + db.exec('ALTER TABLE data RENAME COLUMN a TO b'); + stmt.iterate().toArray(); + stmt.resetStats(); + db.exec('ALTER TABLE data RENAME COLUMN b TO c'); + + t.assert.deepStrictEqual(stmt.iterate().toArray(), [ + { __proto__: null, c: 1 }, + ]); + }); + + test('invalidates the cache when the column count grows', (t) => { + using db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1)'); + const stmt = db.prepare('SELECT * FROM t'); + + db.exec('ALTER TABLE t ADD COLUMN b DEFAULT 2'); + stmt.iterate().toArray(); + stmt.resetStats(); + db.exec('ALTER TABLE t ADD COLUMN c DEFAULT 3'); + + t.assert.deepStrictEqual(stmt.iterate().toArray(), [ + { __proto__: null, a: 1, b: 2, c: 3 }, + ]); + }); + + test('does not reset memused', (t) => { + using db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t(a); INSERT INTO t VALUES (1),(2),(3)'); + const stmt = db.prepare('SELECT * FROM t ORDER BY a'); + stmt.all(); + + // memused reports current memory usage rather than an accumulated + // counter, so SQLite ignores the reset flag for it. + const before = stmt.stat('memused'); + t.assert.ok(before > 0); + stmt.resetStats(); + t.assert.strictEqual(stmt.stat('memused'), before); + }); + test('clears every counter', (t) => { using db = new DatabaseSync(':memory:'); db.exec('CREATE TABLE data(key INTEGER PRIMARY KEY, val TEXT) STRICT;'); From 5a86587a0e4f752cc1eb7f89984939bf8bf59d64 Mon Sep 17 00:00:00 2001 From: geeksilva97 Date: Wed, 12 Aug 2026 18:01:15 -0300 Subject: [PATCH 6/7] fixup: make linter happy Signed-off-by: geeksilva97 --- test/parallel/test-sqlite-statement-sync.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/parallel/test-sqlite-statement-sync.js b/test/parallel/test-sqlite-statement-sync.js index 14725bda92ba..a52634f59ac6 100644 --- a/test/parallel/test-sqlite-statement-sync.js +++ b/test/parallel/test-sqlite-statement-sync.js @@ -712,8 +712,8 @@ suite('StatementSync.prototype.resetStats()', () => { const stmt = db.prepare('SELECT * FROM t ORDER BY a'); stmt.all(); - // memused reports current memory usage rather than an accumulated - // counter, so SQLite ignores the reset flag for it. + // The memused counter reports current memory usage rather than an + // accumulated total, so SQLite ignores the reset flag for it. const before = stmt.stat('memused'); t.assert.ok(before > 0); stmt.resetStats(); From 7d216048398601d302a23837f7a6f9325ddf604d Mon Sep 17 00:00:00 2001 From: geeksilva97 Date: Wed, 12 Aug 2026 18:19:59 -0300 Subject: [PATCH 7/7] fixup: rebase Signed-off-by: geeksilva97 --- src/node_sqlite.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 506a793986c7..156d4f2e5f5c 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -3381,7 +3381,7 @@ void StatementSync::Stat(const FunctionCallbackInfo& args) { // The reset flag is always false; the counter is read without being cleared. int value = sqlite3_stmt_status( - stmt->statement_, status_info->sqlite_status_id, false); + stmt->statement_.get(), status_info->sqlite_status_id, false); args.GetReturnValue().Set(Integer::New(isolate, value)); } @@ -3401,7 +3401,7 @@ void StatementSync::ResetStats(const FunctionCallbackInfo& args) { if (info.sqlite_status_id == SQLITE_STMTSTATUS_MEMUSED) { continue; } - sqlite3_stmt_status(stmt->statement_, info.sqlite_status_id, true); + sqlite3_stmt_status(stmt->statement_.get(), info.sqlite_status_id, true); } // The column name cache is keyed on SQLITE_STMTSTATUS_REPREPARE, which was