Skip to content

fix(knowledge,tables): recover abandoned dispatches, bound the sweep and the workbook preview - #6945

Merged
waleedlatif1 merged 15 commits into
stagingfrom
fix/table-dispatch-recovery
Aug 21, 2026
Merged

fix(knowledge,tables): recover abandoned dispatches, bound the sweep and the workbook preview#6945
waleedlatif1 merged 15 commits into
stagingfrom
fix/table-dispatch-recovery

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

Five defects, each measured in production rather than inferred.

  • Abandoned table run dispatches were never reclaimed. Every terminal transition on table_run_dispatches is user- or flow-initiated, so a dispatcher killed mid-loop left its row dispatching forever — pinning the table's "X running" overlay and blocking re-runs, with no way to clear it from the product. Liveness now comes from a heartbeat_at stamped by the per-window writes that already advance cursor and processed_count, so a slow-but-live dispatch is spared however long it runs — the in-process path has no duration ceiling, so ageing from requested_at would reclaim live self-hosted work. Reads COALESCE(heartbeat_at, requested_at) so rows predating the column stay reclaimable. Runs as the last arm of the existing stale-execution cron. The table_run_dispatches_watchdog_idx index had existed for this sweep since the table was created, unused.
  • The dispatcher OOM is not a leak. Peak RSS is a flat plateau — 457 MB at 20-45s and 461 MB past 200s, so ten times the duration buys four megabytes — that crept ~2%/release for a month past the small-1x ceiling. CPU peaks at 0.19, so the larger preset is bought for RAM alone.
  • maxAttempts never covered an OOM. Trigger.dev retries TASK_PROCESS_OOM_KILLED only when retry.outOfMemory.machine names a preset. Affected runs recorded attempt_count = 1 while a docstring claimed they resumed from the persisted cursor. Fixed on both the dispatcher and the document task.
  • The connector stuck-document sweep dispatched without a bound. Its chunk size paced the loop but the candidate query had no limit, so one connector enqueued 2,959 documents in fifteen seconds onto the queue every workspace shares. Candidates are now taken oldest-first and capped per sync; a deeper backlog is deferred, not dropped.
  • officeparser's named export is undefined under esbuild. It is CommonJS, and the worker bundle puts module.exports on default, so calling the named export threw TypeError. Every parser reads that as a library failure and answers with a degraded scrape the pipeline rejects — so .pptx and legacy .doc reported "No text could be extracted… scanned, image-only, or password-protected", naming a cause unrelated to the fault. Resolved in one shared loader rather than per bundler.
  • The workbook preview allocated from the declared sheet range. sheet_to_json allocates from a worksheet's declared !ref, not its populated cells, and the row cap was applied to the result — bounding the emitted string while the allocation it was meant to bound had already happened. Sub-megabyte workbooks exhausted an 8 GB worker, and 16 GB when this ran inside the sync. Passing the window into the conversion is what makes the cap real; defval goes with it, since defaulting every cell made rows dense and silently defeated the blankrows: false beside it.

Type of Change

  • Bug fix

Testing

  • 2,334 tests across the affected surface passing; type-check, lint, 32/32 audits, check:migrations backward-compatible.
  • 13 mutations run against the new guards — dropping the COALESCE fallback, flipping cancelled to complete, removing the terminal event, removing either limit, removing the sweep ordering, reverting either machine or OOM fallback, restoring defval, and reverting the parser to named-export-only each turn a named test red.
  • The migration is a single nullable ADD COLUMN — additive, no rewrite, no default, and ignored by the currently deployed code.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 21, 2026 9:50pm

Request Review

@cursor

cursor Bot commented Aug 21, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Touches table-run lifecycle (cancel vs complete, SSE overlays), a new cron prune of connector sync logs that gates document deletion safety, and worker machine/retry settings. A bad stale predicate or prune guard could cancel live runs or change reconciliation deletes.

Overview
Fixes several production failures around table runs, knowledge processing, and document parsing.

