fix(slack): win the trigger_id race — dispatch latency cuts + opt-in ingest loading modal - #7054
fix(slack): win the trigger_id race — dispatch latency cuts + opt-in ingest loading modal#7054icecrasher321 wants to merge 10 commits into
Conversation
…+ executor-start metric The "Webhook dispatch latency" line now carries preprocessMs/loadsMs/ providerConfigMs/formatInputMs, and a one-shot onBlockStart callback logs "Webhook executor started" with the true trigger age when the first block runs — the moment that decides a trigger_id-bound provider's 3s race, which the existing metric (emitted before executeWorkflowCore) undercounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…orker preprocessExecution gains trustWorkflowRecord (skip the archived-state re-read for a row fetched in the same request) and skipAccountChecks (skip the ban + subscription re-reads; guarded to checkRateLimit: false). Webhook ingest passes trust for the row findAllWebhooksForPath returned; the inline runner closures hand the ingest-loaded workflow + webhook rows to executeWebhookJob as memory-only warm context, so the worker drops the third workflow fetch, the webhook re-select, and the duplicate account checks. Trigger.dev and recovery jobs pass no warm context and are byte-identical to before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oymentVersionId loadWorkflowDeploymentVersionState consults the existing 5-min LRU before the SELECT (the id is immutable, and entries now carry their workflowId so a mismatched pair still falls through to the query). blockExistsInDeployment routes through that loader when the webhook row's admitted version id is known, instead of re-reading the entire state jsonb for one boolean — which also warms the in-process cache the inline execution path reads moments later. The raw active-version read remains the null-id fallback, and any failure still answers false. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ritical path The worker starts resolveCredentialAccountUserId before the state/webhook loads and awaits it only where the owner id is first consumed, so its two serial reads overlap the deployment-state load and provider-config resolution. The resolved owner rides into formatInput as credentialOwnerUserId (when the provider config names the same credential), letting the Slack handler skip re-running the identical resolveOAuthAccountId + account owner chain before refreshing the token. The Slack token resolution is extracted into resolveSlackWebhookBotToken, shared and behavior-identical when no owner id is provided. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PII-redaction row read (workspace ⋈ organization) joins the existing state + environment Promise.all instead of running serially after logging start, and eligibleOrgForWorkspace resolves its feature-flag and enterprise-plan reads concurrently after the workspace lookup. Identical results; fewer serial round trips for every execution path, including Trigger.dev workers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ert concurrently startWorkflowExecution's existing-log probe and createSnapshotWithDeduplication have no data dependency; running them in parallel cuts a serial round trip from every execution start. The duplicate-executionId path still returns the prior log and snapshot — the concurrent upsert is an idempotent no-op on an unchanged state hash, and a changed-hash orphan is reclaimed by cleanupOrphanedSnapshots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
findAllWebhooksForPath depends only on the request path, so the ingest route starts it before reading the body stream and awaits it after the challenge short-circuit — one round trip off the pre-ack path. A challenge response abandons a read-only query. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Optional provider hook invoked after event filters, the deployment-block check, and admission — immediately before the execution payload is assembled — for work that must happen synchronously at ingest (e.g. opening a Slack loading modal inside the 3-second trigger_id window). The result rides the payload as syncInteraction (identifiers only, never token material) and surfaces to formatInput. A hook failure or throw never blocks dispatch. All three Slack ingest doors converge on this call site; no route changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slack's trigger_id expires 3 seconds after the interaction, and webhook workflows execute after the ack — so views.open from a workflow reliably loses the race. When the slack_oauth trigger opts in, the ingest path now opens a minimal Block Kit loading modal via views.open (2s timeout, any failure logs and continues) for interactive payloads that carry a fresh trigger_id and are not already inside a modal. The created view id rides the payload into the trigger output as loading_view_id, which never expires — the workflow updates it with Slack Update View. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the 'Open loading modal' switch plus title/text inputs to the slack_oauth trigger (visible for block_actions; the ingest hook re-checks payload shape), and declares the loading_view_id output shared by both Slack triggers. buildProviderConfig copies subblock values generically, so no deploy changes. The legacy slack_webhook trigger is deliberately left without the option. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview Latency cuts: Webhook ingest overlaps path lookup with body parsing. The inline runner passes memory-only warm context (workflow/webhook rows) into Observability: Dispatch latency logs add phase timings ( Slack loading modal: New optional Reviewed by Cursor Bugbot for commit 750180e. Configure here. |
Greptile SummaryThis PR reduces Slack webhook dispatch latency by reusing ingest context, parallelizing execution setup, and caching deployment state, while adding an opt-in loading modal whose view ID is exposed to workflows.
Confidence Score: 4/5The PR should not merge until inline webhook execution revalidates revocable workflow and account state after any queue wait. The detached inline runner can wait on concurrency after admission, yet the new warm-context flags suppress both the active-workflow reread and current ban checks, allowing state revoked during that interval to execute. Files Needing Attention: apps/sim/background/webhook-execution.ts, apps/sim/lib/execution/preprocessing.ts
|
| Filename | Overview |
|---|---|
| apps/sim/background/webhook-execution.ts | Adds warm-context reuse and timing instrumentation, but stale request-time records can bypass current revocation checks after queue delay. |
| apps/sim/lib/execution/preprocessing.ts | Adds explicit trust and account-check bypasses used by the inline webhook path. |
| apps/sim/lib/webhooks/processor.ts | Adds synchronous provider preparation, deployment-cache warming, and memory-only context handoff. |
| apps/sim/lib/webhooks/providers/slack.ts | Implements bounded loading-modal creation, shared token resolution, and trigger-output propagation. |
| apps/sim/lib/workflows/persistence/utils.ts | Makes immutable deployment loading cache-first and validates cached entries against workflow identity. |
| apps/sim/lib/logs/execution/logger.ts | Runs duplicate detection and snapshot deduplication concurrently while preserving duplicate handling. |
Sequence Diagram
sequenceDiagram
participant Slack
participant Ingest as Webhook ingest
participant Queue as Inline queue
participant Worker as Webhook worker
participant Core as Workflow core
Slack->>Ingest: Interactive payload + trigger_id
Ingest->>Ingest: Verify, filter, and admit
opt Loading modal enabled
Ingest->>Slack: views.open(trigger_id)
Slack-->>Ingest: loading view ID
end
Ingest->>Queue: Enqueue payload + warm context
Queue-->>Ingest: Accepted
Queue->>Worker: Run after claim/concurrency wait
Worker->>Worker: Preprocess using warm records
Worker->>Core: Execute deployed workflow
Core-->>Worker: First block starts
Worker->>Worker: Log trigger age
Reviews (1): Last reviewed commit: "feat(slack): loading-modal trigger confi..." | Re-trigger Greptile
| workflowRecord: warmWorkflowRecord, | ||
| trustWorkflowRecord: Boolean(warmWorkflowRecord), | ||
| skipAccountChecks: admissionCompleted && Boolean(warmWorkflowRecord), |
There was a problem hiding this comment.
Warm context bypasses revocation checks
When an inline job waits for a concurrency slot and the workflow is archived, undeployed, or its account is banned after admission, these flags suppress the active-workflow reread and current ban check, causing the revoked workflow to execute from stale ingest state.
Knowledge Base Used:
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 750180e. Configure here.
| type: 'section', | ||
| text: { | ||
| type: 'plain_text', | ||
| text: truncate(text, SLACK_SECTION_TEXT_MAX_CHARS - 1, '…'), |
There was a problem hiding this comment.
Exact-length modal text truncated
Medium Severity
truncate only shortens when length is greater than its first argument, and the result length is that argument plus the suffix. Passing cap - 1 therefore truncates strings that are already exactly at Slack’s limit (24 for the title, 3000 for the body), replacing the last character with …. The UI documents a 24-character title max, so exact-max titles are corrupted on every loading modal open.
Reviewed by Cursor Bugbot for commit 750180e. Configure here.


Problem
Slack workflows calling
slack_open_viewfail withThe trigger_id has expired. Trigger IDs are only valid for 3 seconds after the interaction.Webhook workflows execute after the HTTP ack, and the dispatch pipeline's serial pre-execution work eats the 3-second TTL before the first block runs.Measured in prod (read-only replica, last 24h, Slack n=4,726):
async_jobsINSERT)19.5% of all Slack runs are past the 3s TTL before the executor starts (10–42% by hour, tracking load) — and
views.openfires ~1s later still. Not a single bad commit: no stranded INVALID index (pg_indexclean), claim hop innocent, Trigger.dev unreachable for Slack. Slack-trigger volume went ~300–900/day → 3,000–8,100/day starting 2026-08-09 and Slack executes inline in the app pods, so load pushed an always-marginal pipeline over the line.genericwebhooks pay near-identical ingest cost (717ms) — the cuts help every provider.Part A — dispatch latency cuts (commits 1–7)
"Webhook dispatch latency"gainspreprocessMs/loadsMs/providerConfigMs/formatInputMs, and a one-shotonBlockStartcallback logs"Webhook executor started"with the true trigger age at first-block start (the old metric stops beforeexecuteWorkflowCoreand under-reports).trustWorkflowRecord+skipAccountChecksflags on preprocessing; the inline runner closures hand the ingest-loaded workflow + webhook rows to the worker as memory-only warm context (id-guarded). Kills the third workflow fetch, the webhook re-select, and the duplicate ban/subscription reads.loadWorkflowDeploymentVersionStateconsults the existing 5-min LRU before the SELECT (immutable id, entries carryworkflowIdfor integrity), andblockExistsInDeploymentanswers via that loader when the webhook row's admitteddeploymentVersionIdis known — instead of re-reading the entire state jsonb for one boolean — which also pre-warms the cache the inline execution path reads moments later.formatInputascredentialOwnerUserIdso the Slack handler skips the duplicateresolveOAuthAccountId+ account-owner chain (token resolution extracted into sharedresolveSlackWebhookBotToken).Promise.all;eligibleOrgForWorkspaceresolves its flag + plan reads concurrently.Runtime-topology safety: Trigger.dev runs async executions and durable-queue webhooks (polling providers,
sim, tiktok, zoho-desk); Slack and push webhooks run inline in the app process. Every optimization is closure-passed or explicit-flag — no warm context or skip flag ever reaches a Trigger.dev or recovery job, so those paths are byte-identical to before. Admission (billing/usage/rate-limit) still fully gates before enqueue; nothing changes in what executes, bills, rejects, or logs.Expected: ingest 834→
650–700ms, worker 1,032→500–650ms, over-TTL share 19.5% → low single digits.Part B — opt-in sync loading modal (commits 8–10,
slack_oauthtrigger only)Even a perfect pipeline leaves Slack's ~557ms delivery plus workflow time before
open_view— the Slack-sanctioned guaranteed shape is: open a loading modal synchronously inside the 3s window, then update it by view id (no TTL).prepareSyncDispatch, called after filters/deployment-check/admission and before payload assembly — all three Slack ingest doors converge there; zero route changes; a filtered or rejected delivery never opens a stray modal; a hook failure never blocks dispatch.slack_oauthtrigger enables Open loading modal (visible forblock_actions, with optional title/text), eligible interactive payloads (freshtrigger_id, not already inside a modal) get a minimal Block Kit loading view viaviews.open(2s timeout,callback_id: sim_loading_modal). The created view id rides the payload into the trigger output asloading_view_idforslack_update_view.exchanged_trigger_id, logged). The persisted payload carries the view id only — never token material. Legacyslack_webhooktrigger deliberately unchanged.Workflows can also make every step after the first open latency-immune today: in-modal interactions already deliver
view.idin the trigger output forslack_update_view.Verification
bun run type-check,bun run check:api-validation, and the affected battery (126 files / 2,122 tests:lib/webhooks/**,background/**correlation + webhook-execution,lib/execution/**,lib/workflows/{persistence,executor,custom-blocks},lib/logs/execution/logger,app/api/webhooks/**,triggers/**) all pass.Baselines to beat: ingest 834 / worker 1,032 / 19.5% over 3s. Log-side: aggregate the new phase fields and
executorStartTriggerAgeMs(alert on its >3000ms share).🤖 Generated with Claude Code