Skip to content

Commit b347f91

Browse files
committed
fix(tables,knowledge): act on the claim outcome and scope liveness to the dispatch
Three defects, two of them created by the previous round's fixes. Guarding the pending-to-dispatching claim without reading its outcome was the worse half of a fix. When a Stop-all or the stale sweep won the race the row correctly stayed `cancelled`, while the step went on to announce `dispatching`, stamp cells and enqueue a window for it — and an empty window would then reach the unguarded `markDispatchComplete` and overwrite `cancelled` with `complete`. The step now ends when it did not claim the row. The cell-liveness probe was table-scoped, and `table_row_executions` carries no dispatch column, so a live dispatch's cells vouched for an abandoned dispatch beside it and the abandoned row was never reclaimed — turning the stuck overlay this sweep exists to clear into a permanent one. Narrowed to the dispatch's own groups, which it already stores. Two active dispatches over the same groups can still mask each other, but that is the state `markActiveDispatchesCancelled` already prevents. Truncation asks whether the window cut the sheet short — a question about the declared range against the cap. Comparing the converted length to the cap made it unreachable once the conversion was bounded; comparing the declared count to the converted length then reported truncation for any sheet merely containing blank rows, which are now skipped rather than defaulted into existence.
1 parent 03ea949 commit b347f91

4 files changed

Lines changed: 81 additions & 6 deletions

File tree

apps/sim/lib/file-parsers/xlsx-parser.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,16 @@ export class XlsxParser implements FileParser {
177177
}
178178

179179
/**
180-
* Compared against the DECLARED row count, not the converted one. The
181-
* conversion is now bounded to the preview window, so the converted
182-
* length can never exceed it — comparing the two made this unreachable
183-
* and silently dropped the notice from every sheet larger than the cap.
180+
* Truncated means the WINDOW cut the sheet short, which is a question
181+
* about the declared range against the cap — not about how many rows
182+
* survived conversion. Comparing the converted length to the cap made
183+
* this unreachable once the conversion was bounded (the two are equal by
184+
* construction); comparing the declared count to the converted length
185+
* instead reported truncation for any sheet merely containing blank
186+
* rows, since those are now skipped. The CSV parser asks the same
187+
* question the same way.
184188
*/
185-
if (rowCount > rowsToProcess) {
189+
if (rowCount > CONFIG.MAX_PREVIEW_ROWS) {
186190
content += truncationNotice(
187191
`${rowCount.toLocaleString()} total rows, showing first ${rowsToProcess.toLocaleString()}`
188192
)

apps/sim/lib/file-parsers/xlsx-preview-bound.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,23 @@ describe('XlsxParser preview bound', () => {
7979
expect(result.metadata?.truncated).toBe(true)
8080
expect(result.content).toContain('200,000 total rows')
8181
})
82+
83+
/**
84+
* A sheet whose declared range fits inside the window was not cut short, even
85+
* though blank rows mean fewer rows survive conversion than the range names.
86+
* Comparing the declared count against the converted length reported those as
87+
* truncated.
88+
*/
89+
it('does not report truncation for a small sheet containing blank rows', async () => {
90+
// A genuinely empty row — empty strings are still cells and are not skipped.
91+
const sheet = XLSX.utils.aoa_to_sheet([['header-a', 'header-b'], [], ['row-2-a', 'row-2-b']])
92+
const book = XLSX.utils.book_new()
93+
XLSX.utils.book_append_sheet(book, sheet, 'Sheet1')
94+
const buffer = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer
95+
96+
const result = await new XlsxParser().parseBuffer(buffer)
97+
98+
expect(result.metadata?.truncated).toBe(false)
99+
expect(result.content).not.toContain('total rows, showing first')
100+
})
82101
})

