fix(app): bound Android provider snapshot storage (#3234)

Android AsyncStorage's 6 MiB SQLite ceiling was exhausted by per-workspace provider snapshots with no eviction. Raise the native cap to 10 MiB and bound snapshots to 4 MiB with indexed eviction, clearing legacy unindexed data without scanning payloads on every save.
This commit is contained in:
Mohamed Boudra
2026-08-11 22:00:21 +02:00
committed by GitHub
parent 40bdb9a043
commit 7b167784d7
5 changed files with 678 additions and 42 deletions
+2
View File
@@ -1,6 +1,7 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const { getNativeReleaseVersion } = require("./native-release-version");
@@ -138,6 +139,7 @@ export default {
},
plugins: [
"expo-router",
[withAndroidAsyncStorageSize, 10],
...buildProfile.cameraPlugins,
[
"expo-splash-screen",
@@ -0,0 +1,24 @@
const { withGradleProperties } = require("expo/config-plugins");
const ASYNC_STORAGE_SIZE_PROPERTY = "AsyncStorage_db_size_in_MB";
function withAndroidAsyncStorageSize(config, sizeInMegabytes) {
return withGradleProperties(config, (modConfig) => {
const existingProperty = modConfig.modResults.find(
(property) => property.type === "property" && property.key === ASYNC_STORAGE_SIZE_PROPERTY,
);
if (existingProperty?.type === "property") {
existingProperty.value = String(sizeInMegabytes);
return modConfig;
}
modConfig.modResults.push({
type: "property",
key: ASYNC_STORAGE_SIZE_PROPERTY,
value: String(sizeInMegabytes),
});
return modConfig;
});
}
module.exports = withAndroidAsyncStorageSize;
@@ -0,0 +1,27 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("Android AsyncStorage configuration", () => {
it("writes a 10 MiB database limit into Gradle properties", () => {
const appDirectory = path.resolve(import.meta.dirname, "..");
const output = execFileSync(
process.execPath,
[
"-e",
`
const plugin = require("./plugins/with-android-async-storage-size");
const config = plugin({ name: "Paseo", slug: "paseo" }, 10);
const mod = config.mods.android.gradleProperties;
mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config })
.then((result) => process.stdout.write(JSON.stringify(result.modResults)));
`,
],
{ cwd: appDirectory, encoding: "utf8" },
);
expect(JSON.parse(output)).toEqual([
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" },
]);
});
});
@@ -1,24 +1,144 @@
import { describe, expect, it } from "vitest";
import { Buffer } from "buffer";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
import { createProviderSnapshotCache } from "./provider-snapshot-cache";
import { z } from "zod";
import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache";
function createStorage() {
const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:";
const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1";
const SnapshotIndexSchema = z.object({
version: z.literal(1),
entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })),
});
function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) {
const values = new Map<string, string>();
const stats = { getAllKeysCalls: 0 };
type Operation = "setItem" | "removeItem" | "multiRemove";
let failure: { operation: Operation; timing: "before" | "after"; key?: string } | null = null;
function takeFailure(operation: Operation, timing: "before" | "after", key?: string): boolean {
if (
failure?.operation !== operation ||
failure.timing !== timing ||
(failure.key !== undefined && failure.key !== key)
) {
return false;
}
failure = null;
return true;
}
return {
values,
stats,
failNext(operation: Operation, timing: "before" | "after", key?: string) {
failure = { operation, timing, key };
},
async getItem(key: string) {
return values.get(key) ?? null;
},
async setItem(key: string, value: string) {
if (takeFailure("setItem", "before", key)) throw new Error("Injected setItem failure");
if (key.startsWith(SNAPSHOT_KEY_PREFIX)) {
const bytesWithoutReplacement = snapshotBytes(
new Map([...values].filter(([storedKey]) => storedKey !== key)),
);
if (
bytesWithoutReplacement +
Buffer.byteLength(key, "utf8") +
Buffer.byteLength(value, "utf8") >
maxSnapshotBytes
) {
throw new Error("Injected storage capacity failure");
}
}
values.set(key, value);
if (takeFailure("setItem", "after", key)) throw new Error("Injected setItem failure");
},
async removeItem(key: string) {
if (takeFailure("removeItem", "before", key)) throw new Error("Injected removeItem failure");
values.delete(key);
if (takeFailure("removeItem", "after", key)) throw new Error("Injected removeItem failure");
},
async getAllKeys() {
stats.getAllKeysCalls += 1;
return [...values.keys()];
},
async multiGet(keys: readonly string[]) {
return keys.map((key) => [key, values.get(key) ?? null] as const);
},
async multiRemove(keys: readonly string[]) {
if (takeFailure("multiRemove", "before")) throw new Error("Injected multiRemove failure");
for (const [index, key] of keys.entries()) {
values.delete(key);
if (index === 0 && takeFailure("multiRemove", "after")) {
throw new Error("Injected partial multiRemove failure");
}
}
},
};
}
function snapshotEntries(label: string): ProviderSnapshotEntry[] {
return [
{
provider: "pi",
status: "ready",
enabled: true,
models: [
{
provider: "pi",
id: label,
label: label.repeat(20),
thinkingOptions: [],
},
],
},
];
}
function snapshotBytes(values: Map<string, string>): number {
return [...values]
.filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX))
.reduce(
(total, [key, value]) =>
total + Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"),
0,
);
}
function expectIndexMatchesSnapshots(values: Map<string, string>): void {
const storedIndex = values.get(SNAPSHOT_INDEX_KEY);
if (!storedIndex) throw new Error("Expected provider snapshot index");
const index = SnapshotIndexSchema.parse(JSON.parse(storedIndex));
const storedSnapshots = [...values]
.filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX))
.map(([key, value]) => ({
key,
bytes: Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"),
}))
.toSorted((left, right) => left.key.localeCompare(right.key));
const indexedSnapshots = index.entries
.map(({ key, bytes }) => ({ key, bytes }))
.toSorted((left, right) => left.key.localeCompare(right.key));
expect(indexedSnapshots).toEqual(storedSnapshots);
}
function writeSnapshot(
cache: ProviderSnapshotCache,
input: { cwd: string; label: string; generatedAt: string },
): Promise<void> {
return cache.write({
serverId: "server-1",
cwd: input.cwd,
hash: input.label,
generatedAt: input.generatedAt,
compactSnapshot: compactProviderSnapshot(snapshotEntries(input.label)),
});
}
describe("provider snapshot cache", () => {
it("round-trips compact snapshots with shared thinking option references", async () => {
const thinkingOptions = [{ id: "high", label: "High", isDefault: true }];
@@ -60,6 +180,356 @@ describe("provider snapshot cache", () => {
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/repo"]', "not json");
await expect(cache.read("server-1", "/repo")).resolves.toBeNull();
expect(storage.values.size).toBe(0);
expect([...storage.values.keys()].some((key) => key.startsWith(SNAPSHOT_KEY_PREFIX))).toBe(
false,
);
});
it("evicts the oldest snapshots to keep the cache within its byte budget", async () => {
const storage = createStorage();
const maxBytes = 1_000;
const cache = createProviderSnapshotCache(storage, { maxBytes });
await Promise.all([
cache.write({
serverId: "server-1",
cwd: "/oldest",
hash: "oldest",
generatedAt: "2026-08-01T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("oldest")),
}),
cache.write({
serverId: "server-1",
cwd: "/middle",
hash: "middle",
generatedAt: "2026-08-02T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("middle")),
}),
cache.write({
serverId: "server-1",
cwd: "/newest",
hash: "newest",
generatedAt: "2026-08-03T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("newest")),
}),
]);
await expect(cache.read("server-1", "/oldest")).resolves.toBeNull();
await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
});
it("clears unindexed snapshots once when creating the cache index", async () => {
const storage = createStorage();
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy");
const cache = createProviderSnapshotCache(storage);
await cache.write({
serverId: "server-1",
cwd: "/current",
hash: "current",
generatedAt: "2026-08-03T00:00:00.000Z",
compactSnapshot: compactProviderSnapshot(snapshotEntries("current")),
});
expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false);
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
});
it("clears unindexed snapshots on the first read", async () => {
const storage = createStorage();
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy");
const cache = createProviderSnapshotCache(storage);
await expect(cache.read("server-1", "/legacy")).resolves.toBeNull();
expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false);
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
});
it("initializes the cache index once when the first read and write race", async () => {
const storage = createStorage();
storage.values.set('@paseo/provider-snapshot/v1:["server-1","/legacy"]', "legacy");
const cache = createProviderSnapshotCache(storage);
await Promise.all([
cache.read("server-1", "/legacy"),
writeSnapshot(cache, {
cwd: "/current",
label: "current",
generatedAt: "2026-08-02T00:00:00.000Z",
}),
]);
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
});
it("replaces an indexed snapshot without counting both versions", async () => {
const storage = createStorage();
const maxBytes = 1_000;
const cache = createProviderSnapshotCache(storage, { maxBytes });
await writeSnapshot(cache, {
cwd: "/repo",
label: "first",
generatedAt: "2026-08-01T00:00:00.000Z",
});
await writeSnapshot(cache, {
cwd: "/repo",
label: "replacement-is-larger",
generatedAt: "2026-08-02T00:00:00.000Z",
});
await expect(cache.read("server-1", "/repo")).resolves.toMatchObject({
hash: "replacement-is-larger",
});
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expect(
[...storage.values.keys()].filter((key) => key.startsWith(SNAPSHOT_KEY_PREFIX)),
).toHaveLength(1);
expectIndexMatchesSnapshots(storage.values);
});
it("removes the previous value when a replacement exceeds the entire budget", async () => {
const storage = createStorage();
const cache = createProviderSnapshotCache(storage, { maxBytes: 1_000 });
await writeSnapshot(cache, {
cwd: "/repo",
label: "cached",
generatedAt: "2026-08-01T00:00:00.000Z",
});
const tinyCache = createProviderSnapshotCache(storage, { maxBytes: 1 });
await writeSnapshot(tinyCache, {
cwd: "/repo",
label: "too-large",
generatedAt: "2026-08-02T00:00:00.000Z",
});
await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull();
expect(snapshotBytes(storage.values)).toBe(0);
expectIndexMatchesSnapshots(storage.values);
});
it("resets corrupted index state before accepting another snapshot", async () => {
const storage = createStorage();
storage.values.set(SNAPSHOT_INDEX_KEY, "corrupt");
storage.values.set(`${SNAPSHOT_KEY_PREFIX}legacy`, "legacy");
const cache = createProviderSnapshotCache(storage);
await writeSnapshot(cache, {
cwd: "/current",
label: "current",
generatedAt: "2026-08-02T00:00:00.000Z",
});
expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false);
await expect(cache.read("server-1", "/current")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
});
it("measures UTF-8 bytes rather than JavaScript string length", async () => {
const stagingStorage = createStorage();
const stagingCache = createProviderSnapshotCache(stagingStorage);
await writeSnapshot(stagingCache, {
cwd: "/emoji",
label: "🧨".repeat(100),
generatedAt: "2026-08-02T00:00:00.000Z",
});
const storedSnapshot = [...stagingStorage.values].find(([key]) =>
key.startsWith(SNAPSHOT_KEY_PREFIX),
);
if (!storedSnapshot) throw new Error("Expected staged provider snapshot");
const [key, value] = storedSnapshot;
const codeUnitBytes = key.length + value.length;
const utf8Bytes = Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8");
expect(utf8Bytes).toBeGreaterThan(codeUnitBytes);
const storage = createStorage();
const cache = createProviderSnapshotCache(storage, { maxBytes: codeUnitBytes });
await writeSnapshot(cache, {
cwd: "/emoji",
label: "🧨".repeat(100),
generatedAt: "2026-08-02T00:00:00.000Z",
});
await expect(cache.read("server-1", "/emoji")).resolves.toBeNull();
expectIndexMatchesSnapshots(storage.values);
});
it("keeps hundreds of concurrent writes bounded without rescanning snapshots", async () => {
const storage = createStorage();
const maxBytes = 8_000;
const cache = createProviderSnapshotCache(storage, { maxBytes });
const writes = Array.from({ length: 250 }, (_, index) =>
writeSnapshot(cache, {
cwd: `/repo-${index}`,
label: `snapshot-${index}-${"x".repeat(index % 40)}`,
generatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(),
}),
);
await Promise.all(writes);
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(1);
});
it.each(["before", "after"] as const)(
"recovers when a snapshot write fails %s its side effect",
async (timing) => {
const storage = createStorage();
const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 });
await writeSnapshot(cache, {
cwd: "/stable",
label: "stable",
generatedAt: "2026-08-01T00:00:00.000Z",
});
const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`;
storage.failNext("setItem", timing, failedKey);
await writeSnapshot(cache, {
cwd: "/failed",
label: "failed",
generatedAt: "2026-08-02T00:00:00.000Z",
});
const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 });
await writeSnapshot(restarted, {
cwd: "/next",
label: "next",
generatedAt: "2026-08-03T00:00:00.000Z",
});
await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull();
await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000);
expectIndexMatchesSnapshots(storage.values);
},
);
it.each(["before", "after"] as const)(
"reconciles snapshot records when the derived index write fails %s its side effect",
async (timing) => {
const storage = createStorage();
const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 });
await writeSnapshot(cache, {
cwd: "/stable",
label: "stable",
generatedAt: "2026-08-01T00:00:00.000Z",
});
storage.failNext("setItem", timing, SNAPSHOT_INDEX_KEY);
await writeSnapshot(cache, {
cwd: "/orphan-after-index-failure",
label: "orphan",
generatedAt: "2026-08-02T00:00:00.000Z",
});
const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 });
await expect(
restarted.read("server-1", "/orphan-after-index-failure"),
).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
expect(storage.stats.getAllKeysCalls).toBe(2);
},
);
it("repairs partial eviction, stale index metadata, and preserves unrelated storage", async () => {
const storage = createStorage();
const maxBytes = 1_000;
const cache = createProviderSnapshotCache(storage, { maxBytes });
storage.values.set("@paseo/unrelated", "keep-me");
await writeSnapshot(cache, {
cwd: "/oldest",
label: "oldest",
generatedAt: "2026-08-01T00:00:00.000Z",
});
await writeSnapshot(cache, {
cwd: "/middle",
label: "middle",
generatedAt: "2026-08-02T00:00:00.000Z",
});
storage.failNext("multiRemove", "after");
await writeSnapshot(cache, {
cwd: "/large-newest",
label: "newest",
generatedAt: "2026-08-03T00:00:00.000Z",
});
storage.values.set(
SNAPSHOT_INDEX_KEY,
JSON.stringify({
version: 1,
entries: [{ key: `${SNAPSHOT_KEY_PREFIX}phantom`, bytes: 999_999, writtenAt: "bad" }],
}),
);
const restarted = createProviderSnapshotCache(storage, { maxBytes });
await writeSnapshot(restarted, {
cwd: "/final",
label: "final",
generatedAt: "2026-08-04T00:00:00.000Z",
});
expect(storage.values.get("@paseo/unrelated")).toBe("keep-me");
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expectIndexMatchesSnapshots(storage.values);
await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull();
});
it("evicts before writing when storage has no temporary headroom", async () => {
const maxBytes = 1_000;
const storage = createStorage(maxBytes);
const cache = createProviderSnapshotCache(storage, { maxBytes });
await writeSnapshot(cache, {
cwd: "/oldest",
label: "oldest",
generatedAt: "2026-08-01T00:00:00.000Z",
});
await writeSnapshot(cache, {
cwd: "/middle",
label: "middle",
generatedAt: "2026-08-02T00:00:00.000Z",
});
await writeSnapshot(cache, {
cwd: "/newest",
label: "newest",
generatedAt: "2026-08-03T00:00:00.000Z",
});
await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull();
expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes);
expectIndexMatchesSnapshots(storage.values);
});
it.each(["before", "after"] as const)(
"recovers when invalid-record cleanup fails %s deletion",
async (timing) => {
const storage = createStorage();
const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`;
storage.values.set(key, "invalid");
storage.failNext("multiRemove", timing);
const cache = createProviderSnapshotCache(storage);
await expect(cache.read("server-1", "/invalid")).resolves.toBeNull();
await writeSnapshot(cache, {
cwd: "/recovered",
label: "recovered",
generatedAt: "2026-08-03T00:00:00.000Z",
});
expect(storage.values.has(key)).toBe(false);
await expect(cache.read("server-1", "/recovered")).resolves.not.toBeNull();
expectIndexMatchesSnapshots(storage.values);
},
);
});
+152 -39
View File
@@ -1,4 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Buffer } from "buffer";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import {
expandProviderSnapshot,
@@ -8,11 +9,21 @@ import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages";
const CACHE_VERSION = 1;
const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1";
const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v1";
const CACHE_INDEX_VERSION = 1;
const DEFAULT_MAX_CACHE_BYTES = 4 * 1024 * 1024;
interface ProviderSnapshotStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
getAllKeys(): Promise<readonly string[]>;
multiGet(keys: readonly string[]): Promise<ReadonlyArray<readonly [string, string | null]>>;
multiRemove(keys: readonly string[]): Promise<void>;
}
interface ProviderSnapshotCacheOptions {
maxBytes?: number;
}
interface StoredProviderSnapshot {
@@ -22,6 +33,17 @@ interface StoredProviderSnapshot {
compactSnapshot: CompactProviderSnapshot;
}
interface ProviderSnapshotIndexEntry {
key: string;
bytes: number;
writtenAt: string;
}
interface ProviderSnapshotIndex {
version: typeof CACHE_INDEX_VERSION;
entries: ProviderSnapshotIndexEntry[];
}
export interface CachedProviderSnapshot extends StoredProviderSnapshot {
entries: ProviderSnapshotEntry[];
}
@@ -48,6 +70,15 @@ function cacheKey(serverId: string, cwd: string | null): string {
return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`;
}
function storedBytes(key: string, value: string): number {
return Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8");
}
function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number {
const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt);
return writtenAtOrder !== 0 ? writtenAtOrder : left.key.localeCompare(right.key);
}
function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null {
const parsed: unknown = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null) return null;
@@ -65,60 +96,142 @@ function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | nu
) {
return null;
}
return {
version,
hash,
generatedAt,
compactSnapshot: compactSnapshot.data,
};
}
async function discardCachedSnapshot(storage: ProviderSnapshotStorage, key: string): Promise<void> {
try {
await storage.removeItem(key);
} catch (error) {
if (!(error instanceof Error)) throw error;
}
return { version, hash, generatedAt, compactSnapshot: compactSnapshot.data };
}
export function createProviderSnapshotCache(
storage: ProviderSnapshotStorage,
options: ProviderSnapshotCacheOptions = {},
): ProviderSnapshotCache {
const maxBytes = options.maxBytes ?? DEFAULT_MAX_CACHE_BYTES;
let index: ProviderSnapshotIndex | null = null;
let operationQueue = Promise.resolve();
async function persistIndex(entries: ProviderSnapshotIndexEntry[]): Promise<void> {
const nextIndex: ProviderSnapshotIndex = { version: CACHE_INDEX_VERSION, entries };
await storage.setItem(CACHE_INDEX_KEY, JSON.stringify(nextIndex));
index = nextIndex;
}
async function reconcileCache(): Promise<void> {
const keys = (await storage.getAllKeys()).filter((key) =>
key.startsWith(`${CACHE_KEY_PREFIX}:`),
);
const values = await storage.multiGet(keys);
const keysToRemove: string[] = [];
const entries: ProviderSnapshotIndexEntry[] = [];
for (const [key, value] of values) {
if (value === null) continue;
try {
const stored = parseStoredProviderSnapshot(value);
if (!stored) {
keysToRemove.push(key);
continue;
}
entries.push({ key, bytes: storedBytes(key, value), writtenAt: stored.generatedAt });
} catch (error) {
if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error;
keysToRemove.push(key);
}
}
let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0);
const retainedEntries = entries.toSorted(oldestFirst);
while (totalBytes > maxBytes) {
const evicted = retainedEntries.shift();
if (!evicted) break;
totalBytes -= evicted.bytes;
keysToRemove.push(evicted.key);
}
if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove);
await persistIndex(retainedEntries);
}
async function ensureCacheIndex(): Promise<ProviderSnapshotIndex> {
if (index === null) await reconcileCache();
if (index === null) throw new Error("Provider snapshot cache reconciliation failed");
return index;
}
async function runSerialized<T>(operation: () => Promise<T>): Promise<T> {
const result = operationQueue.then(operation);
operationQueue = result.then(
() => undefined,
() => {
index = null;
},
);
return result;
}
async function writeSnapshot(input: {
serverId: string;
cwd: string | null;
hash: string;
generatedAt: string;
compactSnapshot: CompactProviderSnapshot;
}): Promise<void> {
const currentIndex = await ensureCacheIndex();
const key = cacheKey(input.serverId, input.cwd);
const value = JSON.stringify({
version: CACHE_VERSION,
hash: input.hash,
generatedAt: input.generatedAt,
compactSnapshot: input.compactSnapshot,
} satisfies StoredProviderSnapshot);
const incomingBytes = storedBytes(key, value);
const canStoreIncoming = incomingBytes <= maxBytes;
const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key);
let projectedBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0);
if (canStoreIncoming) projectedBytes += incomingBytes;
const keysToRemove: string[] = [];
for (const candidate of retainedEntries.toSorted(oldestFirst)) {
if (projectedBytes <= maxBytes) break;
projectedBytes -= candidate.bytes;
keysToRemove.push(candidate.key);
}
const removedKeys = new Set(keysToRemove);
const nextEntries = retainedEntries.filter((entry) => !removedKeys.has(entry.key));
if (!canStoreIncoming && currentIndex.entries.some((entry) => entry.key === key)) {
keysToRemove.push(key);
}
if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove);
if (canStoreIncoming) {
await storage.setItem(key, value);
nextEntries.push({ key, bytes: incomingBytes, writtenAt: input.generatedAt });
}
await persistIndex(nextEntries);
}
return {
async read(serverId, cwd) {
const key = cacheKey(serverId, cwd);
let value: string | null;
try {
value = await storage.getItem(key);
return await runSerialized(async () => {
const currentIndex = await ensureCacheIndex();
const key = cacheKey(serverId, cwd);
const value = await storage.getItem(key);
if (value === null) return null;
try {
const stored = parseStoredProviderSnapshot(value);
if (!stored) throw new SyntaxError("Invalid provider snapshot");
return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) };
} catch (error) {
if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error;
await storage.removeItem(key);
await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key));
return null;
}
});
} catch (error) {
if (error instanceof Error) return null;
throw error;
}
if (value === null) return null;
try {
const stored = parseStoredProviderSnapshot(value);
if (!stored) {
await discardCachedSnapshot(storage, key);
return null;
}
return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) };
} catch (error) {
if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) {
throw error;
}
await discardCachedSnapshot(storage, key);
return null;
}
},
async write(input) {
const stored: StoredProviderSnapshot = {
version: CACHE_VERSION,
hash: input.hash,
generatedAt: input.generatedAt,
compactSnapshot: input.compactSnapshot,
};
try {
await storage.setItem(cacheKey(input.serverId, input.cwd), JSON.stringify(stored));
await runSerialized(() => writeSnapshot(input));
} catch (error) {
if (!(error instanceof Error)) throw error;
}