fix(skills): isolate cold read database access

Keep caller metadata outside response permits and use dedicated connections for acquisition and cleanup. Cover a transaction contending with two cold reads on a one-connection pool.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-28 18:31:39 -05:00
1 parent 9d7921d1ec
commit a94f0e409f
3 files changed
+79 -25

No files matched your search

+3
View File
@@ -114,6 +114,9 @@ pending delete/existence requests; incomplete cleanup retains the receipt for re
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 Cold administrative responses also have a two-response, 32-waiter bound held
through post-fill cleanup, so decoded inventories cannot accumulate behind storage work. through post-fill cleanup, so decoded inventories cannot accumulate behind storage work.
Caller-scoped metadata is read before taking that permit. Acquisition and cleanup
use a private database connection, so queued transaction readers cannot deadlock
the ordinary pool, including instances configured with one connection.
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.
@@ -576,6 +576,48 @@ describe.skipIf(!support.supported)("external skill packages", () => {
} finally { release(); await Promise.allSettled(reads); } } finally { release(); await Promise.allSettled(reads); }
}); });
it("does not deadlock a transaction behind cold reads on a one-connection pool", async () => {
const fixtures = [await fixture(), await fixture(), await fixture()];
const roots = new Map(fixtures.slice(0, 2).map(f => [skillPackagePaths(f.companyId, f.id).directory, 0]));
let release!: () => void;
const blocked = new Promise<void>(resolve => { release = resolve; });
const lstat = fs.lstat.bind(fs);
const stat = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
const root = String(args[0]);
if (roots.has(root)) {
roots.set(root, roots.get(root)! + 1);
await blocked;
throw Object.assign(new Error("Cold package"), { code: "ENOENT" });
}
return lstat(...args);
});
const reads = fixtures.slice(0, 2).map(f => f.store.versionFiles(f.skills[0]!.version));
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
// Both callers have loaded metadata but have not entered cold fallback.
await vi.waitFor(() => expect([...roots.values()]).toEqual([1, 1]));
await db.transaction(async tx => {
await tx.execute(sql`select 1`);
release();
// Their second cache misses now queue the cold fallback while this
// transaction owns the only ordinary connection.
await vi.waitFor(() => expect([...roots.values()]).toEqual([2, 2]));
const cancellation = new AbortController();
timeout = setTimeout(() => cancellation.abort(new Error("Cold read deadlocked the pool")), 1_000);
const third = fixtures[2]!;
expect(await skillPackageStore(tx as unknown as typeof db, storage, db)
.versionFiles(third.skills[0]!.version, true, cancellation.signal))
.toEqual([{ path: "SKILL.md", kind: "skill", content: files[0]!.content }]);
});
} finally {
clearTimeout(timeout);
release();
stat.mockRestore();
await Promise.allSettled(reads);
}
expect((await Promise.all(reads)).every(result => result[0]!.content === files[0]!.content)).toBe(true);
});
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();
+34 -25
View File
@@ -7,7 +7,7 @@ import { constants, createReadStream, createWriteStream, promises as fs } from "
import path from "node:path"; import path from "node:path";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm"; import { and, eq, getTableColumns, inArray, sql } from "drizzle-orm";
import { companySkillRepositorySnapshots as snapshots, companySkillVersions, companySkillSources, type Db, type SkillPackageFileMapping, type SkillPackageStorageReceipt, type SkillPackageRestoreRecipe } from "@paperclipai/db"; import { companySkillRepositorySnapshots as snapshots, companySkillVersions, companySkillSources, withDedicatedDbConnection, type Db, type SkillPackageFileMapping, type SkillPackageStorageReceipt, type SkillPackageRestoreRecipe } from "@paperclipai/db";
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared"; import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
import { getStorageService, type StorageService } from "../storage/index.js"; import { getStorageService, type StorageService } from "../storage/index.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js";
@@ -193,8 +193,8 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
context.descriptors.set(`${companyId}:${row.id}`, Promise.resolve(row)); context.descriptors.set(`${companyId}:${row.id}`, Promise.resolve(row));
} }
} }
async function restoreRecipe(row: Descriptor) { async function restoreRecipe(row: Descriptor, database = db) {
const [saved] = await db.select({ restore: snapshots.restore }).from(snapshots).where(scope(row.companyId, row.id)); const [saved] = await database.select({ restore: snapshots.restore }).from(snapshots).where(scope(row.companyId, row.id));
const recipe = saved?.restore; const recipe = saved?.restore;
if (!recipe || recipe.version !== 1) throw notFound("This skill package has no exact-commit restore recipe"); if (!recipe || recipe.version !== 1) throw notFound("This skill package has no exact-commit restore recipe");
const manifest = validateSkillPackageManifest(recipe.manifest); const manifest = validateSkillPackageManifest(recipe.manifest);
@@ -203,23 +203,23 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
} }
return recipe; return recipe;
} }
async function acquireFiles(row: Descriptor, signal: AbortSignal) { async function acquireFiles(row: Descriptor, signal: AbortSignal, database = db) {
signal.throwIfAborted(); signal.throwIfAborted();
const [current] = await db.select({ retryAt: snapshots.restoreRetryAt }).from(snapshots).where(scope(row.companyId, row.id)); const [current] = await database.select({ retryAt: snapshots.restoreRetryAt }).from(snapshots).where(scope(row.companyId, row.id));
if (current?.retryAt && current.retryAt.getTime() > Date.now()) throw conflict("The original skill package could not be acquired. Reconnect its repository and retry after the recovery backoff."); if (current?.retryAt && current.retryAt.getTime() > Date.now()) throw conflict("The original skill package could not be acquired. Reconnect its repository and retry after the recovery backoff.");
try { try {
const recipe = await restoreRecipe(row); const recipe = await restoreRecipe(row, database);
const read = await skillPackageRecoveryReader(db, row.companyId, recipe); const read = await skillPackageRecoveryReader(database, row.companyId, recipe);
const files = await recoverSkillPackage(recipe, read, signal); const files = await recoverSkillPackage(recipe, read, signal);
await db.update(snapshots).set({ restoreRetryAt: null }).where(scope(row.companyId, row.id)); await database.update(snapshots).set({ restoreRetryAt: null }).where(scope(row.companyId, row.id));
return files; return files;
} catch (error) { } catch (error) {
if (!signal.aborted) await db.update(snapshots).set({ restoreRetryAt: new Date(Date.now() + 5 * 60_000) }).where(scope(row.companyId, row.id)); if (!signal.aborted) await database.update(snapshots).set({ restoreRetryAt: new Date(Date.now() + 5 * 60_000) }).where(scope(row.companyId, row.id));
throw error; throw error;
} }
} }
async function legacyFiles(row: Descriptor) { async function legacyFiles(row: Descriptor, database = db) {
const [legacy] = await db.select({ files: snapshots.fileInventory, storage: snapshots.storage, upload: snapshots.upload }) const [legacy] = await database.select({ files: snapshots.fileInventory, storage: snapshots.storage, upload: snapshots.upload })
.from(snapshots).where(scope(row.companyId, row.id)); .from(snapshots).where(scope(row.companyId, row.id));
if (!legacy || (!legacy.files.length && !legacy.storage)) throw notFound("Installed skill package has no saved contents"); if (!legacy || (!legacy.files.length && !legacy.storage)) throw notFound("Installed skill package has no saved contents");
// A reader may have loaded its descriptor just before a migration clears // A reader may have loaded its descriptor just before a migration clears
@@ -288,9 +288,10 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
return sharedFlight(db, `manifest:${key}`, cancellation, load); return sharedFlight(db, `manifest:${key}`, cancellation, load);
} }
async function restoreInto(row: Descriptor, manifest: SkillPackageManifest, staging: string, async function restoreInto(row: Descriptor, manifest: SkillPackageManifest, staging: string,
archivePath: string, signal: AbortSignal, allowRecovery = true) { archivePath: string, signal: AbortSignal, allowRecovery = true, database = db,
let legacy: CompanySkillVersionFileInventoryEntry[] | null = null; initialLegacy: CompanySkillVersionFileInventoryEntry[] | null = null) {
if (!row.storage && !row.restorable) legacy = await legacyFiles(row); let legacy = initialLegacy;
if (!row.storage && !row.restorable && !legacy) legacy = await legacyFiles(row, database);
try { try {
if (row.storage) { if (row.storage) {
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed"); if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed");
@@ -305,16 +306,16 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
signal.throwIfAborted(); signal.throwIfAborted();
// A missing/corrupt archive must not hide a still-authoritative legacy // A missing/corrupt archive must not hide a still-authoritative legacy
// body. Only then use the saved, authorized immutable Git recipe. // body. Only then use the saved, authorized immutable Git recipe.
const [saved] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id)); const [saved] = await database.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id));
if (saved?.files.length && skillPackageManifest(saved.files).contentHash === row.contentHash) legacy = saved.files; if (saved?.files.length && skillPackageManifest(saved.files).contentHash === row.contentHash) legacy = saved.files;
else if (row.restorable && allowRecovery) legacy = await acquireFiles(row, signal); else if (row.restorable && allowRecovery) legacy = await acquireFiles(row, signal, database);
else throw error; else throw error;
await fs.rm(staging, { recursive: true, force: true }); await fs.rm(staging, { recursive: true, force: true });
await fs.mkdir(staging); await fs.mkdir(staging);
} }
if (!row.storage && row.restorable && !legacy) { if (!row.storage && row.restorable && !legacy) {
if (!allowRecovery) throw notFound("Skill package bytes are not stored"); if (!allowRecovery) throw notFound("Skill package bytes are not stored");
legacy = await acquireFiles(row, signal); legacy = await acquireFiles(row, signal, database);
} }
if (legacy) { if (legacy) {
for (const file of legacy) { for (const file of legacy) {
@@ -332,13 +333,20 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
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); // Preserve the caller's transaction view before taking an I/O permit. All
return cacheRead ? withPackageIo(read, signal, coldResponses) : read(); // later SQL uses a private connection, so a queued transaction cannot hold
} // the ordinary pool connection needed by an active cold response.
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);
const legacy = !row.storage && !row.restorable ? await legacyFiles(row) : null;
const read = () => withDedicatedDbConnection(cacheOwner, database =>
readIsolatedPackage(row, manifest, consume, allowRecovery, signal, cacheRead, database, legacy));
return cacheRead ? withPackageIo(read, signal, coldResponses) : read();
}
async function readIsolatedPackage<T>(row: Descriptor, manifest: SkillPackageManifest,
consume: (root: string, manifest: SkillPackageManifest) => Promise<T>, allowRecovery: boolean, signal: AbortSignal, cacheRead: boolean,
database: Db, legacy: CompanySkillVersionFileInventoryEntry[] | null) {
const { companyId, id } = row;
let published = false; let published = false;
const result = await withPackageIo(async () => { const result = await withPackageIo(async () => {
const parent = skillPackagePaths(companyId, id).parent; const parent = skillPackagePaths(companyId, id).parent;
@@ -346,7 +354,7 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
const staging = await fs.mkdtemp(path.join(parent, ".verify-")); const staging = await fs.mkdtemp(path.join(parent, ".verify-"));
const archive = `${staging}.tar.gz`; const archive = `${staging}.tar.gz`;
try { try {
await restoreInto(row, manifest, staging, archive, signal, allowRecovery); await restoreInto(row, manifest, staging, archive, signal, allowRecovery, database, legacy);
const result = await consume(staging, manifest); const result = await consume(staging, manifest);
if (cacheRead) { if (cacheRead) {
// Network, extraction, hashing and response reads all happen before // Network, extraction, hashing and response reads all happen before
@@ -373,8 +381,9 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
if (published) { if (published) {
// Verify possible eviction candidates outside both the preparation lock // Verify possible eviction candidates outside both the preparation lock
// and the storage slot used for this fill; take the lock only to remove. // and the storage slot used for this fill; take the lock only to remove.
await trimSkillPackageCache(db, companyId, skillPackagePaths(companyId, id).directory, const reader = skillPackageStore(database, storage(), cacheOwner);
(id, signal) => verifyDurable(companyId, id, signal).catch(() => false), await trimSkillPackageCache(database, companyId, skillPackagePaths(companyId, id).directory,
(id, signal) => reader.verifyDurable(companyId, id, signal).catch(() => false),
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; }