mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
fix(skills): bound concurrent cached version reads
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1eb90d80be
commit
4b7589e14c
3 files changed
+97
-22
No files matched your search
@@ -106,6 +106,9 @@ pending delete/existence requests; incomplete cleanup retains the receipt for re
|
|||||||
the selected source and delivered asset on reuse. It retains no repository bodies.
|
the selected source and delivered asset on reuse. It retains no repository bodies.
|
||||||
Source edits invalidate the reference; missing or corrupt copies rebuild from verified bytes.
|
Source edits invalidate the reference; missing or corrupt copies rebuild from verified bytes.
|
||||||
- Concurrent cold package I/O is limited to two operations, with a queue of at most 32.
|
- Concurrent cold package I/O is limited to two operations, with a queue of at most 32.
|
||||||
|
Cached version inventories and file previews have a separate two-reader, 32-waiter
|
||||||
|
bound, so slow storage cannot prevent verified local reads. Both queues honor deadlines
|
||||||
|
and cancellation; an overloaded local-read queue does not trigger a storage download.
|
||||||
Packages share the Git scanner's limits of 10,000 files and 100 MiB decoded bytes.
|
Packages share the Git scanner's limits of 10,000 files and 100 MiB decoded bytes.
|
||||||
Recovery splits reads within the Git reader's 9,990-file and 61 MiB batch limits.
|
Recovery splits reads within the Git reader's 9,990-file and 61 MiB batch limits.
|
||||||
Individual manifests are limited to 48 MiB; the manifest cache holds 16 MiB of serialized data.
|
Individual manifests are limited to 48 MiB; the manifest cache holds 16 MiB of serialized data.
|
||||||
|
|||||||
@@ -454,6 +454,72 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
|||||||
await expect(f.store.versionFiles(saved.version)).rejects.toThrow("storage offline");
|
await expect(f.store.versionFiles(saved.version)).rejects.toThrow("storage offline");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bounds concurrent cached inventories while leaving warm turn preparation responsive", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.resolve();
|
||||||
|
const target = path.join(skillPackagePaths(f.companyId, f.id).directory, files[0]!.path);
|
||||||
|
const open = fs.open.bind(fs);
|
||||||
|
let entered = 0;
|
||||||
|
let bothEntered!: () => void;
|
||||||
|
const started = new Promise<void>(resolve => { bothEntered = resolve; });
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
vi.spyOn(fs, "open").mockImplementation(async (...args) => {
|
||||||
|
if (args[0] === target) {
|
||||||
|
if (++entered === 2) bothEntered();
|
||||||
|
await blocked;
|
||||||
|
}
|
||||||
|
return open(...args);
|
||||||
|
});
|
||||||
|
const version = { ...f.skills[0]!.version, packageFileMap: null };
|
||||||
|
const storageRead = vi.spyOn(storage, "getObject");
|
||||||
|
const reads = [f.store.versionFiles(version), f.store.versionFiles(version)];
|
||||||
|
let preview: ReturnType<typeof f.store.readVersionFile> | undefined;
|
||||||
|
try {
|
||||||
|
await started;
|
||||||
|
reads.push(f.store.versionFiles(version));
|
||||||
|
preview = f.store.readVersionFile(version, files[0]!.path);
|
||||||
|
reads.push(...Array.from({ length: 30 }, () => f.store.versionFiles(version)));
|
||||||
|
// These queries share the one-connection pool, so the third inventory has
|
||||||
|
// loaded its descriptor by the time preparation finishes. Its byte read
|
||||||
|
// must still be queued behind the two active readers.
|
||||||
|
expect((await f.resolve()).every(entry => entry.sourceStatus === "available")).toBe(true);
|
||||||
|
expect(entered).toBe(2);
|
||||||
|
await expect(f.store.versionFiles(version)).rejects.toThrow("busy");
|
||||||
|
expect(storageRead).not.toHaveBeenCalled();
|
||||||
|
release();
|
||||||
|
expect(await Promise.all(reads)).toEqual(reads.map(() => files));
|
||||||
|
expect(await preview).toMatchObject(files[0]!);
|
||||||
|
} finally { release(); await Promise.allSettled([...reads, preview]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads cached versions while both storage operations are stalled", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.resolve();
|
||||||
|
const get = storage.getObject.bind(storage);
|
||||||
|
let entered = 0;
|
||||||
|
let bothEntered!: () => void;
|
||||||
|
const started = new Promise<void>(resolve => { bothEntered = resolve; });
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
vi.spyOn(storage, "getObject").mockImplementation(async (...args) => {
|
||||||
|
if (++entered === 2) bothEntered();
|
||||||
|
await blocked;
|
||||||
|
return get(...args);
|
||||||
|
});
|
||||||
|
const pending = [f.store.withIsolatedPackage(f.companyId, f.id, async () => null),
|
||||||
|
f.store.withIsolatedPackage(f.companyId, f.id, async () => null)];
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
await started;
|
||||||
|
const reading = db.transaction(tx => skillPackageStore(tx as unknown as typeof db, storage)
|
||||||
|
.versionFiles({ ...f.skills[0]!.version, packageFileMap: null }));
|
||||||
|
expect(await Promise.race([reading, new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error("Cached read waited for storage")), 1_000);
|
||||||
|
})])).toEqual(files);
|
||||||
|
} finally { clearTimeout(timeout); release(); await Promise.allSettled(pending); }
|
||||||
|
});
|
||||||
|
|
||||||
it("retries expired upload claims with fresh keys and only deletes the abandoned attempt", async () => {
|
it("retries expired upload claims with fresh keys and only deletes the abandoned attempt", async () => {
|
||||||
const companyId = await company();
|
const companyId = await company();
|
||||||
const store = skillPackageStore(db, storage);
|
const store = skillPackageStore(db, storage);
|
||||||
|
|||||||
@@ -50,25 +50,28 @@ function flight<T>(db: Db, key: string, action: () => Promise<T>): Promise<T> {
|
|||||||
pending.set(key, work);
|
pending.set(key, work);
|
||||||
return work;
|
return work;
|
||||||
}
|
}
|
||||||
let activeIo = 0;
|
type PackageIoQueue = { active: number; waiting: Array<() => void> };
|
||||||
const waitingIo: Array<() => void> = [];
|
const storageIo: PackageIoQueue = { active: 0, waiting: [] };
|
||||||
async function withPackageIo<T>(action: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
// Bound local response buffers independently so stalled storage does not prevent
|
||||||
|
// verified cached reads, including reads inside an existing DB transaction.
|
||||||
|
const inventoryIo: PackageIoQueue = { active: 0, waiting: [] };
|
||||||
|
async function withPackageIo<T>(action: () => Promise<T>, signal: AbortSignal, queue = storageIo): Promise<T> {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
if (activeIo >= 2) {
|
if (queue.active >= 2) {
|
||||||
if (waitingIo.length >= 32) throw conflict("Skill package storage is busy; retry shortly.");
|
if (queue.waiting.length >= 32) throw conflict("Skill package storage is busy; retry shortly.");
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const ready = () => { signal.removeEventListener("abort", aborted); resolve(); };
|
const ready = () => { signal.removeEventListener("abort", aborted); resolve(); };
|
||||||
const aborted = () => {
|
const aborted = () => {
|
||||||
const index = waitingIo.indexOf(ready);
|
const index = queue.waiting.indexOf(ready);
|
||||||
if (index >= 0) waitingIo.splice(index, 1);
|
if (index >= 0) queue.waiting.splice(index, 1);
|
||||||
reject(signal.reason);
|
reject(signal.reason);
|
||||||
};
|
};
|
||||||
waitingIo.push(ready);
|
queue.waiting.push(ready);
|
||||||
signal.addEventListener("abort", aborted, { once: true });
|
signal.addEventListener("abort", aborted, { once: true });
|
||||||
});
|
});
|
||||||
} else activeIo++;
|
} else queue.active++;
|
||||||
try { signal.throwIfAborted(); return await action(); }
|
try { signal.throwIfAborted(); return await action(); }
|
||||||
finally { const next = waitingIo.shift(); if (next) next(); else activeIo--; }
|
finally { const next = queue.waiting.shift(); if (next) next(); else queue.active--; }
|
||||||
}
|
}
|
||||||
const manifests = new Map<string, { manifest: SkillPackageManifest; bytes: number }>();
|
const manifests = new Map<string, { manifest: SkillPackageManifest; bytes: number }>();
|
||||||
let manifestBytes = 0;
|
let manifestBytes = 0;
|
||||||
@@ -497,15 +500,16 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
|
|||||||
} finally { await handle.close(); }
|
} finally { await handle.close(); }
|
||||||
}
|
}
|
||||||
async function versionFiles(version: StoredSkillVersion, allowRecovery = true, cancellation?: AbortSignal): Promise<CompanySkillVersionFileInventoryEntry[]> {
|
async function versionFiles(version: StoredSkillVersion, allowRecovery = true, cancellation?: AbortSignal): Promise<CompanySkillVersionFileInventoryEntry[]> {
|
||||||
cancellation?.throwIfAborted();
|
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
|
||||||
|
signal.throwIfAborted();
|
||||||
if (!version.packageSnapshotId) {
|
if (!version.packageSnapshotId) {
|
||||||
const [row] = await db.select({ ...skillVersionMetadataColumns, files: companySkillVersions.fileInventory }).from(companySkillVersions).where(and(
|
const [row] = await db.select({ ...skillVersionMetadataColumns, files: companySkillVersions.fileInventory }).from(companySkillVersions).where(and(
|
||||||
eq(companySkillVersions.companyId, version.companyId), eq(companySkillVersions.id, version.id)));
|
eq(companySkillVersions.companyId, version.companyId), eq(companySkillVersions.id, version.id)));
|
||||||
if (row?.packageSnapshotId) return versionFiles(row, allowRecovery, cancellation);
|
if (row?.packageSnapshotId) return versionFiles(row, allowRecovery, signal);
|
||||||
return row?.files ?? [];
|
return row?.files ?? [];
|
||||||
}
|
}
|
||||||
const id = version.packageSnapshotId;
|
const id = version.packageSnapshotId;
|
||||||
const manifest = await manifestFor(await descriptor(version.companyId, id), cancellation);
|
const manifest = await manifestFor(await descriptor(version.companyId, id), signal);
|
||||||
const read = async (root: string) => {
|
const read = async (root: string) => {
|
||||||
const byPath = new Map(manifest.files.map(file => [file.path, file]));
|
const byPath = new Map(manifest.files.map(file => [file.path, file]));
|
||||||
const result: CompanySkillVersionFileInventoryEntry[] = [];
|
const result: CompanySkillVersionFileInventoryEntry[] = [];
|
||||||
@@ -516,7 +520,7 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
|
|||||||
if (!file) throw new Error("Skill version references a missing package file");
|
if (!file) throw new Error("Skill version references a missing package file");
|
||||||
const encoding = mapping.encoding ?? file.encoding;
|
const encoding = mapping.encoding ?? file.encoding;
|
||||||
result.push({ path: mapping.path, kind: mapping.kind,
|
result.push({ path: mapping.path, kind: mapping.kind,
|
||||||
content: (await readPackageFile(root, file, cancellation)).toString(encoding),
|
content: (await readPackageFile(root, file, signal)).toString(encoding),
|
||||||
...(encoding === "base64" ? { encoding } : {}), ...(file.executable ? { executable: true } : {}) });
|
...(encoding === "base64" ? { encoding } : {}), ...(file.executable ? { executable: true } : {}) });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -524,14 +528,13 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
|
|||||||
// Verify bytes as they are read, without holding the warm preparation lock.
|
// Verify bytes as they are read, without holding the warm preparation lock.
|
||||||
// A descriptor survives eviction; a missing/racing file falls back to storage.
|
// A descriptor survives eviction; a missing/racing file falls back to storage.
|
||||||
const root = skillPackagePaths(version.companyId, id).directory;
|
const root = skillPackagePaths(version.companyId, id).directory;
|
||||||
try {
|
const result = await withPackageIo(() => read(root).catch(() => null), signal, inventoryIo);
|
||||||
const result = await read(root);
|
if (result) {
|
||||||
touchSkillPackage(root);
|
touchSkillPackage(root);
|
||||||
return result;
|
return result;
|
||||||
} catch {
|
|
||||||
cancellation?.throwIfAborted();
|
|
||||||
return withIsolatedPackage(version.companyId, id, read, allowRecovery, cancellation);
|
|
||||||
}
|
}
|
||||||
|
signal.throwIfAborted();
|
||||||
|
return withIsolatedPackage(version.companyId, id, read, allowRecovery, signal);
|
||||||
}
|
}
|
||||||
async function readVersionFile(version: StoredSkillVersion, relativePath: string, allowRecovery = true) {
|
async function readVersionFile(version: StoredSkillVersion, relativePath: string, allowRecovery = true) {
|
||||||
assertSkillSnapshotPath(relativePath);
|
assertSkillSnapshotPath(relativePath);
|
||||||
@@ -542,16 +545,19 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
|
|||||||
if (version.packageFileMap && !mapping) return undefined;
|
if (version.packageFileMap && !mapping) return undefined;
|
||||||
const entry = manifest.files.find(file => file.path === (mapping?.sourcePath ?? relativePath));
|
const entry = manifest.files.find(file => file.path === (mapping?.sourcePath ?? relativePath));
|
||||||
if (!entry) return undefined;
|
if (!entry) return undefined;
|
||||||
|
const signal = AbortSignal.timeout(180_000);
|
||||||
const read = async (root: string) => {
|
const read = async (root: string) => {
|
||||||
const bytes = await readPackageFile(root, entry);
|
const bytes = await readPackageFile(root, entry, signal);
|
||||||
const encoding = mapping?.encoding ?? entry.encoding;
|
const encoding = mapping?.encoding ?? entry.encoding;
|
||||||
return { path: relativePath, kind: mapping?.kind ?? entry.kind, content: bytes.toString(encoding),
|
return { path: relativePath, kind: mapping?.kind ?? entry.kind, content: bytes.toString(encoding),
|
||||||
encoding, executable: entry.executable };
|
encoding, executable: entry.executable };
|
||||||
};
|
};
|
||||||
// Immutable cache files can be read without the preparation lock. An open
|
// Immutable cache files can be read without the preparation lock. An open
|
||||||
// descriptor survives eviction; missing/racing files fall back to storage.
|
// descriptor survives eviction; missing/racing files fall back to storage.
|
||||||
try { return await read(skillPackagePaths(version.companyId, id).directory); }
|
const cached = await withPackageIo(() => read(skillPackagePaths(version.companyId, id).directory).catch(() => null), signal, inventoryIo);
|
||||||
catch { return withIsolatedPackage(version.companyId, id, read, allowRecovery); }
|
if (cached) return cached;
|
||||||
|
signal.throwIfAborted();
|
||||||
|
return withIsolatedPackage(version.companyId, id, read, allowRecovery, signal);
|
||||||
}
|
}
|
||||||
async function versionInventory(version: StoredSkillVersion): Promise<Array<{ path: string; kind: CompanySkillVersionFileInventoryEntry["kind"] }>> {
|
async function versionInventory(version: StoredSkillVersion): Promise<Array<{ path: string; kind: CompanySkillVersionFileInventoryEntry["kind"] }>> {
|
||||||
if (version.packageSnapshotId) {
|
if (version.packageSnapshotId) {
|
||||||
|
|||||||
Reference in new issue
Block a user