apps/sim/lib/table/dispatcher.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ export async function dispatcherStep(
405405
// user already saw the column flip to empty/Pending before any cell
406406
// started enqueueing.
407407
if (dispatch.status === 'pending') {
408-
await db
408+
const claimed = await db
409409
.update(tableRunDispatches)
410410
// Opens the heartbeat at the instant a holder takes the dispatch, so the
411411
// cleanup sweep ages it from that rather than from `requested_at`, which
@@ -424,6 +424,19 @@ export async function dispatcherStep(
424424
inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES])
425425
)
426426
)
427+
.returning({ id: tableRunDispatches.id })
428+
429+
/**
430+
* Losing that race ends the step. Guarding the write without reading its
431+
* outcome is the worse half of a fix: the row correctly stays `cancelled`,
432+
* while this step goes on to announce `dispatching`, stamp cells and enqueue
433+
* a window for it — and an empty window would then call the unguarded
434+
* `markDispatchComplete` and overwrite `cancelled` with `complete`.
435+
*/
436+
if (claimed.length === 0) {
437+
logger.info(`[${dispatchId}] dispatch was cancelled before this step claimed it`)
438+
return 'done'
439+
}
427440
// Announce the dispatch the moment it starts — before the first window's
428441
// cells finish. Without this, auto-fired and capped dispatches (no client-
429442
// side optimistic seed) emit their first `dispatch` event only after window
@@ -844,6 +857,14 @@ export async function cancelStaleDispatches(
844857
* beating, nothing executing — is still collected. The subquery rides the
845858
* partial `(table_id, status)` index that already covers exactly these three
846859
* statuses.
860+
*
861+
* Narrowed to the dispatch's OWN groups, because `table_row_executions` has no
862+
* dispatch column. Table-scoped, a live dispatch's cells would read as
863+
* evidence that an abandoned dispatch beside it was still working, and the
864+
* abandoned one would never be reclaimed — the stuck overlay this sweep exists
865+
* to clear, now permanent. Two active dispatches over the SAME groups can
866+
* still mask each other, but that is the state `markActiveDispatchesCancelled`
867+
* already prevents: starting a run cancels prior work on its scope.
847868
*/
848869
const isStale = () =>
849870
and(
@@ -852,6 +873,9 @@ export async function cancelStaleDispatches(
852873
sql`NOT EXISTS (
853874
SELECT 1 FROM ${tableRowExecutions}
854875
WHERE ${tableRowExecutions.tableId} = ${tableRunDispatches.tableId}
876+
AND ${tableRowExecutions.groupId} IN (
877+
SELECT jsonb_array_elements_text(${tableRunDispatches.scope} -> 'groupIds')
878+
)
855879
AND ${tableRowExecutions.status} IN ('queued', 'running', 'pending')
856880
AND ${tableRowExecutions.updatedAt} >= ${sql.param(staleBefore, tableRowExecutions.updatedAt)}
857881
)`

apps/sim/lib/table/stale-dispatch-recovery.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ describe('cancelStaleDispatches', () => {
114114
expect(chunks.some((chunk) => chunk.includes('NOT EXISTS'))).toBe(true)
115115
expect(chunks).toContain('tableRowExecutions.updatedAt')
116116
expect(chunks).toContain('tableRowExecutions.tableId')
117+
/**
118+
* Narrowed to the dispatch's own groups: row executions carry no dispatch
119+
* column, so a table-wide probe lets a LIVE dispatch's cells vouch for an
120+
* ABANDONED one beside it, and the abandoned row is never reclaimed.
121+
*/
122+
expect(chunks).toContain('tableRowExecutions.groupId')
123+
expect(chunks.some((chunk) => chunk.includes('jsonb_array_elements_text'))).toBe(true)
117124
})
118125

119126
it('emits the terminal event so a stuck client overlay clears', async () => {
@@ -165,12 +172,33 @@ describe('dispatcherStep pending transition', () => {
165172
* resurrected the row, and with a fresh heartbeat the sweep would then wait
166173
* out another full window before reclaiming what it had already given up on.
167174
*/
175+
it('stops the step when the claim loses the race', async () => {
176+
mockGetTableById.mockResolvedValue({
177+
id: 'table-1',
178+
schema: { workflowGroups: [{ id: 'group-1' }] },
179+
})
180+
dbChainMockFns.limit.mockResolvedValue([{ ...ABANDONED_ROW, status: 'pending' }])
181+
// The guarded claim matched nothing — a Stop-all or the sweep got there first.
182+
dbChainMockFns.returning.mockResolvedValue([])
183+
184+
const result = await dispatcherStep('tdsp_1')
185+
186+
/**
187+
* Guarding the write without reading its outcome is the worse half of a fix:
188+
* the row correctly stays cancelled while the step announces `dispatching`,
189+
* stamps cells and enqueues a window for it.
190+
*/
191+
expect(result).toBe('done')
192+
expect(mockAppendTableEvent).not.toHaveBeenCalled()
193+
})
194+
168195
it('re-asserts the status it read before claiming the dispatch', async () => {
169196
mockGetTableById.mockResolvedValue({
170197
id: 'table-1',
171198
schema: { workflowGroups: [{ id: 'group-1' }] },
172199
})
173200
dbChainMockFns.limit.mockResolvedValue([{ ...ABANDONED_ROW, status: 'pending' }])
201+
dbChainMockFns.returning.mockResolvedValue([{ id: 'tdsp_1' }])
174202

175203
await dispatcherStep('tdsp_1').catch(() => {})
176204

0 commit comments

Comments
 (0)