mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
fix(skills): bound cold response lifetime through cleanup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
79a80a39d2
commit
9d7921d1ec
4 files changed
+47
-5
No files matched your search
@@ -112,6 +112,8 @@ pending delete/existence requests; incomplete cleanup retains the receipt for re
|
|||||||
Identical cold version/file reads are coalesced. Verified scratch restores repopulate
|
Identical cold version/file reads are coalesced. Verified scratch restores repopulate
|
||||||
the shared cache under a short, nonblocking publication lock, so subsequent reads
|
the shared cache under a short, nonblocking publication lock, so subsequent reads
|
||||||
reuse local bytes. Eviction verification runs outside that publication lock.
|
reuse local bytes. Eviction verification runs outside that publication lock.
|
||||||
|
Cold administrative responses also have a two-response, 32-waiter bound held
|
||||||
|
through post-fill cleanup, so decoded inventories cannot accumulate behind storage work.
|
||||||
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.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { skillPackageWork } from "../services/skill-package-work.js";
|
import { skillPackageWork } from "../services/skill-package-work.js";
|
||||||
|
import * as packageCache from "../services/skill-package-cache.js";
|
||||||
import * as recovery from "../services/skill-package-recovery.js";
|
import * as recovery from "../services/skill-package-recovery.js";
|
||||||
import type { GitHubRead } from "../services/github-skill-source.js";
|
import type { GitHubRead } from "../services/github-skill-source.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
@@ -546,6 +547,35 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
|||||||
} finally { clearTimeout(timeout); release(); await Promise.allSettled([reading, warming]); }
|
} finally { clearTimeout(timeout); release(); await Promise.allSettled([reading, warming]); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bounds cold response lifetimes while cleanup is waiting without blocking warm preparation", async () => {
|
||||||
|
const fixtures = [await fixture(), await fixture(), await fixture()];
|
||||||
|
let bothEntered!: () => void;
|
||||||
|
const started = new Promise<void>(resolve => { bothEntered = resolve; });
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
let decoded = 0;
|
||||||
|
const trim = vi.spyOn(packageCache, "trimSkillPackageCache").mockImplementation(async () => {
|
||||||
|
if (++decoded === 2) bothEntered();
|
||||||
|
await blocked;
|
||||||
|
});
|
||||||
|
const reads = fixtures.slice(0, 2).map(f => f.store.versionFiles(f.skills[0]!.version));
|
||||||
|
const storageRead = vi.spyOn(storage, "getObject");
|
||||||
|
try {
|
||||||
|
await started;
|
||||||
|
reads.push(fixtures[2]!.store.versionFiles(fixtures[2]!.skills[0]!.version));
|
||||||
|
expect((await fixtures[0]!.resolve()).every(entry => entry.sourceStatus === "available")).toBe(true);
|
||||||
|
// Let the third metadata/manifest read settle. Its archive and response
|
||||||
|
// must remain queued while two decoded responses are owned by cleanup.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
expect(decoded).toBe(2);
|
||||||
|
const third = (await fixtures[2]!.store.descriptor(fixtures[2]!.companyId, fixtures[2]!.id)).storage!;
|
||||||
|
expect(storageRead.mock.calls.filter(call => call[1] === third.archiveKey)).toHaveLength(0);
|
||||||
|
release();
|
||||||
|
expect((await Promise.all(reads)).every(files => files[0]!.path === "SKILL.md")).toBe(true);
|
||||||
|
expect(trim).toHaveBeenCalledTimes(3);
|
||||||
|
} finally { release(); await Promise.allSettled(reads); }
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds concurrent cached inventories while leaving warm turn preparation responsive", async () => {
|
it("bounds concurrent cached inventories while leaving warm turn preparation responsive", async () => {
|
||||||
const f = await fixture();
|
const f = await fixture();
|
||||||
await f.resolve();
|
await f.resolve();
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export function touchSkillPackage(directory: string) {
|
|||||||
* check. Version projections and run pins have their own existing retention. */
|
* check. Version projections and run pins have their own existing retention. */
|
||||||
export async function trimSkillPackageCache(db: Db, companyId: string, directory: string,
|
export async function trimSkillPackageCache(db: Db, companyId: string, directory: string,
|
||||||
verifyDurable: (id: string, signal: AbortSignal) => Promise<boolean>,
|
verifyDurable: (id: string, signal: AbortSignal) => Promise<boolean>,
|
||||||
removeVerified: (action: () => Promise<void>) => Promise<void> = action => action()) {
|
removeVerified: (action: () => Promise<void>) => Promise<void> = action => action(), cancellation?: AbortSignal) {
|
||||||
|
cancellation?.throwIfAborted();
|
||||||
const parent = path.dirname(directory);
|
const parent = path.dirname(directory);
|
||||||
await ensureSkillCacheDirectory(parent);
|
await ensureSkillCacheDirectory(parent);
|
||||||
const contents = await fs.readdir(parent, { withFileTypes: true });
|
const contents = await fs.readdir(parent, { withFileTypes: true });
|
||||||
@@ -51,7 +52,8 @@ export async function trimSkillPackageCache(db: Db, companyId: string, directory
|
|||||||
const entries = candidates.filter(entry => entry !== null);
|
const entries = candidates.filter(entry => entry !== null);
|
||||||
let bytes = entries.reduce((total, entry) => total + entry.bytes, 0);
|
let bytes = entries.reduce((total, entry) => total + entry.bytes, 0);
|
||||||
let count = entries.length;
|
let count = entries.length;
|
||||||
const signal = AbortSignal.timeout(10_000);
|
const timeout = AbortSignal.timeout(10_000);
|
||||||
|
const signal = cancellation ? AbortSignal.any([cancellation, timeout]) : timeout;
|
||||||
let attempted = 0;
|
let attempted = 0;
|
||||||
for (const entry of entries.sort((a, b) => a.lastUsed - b.lastUsed)) {
|
for (const entry of entries.sort((a, b) => a.lastUsed - b.lastUsed)) {
|
||||||
if (bytes <= MAX_BYTES && count <= MAX_PACKAGES) break;
|
if (bytes <= MAX_BYTES && count <= MAX_PACKAGES) break;
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ function sharedFlight<T>(db: Db, key: string, cancellation: AbortSignal | undefi
|
|||||||
}
|
}
|
||||||
type PackageIoQueue = { active: number; waiting: Array<() => void> };
|
type PackageIoQueue = { active: number; waiting: Array<() => void> };
|
||||||
const storageIo: PackageIoQueue = { active: 0, waiting: [] };
|
const storageIo: PackageIoQueue = { active: 0, waiting: [] };
|
||||||
|
// A decoded cold response remains owned until its post-fill cleanup finishes.
|
||||||
|
const coldResponses: PackageIoQueue = { active: 0, waiting: [] };
|
||||||
// Bound local response buffers independently so stalled storage does not prevent
|
// Bound local response buffers independently so stalled storage does not prevent
|
||||||
// verified cached reads, including reads inside an existing DB transaction.
|
// verified cached reads, including reads inside an existing DB transaction.
|
||||||
const inventoryIo: PackageIoQueue = { active: 0, waiting: [] };
|
const inventoryIo: PackageIoQueue = { active: 0, waiting: [] };
|
||||||
@@ -324,12 +326,17 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
|
|||||||
}
|
}
|
||||||
if (!await verifySkillPackageDirectory(staging, manifest.files)) throw new Error("Restored skill package does not match its manifest");
|
if (!await verifySkillPackageDirectory(staging, manifest.files)) throw new Error("Restored skill package does not match its manifest");
|
||||||
}
|
}
|
||||||
// Admin reads and retention verification own a scratch directory. They never
|
// Archive reads and verification own private scratch space outside the warm
|
||||||
// wait for or hold the lock used to prepare and pin an agent's warm files.
|
// preparation lock. Administrative reads briefly try that lock to publish.
|
||||||
async function withIsolatedPackage<T>(companyId: string, id: string,
|
async function withIsolatedPackage<T>(companyId: string, id: string,
|
||||||
consume: (root: string, manifest: SkillPackageManifest) => Promise<T>, allowRecovery = true, cancellation?: AbortSignal, cacheRead = false) {
|
consume: (root: string, manifest: SkillPackageManifest) => Promise<T>, allowRecovery = true, cancellation?: AbortSignal, cacheRead = false) {
|
||||||
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
|
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
|
const read = () => readIsolatedPackage(companyId, id, consume, allowRecovery, signal, cacheRead);
|
||||||
|
return cacheRead ? withPackageIo(read, signal, coldResponses) : read();
|
||||||
|
}
|
||||||
|
async function readIsolatedPackage<T>(companyId: string, id: string,
|
||||||
|
consume: (root: string, manifest: SkillPackageManifest) => Promise<T>, allowRecovery: boolean, signal: AbortSignal, cacheRead: boolean) {
|
||||||
const row = await descriptor(companyId, id);
|
const row = await descriptor(companyId, id);
|
||||||
const manifest = await manifestFor(row, signal);
|
const manifest = await manifestFor(row, signal);
|
||||||
let published = false;
|
let published = false;
|
||||||
@@ -371,8 +378,9 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
|
|||||||
async remove => {
|
async remove => {
|
||||||
try { await withSkillVersionCacheLock(cacheOwner, companyId, remove, true); }
|
try { await withSkillVersionCacheLock(cacheOwner, companyId, remove, true); }
|
||||||
catch (error) { if (!(error instanceof SkillCacheBusy)) throw error; }
|
catch (error) { if (!(error instanceof SkillCacheBusy)) throw error; }
|
||||||
});
|
}, signal);
|
||||||
}
|
}
|
||||||
|
signal.throwIfAborted();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
async function materialize(row: Descriptor, manifest: SkillPackageManifest) {
|
async function materialize(row: Descriptor, manifest: SkillPackageManifest) {
|
||||||
|
|||||||
Reference in new issue
Block a user