Attribute SQLite memory consumed to isolate memory consumed#6380
Attribute SQLite memory consumed to isolate memory consumed#6380joshthoward wants to merge 1 commit intomainfrom
Conversation
There was a problem hiding this comment.
I'm Bonk, and I've done a quick review of your PR.
This PR adds per-actor SQLite memory metering by installing a custom sqlite3_mem_methods allocator that routes accounting through jsg::ExternalMemoryAdjustment via a thread-local RAII scope.
Issues (highest severity first)
-
[High]
sqliteMemFreecan assert-fail when freeing memory allocated outside a scope. SQLite may allocate memory outside anySqliteMemoryScope(e.g., duringsqlite3_open_v2()in theActorSqliteconstructor, before any JS turn installs a scope). When that memory is later freed during a JS turn (inside a scope),sqliteMemFreewill decrement the adjustment by the full block size — even though the adjustment was never incremented for that allocation. This callsExternalMemoryAdjustment::adjust(-N)which invokesmaybeDeferAdjustment()containingKJ_ASSERT(amount >= -static_cast<ssize_t>(this->amount)). Sincethis->amountcould be 0 (or less than N), this will assert-fail. The symmetric case (allocated in scope, freed outside scope) causes memory to leak from accounting but won't crash. -
[High] No tests. This is a new subsystem that replaces the process-wide SQLite allocator and intercepts every
malloc/free/realloccall made by SQLite. A unit test exercising the accounting (allocations tracked, frees decremented, realloc delta correct, no-scope passthrough) would catch issues like #1 above and prevent regressions. -
[Medium]
sqliteMemReallocunder-accounts whenoldActualis 0 but scope is active. On line 90, the conditionscope != nullptr && oldActual > 0means ifptrwasnullptr(realloc-as-malloc) orusableSizereturned 0 for the old pointer, the new allocation is never accounted for. While SQLite internally redirectsrealloc(NULL, n)tomalloc(n), the defensive guard still creates an inconsistency: ifusableSizereturns 0 for a valid old pointer (the#elsefallback), the new allocation's size is also lost. The fix is to account for the new allocation even whenoldActualis 0. -
[Medium]
adjustmentis a public data member onSqliteMemoryScope. The KJ style convention is to minimize public surface area. This reference is only accessed by the threefriendallocator functions. Making itprivate(the friends already have access) would prevent misuse. -
[Low] Stale comment referencing nonexistent
ensureMonkeyPatchedSqlite. The comment on line 138 ofsqlite-metering.c++says "We rely on callers (Vfs constructors, ensureMonkeyPatchedSqlite)" butensureMonkeyPatchedSqlitedoes not exist in the codebase. -
[Low] Comment references nonexistent
SQLITE_MEMORY_METERINGautogate. The header comment on line 19 ofsqlite-metering.hmentions aSQLITE_MEMORY_METERINGautogate, but no such autogate exists inautogate.h. If this feature is meant to be gated behind an autogate for staged rollout (which seems wise given it replaces the global SQLite allocator), the autogate should be added in this PR. If it's planned for a follow-up, the comment should say so. -
[Low] Unnecessary
#include <workerd/jsg/setup.h>inactor-sqlite.c++. TheExternalMemoryTargetclass is fully defined injsg/jsg.h(which is already transitively included viaactor-cache.h). Thesetup.hheader pulls in significantly more than needed.
| scope->adjustment.adjust(-static_cast<ssize_t>(actual)); | ||
| } | ||
| } | ||
| std::free(ptr); |
There was a problem hiding this comment.
Assertion risk: If memory was allocated outside a SqliteMemoryScope (e.g., during sqlite3_open_v2() in the ActorSqlite constructor), but freed here inside a scope during a JS turn, this will call adjustment.adjust(-actual) when the adjustment's internal amount may be 0 or smaller than actual. That triggers KJ_ASSERT(amount >= -static_cast<ssize_t>(this->amount)) in ExternalMemoryAdjustment::maybeDeferAdjustment().
Consider tracking only the delta (allocs minus frees) within the scope's lifetime, or guarding against going negative:
| std::free(ptr); | |
| SqliteMemoryScope* scope = SqliteMemoryScope::threadLocalScope; | |
| if (scope != nullptr) { | |
| size_t actual = usableSize(ptr); | |
| if (actual > 0 && scope->adjustment.getAmount() >= actual) { | |
| scope->adjustment.adjust(-static_cast<ssize_t>(actual)); | |
| } | |
| } | |
| std::free(ptr); |
Alternatively, consider using a separate counter that allows negative drift (e.g., a plain std::atomic<ssize_t>) instead of piping raw alloc/free sizes through ExternalMemoryAdjustment which asserts non-negative.
| ssize_t delta = static_cast<ssize_t>(newActual) - static_cast<ssize_t>(oldActual); | ||
| scope->adjustment.adjust(delta); | ||
| } | ||
| } |
There was a problem hiding this comment.
Under-accounting: When oldActual is 0 (either because the #else fallback is hit for the old pointer, or for any unexpected reason), the new allocation's full size is silently dropped from accounting. This makes the guard oldActual > 0 too strict — it conflates "I don't know the old size" with "don't track the new size either."
Consider accounting for the new allocation regardless:
| } | |
| if (scope != nullptr) { | |
| size_t newActual = usableSize(newPtr); | |
| ssize_t delta = static_cast<ssize_t>(newActual) - static_cast<ssize_t>(oldActual); | |
| if (delta != 0) { | |
| scope->adjustment.adjust(delta); | |
| } | |
| } | |
| return newPtr; |
This handles all cases: if oldActual is 0 (unknown or untracked), the full new size is accounted; if both are known, the delta is correctly tracked.
| ~SqliteMemoryScope() noexcept(false); | ||
| KJ_DISALLOW_COPY_AND_MOVE(SqliteMemoryScope); | ||
|
|
||
| jsg::ExternalMemoryAdjustment& adjustment; |
There was a problem hiding this comment.
This data member is only accessed by the three friend functions declared below. Making it private would be more consistent with KJ conventions and prevent unintended external access:
| jsg::ExternalMemoryAdjustment& adjustment; | |
| private: | |
| jsg::ExternalMemoryAdjustment& adjustment; |
(The existing friend declarations already grant the allocator functions access.)
| void installSqliteCustomAllocator() { | ||
| // Use a static once-flag. sqlite3_config() must be called before sqlite3_initialize(), which is | ||
| // itself invoked implicitly by the first sqlite3_vfs_register() or sqlite3_open_v2() call. | ||
| // We rely on callers (Vfs constructors, ensureMonkeyPatchedSqlite) to call this before any of |
There was a problem hiding this comment.
Stale reference: ensureMonkeyPatchedSqlite does not exist in the codebase. Did you mean a different function, or is this a reference to an internal-only caller?
| // We rely on callers (Vfs constructors, ensureMonkeyPatchedSqlite) to call this before any of | |
| // We rely on callers (Vfs constructors) to call this before any of |
| // turn that may invoke SQLite, pointing at the actor's ExternalMemoryAdjustment. | ||
| // | ||
| // When the thread-local pointer is null (outside of an actor JS turn, or when the | ||
| // SQLITE_MEMORY_METERING autogate is disabled), allocations pass through to the underlying |
There was a problem hiding this comment.
This comment references a SQLITE_MEMORY_METERING autogate that doesn't exist in autogate.h. Given that this PR replaces the global SQLite allocator for the entire process, gating behind an autogate seems prudent for staged rollout. If the autogate is planned for a follow-up PR, it would be helpful to note that here.
| #include "io-gate.h" | ||
|
|
||
| #include <workerd/jsg/exception.h> | ||
| #include <workerd/jsg/setup.h> |
There was a problem hiding this comment.
This include may be unnecessary — ExternalMemoryTarget and ExternalMemoryAdjustment are fully defined in jsg/jsg.h, which is already included transitively via actor-cache.h → jsg/jsg.h. setup.h is a heavy header that pulls in the full isolate/V8 setup machinery.
|
Review posted successfully on PR #6380. Here's a summary of what I found: 7 issues identified, with the two highest severity being:
5-7. [Low] Stale comment referencing nonexistent All issues with concrete fixes were posted as inline suggestion comments on the PR. |
|
The generated output of Full Type Diff |
No description provided.