Abandoned table dispatches are now reclaimed by the stale-execution cron via cancelStaleDispatches. A new nullable heartbeat_at is stamped on per-window progress so slow-but-live runs (including in-process) are spared; the sweep also checks scoped cell activity, with a 24h ceiling so overlapping table-wide runs cannot mask a dead row forever. Dispatcher claim/complete paths are status-guarded so Stop-all or the sweep cannot be overwritten with dispatching/complete.

OOM retries actually happen: Trigger.dev retry.outOfMemory is set on the table dispatcher (small-2xmedium-1x) and document processor (large-2x). The dispatcher machine is bumped off the 512 MiB small-1x ceiling.

Knowledge/parser: stuck-document retries are oldest-first and capped at 200 per sync. Terminal connector sync logs older than 30 days are pruned without dropping the newest (or newest completed) row per connector. officeparser is loaded through a bundler-agnostic helper so worker esbuild no longer treats .pptx/.doc as empty. XLSX preview conversion is bounded to the preview window (no defval), so inflated !ref ranges no longer allocate the whole declared sheet.

Reviewed by Cursor Bugbot for commit e7b1301. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds bounded recovery and retention for abandoned table dispatches and connector logs, limits connector reconciliation and workbook parsing work, fixes OfficeParser module loading, and configures memory escalation for background tasks.

  • Adds heartbeat-based stale-dispatch recovery with scoped cell-activity protection, guarded terminal transitions, bounded cancellation events, and a nullable database heartbeat column.
  • Caps connector document reconciliation and sync-log retention work per run.
  • Bounds spreadsheet preview conversion at the parser and centralizes CommonJS-compatible OfficeParser loading.
  • Raises dispatcher memory capacity and configures OOM retry escalation for dispatcher and knowledge-processing tasks.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/table/dispatcher.ts Adds heartbeat-based abandoned-dispatch recovery and consistently guarded claim, completion, and pre-window cancellation handling.
apps/sim/app/api/cron/cleanup-stale-executions/route.ts Integrates bounded dispatch recovery and connector sync-log retention into the existing authenticated cleanup cron.
packages/db/migrations/0299_bright_nemesis.sql Adds the nullable heartbeat column used by dispatch recovery without requiring a table rewrite or backfill.
apps/sim/lib/knowledge/connectors/sync-engine.ts Bounds and orders connector reconciliation candidates to prevent unbounded shared-queue fan-out.
apps/sim/lib/file-parsers/xlsx-parser.ts Applies the workbook preview range limit during conversion rather than after potentially excessive allocation.
apps/sim/lib/file-parsers/officeparser-module.ts Normalizes OfficeParser’s CommonJS/default and named-export module shapes for bundled workers.
apps/sim/background/table-run-dispatcher.ts Increases dispatcher memory capacity and adds explicit OOM retry escalation.
apps/sim/background/knowledge-processing.ts Adds explicit OOM retry escalation for document-processing tasks.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Cron[Stale-execution cron] --> Sweep[Find dispatches with stale heartbeat]
  Sweep --> Activity{Recent scoped cell activity?}
  Activity -->|No| Cancel[Conditionally mark cancelled]
  Activity -->|Yes, below absolute ceiling| Spare[Leave dispatch active]
  Activity -->|Yes, beyond absolute ceiling| Cancel
  Cancel --> Event[Emit bounded cancellation events]
  Dispatcher[Dispatcher window loop] --> Heartbeat[Advance cursor/count and heartbeat]
  Heartbeat --> Sweep
Loading

Reviews (10): Last reviewed commit: "fix(tables): re-read the dispatch before..." | Re-trigger Greptile

Comment thread apps/sim/lib/table/dispatcher.ts
Comment thread apps/sim/lib/table/dispatcher.ts
Comment thread apps/sim/lib/file-parsers/xlsx-parser.ts
Comment thread apps/sim/lib/knowledge/connectors/sync-engine.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/table/dispatcher.ts Outdated
Comment thread apps/sim/lib/table/dispatcher.ts
Comment thread apps/sim/lib/table/dispatcher.ts
Comment thread apps/sim/lib/file-parsers/xlsx-parser.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/table/dispatcher.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit b347f91. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/table/dispatcher.ts Outdated
Comment thread apps/sim/lib/table/dispatcher.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 830b82f. Configure here.

