From 777c0abbeae40061ad3b4d68c7739d75576d0d83 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:31:00 +0000 Subject: [PATCH 1/4] sqlite: turn off SQLite memory statistics With memory statistics on, every sqlite3Malloc and sqlite3_free takes the process-wide SQLITE_MUTEX_STATIC_MEM mutex. Worker threads with private connections serialize on it. Node compiles its SQLite with SQLITE_DEFAULT_MEMSTATUS=0. Bun sets SQLITE_CONFIG_MEMSTATUS to 0 at runtime in the one-time init so the setting also reaches the dlopen'd system or custom libsqlite3 on macOS. Behaviour change: PRAGMA soft_heap_limit and PRAGMA hard_heap_limit no longer bound SQLite memory, and sqlite3_memory_used stays 0. Node behaves the same way. --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 10 +++++ test/js/bun/sqlite/sqlite.test.js | 48 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 949228d753d6..212701bf25d8 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -135,6 +135,16 @@ extern "C" void Bun__initializeSQLite() { static std::once_flag onceFlag; std::call_once(onceFlag, [] { + // With memory statistics on, every sqlite3Malloc/sqlite3_free takes the + // process-wide SQLITE_MUTEX_STATIC_MEM mutex, so connections on + // different Worker threads serialize on it. Off, the counters stay 0 + // and PRAGMA soft_heap_limit/hard_heap_limit bound nothing, as in + // Node (SQLITE_DEFAULT_MEMSTATUS=0). A runtime config also reaches + // the dlopen'd system or custom libsqlite3. + int returnCode = sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0); + ASSERT_WITH_MESSAGE(returnCode == SQLITE_OK, "Unable to disable SQLite memory statistics"); + UNUSED_PARAM(returnCode); + enableFastMallocForSQLite(); }); } diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index ae49e56ef245..ba9f04c4b51b 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -2889,3 +2889,51 @@ it.skipIf(!sqliteAllowsMoreThan65535Parameters)( expect(db.query("SELECT a FROM t").all()).toEqual([{ a: 7 }]); }, ); + +// SQLite memory statistics are off (SQLITE_CONFIG_MEMSTATUS=0, like Node's +// SQLITE_DEFAULT_MEMSTATUS=0). With them on, every sqlite3Malloc/sqlite3_free +// takes one process-wide mutex, so Workers with private connections serialize +// on it. The only JS-visible effect of the setting is that the heap-limit +// pragmas have nothing to count: with statistics on, a limit of 1 byte fails +// the next allocation with SQLITE_NOMEM. Each case runs in its own process +// because the limit is process-global. +describe.concurrent("memory statistics are off", () => { + const inserts = ` + db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, s TEXT)"); + const insert = db.prepare("INSERT INTO t (s) VALUES (?)"); + for (let i = 0; i < 200; i++) insert.run("x".repeat(4096)); + console.log(JSON.stringify(db.prepare("SELECT count(*) AS n FROM t").get())); + `; + + it.each([ + [ + "bun:sqlite", + ` + const { Database } = require("bun:sqlite"); + const db = new Database(":memory:"); + db.run("PRAGMA hard_heap_limit = 1"); + ${inserts} + `, + ], + [ + "node:sqlite", + ` + const { DatabaseSync } = require("node:sqlite"); + const db = new DatabaseSync(":memory:"); + db.exec("PRAGMA hard_heap_limit = 1"); + ${inserts} + `, + ], + ])("%s: PRAGMA hard_heap_limit does not fail allocations", async (_, code) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe(JSON.stringify({ n: 200 })); + expect(exitCode).toBe(0); + }); +}); From b3105edcf0ff73291aa84ad02d4be2b31f50569f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:58:00 +0000 Subject: [PATCH 2/4] sqlite: make the malloc accounting counter thread-local The fastMalloc hooks updated one process-global atomic on every allocation and free, so Worker threads still wrote one shared cache line per call. The two readers only need the delta across a synchronous call on the current thread. Also build the test row with Buffer.alloc and note that the macOS system libsqlite3 already ships DEFAULT_MEMSTATUS=0. --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 7 ++++--- test/js/bun/sqlite/sqlite.test.js | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 212701bf25d8..8312c7a45fcb 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -80,7 +80,10 @@ static inline int lazyLoadSQLite() #define ENABLE_SQLITE_FAST_MALLOC (BENABLE(MALLOC_SIZE) && BENABLE(MALLOC_GOOD_SIZE)) #endif -static std::atomic sqlite_malloc_amount = 0; +// Per-thread so the malloc hooks do not write one shared cache line from +// every Worker. The readers only want the delta across a synchronous call on +// the current thread, which is what a thread-local gives them. +static thread_local int64_t sqlite_malloc_amount = 0; static void enableFastMallocForSQLite() { @@ -1740,8 +1743,6 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementPrepareStatementFunction, (JSC::JSGlobalO sqlite3_stmt* statement = nullptr; - // This is inherently somewhat racy if using Worker - // but that should be okay. int64_t currentMemoryUsage = sqlite_malloc_amount; int rc = SQLITE_OK; diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index ba9f04c4b51b..88dadf2de963 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -2896,12 +2896,15 @@ it.skipIf(!sqliteAllowsMoreThan65535Parameters)( // on it. The only JS-visible effect of the setting is that the heap-limit // pragmas have nothing to count: with statistics on, a limit of 1 byte fails // the next allocation with SQLITE_NOMEM. Each case runs in its own process -// because the limit is process-global. +// because the limit is process-global. On macOS the system libsqlite3 already +// ships DEFAULT_MEMSTATUS=0, so only the bundled build (Linux, Windows) can +// fail this without the runtime config. describe.concurrent("memory statistics are off", () => { const inserts = ` db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, s TEXT)"); const insert = db.prepare("INSERT INTO t (s) VALUES (?)"); - for (let i = 0; i < 200; i++) insert.run("x".repeat(4096)); + const row = Buffer.alloc(4096, "x").toString(); + for (let i = 0; i < 200; i++) insert.run(row); console.log(JSON.stringify(db.prepare("SELECT count(*) AS n FROM t").get())); `; From 2b8c8848c2f642ff3e0a659115a685f3a51ce36f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:01:41 +0000 Subject: [PATCH 3/4] sqlite: shorten the config comments --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 8312c7a45fcb..aaa538bcf3d1 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -80,9 +80,7 @@ static inline int lazyLoadSQLite() #define ENABLE_SQLITE_FAST_MALLOC (BENABLE(MALLOC_SIZE) && BENABLE(MALLOC_GOOD_SIZE)) #endif -// Per-thread so the malloc hooks do not write one shared cache line from -// every Worker. The readers only want the delta across a synchronous call on -// the current thread, which is what a thread-local gives them. +// Read as a delta across one synchronous call, so per-thread is enough. static thread_local int64_t sqlite_malloc_amount = 0; static void enableFastMallocForSQLite() @@ -138,12 +136,8 @@ extern "C" void Bun__initializeSQLite() { static std::once_flag onceFlag; std::call_once(onceFlag, [] { - // With memory statistics on, every sqlite3Malloc/sqlite3_free takes the - // process-wide SQLITE_MUTEX_STATIC_MEM mutex, so connections on - // different Worker threads serialize on it. Off, the counters stay 0 - // and PRAGMA soft_heap_limit/hard_heap_limit bound nothing, as in - // Node (SQLITE_DEFAULT_MEMSTATUS=0). A runtime config also reaches - // the dlopen'd system or custom libsqlite3. + // Memory statistics put a process-wide mutex in every SQLite malloc/free. + // Node ships SQLITE_DEFAULT_MEMSTATUS=0 too. int returnCode = sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0); ASSERT_WITH_MESSAGE(returnCode == SQLITE_OK, "Unable to disable SQLite memory statistics"); UNUSED_PARAM(returnCode); From 908e9f57bb2be520eb5f6ce05991ac36dfbfa38f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:06:06 +0000 Subject: [PATCH 4/4] sqlite: one-line comment on the memstatus config --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index aaa538bcf3d1..258ff301e6ea 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -136,8 +136,7 @@ extern "C" void Bun__initializeSQLite() { static std::once_flag onceFlag; std::call_once(onceFlag, [] { - // Memory statistics put a process-wide mutex in every SQLite malloc/free. - // Node ships SQLITE_DEFAULT_MEMSTATUS=0 too. + // Off, SQLite skips its process-wide mutex in every malloc and free. int returnCode = sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0); ASSERT_WITH_MESSAGE(returnCode == SQLITE_OK, "Unable to disable SQLite memory statistics"); UNUSED_PARAM(returnCode);