blob: 5558fd56027dc37d28844be106d22b09bc2b16f8 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { DatabaseSync } from 'node:sqlite';
export const SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION = 3;
const SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS = 5_000;
const SQLITE_INITIALIZATION_RETRY_DELAY_MS = 10;
const initializationRetryGate = new Int32Array(new SharedArrayBuffer(4));
const INITIAL_SCHEMA = `
CREATE TABLE context_blobs (
blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32),
storage_kind TEXT NOT NULL CHECK(storage_kind IN ('inline', 'managed_file')),
payload BLOB NOT NULL,
size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
created_at INTEGER NOT NULL CHECK(created_at >= 0),
CHECK(
(storage_kind = 'inline' AND length(payload) = size_bytes) OR
(storage_kind = 'managed_file' AND length(payload) BETWEEN 1 AND 512)
)
);
CREATE TABLE context_refs (
ref_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
owner_kind TEXT NOT NULL CHECK(
owner_kind IN ('read_image_snapshot', 'tool_result_archive')
),
owner_id TEXT NOT NULL,
blob_id BLOB NOT NULL REFERENCES context_blobs(blob_id) ON DELETE RESTRICT,
media_type TEXT NOT NULL,
created_at INTEGER NOT NULL CHECK(created_at >= 0),
UNIQUE(session_id, owner_kind, owner_id)
);
CREATE INDEX context_refs_session
ON context_refs(session_id, created_at, ref_id);
CREATE INDEX context_refs_blob
ON context_refs(blob_id);
CREATE TABLE context_gc_candidates (
blob_id BLOB PRIMARY KEY
REFERENCES context_blobs(blob_id) ON DELETE CASCADE,
unreferenced_at INTEGER NOT NULL CHECK(unreferenced_at >= 0)
);
CREATE INDEX context_gc_candidates_eligible
ON context_gc_candidates(unreferenced_at, blob_id);
CREATE TABLE context_file_deletions (
locator BLOB PRIMARY KEY CHECK(length(locator) BETWEEN 1 AND 512),
size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
enqueued_at INTEGER NOT NULL CHECK(enqueued_at >= 0)
);
CREATE INDEX context_file_deletions_pending
ON context_file_deletions(enqueued_at, locator);
CREATE TABLE context_session_usage (
session_id TEXT PRIMARY KEY,
reference_count INTEGER NOT NULL CHECK(reference_count >= 0),
logical_bytes INTEGER NOT NULL CHECK(logical_bytes >= 0)
);
CREATE TABLE context_store_usage (
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
blob_count INTEGER NOT NULL CHECK(blob_count >= 0),
physical_bytes INTEGER NOT NULL CHECK(physical_bytes >= 0)
);
INSERT INTO context_store_usage(singleton, blob_count, physical_bytes)
VALUES (1, 0, 0);
`;
const SCHEMA_V2_MIGRATION = `
CREATE TABLE context_gc_candidates (
blob_id BLOB PRIMARY KEY
REFERENCES context_blobs(blob_id) ON DELETE CASCADE,
unreferenced_at INTEGER NOT NULL CHECK(unreferenced_at >= 0)
);
CREATE INDEX context_gc_candidates_eligible
ON context_gc_candidates(unreferenced_at, blob_id);
INSERT INTO context_gc_candidates(blob_id, unreferenced_at)
SELECT b.blob_id, b.created_at
FROM context_blobs b
WHERE NOT EXISTS (
SELECT 1 FROM context_refs r WHERE r.blob_id = b.blob_id
);
`;
const SCHEMA_V3_MIGRATION = `
CREATE TABLE context_blobs_v3 (
blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32),
storage_kind TEXT NOT NULL CHECK(storage_kind IN ('inline', 'managed_file')),
payload BLOB NOT NULL,
size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
created_at INTEGER NOT NULL CHECK(created_at >= 0),
CHECK(
(storage_kind = 'inline' AND length(payload) = size_bytes) OR
(storage_kind = 'managed_file' AND length(payload) BETWEEN 1 AND 512)
)
);
CREATE TABLE context_refs_v3 (
ref_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
owner_kind TEXT NOT NULL CHECK(
owner_kind IN ('read_image_snapshot', 'tool_result_archive')
),
owner_id TEXT NOT NULL,
blob_id BLOB NOT NULL REFERENCES context_blobs_v3(blob_id) ON DELETE RESTRICT,
media_type TEXT NOT NULL,
created_at INTEGER NOT NULL CHECK(created_at >= 0),
UNIQUE(session_id, owner_kind, owner_id)
);
CREATE TABLE context_gc_candidates_v3 (
blob_id BLOB PRIMARY KEY
REFERENCES context_blobs_v3(blob_id) ON DELETE CASCADE,
unreferenced_at INTEGER NOT NULL CHECK(unreferenced_at >= 0)
);
INSERT INTO context_blobs_v3(blob_id, storage_kind, payload, size_bytes, created_at)
SELECT blob_id, 'inline', payload, size_bytes, created_at FROM context_blobs;
INSERT INTO context_refs_v3(
ref_id, session_id, owner_kind, owner_id, blob_id, media_type, created_at
)
SELECT ref_id, session_id, owner_kind, owner_id, blob_id, media_type, created_at
FROM context_refs;
INSERT INTO context_gc_candidates_v3(blob_id, unreferenced_at)
SELECT blob_id, unreferenced_at FROM context_gc_candidates;
DROP TABLE context_refs;
DROP TABLE context_gc_candidates;
DROP TABLE context_blobs;
ALTER TABLE context_blobs_v3 RENAME TO context_blobs;
ALTER TABLE context_refs_v3 RENAME TO context_refs;
ALTER TABLE context_gc_candidates_v3 RENAME TO context_gc_candidates;
CREATE INDEX context_refs_session
ON context_refs(session_id, created_at, ref_id);
CREATE INDEX context_refs_blob
ON context_refs(blob_id);
CREATE INDEX context_gc_candidates_eligible
ON context_gc_candidates(unreferenced_at, blob_id);
CREATE TABLE context_file_deletions (
locator BLOB PRIMARY KEY CHECK(length(locator) BETWEEN 1 AND 512),
size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
enqueued_at INTEGER NOT NULL CHECK(enqueued_at >= 0)
);
CREATE INDEX context_file_deletions_pending
ON context_file_deletions(enqueued_at, locator);
`;
const REQUIRED_SCHEMA_OBJECTS = Object.freeze([
['table', 'context_blobs'],
['table', 'context_refs'],
['table', 'context_session_usage'],
['table', 'context_store_usage'],
['table', 'context_gc_candidates'],
['table', 'context_file_deletions'],
['index', 'context_refs_session'],
['index', 'context_refs_blob'],
['index', 'context_gc_candidates_eligible'],
['index', 'context_file_deletions_pending'],
] as const);
const REQUIRED_TABLE_COLUMNS = Object.freeze({
context_blobs: ['blob_id', 'storage_kind', 'payload', 'size_bytes', 'created_at'],
context_refs: [
'ref_id',
'session_id',
'owner_kind',
'owner_id',
'blob_id',
'media_type',
'created_at',
],
context_session_usage: ['session_id', 'reference_count', 'logical_bytes'],
context_store_usage: ['singleton', 'blob_count', 'physical_bytes'],
context_gc_candidates: ['blob_id', 'unreferenced_at'],
context_file_deletions: ['locator', 'size_bytes', 'enqueued_at'],
} as const);
const REQUIRED_INDEX_COLUMNS = Object.freeze({
context_refs_session: ['session_id', 'created_at', 'ref_id'],
context_refs_blob: ['blob_id'],
context_gc_candidates_eligible: ['unreferenced_at', 'blob_id'],
context_file_deletions_pending: ['enqueued_at', 'locator'],
} as const);
export function configureSqliteContextOffloadDatabase(db: DatabaseSync): void {
db.exec(`PRAGMA busy_timeout = ${SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS}`);
// WAL initialization fixes the database header in a form where changing
// auto_vacuum from NONE is no longer accepted. Configure it first, while a
// brand-new dedicated database still has no application schema objects.
if (readSqliteContextOffloadSchemaVersion(db) === 0 && !hasApplicationSchemaObjects(db)) {
db.exec('PRAGMA auto_vacuum = INCREMENTAL');
}
ensureWalJournalMode(db);
db.exec('PRAGMA synchronous = FULL');
db.exec('PRAGMA foreign_keys = ON');
}
export function migrateSqliteContextOffloadDatabase(db: DatabaseSync): void {
const observedVersion = readSqliteContextOffloadSchemaVersion(db);
if (observedVersion > SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION) {
throw newerSchemaError(observedVersion);
}
if (observedVersion === SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION) {
validateSchema(db);
return;
}
db.exec('BEGIN IMMEDIATE');
try {
let current = readSqliteContextOffloadSchemaVersion(db);
if (current > SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION) throw newerSchemaError(current);
if (current === 0) {
if (hasApplicationSchemaObjects(db)) {
throw new Error('Unversioned context-offload SQLite schema is not supported');
}
db.exec(INITIAL_SCHEMA);
db.exec(`PRAGMA user_version = ${SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION}`);
current = SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION;
}
if (current === 1) {
db.exec(SCHEMA_V2_MIGRATION);
db.exec('PRAGMA user_version = 2');
current = 2;
}
if (current === 2) {
db.exec(SCHEMA_V3_MIGRATION);
db.exec(`PRAGMA user_version = ${SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION}`);
current = SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION;
}
validateSchema(db);
db.exec('COMMIT');
} catch (error) {
rollback(db);
throw error;
}
}
export function readSqliteContextOffloadSchemaVersion(db: DatabaseSync): number {
const row = retryWhileSqliteBusy(
() => db.prepare('PRAGMA user_version').get() as { user_version?: unknown } | undefined,
);
const value = row?.user_version;
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new Error('Invalid context-offload SQLite schema version');
}
return value;
}
function validateSchema(db: DatabaseSync): void {
const autoVacuum = db.prepare('PRAGMA auto_vacuum').get() as
| { auto_vacuum?: unknown }
| undefined;
if (autoVacuum?.auto_vacuum !== 2) {
throw new Error('Incomplete context-offload SQLite schema: incremental auto-vacuum required');
}
const readObject = db.prepare('SELECT type FROM sqlite_schema WHERE name = ?');
for (const [type, name] of REQUIRED_SCHEMA_OBJECTS) {
const row = readObject.get(name) as { type?: unknown } | undefined;
if (row?.type !== type) {
throw new Error(`Incomplete context-offload SQLite schema: missing ${type} ${name}`);
}
}
for (const [table, requiredColumns] of Object.entries(REQUIRED_TABLE_COLUMNS)) {
const columns = new Set(
(db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: unknown }>).flatMap(
(row) => (typeof row.name === 'string' ? [row.name] : []),
),
);
for (const column of requiredColumns) {
if (!columns.has(column)) {
throw new Error(
`Incomplete context-offload SQLite schema: table ${table} is missing column ${column}`,
);
}
}
}
for (const [index, requiredColumns] of Object.entries(REQUIRED_INDEX_COLUMNS)) {
const columns = (
db.prepare(`PRAGMA index_info(${index})`).all() as Array<{
seqno?: unknown;
name?: unknown;
}>
)
.filter(
(row): row is { seqno: number; name: string } =>
typeof row.seqno === 'number' && typeof row.name === 'string',
)
.sort((left, right) => left.seqno - right.seqno)
.map((row) => row.name);
if (requiredColumns.some((column, position) => columns[position] !== column)) {
throw new Error(
`Incomplete context-offload SQLite schema: index ${index} has incompatible columns`,
);
}
}
const usage = db
.prepare('SELECT blob_count, physical_bytes FROM context_store_usage WHERE singleton = 1')
.get() as { blob_count?: unknown; physical_bytes?: unknown } | undefined;
if (!isNonNegativeInteger(usage?.blob_count) || !isNonNegativeInteger(usage.physical_bytes)) {
throw new Error('Incomplete context-offload SQLite schema: missing store usage row');
}
}
function hasApplicationSchemaObjects(db: DatabaseSync): boolean {
const row = db
.prepare(
`SELECT 1 AS present FROM sqlite_schema
WHERE name NOT LIKE 'sqlite_%' LIMIT 1`,
)
.get() as { present?: unknown } | undefined;
return row?.present === 1;
}
function ensureWalJournalMode(db: DatabaseSync): void {
const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS;
while (true) {
const journalMode = readJournalMode(db);
if (journalMode === 'wal' || journalMode === 'memory') return;
try {
db.exec('PRAGMA journal_mode = WAL');
const configuredMode = readJournalMode(db);
if (configuredMode !== 'wal') {
throw new Error(
`Context-offload SQLite requires WAL journal mode, received ${configuredMode}`,
);
}
return;
} catch (error) {
if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
Atomics.wait(
initializationRetryGate,
0,
0,
Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())),
);
}
}
}
function readJournalMode(db: DatabaseSync): string {
const row = retryWhileSqliteBusy(
() => db.prepare('PRAGMA journal_mode').get() as { journal_mode?: unknown } | undefined,
);
if (typeof row?.journal_mode !== 'string') {
throw new Error('Invalid context-offload SQLite journal mode');
}
return row.journal_mode.toLowerCase();
}
function retryWhileSqliteBusy<T>(operation: () => T): T {
const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS;
while (true) {
try {
return operation();
} catch (error) {
if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
Atomics.wait(
initializationRetryGate,
0,
0,
Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())),
);
}
}
}
function isSqliteBusy(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const code = 'code' in error ? String(error.code) : '';
return code === 'SQLITE_BUSY' || /database (?:is )?(?:locked|busy)/i.test(error.message);
}
function newerSchemaError(version: number): Error {
return new Error(
`Context-offload SQLite schema ${version} is newer than supported version ${SQLITE_CONTEXT_OFFLOAD_SCHEMA_VERSION}`,
);
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
function rollback(db: DatabaseSync): void {
try {
db.exec('ROLLBACK');
} catch {
// Preserve the migration failure that triggered rollback.
}
}