Repository navigation
fix(usage): keep the filter index's WAL sidecars so filtered queries stop churning the config directory #8442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9f895ed
d81706b
eff6ed5
cb1588b
fcf9ebd
45e0138
05966a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,7 +48,7 @@ | |
| * `device=all`) still has to normalize every row in its window; the index makes the | ||
| * selection exact, not sublinear. | ||
| */ | ||
| import { Database } from "bun:sqlite"; | ||
| import { constants as sqliteConstants, Database } from "bun:sqlite"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { chmodSync, existsSync } from "node:fs"; | ||
| import { getConfigDir } from "../config"; | ||
|
|
@@ -87,6 +87,8 @@ const MAX_REFRESH_ATTEMPTS = 3; | |
| // asynchronously instead of parking the event loop behind another writer. A single ingest batch | ||
| // or publish holds the write lock for a few milliseconds. | ||
| const SQLITE_BUSY_TIMEOUT_MS = 250; | ||
| /** Bound on the WAL kept between checkpoints; it is truncated to empty on the last close. */ | ||
| const RETAINED_WAL_LIMIT_BYTES = 4 * 1024 * 1024; | ||
| const BATCH_BUSY_RETRIES = 3; | ||
| const BATCH_BUSY_RETRY_MS = 5; | ||
| const BUSY_RETRY_DELAYS_MS = [10, 25, 50] as const; | ||
|
|
@@ -280,6 +282,8 @@ interface IndexMeta { | |
| let ingestFlight: { path: string; promise: Promise<UsageFilterIndexSnapshot> } | null = null; | ||
| const ingestPassesByPath = new Map<string, number>(); | ||
| const rebuildRequestedPaths = new Set<string>(); | ||
| /** Index paths whose kept sidecars this process has already registered for uninstall. */ | ||
| const sidecarOwnershipChecked = new Set<string>(); | ||
| let testHooks: UsageFilterIndexTestHooks | null = null; | ||
|
|
||
| export function usageFilterIndexPath(configDir: string): string { | ||
|
|
@@ -498,6 +502,52 @@ function ensureSchema(db: Database, classification: "fresh" | "ours"): void { | |
| recreateSchema(db, { onlyIfStale: true }); | ||
| } | ||
|
|
||
| /** | ||
| * Keep the WAL and shared-memory sidecars after the last connection closes. | ||
| * | ||
| * SQLite's default deletes both on the final close, so every filtered query created and removed two | ||
| * entries in the config directory. On Windows that churn is not free: the config directory's ACL | ||
| * memo is keyed on its change time, so the next config load re-ran icacls over the directory, the | ||
| * inherited-ACE rewrite moved usage.jsonl's ChangeTime, and both the usage aggregate and this | ||
| * index then rebuilt from the whole ledger. macOS's system SQLite already persists WAL; this makes | ||
| * every platform behave that way. Read-only connections never delete the sidecars, so only the | ||
| * connections that can write set it, and only after the file classified as this projection: a | ||
| * foreign WAL database keeps SQLite's default and leaves nothing behind. | ||
| * | ||
| * The writer also checkpoints with TRUNCATE before it closes (see refreshUsageFilterIndex), so a | ||
| * clean close leaves the kept WAL empty on every SQLite build: a large rebuild does not leave its | ||
| * bytes on disk, and no stale frames remain to replay into whatever file later sits at this path. | ||
| * The size limit bounds the WAL between checkpoints. | ||
| */ | ||
| function keepWalSidecars(db: Database): void { | ||
| db.fileControl(sqliteConstants.SQLITE_FCNTL_PERSIST_WAL, 1); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| db.exec(`PRAGMA journal_size_limit = ${RETAINED_WAL_LIMIT_BYTES}`); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| /** | ||
| * Register the WAL sidecars that now persist beside this projection. | ||
| * | ||
| * Creation registers the sidecar names up front, but an existing file skips that: a 0-byte | ||
| * projection left by a crash between the private create and the first open, or one whose names | ||
| * were never recorded. Its sidecars now persist and would otherwise survive uninstall. Called only | ||
| * after the file classified as this projection and WAL mode is on, so every sidecar present is | ||
| * this database's own. Sidecars appear only once SQLite has used the WAL, so the path is | ||
| * remembered only when both exist and were offered for registration; until then each open checks. | ||
| */ | ||
| function registerKeptSidecars(dir: string, path: string): void { | ||
| if (sidecarOwnershipChecked.has(path)) return; | ||
| // The same crash leaves the projection file itself unrecorded; it is this projection now. | ||
| recordOwnedConfigPath(dir, path); | ||
| let present = 0; | ||
| for (const suffix of ["-wal", "-shm"] as const) { | ||
| const sidecar = `${path}${suffix}`; | ||
| if (!existsSync(sidecar)) continue; | ||
| recordOwnedConfigPath(dir, sidecar); | ||
| present += 1; | ||
| } | ||
| if (present === 2) sidecarOwnershipChecked.add(path); | ||
| } | ||
|
|
||
| function openIndexForWrite(dir: string, path: string): Database { | ||
| const existed = existsSync(path); | ||
| preparePrivateSqliteDatabase(path); | ||
|
|
@@ -508,6 +558,7 @@ function openIndexForWrite(dir: string, path: string): Database { | |
| db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`); | ||
| const classification = classifyDatabase(db); | ||
| if (classification === "foreign") throw new UsageFilterIndexForeignError(); | ||
| keepWalSidecars(db); | ||
| if (!existed) { | ||
| // Register only names this module is about to create; an existing sidecar may belong to | ||
| // something else and is never adopted. | ||
|
|
@@ -522,6 +573,7 @@ function openIndexForWrite(dir: string, path: string): Database { | |
| hardenSecretPath(path, { required: false }); | ||
| } | ||
| db.exec("PRAGMA journal_mode = WAL"); | ||
| registerKeptSidecars(dir, path); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ensureSchema(db, classification); | ||
| return db; | ||
| } catch (error) { | ||
|
|
@@ -550,6 +602,7 @@ function openIndexForRead(path: string): Database { | |
| const db = new Database(path, { create: false }); | ||
| try { | ||
| if (classifyDatabase(db) !== "ours") throw new UsageFilterIndexForeignError(); | ||
| keepWalSidecars(db); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test WAL retention after writable recovery. If the read-only open fails transiently, the writable fallback can be the last connection to close. The sequential test does not force this branch, so it would miss removal of Add a test that forces the read-only open to fail, confirms writable recovery, and checks that the sidecars remain after the recovery connection closes on Linux or Windows.
|
||
| return db; | ||
| } catch (inner) { | ||
| try { db.close(); } catch { /* already closed */ } | ||
|
|
@@ -1077,6 +1130,10 @@ async function refreshUsageFilterIndex( | |
| } | ||
| return await refreshWithRetry(db, dir, path); | ||
| } finally { | ||
| // A reader in another process can still pin the WAL; then this checkpoint is partial (SQLite | ||
| // reports that as a result row, not an error) and a later writer's TRUNCATE empties it once | ||
| // no reader pins it. The close itself never empties a kept WAL that still has frames. | ||
| try { db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } catch { /* the close below still runs */ } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| db.close(); | ||
| } | ||
| })(); | ||
|
|
@@ -1407,5 +1464,6 @@ export function resetUsageFilterIndexForTests(): void { | |
| ingestFlight = null; | ||
| ingestPassesByPath.clear(); | ||
| rebuildRequestedPaths.clear(); | ||
| sidecarOwnershipChecked.clear(); | ||
| testHooks = null; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import { | |
| existsSync, | ||
| mkdtempSync, | ||
| readFileSync, | ||
| readdirSync, | ||
| renameSync, | ||
| rmSync, | ||
| statSync, | ||
|
|
@@ -15,6 +16,7 @@ import { tmpdir } from "node:os"; | |
| import { join } from "node:path"; | ||
|
|
||
| import { resetAppOwnedMemoryForTests } from "../../src/lib/app-owned-memory"; | ||
| import { initializeConfigOwnership } from "../../src/lib/config-ownership"; | ||
| import { runPassiveRead } from "../../src/lib/passive-read"; | ||
| import { | ||
| getFilteredUsageAggregate, | ||
|
|
@@ -1126,6 +1128,72 @@ describe("filtered usage index availability", () => { | |
| } | ||
| }); | ||
|
|
||
| test("filtered queries after the first leave the config directory's entries unchanged", async () => { | ||
| // On Windows a changed config directory re-runs its ACL harden, which moves usage.jsonl's | ||
| // ChangeTime and forces whole-ledger rebuilds; SQLite deleting its WAL sidecars on every | ||
| // close changed the directory on each filtered query. | ||
| // Discriminates only on stock SQLite (Linux, Windows): macOS's system SQLite already keeps | ||
| // the sidecars, so a local macOS pass is not evidence for this guard. | ||
| writeRows(Array.from({ length: 20 }, (_, index) => syntheticRow(index))); | ||
| const first = await getFilteredUsageAggregate({ provider: "openai" }); | ||
| const observeHome = () => { | ||
| const stat = statSync(testDir); | ||
| return { entries: readdirSync(testDir).sort(), mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs }; | ||
| }; | ||
| const before = observeHome(); | ||
| const other = await getFilteredUsageAggregate({ provider: "kimi" }); | ||
| // An append makes the refresh open the index for writing again. | ||
| appendRows([{ ...syntheticRow(99), requestId: "late" }]); | ||
| const appended = await getFilteredUsageAggregate({ provider: "openai" }); | ||
| expect(observeHome()).toEqual(before); | ||
| for (const result of [first, other, appended]) expect(result.filterIndex).toBeDefined(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| // The append really went through a writable refresh, not a retained answer. | ||
| expect(appended.filterIndex?.totalRows).toBe(21); | ||
| // The kept WAL is empty after the writer's clean close. | ||
| expect(statSync(`${usageFilterIndexPath(testDir)}-wal`).size).toBe(0); | ||
|
Comment on lines
+1152
to
+1153
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }); | ||
|
|
||
| test("a projection whose sidecars are not in the uninstall manifest is repaired on the next write", async () => { | ||
| // Sidecars now persist, so any that exist beside this projection must be owned, or uninstall | ||
| // leaves them behind. | ||
| expect(initializeConfigOwnership(testDir)).toBe(true); | ||
| writeRows(Array.from({ length: 20 }, (_, index) => syntheticRow(index))); | ||
| await getFilteredUsageAggregate({ provider: "openai" }); | ||
| const indexPath = usageFilterIndexPath(testDir); | ||
| const manifestPath = join(testDir, ".opencodex-uninstall.json"); | ||
| const owned = () => (JSON.parse(readFileSync(manifestPath, "utf8")) as { paths: string[] }).paths; | ||
| const sidecars = ["usage-filter-index.sqlite-wal", "usage-filter-index.sqlite-shm"]; | ||
| const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { paths: string[] }; | ||
| writeFileSync(manifestPath, JSON.stringify({ ...manifest, paths: manifest.paths.filter(path => !sidecars.includes(path)) }, null, 2)); | ||
| for (const suffix of ["-wal", "-shm"]) rmSync(`${indexPath}${suffix}`, { force: true }); | ||
| resetUsageAggregateCacheForTests(); | ||
| resetAppOwnedMemoryForTests(); | ||
| expect(owned().filter(path => sidecars.includes(path))).toEqual([]); | ||
|
|
||
| appendRows([{ ...syntheticRow(99), requestId: "after-upgrade" }]); | ||
| expect((await getFilteredUsageAggregate({ provider: "openai" })).filterIndex).toBeDefined(); | ||
| expect(owned().filter(path => sidecars.includes(path)).sort()).toEqual([...sidecars].sort()); | ||
|
Comment on lines
+1174
to
+1175
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }); | ||
|
|
||
| test("a 0-byte projection left by a crash and its sidecars are registered once they appear", async () => { | ||
| // A crash between the private create and the first open leaves an empty file, so the next open | ||
| // sees an existing projection and skips creation-time registration. The sidecars only appear | ||
| // once SQLite uses the WAL, after this open checked, so a later open in the same process must | ||
| // still register them. | ||
| expect(initializeConfigOwnership(testDir)).toBe(true); | ||
| writeRows(Array.from({ length: 20 }, (_, index) => syntheticRow(index))); | ||
| const indexPath = usageFilterIndexPath(testDir); | ||
| writeFileSync(indexPath, "", { mode: 0o600 }); | ||
| const manifestPath = join(testDir, ".opencodex-uninstall.json"); | ||
| const projection = ["usage-filter-index.sqlite", "usage-filter-index.sqlite-wal", "usage-filter-index.sqlite-shm"]; | ||
| const ownedSidecars = () => (JSON.parse(readFileSync(manifestPath, "utf8")) as { paths: string[] }).paths | ||
| .filter(path => projection.includes(path)).sort(); | ||
| expect((await getFilteredUsageAggregate({ provider: "openai" })).filterIndex).toBeDefined(); | ||
| appendRows([{ ...syntheticRow(99), requestId: "after-crash" }]); | ||
| expect((await getFilteredUsageAggregate({ provider: "openai" })).filterIndex).toBeDefined(); | ||
| expect(ownedSidecars()).toEqual([...projection].sort()); | ||
| }); | ||
|
|
||
| test("a passive read never creates or writes index files", async () => { | ||
| const rows = Array.from({ length: 5 }, (_, index) => syntheticRow(index)); | ||
| writeRows(rows); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
rg -n 'journal_size_limit|wal_checkpoint|BEGIN|COMMIT|openIndexFor(Read|Write)|\.close\(' src/usage/filter-index.ts tests/usage/usage-filter-index.test.tsRepository: lidge-jun/opencodex
Length of output: 3528
Script executed:
Repository: lidge-jun/opencodex
Length of output: 42587
Web query:
SQLite SQLITE_FCNTL_PERSIST_WAL final close truncate WAL file journal_size_limit sqlite sourceResult:
Script executed:
Repository: lidge-jun/opencodex
Length of output: 22990
Web query:
SQLite sqlite3WalClose PERSIST_WAL walLimitSize mxWalSize final checkpoint truncate persistent WAL file sourceResult:
Web query:
oven-sh bun 1.4.4 bundled sqlite version SQLite Linux Windows bun sqlite sourceResult:
Web query:
Bun 1.4.4 sqlite bundled version sqlite3WalClose sqlite source dependencies bun-v1.4.4 githubResult:
Script executed:
Repository: lidge-jun/opencodex
Length of output: 12188
Web query:
sqlite 3.46 sqlite3WalClose bPersist mxWalSize walLimitSize persistent WAL journal_size_limit sourceResult:
Web query:
Bun 1.4.4 github sqlite version bundled build dependencies CMake sqlite github bun-v1.4.4Result:
Script executed:
Repository: lidge-jun/opencodex
Length of output: 1767
Script executed:
Repository: lidge-jun/opencodex
Length of output: 9014
Bound the retained WAL size.
A large rebuild can leave a large WAL after the write connection closes.
publishdeletes the retired generation in one transaction (src/usage/filter-index.ts:831-860), and persistence retains the file afterward. Manage the WAL size without deleting the sidecars, including when a reader outlives the writer.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in d81706b. Writable connections now set
PRAGMA journal_size_limit(4 MiB) together withPERSIST_WAL. With persistence on and a limit of 0 or more, SQLite's WAL close path truncates the kept WAL to zero bytes when the last connection closes. The file stays, so the directory does not change, and the bytes from a large rebuild do not. Between checkpoints, the same limit bounds what a reset leaves behind.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Analysis chain
Script executed:
Length of output: 26266
Script executed:
Length of output: 11656
Script executed:
Length of output: 2477
@lidge-junThanks for adding the limit. The reader-outlives-writer case remains unverified on Bun 1.4.4.keepWalSidecarssets the limit on writable connections insrc/usage/filter-index.ts, Line 481. The read-only connection at Line 527 does not set it. In a standalone SQLite 3.40.0 test, an 8 MiB WAL remained after both the writer and the later-closing reader closed.Bun was unavailable for the retry. Please test this sequence with Bun 1.4.4, including on Windows, and check the WAL size after the reader closes. If the WAL remains large, a deferred writable checkpoint may be needed without removing the sidecars.
If you found this review helpful, would you consider giving us a shout-out on X?
Thank you for using CodeRabbit!
You are interacting with an AI system.