fix(mnemopi): make SQLite page-size alignment opt-in
Keep SQLite default behavior unless a page size is explicitly configured, and route file-backed Mnemopi databases through the shared opener.
This commit is contained in:
@@ -8,8 +8,9 @@
|
||||
|
||||
- Stripped `<think>…</think>` reasoning blocks from remote LLM output in `cleanOutput`, so reasoning-model responses no longer leak into consolidated memories or corrupt fact extraction (the reasoning wrapper previously survived parsing and every stored fact became reasoning prose). ([#7231](https://github.com/can1357/oh-my-pi/issues/7231))
|
||||
### Fixed
|
||||
### Added
|
||||
|
||||
- Fixed SQLite databases being created with a hardcoded 4 KB page size regardless of the OS page size. On systems with larger pages (e.g. Apple Silicon / Asahi Linux at 16 KB), every SQLite page read touches 4 OS pages, causing 4x I/O amplification. Mnemopi now detects the OS page size at startup via `getconf PAGE_SIZE` and sets `PRAGMA page_size` before any tables are created, eliminating the write amplification on non-4K-page systems.
|
||||
- Added opt-in SQLite page-size selection for file-backed databases. Set `MNEMOPI_DB_PAGE_SIZE` to a valid SQLite page size (a power of two from 512 to 65536) or `os`, or pass `pageSize` to `openDatabase`. The default remains SQLite's own page size because the virtual-memory page size does not establish storage-block behavior and larger WAL pages can increase sparse-write cost. Existing databases retain their page size.
|
||||
|
||||
## [17.2.2] - 2026-07-31
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ In `per-project-tagged`, the wrapper is responsible for combining project-local
|
||||
Common environment fallbacks:
|
||||
|
||||
- `MNEMOPI_DATA_DIR` / `MNEMOPI_DB_PATH`: default storage location.
|
||||
- `MNEMOPI_DB_PAGE_SIZE`: optional SQLite page size for new file-backed databases; use a valid power of two from 512 to 65536 or `os` to request the detected system page size. Unset preserves SQLite's default.
|
||||
- `MNEMOPI_NO_EMBEDDINGS=1`: force FTS-only recall.
|
||||
- `MNEMOPI_EMBEDDING_MODEL`: defaults to `BAAI/bge-small-en-v1.5`.
|
||||
- `MNEMOPI_EMBEDDING_API_URL` and `MNEMOPI_EMBEDDING_API_KEY`: OpenAI-compatible embedding endpoint.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { join } from "node:path";
|
||||
import { openDatabase } from "../db";
|
||||
export const DEFAULT_LOG_DIR = join(homedir(), ".mnemopi", "data");
|
||||
export const DEFAULT_LOG_DB = join(DEFAULT_LOG_DIR, "cost_log.db");
|
||||
|
||||
@@ -22,8 +21,7 @@ type AggregateRow = {
|
||||
|
||||
export function getConn(dbPath?: string): Database {
|
||||
const path = dbPath ?? DEFAULT_LOG_DB;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
return new Database(path, { create: true, readwrite: true, strict: true });
|
||||
return openDatabase(path, { pragmas: false });
|
||||
}
|
||||
|
||||
export function initCostLog(dbPath?: string): void {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { type Env, enhancedRecallEnabled } from "../config";
|
||||
import { openDatabase } from "../db";
|
||||
import { cosineSimilarity } from "./vector-math";
|
||||
|
||||
export type QueryCacheResult = Record<string, unknown>;
|
||||
@@ -80,8 +79,7 @@ export class QueryCache {
|
||||
}
|
||||
|
||||
#initDb(dbPath: string): void {
|
||||
if (dbPath !== ":memory:") mkdirSync(dirname(dbPath), { recursive: true });
|
||||
const db = new Database(dbPath, { create: true, readwrite: true, strict: true });
|
||||
const db = openDatabase(dbPath, { pragmas: false });
|
||||
this.#conn = db;
|
||||
if (dbPath !== ":memory:") db.exec("PRAGMA journal_mode=WAL");
|
||||
db.exec(`
|
||||
|
||||
+50
-13
@@ -3,22 +3,57 @@ import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { dbPath } from "./config";
|
||||
|
||||
let _osPageSize: number | undefined;
|
||||
export type SqlitePageSize = number | "os";
|
||||
|
||||
function osPageSize(): number {
|
||||
if (_osPageSize !== undefined) return _osPageSize;
|
||||
const MIN_SQLITE_PAGE_SIZE = 512;
|
||||
const MAX_SQLITE_PAGE_SIZE = 65536;
|
||||
let detectedSystemPageSize: number | null | undefined;
|
||||
|
||||
function isValidPageSize(size: number): boolean {
|
||||
return (
|
||||
Number.isInteger(size) &&
|
||||
size >= MIN_SQLITE_PAGE_SIZE &&
|
||||
size <= MAX_SQLITE_PAGE_SIZE &&
|
||||
(size & (size - 1)) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function detectSystemPageSize(): number | undefined {
|
||||
if (detectedSystemPageSize !== undefined) return detectedSystemPageSize ?? undefined;
|
||||
try {
|
||||
const proc = Bun.spawnSync(["getconf", "PAGE_SIZE"], { stdout: "pipe" });
|
||||
if (proc.exitCode === 0) {
|
||||
const size = Number.parseInt(proc.stdout.toString().trim(), 10);
|
||||
if (size >= 512 && size <= 65536 && (size & (size - 1)) === 0) {
|
||||
_osPageSize = size;
|
||||
const size = Number(proc.stdout.toString().trim());
|
||||
if (isValidPageSize(size)) {
|
||||
detectedSystemPageSize = size;
|
||||
return size;
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
_osPageSize = 4096;
|
||||
return 4096;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
detectedSystemPageSize = null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function configuredPageSize(): SqlitePageSize | undefined {
|
||||
const raw = process.env.MNEMOPI_DB_PAGE_SIZE?.trim();
|
||||
if (!raw) return undefined;
|
||||
if (raw.toLowerCase() === "os") return "os";
|
||||
const size = Number(raw);
|
||||
return isValidPageSize(size) ? size : undefined;
|
||||
}
|
||||
|
||||
function resolvePageSize(requested?: SqlitePageSize): number | undefined {
|
||||
const pageSize = requested ?? configuredPageSize();
|
||||
if (pageSize === "os") return detectSystemPageSize();
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
function applyPageSize(db: Database, path: DatabasePath, requested?: SqlitePageSize): void {
|
||||
if (path === ":memory:") return;
|
||||
const pageSize = resolvePageSize(requested);
|
||||
if (pageSize !== undefined) db.exec(`PRAGMA page_size=${pageSize}`);
|
||||
}
|
||||
|
||||
export type DatabasePath = string | ":memory:";
|
||||
@@ -29,6 +64,7 @@ export interface OpenDatabaseOptions {
|
||||
readonly strict?: boolean;
|
||||
readonly loadExtension?: string | readonly string[];
|
||||
readonly pragmas?: boolean;
|
||||
readonly pageSize?: SqlitePageSize;
|
||||
}
|
||||
|
||||
interface TxState {
|
||||
@@ -47,16 +83,17 @@ export function openDatabase(path: DatabasePath = dbPath(), options: OpenDatabas
|
||||
readwrite: options.readwrite ?? true,
|
||||
strict: options.strict ?? true,
|
||||
});
|
||||
if (options.pragmas !== false) enablePragmas(db, path);
|
||||
if (options.pragmas !== false) enablePragmas(db, path, options.pageSize);
|
||||
else if (options.readwrite !== false) applyPageSize(db, path, options.pageSize);
|
||||
if (options.loadExtension !== undefined) loadExtensions(db, options.loadExtension);
|
||||
return db;
|
||||
}
|
||||
export function enablePragmas(db: Database, path?: DatabasePath): void {
|
||||
|
||||
export function enablePragmas(db: Database, path?: DatabasePath, pageSize?: SqlitePageSize): void {
|
||||
db.exec("PRAGMA foreign_keys=ON");
|
||||
db.exec("PRAGMA busy_timeout=5000");
|
||||
if (path !== ":memory:") {
|
||||
const pageSize = osPageSize();
|
||||
if (pageSize !== 4096) db.exec(`PRAGMA page_size=${pageSize}`);
|
||||
applyPageSize(db, path ?? dbPath(), pageSize);
|
||||
db.exec("PRAGMA journal_mode=WAL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
@@ -188,7 +188,7 @@ function isSqliteFile(bytes: Uint8Array): boolean {
|
||||
function writeGzippedSqlDump(sql: string, tempPath: string): void {
|
||||
let db: Database | null = null;
|
||||
try {
|
||||
db = new Database(tempPath, { create: true, readwrite: true, strict: true });
|
||||
db = openDatabase(tempPath, { pragmas: false });
|
||||
db.exec(sql);
|
||||
} finally {
|
||||
closeQuietly(db);
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { openDatabase, closeQuietly } from "../src/db";
|
||||
|
||||
function expectedOsPageSize(): number {
|
||||
try {
|
||||
const proc = Bun.spawnSync(["getconf", "PAGE_SIZE"], { stdout: "pipe" });
|
||||
if (proc.exitCode === 0) {
|
||||
const size = Number.parseInt(proc.stdout.toString().trim(), 10);
|
||||
if (size >= 512 && size <= 65536 && (size & (size - 1)) === 0) return size;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return 4096;
|
||||
}
|
||||
import { closeQuietly, openDatabase } from "../src/db";
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function tempDb(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "mnemopi-page-size-"));
|
||||
roots.push(root);
|
||||
return join(root, "test.db");
|
||||
}
|
||||
|
||||
interface PageSizeRow {
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
function pageSize(db: Database): number {
|
||||
const row = db.query("PRAGMA page_size").get() as PageSizeRow;
|
||||
return row.page_size;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (;;) {
|
||||
const root = roots.pop();
|
||||
@@ -26,27 +31,36 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("db page size", () => {
|
||||
it("creates a new database with page size matching the OS page size", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "mnemopi-page-size-"));
|
||||
roots.push(root);
|
||||
const path = join(root, "test.db");
|
||||
|
||||
const db = openDatabase(path);
|
||||
it("uses an explicitly requested page size for a new file-backed database", () => {
|
||||
const db = openDatabase(tempDb(), { pageSize: 16384 });
|
||||
try {
|
||||
const pageSize = db.query("PRAGMA page_size").get() as { page_size: number };
|
||||
expect(pageSize.page_size).toBe(expectedOsPageSize());
|
||||
expect(pageSize(db)).toBe(16384);
|
||||
} finally {
|
||||
closeQuietly(db);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not set page_size on in-memory databases", () => {
|
||||
const db = openDatabase(":memory:");
|
||||
it("keeps the existing page size when reopening a database with another request", () => {
|
||||
const path = tempDb();
|
||||
const initial = openDatabase(path, { pageSize: 4096 });
|
||||
try {
|
||||
const pageSize = db.query("PRAGMA page_size").get() as { page_size: number };
|
||||
// In-memory DBs keep the SQLite default (4096) since there is no
|
||||
// persistent file whose I/O would benefit from OS page alignment.
|
||||
expect(pageSize.page_size).toBe(4096);
|
||||
initial.run("CREATE TABLE existing_data (value TEXT NOT NULL)");
|
||||
} finally {
|
||||
closeQuietly(initial);
|
||||
}
|
||||
|
||||
const reopened = openDatabase(path, { pageSize: 16384 });
|
||||
try {
|
||||
expect(pageSize(reopened)).toBe(4096);
|
||||
} finally {
|
||||
closeQuietly(reopened);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not set page size on in-memory databases", () => {
|
||||
const db = openDatabase(":memory:", { pageSize: 16384 });
|
||||
try {
|
||||
expect(pageSize(db)).toBe(4096);
|
||||
} finally {
|
||||
closeQuietly(db);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user