mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
perf(skills): coalesce inventory before run cache locking (#16576)
Share each active company skill-inventory refresh before taking the version-cache lock. Read actor visibility under the lock and keep snapshot publication protected through durable run pinning. Preserve empty-selection behavior and the list API. Validation: 176 affected cases, full repository typecheck/build, fresh full CI/E2E, security and exact-head Apex 5/5 pass. The local six-reader latency experiment improves from 50.4s to 18.3s; live response latency is a separate acceptance check. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5fbece9dcc
commit
06689c1b34
8 files changed
+173
-45
No files matched your search
@@ -3383,6 +3383,11 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
async function listFull(companyId: string): Promise<CompanySkill[]> {
|
||||
if (!visibility().canReadCompany(companyId)) return [];
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
return readFullSkillRows(companyId);
|
||||
}
|
||||
|
||||
async function readFullSkillRows(companyId: string): Promise<CompanySkill[]> {
|
||||
if (!visibility().canReadCompany(companyId)) return [];
|
||||
const rows = await db
|
||||
.select(selectCompanySkillColumns())
|
||||
.from(companySkills)
|
||||
@@ -6152,16 +6157,25 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
companyId: string,
|
||||
options: RuntimeSkillEntryOptions = {},
|
||||
): Promise<PaperclipSkillEntry[]> {
|
||||
return withRuntimeSkillEntries(companyId, options, entries => entries);
|
||||
}
|
||||
|
||||
async function withRuntimeSkillEntries<T>(
|
||||
companyId: string,
|
||||
options: RuntimeSkillEntryOptions,
|
||||
consume: (entries: PaperclipSkillEntry[]) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
if (options.onlyKeys?.size === 0 || (!options.onlyKeys && options.selectionConfig && !options.includeLegacy
|
||||
&& readPaperclipSkillSyncPreference(options.selectionConfig).desiredSkills.length === 0)) return [];
|
||||
return withSkillVersionCacheLock(db, companyId, () => listRuntimeSkillEntriesLocked(companyId, options));
|
||||
&& readPaperclipSkillSyncPreference(options.selectionConfig).desiredSkills.length === 0)) return consume([]);
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
return withSkillVersionCacheLock(db, companyId, async () => consume(await listRuntimeSkillEntriesLocked(companyId, options)));
|
||||
}
|
||||
|
||||
async function listRuntimeSkillEntriesLocked(
|
||||
companyId: string,
|
||||
options: RuntimeSkillEntryOptions,
|
||||
): Promise<PaperclipSkillEntry[]> {
|
||||
const skills = await listFull(companyId);
|
||||
const skills = await readFullSkillRows(companyId);
|
||||
const selectedKeys = options.onlyKeys ?? (options.selectionConfig ? new Set(
|
||||
(options.includeLegacy ? resolveLegacyPaperclipDesiredSkillNames : resolvePaperclipDesiredSkillNames)(
|
||||
options.selectionConfig, skills.map(skill => ({ key: skill.key, runtimeName: buildSkillRuntimeName(skill.key, skill.slug) }))),
|
||||
@@ -7442,5 +7456,6 @@ export function companySkillService(db: Db, options: { actor?: AuthorizationActo
|
||||
installUpdate,
|
||||
resetSkill,
|
||||
listRuntimeSkillEntries,
|
||||
withRuntimeSkillEntries,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { withSkillActor } from "../skill-access.js";
|
||||
import { withSkillVersionCacheLock } from "../skill-version-cache.js";
|
||||
import type { AuthorizationActor } from "../authorization.js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, type Db, type agents, type projects } from "@paperclipai/db";
|
||||
@@ -70,7 +69,7 @@ export interface HeartbeatExecutionConfigurationInput {
|
||||
"selectedEnvironmentId" | "selectedEnvironmentForConfig" | "resolvedInstanceSettings" | "persistedNativeExecutionInput">;
|
||||
services: {
|
||||
secretsSvc: Parameters<typeof resolveExecutionRunAdapterConfig>[0]["secretsSvc"];
|
||||
companySkills: Pick<ReturnType<typeof companySkillService>, "listRuntimeSkillEntries">;
|
||||
companySkills: Pick<ReturnType<typeof companySkillService>, "withRuntimeSkillEntries">;
|
||||
appendRunEvent: ReturnType<typeof createHeartbeatLifecycle>["appendRunEvent"];
|
||||
finalizeAiConnectionBusyDeferral: ReturnType<typeof createHeartbeatRetries>["finalizeAiConnectionBusyDeferral"];
|
||||
};
|
||||
@@ -224,9 +223,6 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
|
||||
let skillDeliveryConfig = effectiveResolvedConfig;
|
||||
const storedVersions = parseObject(run.contextSnapshot?.skillVersions);
|
||||
const hasStoredVersions = Object.prototype.hasOwnProperty.call(run.contextSnapshot ?? {}, "skillVersions");
|
||||
const emptySkillSelection = hasStoredVersions
|
||||
? run.contextSnapshot?.skillSelectionSnapshot === true && Object.keys(storedVersions).length === 0
|
||||
: agent.adapterType === "paperclip_runner" && runtimeSkillPreference.desiredSkills.length === 0;
|
||||
const prepareSkills = async () => {
|
||||
|
||||
try {
|
||||
@@ -235,40 +231,41 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
|
||||
: skillVersionSelectionMap(runtimeSkillPreference.desiredSkillEntries, {
|
||||
versionPinsEnabled: resolvedInstanceSettings.experimental.enableBetaSkills === true,
|
||||
});
|
||||
const entries = await withSkillActor(skillActor, () => companySkills.listRuntimeSkillEntries(agent.companyId, {
|
||||
return await withSkillActor(skillActor, () => companySkills.withRuntimeSkillEntries(agent.companyId, {
|
||||
versionSelections, snapshotLatest: true,
|
||||
...(hasStoredVersions && run.contextSnapshot?.skillSelectionSnapshot === true
|
||||
? { onlyKeys: new Set(Object.keys(storedVersions)) }
|
||||
: { selectionConfig: effectiveResolvedConfig, includeLegacy: agent.adapterType !== "paperclip_runner" }),
|
||||
}));
|
||||
const available = hasStoredVersions ? entries.filter(entry => Object.prototype.hasOwnProperty.call(storedVersions, entry.key)) : entries;
|
||||
const availableKeys = new Set(available.map(entry => entry.key));
|
||||
const desiredKeys = hasStoredVersions && run.contextSnapshot?.skillSelectionSnapshot === true
|
||||
? Object.keys(storedVersions)
|
||||
: agent.adapterType === "paperclip_runner"
|
||||
? resolvePaperclipDesiredSkillNames(effectiveResolvedConfig, available)
|
||||
: resolveLegacyPaperclipDesiredSkillNames(effectiveResolvedConfig, available);
|
||||
const selectedKeys = new Set(desiredKeys.filter(key => availableKeys.has(key)));
|
||||
const resolved = available.filter(entry => selectedKeys.has(entry.key));
|
||||
// Optional assignments can outlive project membership. Drop inaccessible
|
||||
// assignments from delivery; explicit task mentions remain errors below.
|
||||
skillDeliveryConfig = writePaperclipSkillSyncPreference(effectiveResolvedConfig,
|
||||
resolved.map(entry => ({ key: entry.key, versionId: entry.versionId ?? null })));
|
||||
for (const key of runScopedMentionedSkillKeys) {
|
||||
const entry = resolved.find(candidate => candidate.key === key);
|
||||
if (!entry || entry.sourceStatus === "missing") {
|
||||
throw new ConfigurationIncompleteFailure("A referenced skill is unavailable. Restore its installed version or update the task's skill reference.", {
|
||||
configurationIncomplete: { reason: "skill_unavailable", skillKey: key, actionUrl: "/skills" },
|
||||
});
|
||||
}, async entries => {
|
||||
const available = hasStoredVersions ? entries.filter(entry => Object.prototype.hasOwnProperty.call(storedVersions, entry.key)) : entries;
|
||||
const availableKeys = new Set(available.map(entry => entry.key));
|
||||
const desiredKeys = hasStoredVersions && run.contextSnapshot?.skillSelectionSnapshot === true
|
||||
? Object.keys(storedVersions)
|
||||
: agent.adapterType === "paperclip_runner"
|
||||
? resolvePaperclipDesiredSkillNames(effectiveResolvedConfig, available)
|
||||
: resolveLegacyPaperclipDesiredSkillNames(effectiveResolvedConfig, available);
|
||||
const selectedKeys = new Set(desiredKeys.filter(key => availableKeys.has(key)));
|
||||
const resolved = available.filter(entry => selectedKeys.has(entry.key));
|
||||
// Optional assignments can outlive project membership. Drop inaccessible
|
||||
// assignments from delivery; explicit task mentions remain errors below.
|
||||
skillDeliveryConfig = writePaperclipSkillSyncPreference(effectiveResolvedConfig,
|
||||
resolved.map(entry => ({ key: entry.key, versionId: entry.versionId ?? null })));
|
||||
for (const key of runScopedMentionedSkillKeys) {
|
||||
const entry = resolved.find(candidate => candidate.key === key);
|
||||
if (!entry || entry.sourceStatus === "missing") {
|
||||
throw new ConfigurationIncompleteFailure("A referenced skill is unavailable. Restore its installed version or update the task's skill reference.", {
|
||||
configurationIncomplete: { reason: "skill_unavailable", skillKey: key, actionUrl: "/skills" },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasStoredVersions) {
|
||||
const skillVersions = Object.fromEntries(resolved.map(entry => [entry.key, entry.versionId ?? entry.currentVersionId ?? null]));
|
||||
context.skillVersions = skillVersions;
|
||||
context.skillSelectionSnapshot = true;
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ skillVersions, skillSelectionSnapshot: true })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
return resolved;
|
||||
if (!hasStoredVersions) {
|
||||
const skillVersions = Object.fromEntries(resolved.map(entry => [entry.key, entry.versionId ?? entry.currentVersionId ?? null]));
|
||||
context.skillVersions = skillVersions;
|
||||
context.skillSelectionSnapshot = true;
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ skillVersions, skillSelectionSnapshot: true })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
return resolved;
|
||||
}));
|
||||
} catch (error) {
|
||||
if (agent.adapterType === "paperclip_runner") {
|
||||
await recordFailedSkillPreparation({
|
||||
@@ -282,9 +279,7 @@ export async function prepareHeartbeatExecutionConfiguration(db: Db, input: Hear
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const runtimeSkillEntries = preconfiguredHome ? [] : await time("configuration_skills", () => emptySkillSelection
|
||||
? prepareSkills()
|
||||
: withSkillVersionCacheLock(db, agent.companyId, prepareSkills));
|
||||
const runtimeSkillEntries = preconfiguredHome ? [] : await time("configuration_skills", prepareSkills);
|
||||
nativeRunnerPreparationSpans.push({
|
||||
name: "skills.prepare",
|
||||
parentName: "task.prepare",
|
||||
|
||||
Reference in new issue
Block a user