Skip to content

Commit 1a22750

Browse files
committed
fix skill memory bounding;
1 parent 49e129b commit 1a22750

6 files changed

Lines changed: 230 additions & 51 deletions

File tree

apps/sim/lib/workspaces/fork/copy/content-copy-runner.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { serializeContentRefMaps } from '@/lib/workspaces/fork/copy/content-copy-runner'
5+
import {
6+
hasForkContentToCopy,
7+
serializeContentRefMaps,
8+
} from '@/lib/workspaces/fork/copy/content-copy-runner'
9+
import type { BlobCopyTask } from '@/lib/workspaces/fork/copy/copy-files'
10+
import type { ForkContentPlan } from '@/lib/workspaces/fork/copy/copy-resources'
611

712
describe('serializeContentRefMaps', () => {
813
it('converts each map to a record and drops empty maps', () => {
@@ -34,3 +39,46 @@ describe('serializeContentRefMaps', () => {
3439
expect(result.skills).toBeUndefined()
3540
})
3641
})
42+
43+
describe('hasForkContentToCopy', () => {
44+
const emptyPlan = (): ForkContentPlan => ({
45+
sourceWorkspaceId: 'src',
46+
childWorkspaceId: 'child',
47+
userId: 'u',
48+
tables: [],
49+
knowledgeBases: [],
50+
skills: [],
51+
documents: [],
52+
})
53+
// The helper only inspects array lengths, so a single placeholder entry per kind is enough.
54+
const oneSkill = [{}] as unknown as ForkContentPlan['skills']
55+
const oneDoc = [{}] as unknown as ForkContentPlan['documents']
56+
const oneTable = [{}] as unknown as ForkContentPlan['tables']
57+
const oneKb = [{}] as unknown as ForkContentPlan['knowledgeBases']
58+
const oneBlob = [{}] as unknown as BlobCopyTask[]
59+
const noBlobs: BlobCopyTask[] = []
60+
61+
it('is true when skills are non-empty (the create-fork skill-only fix)', () => {
62+
expect(hasForkContentToCopy({ ...emptyPlan(), skills: oneSkill }, noBlobs)).toBe(true)
63+
})
64+
65+
it('is true when documents are non-empty', () => {
66+
expect(hasForkContentToCopy({ ...emptyPlan(), documents: oneDoc }, noBlobs)).toBe(true)
67+
})
68+
69+
it('is true when tables are non-empty', () => {
70+
expect(hasForkContentToCopy({ ...emptyPlan(), tables: oneTable }, noBlobs)).toBe(true)
71+
})
72+
73+
it('is true when knowledgeBases are non-empty', () => {
74+
expect(hasForkContentToCopy({ ...emptyPlan(), knowledgeBases: oneKb }, noBlobs)).toBe(true)
75+
})
76+
77+
it('is true when there are blob tasks', () => {
78+
expect(hasForkContentToCopy(emptyPlan(), oneBlob)).toBe(true)
79+
})
80+
81+
it('is false for an all-empty plan with no blob tasks', () => {
82+
expect(hasForkContentToCopy(emptyPlan(), noBlobs)).toBe(false)
83+
})
84+
})

apps/sim/lib/workspaces/fork/copy/content-copy-runner.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,26 @@ import type { ForkContentRefMaps } from '@/lib/workspaces/fork/remap/remap-conte
1313

1414
const logger = createLogger('WorkspaceForkContentCopy')
1515

16+
/**
17+
* Whether a fork/sync has any heavy content to copy after the commit: table rows, KB documents,
18+
* copied skill bodies, standalone documents, or file blobs. The single gate for scheduling the
19+
* background content fill - shared by fork-create and promote so the two can't diverge. A
20+
* skill-only (or documents-only) copy must still schedule it, otherwise the in-content `sim:` /
21+
* serve-link rewrite in {@link runForkContentCopy} never runs and copied bodies keep source links.
22+
*/
23+
export function hasForkContentToCopy(
24+
contentPlan: ForkContentPlan,
25+
blobTasks: BlobCopyTask[]
26+
): boolean {
27+
return (
28+
contentPlan.tables.length > 0 ||
29+
contentPlan.knowledgeBases.length > 0 ||
30+
contentPlan.skills.length > 0 ||
31+
contentPlan.documents.length > 0 ||
32+
blobTasks.length > 0
33+
)
34+
}
35+
1636
/**
1737
* JSON-serializable form of {@link ForkContentRefMaps} (Maps become Records) so the in-content
1838
* reference maps survive the Trigger.dev payload boundary. Rehydrated to Maps in the runner.

apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,14 @@ describe('copyForkResourceContent', () => {
115115
})
116116
})
117117

118-
it('#4 rewrites a copied skill body post-commit via db.update using the content maps', async () => {
118+
it('#4 re-reads a copied skill body post-commit and rewrites it via db.update (never from payload)', async () => {
119+
// The body is no longer carried in the plan - the content phase keyset-re-reads the child row.
120+
dbChainMockFns.limit.mockResolvedValueOnce([
121+
{ id: 'child-skill-1', content: 'see [K](sim:knowledge/src-kb)' },
122+
])
123+
119124
const result = await copyForkResourceContent({
120-
contentPlan: basePlan({
121-
skills: [{ childId: 'child-skill-1', content: 'see [K](sim:knowledge/src-kb)' }],
122-
}),
125+
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
123126
contentRefMaps: { knowledgeBases: new Map([['src-kb', 'child-kb']]) },
124127
requestId: 'test',
125128
})
@@ -131,11 +134,13 @@ describe('copyForkResourceContent', () => {
131134
})
132135
})
133136

134-
it('#4 leaves a skill untouched when nothing in its body remaps', async () => {
137+
it('#4 leaves a skill untouched when nothing in its re-read body remaps', async () => {
138+
dbChainMockFns.limit.mockResolvedValueOnce([
139+
{ id: 'child-skill-1', content: 'no references here' },
140+
])
141+
135142
const result = await copyForkResourceContent({
136-
contentPlan: basePlan({
137-
skills: [{ childId: 'child-skill-1', content: 'no references here' }],
138-
}),
143+
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
139144
contentRefMaps: { knowledgeBases: new Map([['src-kb', 'child-kb']]) },
140145
requestId: 'test',
141146
})
@@ -144,14 +149,14 @@ describe('copyForkResourceContent', () => {
144149
expect(dbChainMockFns.update).not.toHaveBeenCalled()
145150
})
146151

147-
it('#4 skips the skill rewrite entirely when no content maps are supplied', async () => {
152+
it('#4 skips the skill re-read + rewrite entirely when no content maps are supplied', async () => {
148153
await copyForkResourceContent({
149-
contentPlan: basePlan({
150-
skills: [{ childId: 'child-skill-1', content: 'see [K](sim:knowledge/src-kb)' }],
151-
}),
154+
contentPlan: basePlan({ skills: [{ childId: 'child-skill-1' }] }),
152155
requestId: 'test',
153156
})
154157

158+
// No maps -> the body is neither re-read from the DB nor updated.
159+
expect(dbChainMockFns.select).not.toHaveBeenCalled()
155160
expect(dbChainMockFns.update).not.toHaveBeenCalled()
156161
})
157162

@@ -289,6 +294,67 @@ describe('copyForkResourceContainers custom-tool code env rewrite', () => {
289294
})
290295
})
291296

297+
describe('copyForkResourceContainers skill copy', () => {
298+
function makeSkillTx(rows: Array<Record<string, unknown>>) {
299+
const inserted: Array<Record<string, unknown>> = []
300+
const tx = {
301+
select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }),
302+
insert: () => ({
303+
values: (values: Array<Record<string, unknown>>) => {
304+
inserted.push(...values)
305+
return Promise.resolve()
306+
},
307+
}),
308+
}
309+
return { tx: tx as unknown as DbOrTx, inserted }
310+
}
311+
312+
const skillSelection = {
313+
customTools: [],
314+
skills: ['sk-1'],
315+
workflowMcpServers: [],
316+
tables: [],
317+
knowledgeBases: [],
318+
}
319+
320+
it('copies the skill body IN-DB and carries only the child id in the content plan', async () => {
321+
// The source projection deliberately omits `content` (it is copied server-side), so the row
322+
// fed to the tx mock has none - the body must never be materialized in app memory here.
323+
const { tx, inserted } = makeSkillTx([
324+
{
325+
id: 'sk-1',
326+
name: 'My Skill',
327+
description: 'desc',
328+
workspaceId: 'src-ws',
329+
userId: 'src-user',
330+
createdAt: new Date(),
331+
updatedAt: new Date(),
332+
},
333+
])
334+
335+
const result = await copyForkResourceContainers({
336+
tx,
337+
sourceWorkspaceId: 'src-ws',
338+
childWorkspaceId: 'child-ws',
339+
userId: 'user-1',
340+
now: new Date(),
341+
selection: skillSelection,
342+
workflowIdMap: new Map(),
343+
})
344+
345+
expect(inserted).toHaveLength(1)
346+
const childId = inserted[0].id as string
347+
expect(childId).not.toBe('sk-1')
348+
expect(inserted[0].workspaceId).toBe('child-ws')
349+
expect(inserted[0].userId).toBe('user-1')
350+
// The body is deferred to a correlated subquery (in-DB copy), never a materialized string.
351+
expect(typeof inserted[0].content).not.toBe('string')
352+
// The content plan carries ONLY the child id - no skill body text crosses the job payload.
353+
expect(result.contentPlan.skills).toEqual([{ childId }])
354+
expect(result.names.skills).toEqual(['My Skill'])
355+
})
356+
})
357+
292358
describe('planForkMappedKbDocumentCopies', () => {
293359
const sourceRow = (id: string, knowledgeBaseId: string) => ({
294360
id,

apps/sim/lib/workspaces/fork/copy/copy-resources.ts

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { createLogger } from '@sim/logger'
1313
import { getErrorMessage } from '@sim/utils/errors'
1414
import { generateId } from '@sim/utils/id'
15-
import { and, asc, eq, gt, inArray, isNull, type SQL } from 'drizzle-orm'
15+
import { and, asc, eq, gt, inArray, isNull, type SQL, sql } from 'drizzle-orm'
1616
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
1717
import type { DbOrTx } from '@/lib/db/types'
1818
import type { TableSchema } from '@/lib/table/types'
@@ -47,6 +47,13 @@ const CONTENT_PAGE = 500
4747
*/
4848
const KB_DOCUMENT_COPY_CONCURRENCY = 5
4949

50+
/**
51+
* Max copied skill bodies rewritten concurrently within one keyset page. Bounds the per-skill
52+
* re-read + UPDATE fan-out so a page of copied skills doesn't issue every write at once; the keyset
53+
* loop still processes one page at a time, so peak concurrency stays at this cap.
54+
*/
55+
const SKILL_REWRITE_CONCURRENCY = 5
56+
5057
export interface CopyResourcesParams {
5158
tx: DbOrTx
5259
sourceWorkspaceId: string
@@ -95,13 +102,14 @@ export interface ForkContentKbEntry extends ForkContentPlanEntry {
95102

96103
/**
97104
* A copied skill whose body's in-content references (`sim:` links + embedded file URLs) are
98-
* rewritten post-commit. The child row is inserted in the fork tx with the SOURCE body; the
99-
* content phase rewrites it best-effort so it points at the copied child resources (an unmapped
100-
* target degrades to a graceful broken link, never an FK/subblock reference).
105+
* rewritten post-commit. Only the child id is carried: the child row is inserted in the fork tx
106+
* with the SOURCE body copied IN-DB (never materialized in app memory or embedded in the job
107+
* payload), and the content phase RE-READS the body keyset-paginated to rewrite it best-effort so
108+
* it points at the copied child resources (an unmapped target degrades to a graceful broken link,
109+
* never an FK/subblock reference).
101110
*/
102111
export interface ForkContentSkillEntry {
103112
childId: string
104-
content: string
105113
}
106114

107115
/**
@@ -181,6 +189,13 @@ function setId(idMap: Map<ForkResourceType, Map<string, string>>, type: ForkReso
181189
return map
182190
}
183191

192+
/**
193+
* Child `skill` insert whose `content` is a correlated subquery (copied server-side from the source
194+
* row) rather than a materialized string, so the fork tx never pulls skill bodies into app memory -
195+
* see the skeleton skill copy in {@link copyForkResourceContainers}.
196+
*/
197+
type SkillSkeletonInsert = Omit<typeof skill.$inferInsert, 'content'> & { content: SQL }
198+
184199
/**
185200
* Copy the selected resources' **container rows** into the child workspace inside
186201
* the fork transaction: custom tools, skills, and MCP server configs (each a
@@ -261,25 +276,37 @@ export async function copyForkResourceContainers(
261276
}
262277

263278
if (selection.skills.length > 0) {
279+
// Select every skill column EXCEPT `content`: the body (capped at 50 KB each, up to 2000 skills)
280+
// is copied server-side via the correlated subquery below, so it is never materialized in app
281+
// memory while the fork tx holds its locks - nor carried in the background-job payload.
264282
const rows = await tx
265-
.select()
283+
.select({
284+
id: skill.id,
285+
workspaceId: skill.workspaceId,
286+
userId: skill.userId,
287+
name: skill.name,
288+
description: skill.description,
289+
createdAt: skill.createdAt,
290+
updatedAt: skill.updatedAt,
291+
})
266292
.from(skill)
267293
.where(and(inArray(skill.id, selection.skills), eq(skill.workspaceId, sourceWorkspaceId)))
268-
const inserts: (typeof skill.$inferInsert)[] = []
294+
const inserts: SkillSkeletonInsert[] = []
269295
for (const row of rows) {
270296
const childId = generateId()
271297
inserts.push({
272298
...row,
273299
id: childId,
274300
workspaceId: childWorkspaceId,
275301
userId,
302+
// Copy the body straight from the source row in-DB (never through app memory). Re-read and
303+
// rewritten post-commit (see copyForkResourceContent), out of the locked fork tx.
304+
content: sql`(SELECT ${skill.content} FROM ${skill} WHERE ${skill.id} = ${row.id})`,
276305
createdAt: now,
277306
updatedAt: now,
278307
})
279308
record('skill', row.id, childId)
280-
// Rewritten post-commit (see copyForkResourceContent): the row is inserted with the
281-
// source body here so the locked fork tx never runs the per-skill content UPDATE.
282-
contentPlan.skills.push({ childId, content: row.content })
309+
contentPlan.skills.push({ childId })
283310
names.skills.push(row.name)
284311
}
285312
if (inserts.length > 0) await tx.insert(skill).values(inserts)
@@ -578,8 +605,9 @@ function remapTableRowResourceUrls(value: unknown, maps: ForkContentRefMaps): un
578605
* resource is copied in its own short statements (never one long transaction).
579606
* Best-effort: a failure on one resource is logged and the others continue - the
580607
* fork itself (workflows + container rows) already succeeded. Copied skill bodies are
581-
* rewritten here too (`contentRefMaps`), out of the locked fork tx; that rewrite is an
582-
* in-content link fixup, so a failure degrades to a broken link and never fails a resource.
608+
* RE-READ (keyset-paginated) and rewritten here too (`contentRefMaps`), out of the locked
609+
* fork tx; that rewrite is an in-content link fixup, so a failure degrades to a broken link
610+
* and never fails a resource.
583611
*/
584612
export async function copyForkResourceContent(params: {
585613
contentPlan: ForkContentPlan
@@ -760,26 +788,47 @@ export async function copyForkResourceContent(params: {
760788
}
761789
}
762790

763-
// Rewrite copied skill bodies out of the locked fork tx (the rows were inserted with the
764-
// source body). Their `sim:` links + embedded file URLs are remapped to the child resources;
765-
// this is an in-content link fixup, so a per-skill failure degrades to a broken link and is
766-
// never counted as a failed resource (unmapped targets are left as graceful broken links).
791+
// Rewrite copied skill bodies out of the locked fork tx (the rows were inserted with the source
792+
// body via an in-DB copy). RE-READ each child body keyset-paginated - it is never carried in the
793+
// job payload - remap its `sim:` links + embedded file URLs to the child resources, and write it
794+
// back. An in-content link fixup: a per-skill failure degrades to a broken link and is never
795+
// counted as a failed resource (unmapped targets are left as graceful broken links).
767796
if (contentRefMaps && contentPlan.skills.length > 0) {
768-
for (const copiedSkill of contentPlan.skills) {
769-
try {
770-
const rewritten = rewriteForkContentRefs(copiedSkill.content, contentRefMaps)
771-
if (rewritten !== copiedSkill.content) {
772-
await db
773-
.update(skill)
774-
.set({ content: rewritten })
775-
.where(eq(skill.id, copiedSkill.childId))
797+
const childSkillIds = contentPlan.skills.map((entry) => entry.childId)
798+
let afterId: string | null = null
799+
for (;;) {
800+
const where: SQL<unknown> | undefined =
801+
afterId === null
802+
? inArray(skill.id, childSkillIds)
803+
: and(inArray(skill.id, childSkillIds), gt(skill.id, afterId))
804+
const rows = await db
805+
.select({ id: skill.id, content: skill.content })
806+
.from(skill)
807+
.where(where)
808+
.orderBy(asc(skill.id))
809+
.limit(CONTENT_PAGE)
810+
if (rows.length === 0) break
811+
// Bounded fan-out: the mapper never rejects (it captures its own error), so mapWithConcurrency
812+
// settles the whole page. A rewrite is a best-effort link fixup, so a per-skill failure is
813+
// logged and the body keeps its source links rather than failing a resource.
814+
await mapWithConcurrency(rows, SKILL_REWRITE_CONCURRENCY, async (row): Promise<void> => {
815+
try {
816+
const rewritten = rewriteForkContentRefs(row.content, contentRefMaps)
817+
if (rewritten !== row.content) {
818+
await db.update(skill).set({ content: rewritten }).where(eq(skill.id, row.id))
819+
}
820+
} catch (error) {
821+
logger.warn(
822+
`[${requestId}] Failed to rewrite copied skill content; keeping source links`,
823+
{
824+
childSkillId: row.id,
825+
error: getErrorMessage(error),
826+
}
827+
)
776828
}
777-
} catch (error) {
778-
logger.warn(`[${requestId}] Failed to rewrite copied skill content; keeping source links`, {
779-
childSkillId: copiedSkill.childId,
780-
error: getErrorMessage(error),
781-
})
782-
}
829+
})
830+
afterId = rows[rows.length - 1].id
831+
if (rows.length < CONTENT_PAGE) break
783832
}
784833
}
785834

0 commit comments

Comments
 (0)