Three defects measured in production this afternoon.

A dispatcher killed by an OOM left `table_run_dispatches` at `dispatching`
forever. Every terminal transition on that table is user- or flow-initiated, so
nothing reclaimed the row: four dispatches were stranded in one afternoon,
pinning each table's "X running" overlay and blocking re-runs, with no way to
clear them from the product. The `table_run_dispatches_watchdog_idx` index has
existed for this sweep since the table was created, unused.

Liveness comes from a new `heartbeat_at`, stamped by the per-window writes that
already advance `cursor` and `processed_count`, so a slow-but-live dispatch is
spared however long it runs — the in-process path has no duration ceiling, so
ageing from `requested_at` would reclaim live self-hosted work. The sweep reads
`COALESCE(heartbeat_at, requested_at)` so rows written before the column stay
reclaimable rather than NULL-false forever, and runs as the last arm of the
existing stale-execution cron at the same 95-minute window its table-job sibling
uses. Rows are cancelled, not completed: the scope never finished.

The OOM itself is not a leak. Peak RSS is a flat plateau — 457 MB at 20-45s and
461 MB past 200s, so ten times the duration buys four megabytes — that has crept
about two percent per release for a month, from 446 MB in late July to 545 MB,
past the 512 MiB `small-1x` ceiling. CPU peaks at 0.19, so the larger preset is
bought for RAM alone. `maxAttempts` never covered the kill either: Trigger.dev
retries `TASK_PROCESS_OOM_KILLED` only when `retry.outOfMemory.machine` names a
preset, and all four runs recorded `attempt_count = 1` while the docstring
claimed they resumed from the persisted cursor.

The connector stuck-document sweep dispatched without a bound. Its chunk size
paced the loop but the candidate query had no limit, so one connector enqueued
2,959 documents in fifteen seconds onto the queue every workspace shares.
Nothing was double-billed — those documents were genuinely unindexed — but one
connector monopolized the queue, and each dispatch mints a fresh requestId, so
the idempotency key differs every pass and none of it deduplicates. Candidates
are now taken oldest-first and capped per sync; a deeper backlog is deferred to
the next sync rather than dropped.
`officeparser` is CommonJS — `main: officeParser.js`, no `type`, no `exports`
map — so what `await import('officeparser')` yields depends on who built the
code. Node and webpack synthesize named exports from `module.exports`, so
`.parseOfficeAsync` is there. esbuild, which builds the Trigger.dev worker
bundle, puts `module.exports` on `.default` and leaves the named export
undefined, and the package is in neither `build.external` nor
`additionalPackages`, so it is bundled.

Reading the named export directly therefore worked everywhere except the
worker, where calling it threw `TypeError: parseOfficeAsync is not a function`.
All four parsers treat that as "the library failed" and answer with a scrape of
the archive, which returns `degraded: true`, and the document pipeline rejects a
degraded parse outright. The visible result was every `.pptx` and legacy `.doc`
reporting "No text could be extracted from this file — it may be scanned,
image-only, or password-protected", naming a cause that had nothing to do with
the fault. 118 pptx and 14 doc failures landed in a single burst when one
connector's sync first succeeded after ten consecutive crashes.

Resolved in one shared loader rather than per bundler: externalizing the package
has to be repeated in every build config this code runs under and regresses
silently the day one is missed.

The shape handling is split into a pure `resolveParseOfficeAsync` because the
failing shape cannot be reproduced by mocking the specifier — Vitest's
module-namespace proxy throws on a missing export rather than yielding the
`undefined` a real bundle produces, so a test going through `import` can only
assert the shape that already worked. That is also why the existing parser
suites never caught this: each mocks `officeparser` with a fabricated named
export, which presupposes the interop being broken here.
`sheet_to_json` allocates from a worksheet's DECLARED `!ref` range rather than
its populated cells, and Excel routinely writes an inflated range from stray
formatting. The 1,000-row preview cap was applied to the result, so it bounded
the emitted string while the allocation it was meant to bound had already
happened. An 880 KB workbook exhausted an 8 GB worker; the same content
exhausted 16 GB when this ran inside the connector sync. No machine size fixes
that, because the allocation scales with a number the file declares about
itself — fleet p99 for this task is 691 MB against 8 GB, so this is a cliff, not
pressure.

