Skip to main content

Inngest Durable Steps

When to read this​

Not a runbook so much as a permanent reference. Read this once before you touch src/inngest/* — then read the Inngest folder guide (src/inngest/AGENTS.md), which is the authoritative and continuously-maintained version.

The scheduled sync model (current — changed 2026-08-18)​

Scheduled paid (ad-platform) fetching is a conveyor, not a fleet sweep. Any doc describing a twin-cron model where the fleet cycle fetched ad platforms is stale.

FunctionTriggerRole
paidAccountConveyorcron */10 * * * *, concurrency [{limit:1}]Dispatches exactly one marketing/account-sync.requested per tick, for the least-recently-synced active paid binding.
syncMarketingAccountthat eventOne ad account per run. concurrency [{limit:1 global}, {limit:1, key:binding_id}] — strictly serial, one ad-account fetch in flight fleet-wide.
syncMarketingMetricscron env.MARKETING_SYNC_CRONParent. Unscoped runs fan out one client-scoped event each, staggered ~35s apart, marked include_paid:false.
syncCommerceAccountmarketing/commerce-sync.requestedOne store/CRM binding per run. concurrency [{limit:1, key:binding_id}, {limit:4}].
sync-hyros-ordersown cron 43 */6 * * *Order-grain Hyros events, separate from the above.

Consequences that catch people out:

  • The conveyor is the sole scheduled dispatcher of paid fetches. Scheduled unscoped runs never fetch ad platforms. Manual syncs (sync-now, no flag on the event) still run everything including paid.
  • Freshness comes from the warehouse itself — max(synced_at) per account, excluding PAID_LOCK_VERSION rows — not from cursor state. Never-synced accounts sort first; a throttled account rotates back after cooldown.
  • Env-configured bindings with no row id cannot ride the conveyor at all. They sync only via manual full-client syncs.
  • Meta device/country segment cuts refresh only on hour-divisible-by-6 cycles (heavyCutsDue). Age/gender/all-grain run every pass. Missing segment cuts inside a 6-hour window are expected.
Do not raise the concurrency limits

The global {limit: 1} on syncMarketingAccount is an operator directive (2026-08-18), set after live Meta Business Use Case measurement — even 3-per-platform tripped throttles. bind-ads-to-clickup sits at 2 for the same reason. These are measured decisions with inline comments, not defaults waiting to be tuned.

Never read the code default as the live cadence

syncMarketingMetrics uses env.MARKETING_SYNC_CRON. The code default is '7,37 * * * *', but production overrides it via the env var. The inline comment at the trigger says exactly this. To learn the live cadence, check the Vercel env store or the Inngest dashboard — never the source.

The rules​

  1. Return compact. Never return arrays or vendor payloads from step.run. Return counts and ids. Step outputs are persisted forever and re-read on every retry.
  2. Concurrency is per-function and deliberate. There is no single "key by client_id" rule — read each function's config and its comment. Several are keyed on binding_id, and the account child is globally serial on purpose.
  3. Deterministic step ids. Include the natural key (step.run('fetch-2026-08-14', …)). Never a timestamp or uuid.
  4. synced_at derives from event.ts, never new Date(). Inngest re-executes the body on every retry/replay; a wall-clock stamp mints a new version per attempt and splits one run's rows across ReplacingMergeTree versions.
  5. Money is decimal dollars end to end. Never cents, never /100 or *100.
  6. Config-missing errors are NonRetriableError. Don't burn retries on a binding that can never succeed.

Registration — the silent failure​

functions.ts is both a function definition and the registry. Export a function but forget to add it to the functions array and it never runs, and nothing errors.

New function checklist:

  1. Define it in its own file under src/inngest/functions/.
  2. Type its event in src/inngest/client.ts.
  3. Add it to the functions array in src/inngest/functions.ts.
  4. Pick a cron minute that does not collide with existing ticks.

Plan concurrency cap is 5. A function declaring concurrency above 5 gets a 400 at sync time and blocks registration of every function in the app.

Deployment traps​

  • serveHost is pinned to APP_BASE_URL (https://launch.lynkr.cc) in src/app/api/inngest/route.ts. Without it, Inngest Cloud memorizes a per-deployment *.vercel.app URL, keeps accepting events, and silently never invokes anything.
  • modified: true on a manual PUT sync is not drift on SDK v3.54+. Check latestSync.status ("duplicate" is healthy) and the cron_liveness block in /api/admin/sync-health.
  • Changing a cron expression forces Inngest Cloud to recreate the stored schedule — the documented fix when a schedule silently stops firing (sync-hyros-orders, 2026-08-17).
  • Vercel deployment protection blocks Inngest callbacks unless the bypass secret is wired, or the sync targets launch.lynkr.cc. See Deployment and Vercel Protection Bypass.
  • maxDuration = 800 buys headroom, not boundedness. A step that outgrows 800s must be split. At the 300s default this endpoint threw 626 runtime timeouts in one week.

Other gotchas​

  • ReplacingMergeTree dedupe is by the table's ORDER BY grain, not by event_id. event_id is an audit stamp only.
  • platform=instagram normalizes to facebook (canonicalPaidDataPlatform). Without that it matches no stage and the whole sync no-ops.
  • syncMarketingMetrics deliberately has no debounce/cancelOn — each killed tail-of-stagger clients. timeouts.finish: '35m' must stay larger than the paid (15m) + commerce (8m) invoke timeouts so children die before the parent body does.
  • Commerce bindings are re-resolved inside the run, never carried on the event — credential refs must not become durable payload, and a paused binding must drop out.

Signing keys​

INNGEST_SIGNING_KEY and INNGEST_EVENT_KEY. Rotate together, never one at a time. An event key authorizes ingestion only — inngest.send() returning ok with a real event id proves nothing about execution. See Credentials.

Local dev​

Run the Inngest dev server against your local Next.js app. Events fire in-process. Use it before pushing anything durable.

See also​

  • The Inngest folder guide (src/inngest/AGENTS.md) — the authoritative, maintained version of this page.
  • Sync failures — when scheduled runs fail.
  • Deployment — sync-after-deploy and post-deploy verification.