Skip to content

Commit d2c93ae

Browse files
improvement(mothership): make user_table limit cap internal, not model-facing
The model can now pass any limit — no "cannot exceed 1000" rejection. 1000 becomes an internal threshold: query_rows clamps the page to MAX_QUERY_LIMIT (totalCount signals truncation; the model pages with offset), and bulk filter ops above the cap run as background jobs. update_rows_by_filter loads full row data inline, so an explicit limit above the cap escalates to the background worker with a new maxRows budget (the worker stops after maxRows; update has no read mask so the cap is exact). delete only loads ids inline, so an explicit limit (any size) stays inline — only unbounded deletes use the masked background path, which would over-hide a bounded delete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4ee3937 commit d2c93ae

7 files changed

Lines changed: 101 additions & 46 deletions

File tree

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3959,7 +3959,7 @@ export const UserTable: ToolCatalogEntry = {
39593959
limit: {
39603960
type: 'number',
39613961
description:
3962-
'Maximum rows to return or affect (default 100, max 1000). For update_rows_by_filter / delete_rows_by_filter, omit to act on every match — large match sets run as a background job.',
3962+
'Maximum rows to return or affect (optional, default 100). Any value is allowed — large operations run in the background automatically. Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.',
39633963
},
39643964
mapping: {
39653965
type: 'object',

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3687,7 +3687,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
36873687
limit: {
36883688
type: 'number',
36893689
description:
3690-
'Maximum rows to return or affect (default 100, max 1000). For update_rows_by_filter / delete_rows_by_filter, omit to act on every match — large match sets run as a background job.',
3690+
'Maximum rows to return or affect (optional, default 100). Any value is allowed — large operations run in the background automatically. Omit on update_rows_by_filter / delete_rows_by_filter to act on every match.',
36913691
},
36923692
mapping: {
36933693
type: 'object',

apps/sim/lib/copilot/tools/server/table/user-table.test.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -692,15 +692,15 @@ describe('userTableServerTool.query_rows', () => {
692692
})
693693
})
694694

695-
it('rejects limits above MAX_QUERY_LIMIT', async () => {
695+
it('clamps an over-large query limit to MAX_QUERY_LIMIT instead of rejecting', async () => {
696696
const result = await userTableServerTool.execute(
697697
{ operation: 'query_rows', args: { tableId: 'tbl_1', limit: 100000 } },
698698
{ userId: 'user-1', workspaceId: 'workspace-1' }
699699
)
700700

701-
expect(result.success).toBe(false)
702-
expect(result.message).toBe('Limit cannot exceed 1000')
703-
expect(mockQueryRows).not.toHaveBeenCalled()
701+
expect(result.success).toBe(true)
702+
const options = mockQueryRows.mock.calls[0][1] as Record<string, unknown>
703+
expect(options.limit).toBe(1000)
704704
})
705705

706706
it('queries without execution metadata and passes limit/offset through', async () => {
@@ -732,7 +732,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => {
732732
})
733733
})
734734

735-
it('rejects limits above MAX_BULK_OPERATION_SIZE', async () => {
735+
it('runs an explicit large limit inline without escalating (delete loads only ids)', async () => {
736736
const result = await userTableServerTool.execute(
737737
{
738738
operation: 'delete_rows_by_filter',
@@ -741,9 +741,11 @@ describe('userTableServerTool.delete_rows_by_filter', () => {
741741
{ userId: 'user-1', workspaceId: 'workspace-1' }
742742
)
743743

744-
expect(result.success).toBe(false)
745-
expect(result.message).toBe('Limit cannot exceed 1000')
746-
expect(mockDeleteRowsByFilter).not.toHaveBeenCalled()
744+
expect(result.success).toBe(true)
745+
// An explicit limit never counts/escalates — it deletes inline, bounded by the limit.
746+
expect(mockQueryRows).not.toHaveBeenCalled()
747+
expect(mockDeleteRowsByFilter).toHaveBeenCalledTimes(1)
748+
expect(mockDeleteRowsByFilter.mock.calls[0][1]).toMatchObject({ limit: 5000 })
747749
})
748750

749751
it('deletes inline when the unbounded match count is within the cap', async () => {
@@ -853,17 +855,31 @@ describe('userTableServerTool.update_rows_by_filter', () => {
853855
mockQueryRows.mockResolvedValue({ rows: [], rowCount: 0, totalCount: 5, limit: 1, offset: 0 })
854856
})
855857

856-
it('rejects limits above MAX_BULK_OPERATION_SIZE', async () => {
858+
it('escalates an explicit limit above the cap to a background update with maxRows', async () => {
859+
mockQueryRows.mockResolvedValueOnce({
860+
rows: [],
861+
rowCount: 0,
862+
totalCount: 20000,
863+
limit: 1,
864+
offset: 0,
865+
})
857866
const result = await userTableServerTool.execute(
858867
{
859868
operation: 'update_rows_by_filter',
860869
args: { tableId: 'tbl_1', filter: { name: 'x' }, data: { age: 1 }, limit: 5000 },
861870
},
862871
{ userId: 'user-1', workspaceId: 'workspace-1' }
863872
)
864-
expect(result.success).toBe(false)
865-
expect(result.message).toBe('Limit cannot exceed 1000')
873+
await flushDetached()
874+
875+
expect(result.success).toBe(true)
876+
// target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background.
877+
expect(result.data?.affectedCount).toBe(5000)
866878
expect(mockUpdateRowsByFilter).not.toHaveBeenCalled()
879+
const [, , type, payload] = mockMarkTableJobRunning.mock.calls[0]
880+
expect(type).toBe('update')
881+
expect(payload).toMatchObject({ affectedCount: 5000, maxRows: 5000 })
882+
expect(mockRunTableUpdate.mock.calls[0][0]).toMatchObject({ maxRows: 5000 })
867883
})
868884

869885
it('updates inline when the unbounded match count is within the cap', async () => {
@@ -909,6 +925,8 @@ describe('userTableServerTool.update_rows_by_filter', () => {
909925
cutoff: expect.any(String),
910926
data: { age: 1 },
911927
})
928+
// Unbounded match (no explicit limit) → the worker patches every match, no cap.
929+
expect((payload as { maxRows?: number }).maxRows).toBeUndefined()
912930
expect(mockRunTableUpdate).toHaveBeenCalledTimes(1)
913931
expect(mockRunTableUpdate.mock.calls[0][0]).toMatchObject({
914932
jobId,

apps/sim/lib/copilot/tools/server/table/user-table.ts

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,9 @@ async function dispatchUpdateJob(params: {
200200
filter: Filter
201201
data: RowData
202202
cutoff: Date
203+
maxRows?: number
203204
}): Promise<void> {
204-
const { jobId, tableId, workspaceId, filter, data, cutoff } = params
205+
const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = params
205206
if (isTriggerDevEnabled) {
206207
try {
207208
const [{ tableUpdateTask }, { tasks }] = await Promise.all([
@@ -210,7 +211,7 @@ async function dispatchUpdateJob(params: {
210211
])
211212
await tasks.trigger<typeof tableUpdateTask>(
212213
'table-update',
213-
{ jobId, tableId, workspaceId, filter, data, cutoff: cutoff.toISOString() },
214+
{ jobId, tableId, workspaceId, filter, data, cutoff: cutoff.toISOString(), maxRows },
214215
{ tags: [`tableId:${tableId}`, `jobId:${jobId}`] }
215216
)
216217
} catch (error) {
@@ -219,10 +220,12 @@ async function dispatchUpdateJob(params: {
219220
}
220221
} else {
221222
runDetached('table-update', () =>
222-
runTableUpdate({ jobId, tableId, workspaceId, filter, data, cutoff }).catch(async (error) => {
223-
await markTableUpdateFailed(tableId, jobId, error)
224-
throw error
225-
})
223+
runTableUpdate({ jobId, tableId, workspaceId, filter, data, cutoff, maxRows }).catch(
224+
async (error) => {
225+
await markTableUpdateFailed(tableId, jobId, error)
226+
throw error
227+
}
228+
)
226229
)
227230
}
228231
}
@@ -280,17 +283,16 @@ function parseDeploymentMode(value: unknown): WorkflowGroupDeploymentMode | unde
280283
}
281284

282285
/**
283-
* Validates an optional row limit against the same bounds the HTTP contracts
284-
* enforce. Returns an error message, or `null` when the limit is acceptable.
286+
* Validates an optional row limit. There's no upper bound the caller must respect — the model may
287+
* ask for any number. `MAX_QUERY_LIMIT` / `MAX_BULK_OPERATION_SIZE` are applied internally instead
288+
* (query_rows clamps the page; bulk ops above the bound run as a background job). Returns an error
289+
* message, or `null` when the limit is acceptable.
285290
*/
286-
function limitError(limit: unknown, max: number): string | null {
291+
function limitError(limit: unknown): string | null {
287292
if (limit === undefined) return null
288293
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) {
289294
return 'Limit must be an integer of at least 1'
290295
}
291-
if (limit > max) {
292-
return `Limit cannot exceed ${max}`
293-
}
294296
return null
295297
}
296298

@@ -579,7 +581,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
579581
return { success: false, message: 'Workspace ID is required' }
580582
}
581583

582-
const queryLimitError = limitError(args.limit, TABLE_LIMITS.MAX_QUERY_LIMIT)
584+
const queryLimitError = limitError(args.limit)
583585
if (queryLimitError) {
584586
return { success: false, message: queryLimitError }
585587
}
@@ -592,12 +594,18 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
592594
const requestId = generateId().slice(0, 8)
593595
const idByName = buildIdByName(table.schema)
594596
const nameById = buildNameById(table.schema)
597+
// The model may request any number; we serve at most MAX_QUERY_LIMIT per page so a single
598+
// tool result can't drain a whole table. `totalCount` in the response signals truncation,
599+
// and the model pages with `offset`.
595600
const result = await queryRows(
596601
table,
597602
{
598603
filter: args.filter ? filterNamesToIds(args.filter, idByName) : undefined,
599604
sort: args.sort ? sortNamesToIds(args.sort, idByName) : undefined,
600-
limit: args.limit,
605+
limit:
606+
args.limit !== undefined
607+
? Math.min(args.limit, TABLE_LIMITS.MAX_QUERY_LIMIT)
608+
: undefined,
601609
offset: args.offset,
602610
withExecutions: false,
603611
},
@@ -700,7 +708,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
700708
if (!workspaceId) {
701709
return { success: false, message: 'Workspace ID is required' }
702710
}
703-
const updateLimitError = limitError(args.limit, TABLE_LIMITS.MAX_BULK_OPERATION_SIZE)
711+
const updateLimitError = limitError(args.limit)
704712
if (updateLimitError) {
705713
return { success: false, message: updateLimitError }
706714
}
@@ -715,29 +723,34 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
715723
const idFilter = filterNamesToIds(args.filter, idByName)
716724
const idData = rowDataNameToId(args.data, idByName)
717725

718-
// Unbounded "update everything matching": measure the blast radius first and hand
719-
// anything past the inline cap to the background update worker — same escalation as
720-
// delete_rows_by_filter, so a broad update on a huge table doesn't load every matching
721-
// row into this request. A patch touching a unique column stays inline (the service
722-
// rejects bulk-setting a unique value across multiple rows).
726+
// Inline handles up to MAX_BULK_OPERATION_SIZE rows in one request; a larger operation
727+
// (an explicit limit above the cap, or unbounded "update everything matching") runs in the
728+
// background worker so a broad update on a huge table doesn't load every matching row into
729+
// this request. A small explicit limit is the fast path — no count needed. A patch
730+
// touching a unique column always stays inline (the service rejects bulk-setting a unique
731+
// value across multiple rows).
723732
const patchTouchesUnique = table.schema.columns.some(
724733
(c) => c.unique === true && (c.id ?? c.name) in idData
725734
)
726-
if (args.limit === undefined && !patchTouchesUnique) {
735+
const updateInlineEligible =
736+
args.limit !== undefined && args.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE
737+
if (!updateInlineEligible && !patchTouchesUnique) {
727738
const { totalCount } = await queryRows(
728739
table,
729740
{ filter: idFilter, limit: 1, withExecutions: false },
730741
requestId
731742
)
732743
const matchCount = totalCount ?? 0
733-
if (matchCount > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
744+
const target = args.limit !== undefined ? Math.min(args.limit, matchCount) : matchCount
745+
if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
734746
const cutoff = new Date()
735747
const jobId = generateId()
736748
const payload: TableUpdateJobPayload = {
737749
filter: idFilter,
738750
data: idData,
739751
cutoff: cutoff.toISOString(),
740-
affectedCount: matchCount,
752+
affectedCount: target,
753+
maxRows: args.limit,
741754
}
742755
assertNotAborted()
743756
const claimed = await markTableJobRunning(table.id, jobId, 'update', payload)
@@ -751,11 +764,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
751764
filter: idFilter,
752765
data: idData,
753766
cutoff,
767+
maxRows: args.limit,
754768
})
755769
return {
756770
success: true,
757-
message: `Started background update of ${matchCount} matching rows (job ${jobId}). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.`,
758-
data: { jobId, affectedCount: matchCount },
771+
message: `Started background update of ${target} matching rows (job ${jobId}). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.`,
772+
data: { jobId, affectedCount: target },
759773
}
760774
}
761775
}
@@ -789,7 +803,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
789803
if (!workspaceId) {
790804
return { success: false, message: 'Workspace ID is required' }
791805
}
792-
const deleteLimitError = limitError(args.limit, TABLE_LIMITS.MAX_BULK_OPERATION_SIZE)
806+
const deleteLimitError = limitError(args.limit)
793807
if (deleteLimitError) {
794808
return { success: false, message: deleteLimitError }
795809
}
@@ -803,10 +817,11 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
803817
const idByName = buildIdByName(table.schema)
804818
const idFilter = filterNamesToIds(args.filter, idByName)
805819

806-
// Unbounded "delete everything matching": measure the blast radius
807-
// first, and hand anything past the inline cap to the background
808-
// delete worker (same path as the UI's select-all delete) instead of
809-
// loading every matching row id into this request.
820+
// An explicit limit runs inline (delete loads only row ids, so even a large bounded
821+
// delete is light). Only an unbounded "delete everything matching" measures the blast
822+
// radius and hands off to the background delete worker (same path as the UI's select-all
823+
// delete) — the read-path mask hides exactly the all-matching set, which a bounded delete
824+
// would over-hide.
810825
if (args.limit === undefined) {
811826
const { totalCount } = await queryRows(
812827
table,

apps/sim/lib/table/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ export interface TableUpdateJobPayload {
231231
/** ISO timestamp; rows created after it are not patched. */
232232
cutoff: string
233233
affectedCount?: number
234+
/** Stop after updating this many rows (an explicit caller-supplied limit). Omitted = every match. */
235+
maxRows?: number
234236
}
235237

236238
/**

apps/sim/lib/table/update-runner.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,23 @@ describe('runTableUpdate', () => {
163163
expect(mockUpdatePageByIds).not.toHaveBeenCalled()
164164
})
165165

166+
it('stops once maxRows is reached and never over-fetches a page', async () => {
167+
// budget 3 with page size 2: first page fills 2, second page is capped to the remaining 1.
168+
mockSelectRowDataPage
169+
.mockResolvedValueOnce([row('a'), row('b')])
170+
.mockResolvedValueOnce([row('c')])
171+
172+
await runTableUpdate(basePayload({ maxRows: 3 }))
173+
174+
expect(mockSelectRowDataPage).toHaveBeenCalledTimes(2)
175+
expect(mockSelectRowDataPage.mock.calls[0][0]).toMatchObject({ limit: 2 })
176+
expect(mockSelectRowDataPage.mock.calls[1][0]).toMatchObject({ limit: 1 })
177+
expect(mockUpdatePageByIds).toHaveBeenCalledTimes(2)
178+
expect(mockAppendTableEvent).toHaveBeenCalledWith(
179+
expect.objectContaining({ status: 'ready', progress: 3 })
180+
)
181+
})
182+
166183
it('passes the cutoff and filter clause through to the page query', async () => {
167184
mockSelectRowDataPage.mockResolvedValueOnce([])
168185

apps/sim/lib/table/update-runner.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface TableUpdatePayload {
3737
data: RowData
3838
/** Only rows created at/before this instant are patched, so mid-job inserts are spared. */
3939
cutoff: Date
40+
/** Stop after updating this many rows (an explicit caller-supplied limit). Omitted = every match. */
41+
maxRows?: number
4042
}
4143

4244
/**
@@ -57,8 +59,9 @@ export interface TableUpdatePayload {
5759
* failed via `markTableUpdateFailed`. A superseded run returns quietly.
5860
*/
5961
export async function runTableUpdate(payload: TableUpdatePayload): Promise<void> {
60-
const { jobId, tableId, workspaceId, filter, data, cutoff } = payload
62+
const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = payload
6163
const requestId = generateId().slice(0, 8)
64+
const budget = maxRows ?? Number.POSITIVE_INFINITY
6265

6366
try {
6467
const table = await getTableById(tableId, { includeArchived: true })
@@ -81,7 +84,7 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise<void>
8184
let lastReported = resumed
8285
let afterId: string | undefined
8386

84-
while (true) {
87+
while (processed < budget) {
8588
const owns = await updateJobProgress(tableId, processed, jobId)
8689
if (!owns) throw new JobSupersededError()
8790

@@ -91,7 +94,7 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise<void>
9194
cutoff,
9295
filterClause,
9396
afterId,
94-
limit: TABLE_LIMITS.DELETE_PAGE_SIZE,
97+
limit: Math.min(TABLE_LIMITS.DELETE_PAGE_SIZE, budget - processed),
9598
// Skip rows already carrying the patch so a retried run resumes without re-walking /
9699
// double-counting the rows an earlier attempt updated (updated rows still exist and may
97100
// still match the filter, unlike deletes).

0 commit comments

Comments
 (0)