mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
fix(skills): bound legacy package body reads
Load legacy bodies only after I/O admission, including manifest derivation and fallback. Keep queued readers metadata-only and cover saturation with real PostgreSQL. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a94f0e409f
commit
276e353a37
3 files changed
+74
-13
No files matched your search
@@ -117,6 +117,8 @@ pending delete/existence requests; incomplete cleanup retains the receipt for re
|
|||||||
Caller-scoped metadata is read before taking that permit. Acquisition and cleanup
|
Caller-scoped metadata is read before taking that permit. Acquisition and cleanup
|
||||||
use a private database connection, so queued transaction readers cannot deadlock
|
use a private database connection, so queued transaction readers cannot deadlock
|
||||||
the ordinary pool, including instances configured with one connection.
|
the ordinary pool, including instances configured with one connection.
|
||||||
|
Legacy repository bodies load only inside the I/O limit, including when deriving
|
||||||
|
a missing manifest. Waiting readers retain metadata rather than repository contents.
|
||||||
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.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { and, eq, sql } from "drizzle-orm";
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots as snapshots,
|
import { companies, companySkills, companySkillVersions, companySkillRepositorySnapshots as snapshots,
|
||||||
skillPackageDeletions, createDb, withDedicatedDbConnection } from "@paperclipai/db";
|
skillPackageDeletions, createDb, withDedicatedDbConnection } from "@paperclipai/db";
|
||||||
|
import * as dbModule from "@paperclipai/db";
|
||||||
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
import type { CompanySkillVersionFileInventoryEntry } from "@paperclipai/shared";
|
||||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||||
import * as storageModule from "../storage/index.js";
|
import * as storageModule from "../storage/index.js";
|
||||||
@@ -576,6 +577,62 @@ describe.skipIf(!support.supported)("external skill packages", () => {
|
|||||||
} finally { release(); await Promise.allSettled(reads); }
|
} finally { release(); await Promise.allSettled(reads); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["manifest", "files"] as const)("does not load queued legacy bodies for %s reads", async kind => {
|
||||||
|
const f = await fixture();
|
||||||
|
const companyId = await company();
|
||||||
|
const rows = await db.insert(snapshots).values(Array.from({ length: 33 }, (_, index) => {
|
||||||
|
const inventory = files.map((file, i) => i ? file : { ...file, content: `${file.content}${index}` });
|
||||||
|
return { companyId, contentHash: skillSnapshotHash(inventory), fileInventory: inventory };
|
||||||
|
})).returning({ id: snapshots.id });
|
||||||
|
const store = skillPackageStore(db, storage);
|
||||||
|
if (kind === "files") for (const row of rows) await store.manifest(companyId, row.id);
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||||
|
let active = 0;
|
||||||
|
const occupying = Array.from({ length: 2 }, () => f.store.withIsolatedPackage(f.companyId, f.id, async () => {
|
||||||
|
active++;
|
||||||
|
await blocked;
|
||||||
|
}));
|
||||||
|
await vi.waitFor(() => expect(active).toBe(2));
|
||||||
|
let bodies = 0;
|
||||||
|
const observe = (_connection: number, query: string) => {
|
||||||
|
if (query.includes('"file_inventory"') && query.includes('"company_skill_repository_snapshots"')) bodies++;
|
||||||
|
};
|
||||||
|
const debug = db.$client.options.debug;
|
||||||
|
db.$client.options.debug = observe;
|
||||||
|
const dedicated = dbModule.withDedicatedDbConnection;
|
||||||
|
let ready = 0;
|
||||||
|
vi.spyOn(dbModule, "withDedicatedDbConnection").mockImplementation((owner, action) => dedicated(owner, connection => {
|
||||||
|
connection.$client.options.debug = observe;
|
||||||
|
if (kind === "files") ready++;
|
||||||
|
return action(connection);
|
||||||
|
}));
|
||||||
|
if (kind === "manifest") {
|
||||||
|
const parent = skillPackagePaths(companyId, rows[0]!.id).parent;
|
||||||
|
const lstat = fs.lstat.bind(fs);
|
||||||
|
vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
|
||||||
|
const result = await lstat(...args);
|
||||||
|
if (args[0] === parent) ready++;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const read = (id: string) => kind === "files" ? store.files(companyId, id, true) : store.manifest(companyId, id);
|
||||||
|
const queued = rows.slice(0, 32).map(row => read(row.id));
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(ready).toBe(32));
|
||||||
|
// The final reader observes the full queue only after all earlier readers
|
||||||
|
// have reached admission. None may preload repository bodies while waiting.
|
||||||
|
await expect(read(rows[32]!.id)).rejects.toThrow("busy");
|
||||||
|
expect(bodies).toBe(0);
|
||||||
|
release();
|
||||||
|
expect(await Promise.all(queued)).toHaveLength(32);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
await Promise.allSettled([...occupying, ...queued]);
|
||||||
|
db.$client.options.debug = debug;
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
it("does not deadlock a transaction behind cold reads on a one-connection pool", async () => {
|
it("does not deadlock a transaction behind cold reads on a one-connection pool", async () => {
|
||||||
const fixtures = [await fixture(), await fixture(), await fixture()];
|
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]));
|
const roots = new Map(fixtures.slice(0, 2).map(f => [skillPackagePaths(f.companyId, f.id).directory, 0]));
|
||||||
|
|||||||
@@ -259,8 +259,10 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
|
|||||||
row.storage!.manifestBytes, row.storage!.manifestSha256, signal), signal);
|
row.storage!.manifestBytes, row.storage!.manifestSha256, signal), signal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const [legacy] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id));
|
const saved = await withPackageIo(() => withDedicatedDbConnection(cacheOwner, async database => {
|
||||||
const saved = legacy?.files.length ? skillPackageManifestBytes(skillPackageManifest(legacy.files)) : null;
|
const [legacy] = await database.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id));
|
||||||
|
return legacy?.files.length ? skillPackageManifestBytes(skillPackageManifest(legacy.files)) : null;
|
||||||
|
}), signal);
|
||||||
if (saved && packageSha256(saved) === row.storage!.manifestSha256) bytes = saved;
|
if (saved && packageSha256(saved) === row.storage!.manifestSha256) bytes = saved;
|
||||||
else if (row.restorable) bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
|
else if (row.restorable) bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
|
||||||
else throw error;
|
else throw error;
|
||||||
@@ -269,9 +271,11 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
|
|||||||
} else if (row.restorable) {
|
} else if (row.restorable) {
|
||||||
bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
|
bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
|
||||||
} else if (!bytes) {
|
} else if (!bytes) {
|
||||||
const files = await legacyFiles(row);
|
bytes = await withPackageIo(() => withDedicatedDbConnection(cacheOwner, async database => {
|
||||||
if (row.storage) return manifestFor(row, signal);
|
const files = await legacyFiles(row, database);
|
||||||
bytes = skillPackageManifestBytes(skillPackageManifest(files));
|
return row.storage ? null : skillPackageManifestBytes(skillPackageManifest(files));
|
||||||
|
}), signal);
|
||||||
|
if (!bytes) return manifestFor(row, signal);
|
||||||
}
|
}
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
if (bytes.length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Skill manifest exceeds its byte bound");
|
if (bytes.length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Skill manifest exceeds its byte bound");
|
||||||
@@ -288,10 +292,9 @@ 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, database = db,
|
archivePath: string, signal: AbortSignal, allowRecovery = true, database = db) {
|
||||||
initialLegacy: CompanySkillVersionFileInventoryEntry[] | null = null) {
|
let legacy: CompanySkillVersionFileInventoryEntry[] | null = null;
|
||||||
let legacy = initialLegacy;
|
if (!row.storage && !row.restorable) legacy = await legacyFiles(row, database);
|
||||||
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");
|
||||||
@@ -338,14 +341,13 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService, cach
|
|||||||
// the ordinary pool connection needed by an active cold response.
|
// the ordinary pool connection needed by an active cold response.
|
||||||
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 =>
|
const read = () => withDedicatedDbConnection(cacheOwner, database =>
|
||||||
readIsolatedPackage(row, manifest, consume, allowRecovery, signal, cacheRead, database, legacy));
|
readIsolatedPackage(row, manifest, consume, allowRecovery, signal, cacheRead, database));
|
||||||
return cacheRead ? withPackageIo(read, signal, coldResponses) : read();
|
return cacheRead ? withPackageIo(read, signal, coldResponses) : read();
|
||||||
}
|
}
|
||||||
async function readIsolatedPackage<T>(row: Descriptor, manifest: SkillPackageManifest,
|
async function readIsolatedPackage<T>(row: Descriptor, manifest: SkillPackageManifest,
|
||||||
consume: (root: string, manifest: SkillPackageManifest) => Promise<T>, allowRecovery: boolean, signal: AbortSignal, cacheRead: boolean,
|
consume: (root: string, manifest: SkillPackageManifest) => Promise<T>, allowRecovery: boolean, signal: AbortSignal, cacheRead: boolean,
|
||||||
database: Db, legacy: CompanySkillVersionFileInventoryEntry[] | null) {
|
database: Db) {
|
||||||
const { companyId, id } = row;
|
const { companyId, id } = row;
|
||||||
let published = false;
|
let published = false;
|
||||||
const result = await withPackageIo(async () => {
|
const result = await withPackageIo(async () => {
|
||||||
@@ -354,7 +356,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, database, legacy);
|
await restoreInto(row, manifest, staging, archive, signal, allowRecovery, database);
|
||||||
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
|
||||||
|
|||||||
Reference in new issue
Block a user