Passing the window into the conversion is what makes the cap real. `defval` goes
with it: defaulting every cell in the range made each row dense, so allocation
scaled with columns x declared rows rather than with populated cells, and
because no row was left empty it silently defeated the `blankrows: false` beside
it. Reported totals still come from the declared range, so bounding the
conversion does not change what the metadata says the workbook holds.

The eleven documents killed this way recorded `attempt_count = 1`: `maxAttempts`
does not cover `TASK_PROCESS_OOM_KILLED`, which Trigger.dev retries only when a
larger preset is named. Adding that escalation is a safety net rather than the
fix, and the same gap the dispatcher had.

Also corrects the machine comment, which claimed `large-1x` was 2 vCPU / 2 GB.
It is 4 vCPU / 8 GB, and believing the stale figure makes a resize look like the
answer when the parser is what is unbounded.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/table/dispatcher.ts
Comment thread apps/sim/lib/table/dispatcher.ts
The liveness probe cannot tell whose cells it is looking at when two table-wide
dispatches share a group, because `table_row_executions` carries no dispatch
column. On a quiet table that is only a delay — the neighbour finishes and the
next sweep reclaims — but a busy table with continuous auto-fired work can keep
an abandoned dispatch masked indefinitely, which is the stuck overlay this sweep
exists to clear.

A ceiling bounds it: past a day without a heartbeat, a dispatch is reclaimed
whatever its cells are doing. That is safe because a live dispatch stamps its
heartbeat between windows regardless of cell activity, so only a single window
outliving the ceiling could be reclaimed wrongly, and no window lasts a day on
any path — the Trigger.dev run ceiling is ninety minutes.

The real fix is a `dispatch_id` on the executions row. Threading it through the
patch layer and the upserts underneath it is a change to the hottest write path
in tables and belongs in its own review, not on the sixth round of this one.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 65ed5e6. Configure here.

`COALESCE(heartbeat_at, requested_at)` was written twice — once for the stale
threshold and again for the absolute ceiling — so the two could drift into
disagreeing about what proof of life means. One `lastBeat` fragment, one
`notBeatingSince(cutoff)` helper, both cutoffs expressed through it.

Also corrects the ceiling's comment: it triggers a day past the stale
threshold, not a day past now.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 787c845. Configure here.

Comment thread apps/sim/lib/table/dispatcher.ts
…ourth time

The two pre-claim exits — table missing, no target groups — still wrote through
`markDispatchComplete`. Last round I argued they run before the claim, "where
forcing a terminal state is the intent". That was wrong twice over: the table
lookup is awaited, so a cancel lands in that window like any other, and a
dispatch cancelled mid-lookup has not completed its scope any more than one
cancelled mid-window has.

Routing them through `completeDispatchIfActive` left `markDispatchComplete` with
no callers, so it is gone. That is the part worth having: this is the fourth
place the same defect appeared, each time because an unguarded writer was
sitting there to be reached. With it deleted, `completeDispatchIfActive` is the
only way to complete a dispatch and the class cannot recur.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/table/dispatcher.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 946dd8f. Configure here.

Several round trips separate the claim from the enqueue — the window query, the
executions prefetch, the tombstone filter — and nothing rechecked the dispatch
across them. A Stop-all or the stale sweep landing in that gap had the step
stamp cells and run a whole window for a dispatch already recorded as cancelled;
the existing recheck sits after the window, which is too late to prevent it.

Mirrors that existing check on the other side of the enqueue. It narrows the gap
to a single statement rather than closing it — a cancel arriving after this read
still races the enqueue, and no check can fix that. The cell-level
`cancellationGuard` and the `isExecCancelledAfter` tombstone filter are what
catch the remainder.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit e7b1301. Configure here.

@waleedlatif1
waleedlatif1 merged commit 7b6c581 into staging Aug 21, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/table-dispatch-recovery branch August 21, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant