Skip to content

fix(slack): win the trigger_id race — dispatch latency cuts + opt-in ingest loading modal - #7054

Closed
icecrasher321 wants to merge 10 commits into
mainfrom
slack-trigger-id-expired
Closed

fix(slack): win the trigger_id race — dispatch latency cuts + opt-in ingest loading modal#7054
icecrasher321 wants to merge 10 commits into
mainfrom
slack-trigger-id-expired

Conversation

@icecrasher321

Copy link
Copy Markdown
Collaborator

Problem

Slack workflows calling slack_open_view fail with The 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):

Phase p50 p95
Slack delivery (interaction → receipt) 557ms
Ingest (receipt → async_jobs INSERT) 834ms 1,301ms
Claim hop (INSERT → claim) 50ms 82ms
Worker (claim → logging start) 1,032ms 1,793ms
Trigger age at logging start 2,508ms 3,701ms

19.5% of all Slack runs are past the 3s TTL before the executor starts (10–42% by hour, tracking load) — and views.open fires ~1s later still. Not a single bad commit: no stranded INVALID index (pg_index clean), 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. generic webhooks pay near-identical ingest cost (717ms) — the cuts help every provider.

Part A — dispatch latency cuts (commits 1–7)

  1. Observability first: "Webhook dispatch latency" gains preprocessMs/loadsMs/providerConfigMs/formatInputMs, and a one-shot onBlockStart callback logs "Webhook executor started" with the true trigger age at first-block start (the old metric stops before executeWorkflowCore and under-reports).
  2. Stop redoing ingest's work: trustWorkflowRecord + skipAccountChecks flags 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.
  3. Cache-first deployed state: loadWorkflowDeploymentVersionState consults the existing 5-min LRU before the SELECT (immutable id, entries carry workflowId for integrity), and blockExistsInDeployment answers via that loader when the webhook row's admitted deploymentVersionId is 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.
  4. Credential resolution once, off the critical path: start early/await late in the worker; the resolved owner rides into formatInput as credentialOwnerUserId so the Slack handler skips the duplicate resolveOAuthAccountId + account-owner chain (token resolution extracted into shared resolveSlackWebhookBotToken).
  5. execution-core parallel front: PII-redaction row joins the existing state+env Promise.all; eligibleOrgForWorkspace resolves its flag + plan reads concurrently.
  6. Logging start 3→2 round trips: duplicate-execution probe ∥ snapshot upsert (duplicate path still returns the prior log/snapshot).
  7. Ingest: webhook path lookup overlaps the body stream read.

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_oauth trigger 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).

  • New optional provider hook 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.
  • When the slack_oauth trigger enables Open loading modal (visible for block_actions, with optional title/text), eligible interactive payloads (fresh trigger_id, not already inside a modal) get a minimal Block Kit loading view via views.open (2s timeout, callback_id: sim_loading_modal). The created view id rides the payload into the trigger output as loading_view_id for slack_update_view.
  • Slack does not retry interactivity payloads (retries are Events API), and the single-use trigger_id makes multi-webhook fan-out first-wins (later attempts fail fast as exchanged_trigger_id, logged). The persisted payload carries the view id only — never token material. Legacy slack_webhook trigger deliberately unchanged.

Workflows can also make every step after the first open latency-immune today: in-modal interactions already deliver view.id in the trigger output for slack_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.
  • Prod before/after (same replica SQL used for the baseline):
SELECT round((percentile_cont(0.5) WITHIN GROUP (ORDER BY (extract(epoch FROM aj.created_at)*1000 - (aj.payload->>'webhookReceivedAt')::numeric)))::numeric) AS p50_ingest_ms,
       round((percentile_cont(0.5) WITHIN GROUP (ORDER BY extract(epoch FROM (wel.started_at - aj.started_at))*1000))::numeric) AS p50_worker_ms,
       round(100.0*count(*) FILTER (WHERE (extract(epoch FROM wel.started_at)*1000 - (aj.payload->>'triggerTimestampMs')::numeric) > 3000)/count(*),1) AS pct_over_3s
FROM async_jobs aj JOIN workflow_execution_logs wel ON wel.execution_id = aj.payload->>'executionId'
WHERE aj.type='webhook-execution' AND aj.payload->>'provider' IN ('slack','slack_app') AND aj.created_at > now() - interval '24 hours';

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

icecrasher321 and others added 10 commits August 24, 2026 17:07
…+ 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>
@vercel

vercel Bot commented Aug 25, 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 25, 2026 12:30am

Request Review

@cursor

cursor Bot commented Aug 25, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes the hot webhook ingest and inline execution path (skipped checks only when warm context and admission align), plus synchronous Slack API calls before enqueue; Trigger.dev and recovery paths are designed to stay unchanged.

Overview
This PR shortens the path from Slack interaction to first block run and adds an opt-in synchronous loading modal so workflows can update a view by id instead of racing trigger_id.

Latency cuts: Webhook ingest overlaps path lookup with body parsing. The inline runner passes memory-only warm context (workflow/webhook rows) into executeWebhookJob, with trustWorkflowRecord and skipAccountChecks on preprocessing when admission already ran—avoiding duplicate DB reads and ban/subscription checks. Deployed state uses cache-first loadWorkflowDeploymentVersionState and blockExistsInDeployment with the admitted deploymentVersionId to warm the LRU. Credential owner resolution starts early and overlaps loads; credentialOwnerUserId and syncInteraction reach provider formatInput. Execution logging runs duplicate-probe and snapshot upsert in parallel; execution-core loads PII policy alongside state/env.

Observability: Dispatch latency logs add phase timings (preprocessMs, loadsMs, etc.) and a one-shot Webhook executor started metric on first onBlockStart (true trigger age at block start).

Slack loading modal: New optional prepareSyncDispatch runs after filters/admission; for slack_oauth with Open loading modal, eligible interactive payloads call views.open during ingest and persist loading_view_id on the job payload (ids only). Trigger output includes loading_view_id for slack_update_view. Hook failures never block dispatch; durable-queue jobs do not receive warm context.

Reviewed by Cursor Bugbot for commit 750180e. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This 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.

  • Adds phase-level dispatch and first-block-start latency metrics.
  • Reuses workflow and webhook rows for same-process inline execution.
  • Adds synchronous Slack views.open preparation and loading_view_id trigger output.
  • Parallelizes independent database and configuration work.

Confidence Score: 4/5

The 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

Important Files Changed

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
Loading

Reviews (1): Last reviewed commit: "feat(slack): loading-modal trigger confi..." | Re-trigger Greptile

Comment on lines +535 to +537
workflowRecord: warmWorkflowRecord,
trustWorkflowRecord: Boolean(warmWorkflowRecord),
skipAccountChecks: admissionCompleted && Boolean(warmWorkflowRecord),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 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:

@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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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, '…'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 750180e. Configure here.

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