Skip to content

Job catalogue

Every async job runs through the same BullMQ queue (scani-jobs), consumed by apps/backend/worker. Wire names live in packages/business/jobs/src/job-names.ts; descriptors in packages/business/jobs/src/scheduled-jobs/ (for repeatable jobs) or packages/business/jobs/src/user-jobs/ (for user-initiated jobs); processors in apps/backend/worker/src/processors/.

Scheduled jobs use the advisory-lock wrapper — two overlapping fires of the same name silently no-op rather than race.

The reconcilers and probes all run on the same quarter-hour cadence on purpose: aligning them means their advisory locks batch into one wake, so the database can scale to zero between runs instead of being nudged awake four times an hour.

NameFrequencyPurpose
pricingHourly (0 * * * *)Refresh current prices for every token referenced by an active holding.
wallet-balancesHourly (0 * * * *)Re-sync on-chain wallet balances + transactions across Etherscan, Helius, Bitcoin, Tron, TON.
exchange-balancesHourly (0 * * * *)Re-sync exchange holdings + recent trades for every connected exchange integration.
exchange-transactionsDaily (0 1 * * *)Refresh the transaction ledger for every connected exchange/broker/bank integration — fans out a transaction-import per account with a 30-day rolling window.
apy-payoutsDaily, 00:00 UTC (0 0 * * *)Apply accrued interest to holdings with an APY config due for payout.
historical-price-backfillNightly, 03:00 UTC (0 3 * * *)Fill daily-granularity price history for tokens with holdings; respects unpriceableUntil cooldown.
forex-backfillNightly, 03:30 UTC (30 3 * * *)Fill historical FX pairs (via Frankfurter) needed by the rollup.
token-prices-downsampleNightly, 05:00 UTC (0 5 * * *)Collapse intraday prices older than 7 days into one daily row per token/base/day (keeps the day’s last reading); preserves existing daily and tx-exact rows. Caps token_prices growth.
portfolio-value-rollupNightly, 04:00 UTC (0 4 * * *)Recompute portfolio_value_daily for every user at user / institution / account / holding scope.
transfer-linkingNightly, 03:45 UTC (45 3 * * *)Pair CEX withdrawals with wallet deposits via LinkTransferPairsUseCase.
backfill-token-identityWeekly, Sunday 02:00 UTC (0 2 * * 0)Re-enrich tokens whose providerMetadata hasn’t been touched lately.
backfill-counterpartyNightly, 05:30 UTC (30 5 * * *)Extract a counterparty + description onto holding_transactions rows that predate the per-provider extractors.
reconcile-pending-credentialsEvery 15 minutes (*/15 * * * *)Sweep stuck pending integration-credential rows (UI flow interruptions).
reconcile-orphaned-user-jobsEvery 15 minutes (*/15 * * * *)Sweep stuck running user-job rows whose worker process died.
dlq-depth-probeEvery 15 minutes (*/15 * * * *)Read the dead-letter queue depth; emit a warn log when it crosses thresholds.
job-heartbeat-probeEvery 15 minutes (*/15 * * * *)Detect jobs whose heartbeat went silent; mark them stuck.
stale-sync-probeHourly (0 * * * *)Detect active, credentialed integrations that have silently stopped syncing — stale lastSync or zero accounts — and alert via Sentry.
hide-closed-holdingsNightly, 04:30 UTC (30 4 * * *)Auto-hide holdings that have been at zero balance for the configured window.
rescore-scam-tokensNightly, 02:30 UTC (30 2 * * *)Recompute is_scam_probability for crypto tokens whose scam_score_version is stale, so a change to the scoring heuristic reaches tokens already stored. Non-crypto tokens are unscored and never enter the population; rows marked by a person are never recomputed.
payment-due-reminderHourly (5 * * * *)One Web Push a day at ~17:00 in each user’s own local time, summarising the payments due on their local tomorrow as a count and a per-currency total. Fires hourly and selects only the users for whom it is currently 17:00 locally — a single daily fire happens at one UTC hour, and one UTC hour is a different clock time in every zone. A user whose users.timezone is still NULL is SKIPPED, never defaulted to UTC, and counted in the job’s log line. Nothing is sent on a day with nothing due. Needs VAPID_SUBJECT / VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY; without them the job logs a refusal on every fire and sends nothing.

Scheduled jobs — declared but not registered

Section titled “Scheduled jobs — declared but not registered”

A descriptor can exist in packages/business/jobs/src/scheduled-jobs/ without being listed in SCHEDULED_JOB_DESCRIPTORS, and then the worker never registers it: the jobs below do not run. They are listed here because the file is in the tree and a reader who finds it deserves to know which half is missing — not because they are live. The table above is the list of live jobs.

There are none right now. The table below is empty, and that is the correct steady state — a descriptor belongs here only for as long as its processor is unwritten. payment-due-reminder was the last one; it moved to the live table above when PaymentDueReminderProcessor landed in the same commit that registered it (SC-226).

The heading stays even when empty, because scripts/check-docs.ts reads both tables and fails if either is missing — and a page that silently loses the distinction is how a job that never runs gets read as one that does.

NameFrequency when registeredBlocked onPurpose

Enqueued by the api in response to a user action. They use a stable per-user job ID so the user can see “in flight” status in the SPA.

NameTriggered byPurpose
screenshot-parseUpload a screenshotSend to OpenAI Vision; materialise the extracted holdings under a manual institution.
document-parseUpload an invoiceClassify the PDF, extract text (OCR only for pages that need it), then read vendor / amount / dates via the AI provider. Lands in the review feed for confirmation.
exchange-importConnect an exchangeFirst-time backfill: sync balances + transactions; create accounts/holdings.
wallet-importAdd a walletFirst-time backfill: scan the address across the chain; create holdings.
file-importUpload a CSV / fileParse and ingest.
holding-price-updateUser edits a private-token pricePersist the new price + audit row in token_price_edit_history.
refresh-account-balanceUser triggers a manual syncForce-refresh one account’s balances + transactions.
manual-holdings-createUser creates a manual holdingInsert under the manual institution; seed observation.
portfolio-history-backfillAfter import / manual editRebuild portfolio_value_daily for the affected date range for one user.
currency-rate-refreshA read path needed a currency pair storage could not answerFetch the pair off the request. The upstream call sits behind a two-per-sixty-seconds limiter whose acquire sleeps, so on a read path the third uncovered currency waited ~26 s; here nobody waits. The figure renders without the pair and says so, and the next read has it (SC-222).
transaction-import(Reserved)One-off transaction-only import flow.
user-data-deleteUser requests account / data deletionDelete (or export, depending on the flag) all user data per GDPR-style flow.

Defined in packages/business/jobs/src/retry-policies.ts:

PolicyShapeDefault for
standard5 attempts, exponential backoff, 60s base.Most scheduled jobs.
aggressive10 attempts, exponential, 5s base.Reconcilers (reconcile-pending-credentials, reconcile-orphaned-user-jobs).
none1 attempt.Probes (dlq-depth-probe, job-heartbeat-probe).
user-import3 attempts, longer base.User-import jobs — fail fast so the user can re-try.

Jobs that exhaust their retries land in scani-dlq. The dlq-depth-probe job alarms when depth grows. Operators replay via the HMAC-gated jobs.dlqReplay endpoint on the api.

See Adding a scheduled job for the three-place change required.