Sync Failures
Symptom
One of:
- A scheduled paid-acquisition sync fails (Inngest run marked failed).
- A manual re-run of the same sync succeeds.
- Ad-platform data simply stops arriving, with no failed run anywhere.
Diagnosis
Work down this list. The last two causes produce no error at all, which is why they take longest to find.
1. Is anything registered?
A function missing from the functions array in src/inngest/functions.ts never runs and never errors. Check that first if the function is new or was recently refactored.
Then check the app actually synced after the last deploy. modified: true on a manual PUT is not drift on SDK v3.54+ — check latestSync.status ("duplicate" is healthy) and the cron_liveness block in /api/admin/sync-health.
If registration is failing for everything at once: the plan concurrency cap is 5. One function declaring more gets a 400 at sync time and blocks registration of every function in the app.
2. Is it actually a failure, or the conveyor cadence?
Scheduled paid fetching is a conveyor. paidAccountConveyor dispatches exactly one account per */10 tick, fleet-wide, and syncMarketingAccount runs strictly serially (global concurrency limit 1). On a large fleet an individual account waits its turn — that is the design, not a failure.
Two related non-failures:
- Env-configured bindings with no row id never ride the conveyor. They only sync via manual full-client syncs.
- Meta device/country segment cuts refresh only on hour-divisible-by-6 cycles (
heavyCutsDue). Missing segment cuts inside a 6-hour window are expected.
3. Is the binding credentialed and active?
A binding with no usable credentials is a logged no-op — by design it never fabricates data and never falls back to another tenant's account. So "no data" can be the correct, safe outcome.
Check client_ad_accounts.status (a paused/archived binding is skipped), then Connectors on /marketing/settings. If every connector looks uncredentialed at once, suspect CREDENTIAL_ENCRYPTION_KEY being unset — both vaults fail closed without it.
Config-missing errors are raised as NonRetriableError deliberately, so they fail fast rather than burning retries.
4. Is the platform value normalized?
platform=instagram must canonicalize to facebook (canonicalPaidDataPlatform). Without that it matches no stage and the whole sync no-ops silently.
5. Did a step blow its budget?
If a run fails mid-way with no vendor error, check step size and duration:
- Durable-step output size. A step returning row arrays or raw vendor payloads can exceed the limit. Step outputs are persisted forever and re-read on every retry.
maxDuration = 800on the serve route buys headroom, not boundedness. A step that outgrows it must be split. (At the old 300s default this endpoint threw 626 runtime timeouts in one week.)
6. Did the schedule stop firing entirely?
If a cron function simply stopped with no failed runs, changing the cron expression forces Inngest Cloud to recreate the stored schedule. That is the documented fix — it resolved sync-hyros-orders on 2026-08-17.
Fix
For a step-output failure, keep the rows inside the step and return metadata:
// ❌ Wrong — returns an unbounded array that is persisted forever
const rows = await step.run('fetch-day', async () => {
return await fetchDay({ creds, date })
})
// ✅ Right — writes inside, returns a compact summary
const result = await step.run('fetch-day', async () => {
const rows = await fetchDay({ creds, date })
await writeToClickHouse(rows)
return { count: rows.length, date }
})
For everything else, fix the specific cause found above, then trigger a manual sync (/admin/sync-health per-account buttons, or npm run sync:marketing). A manual sync runs everything including paid, so it is the fastest way to prove credentials independently of the conveyor.
Re-running is safe: writes are idempotent. ReplacingMergeTree dedupes by the table's ORDER BY grain, and synced_at derives from event.ts rather than wall-clock time, so a replay does not split one run's rows across versions.
Prevent
- Every fetcher's step returns a
{ count, date, … }shape by convention. - Never use
new Date()forsynced_atin a durable function body — a wall-clock stamp mints a new version per retry attempt. - Do not raise the deliberate low concurrency caps. The global
limit: 1onsyncMarketingAccountis a measured operator directive from live Meta throttle testing. - After any deploy that changes an Inngest function, verify against
/admin/sync-health— a green Vercel deploy is not evidence the job runs.
See also
- The Inngest folder guide (
src/inngest/AGENTS.md) — authoritative current sync model. - Inngest durable steps — the permanent reference.
- Deployment — sync-after-deploy and verification.