bdd3da17ef
Replace the v2->current version UPDATE with a DELETE that clears every row not matching the active schema, so cache-schema bumps actually invalidate stale entries (including pre-V2 Codex rows that still lack remoteCompaction.v2StreamingEnabled). Fixes #4146
172 lines
5.1 KiB
TypeScript
172 lines
5.1 KiB
TypeScript
/**
|
|
* SQLite-backed model cache for atomic cross-process access.
|
|
* Replaces per-provider JSON files with a single cache.db.
|
|
*/
|
|
import { Database } from "bun:sqlite";
|
|
import { getModelDbPath } from "@oh-my-pi/pi-utils";
|
|
import type { Api, Model, ModelSpec } from "./types";
|
|
|
|
// Rows persist ModelSpec JSON (sparse `compat`, never the resolved record);
|
|
// the model manager rebuilds via `buildModel` on load. v8 invalidates Codex
|
|
// discovery rows predating provider-native V2 compaction metadata; v7
|
|
// invalidated rows predating the Antigravity Gemini budget-mode migration
|
|
// (cached specs still carrying `thinking.mode: "google-level"` and the old
|
|
// 3.5-flash effort routing); v6 invalidated rows that may contain the retired
|
|
// unknown-limit sentinels (222222/8888); v5 invalidated rows predating
|
|
// effort-tier variant collapsing (raw `-low`/`-high`/`-thinking` member ids);
|
|
// v4 dropped the pre-efforts ThinkingConfig shape.
|
|
const CACHE_SCHEMA_VERSION = 8;
|
|
|
|
interface CacheRow {
|
|
provider_id: string;
|
|
version: number;
|
|
updated_at: number;
|
|
authoritative: number;
|
|
static_fingerprint: string;
|
|
models: string;
|
|
}
|
|
|
|
interface TableInfoRow {
|
|
name: string;
|
|
}
|
|
|
|
interface CacheEntry<TApi extends Api = Api> {
|
|
models: ModelSpec<TApi>[];
|
|
fresh: boolean;
|
|
authoritative: boolean;
|
|
updatedAt: number;
|
|
/**
|
|
* Hash of the static catalog slice that was merged into `models` when this
|
|
* row was written. `resolveProviderModels` compares against the current
|
|
* static fingerprint and bypasses the static+cache re-merge when they
|
|
* match — the cache already incorporates the same static state.
|
|
*/
|
|
staticFingerprint: string;
|
|
}
|
|
|
|
let sharedDb: Database | null = null;
|
|
let sharedDbPath: string | null = null;
|
|
|
|
function openDb(resolvedPath: string): Database {
|
|
const db = new Database(resolvedPath, { create: true });
|
|
// Install the busy handler BEFORE any lock-taking statement. See
|
|
// https://github.com/can1357/oh-my-pi/issues/2421.
|
|
db.run("PRAGMA busy_timeout = 3000");
|
|
db.run("PRAGMA journal_mode = WAL");
|
|
db.run(`
|
|
CREATE TABLE IF NOT EXISTS model_cache (
|
|
provider_id TEXT PRIMARY KEY,
|
|
version INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
authoritative INTEGER NOT NULL DEFAULT 0,
|
|
static_fingerprint TEXT NOT NULL DEFAULT '',
|
|
models TEXT NOT NULL
|
|
)
|
|
`);
|
|
migrateCacheSchema(db);
|
|
return db;
|
|
}
|
|
|
|
function getSharedDb(): Database {
|
|
const resolvedPath = getModelDbPath();
|
|
if (sharedDb && sharedDbPath === resolvedPath) {
|
|
return sharedDb;
|
|
}
|
|
if (sharedDb) {
|
|
sharedDb.close();
|
|
}
|
|
const db = openDb(resolvedPath);
|
|
sharedDb = db;
|
|
sharedDbPath = resolvedPath;
|
|
return db;
|
|
}
|
|
|
|
function withModelCacheDb<T>(dbPath: string | undefined, useDb: (db: Database) => T): T {
|
|
if (!dbPath) return useDb(getSharedDb());
|
|
const db = openDb(dbPath);
|
|
try {
|
|
return useDb(db);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function migrateCacheSchema(db: Database): void {
|
|
const stmt = db.prepare("PRAGMA table_info(model_cache)");
|
|
try {
|
|
const columns = stmt.all() as TableInfoRow[];
|
|
if (!columns.some(column => column.name === "static_fingerprint")) {
|
|
db.run("ALTER TABLE model_cache ADD COLUMN static_fingerprint TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
} finally {
|
|
stmt.finalize();
|
|
}
|
|
// Delete rows written under any older schema so they cannot be reused. The
|
|
// legacy `UPDATE ... WHERE version = 2` migration silently promoted the very
|
|
// first cache version to whatever the current one is, defeating every
|
|
// subsequent invalidation (see #4146: pre-V2 Codex rows kept the legacy
|
|
// compaction path even after CACHE_SCHEMA_VERSION was bumped).
|
|
db.run("DELETE FROM model_cache WHERE version <> ?", [CACHE_SCHEMA_VERSION]);
|
|
}
|
|
|
|
export function readModelCache<TApi extends Api>(
|
|
providerId: string,
|
|
ttlMs: number,
|
|
now: () => number,
|
|
dbPath?: string,
|
|
): CacheEntry<TApi> | null {
|
|
try {
|
|
return withModelCacheDb(dbPath, db => {
|
|
const stmt = db.query<CacheRow, [string]>("SELECT * FROM model_cache WHERE provider_id = ?");
|
|
try {
|
|
const row = stmt.get(providerId);
|
|
if (!row || row.version !== CACHE_SCHEMA_VERSION) {
|
|
return null;
|
|
}
|
|
const models = JSON.parse(row.models) as ModelSpec<TApi>[];
|
|
const ageMs = now() - row.updated_at;
|
|
const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
|
|
return {
|
|
models,
|
|
fresh,
|
|
authoritative: row.authoritative === 1,
|
|
updatedAt: row.updated_at,
|
|
staticFingerprint: row.static_fingerprint ?? "",
|
|
};
|
|
} finally {
|
|
stmt.finalize();
|
|
}
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function writeModelCache<TApi extends Api>(
|
|
providerId: string,
|
|
updatedAt: number,
|
|
models: Model<TApi>[],
|
|
authoritative: boolean,
|
|
staticFingerprint: string,
|
|
dbPath?: string,
|
|
): void {
|
|
try {
|
|
withModelCacheDb(dbPath, db => {
|
|
db.run(
|
|
`INSERT OR REPLACE INTO model_cache (provider_id, version, updated_at, authoritative, static_fingerprint, models)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
providerId,
|
|
CACHE_SCHEMA_VERSION,
|
|
updatedAt,
|
|
authoritative ? 1 : 0,
|
|
staticFingerprint,
|
|
JSON.stringify(models.map(model => ({ ...model, compat: model.compatConfig, compatConfig: undefined }))),
|
|
],
|
|
);
|
|
});
|
|
} catch {
|
|
// Cache writes are best-effort; failures should not break model resolution.
|
|
}
|
|
}
|