Browse documentation

A project API runs seven background workers in-process. There is no queue service, no external scheduler, and no second daemon — deliberately, because a single-VPS deployment should not need one.

The workers

WorkerServiceCadenceTrigger
Realtime outboxRealtimeOutboxSchedulerService2 s poll + Postgres LISTEN/NOTIFYNew outbox row
Bucket deletionBucketDeletionSchedulerService2 s poll, 2 jobs in parallelQueued deletion job
Storage cleanupStorageCleanupSchedulerServicecron */5 * * * *Expired upload reservations
BackupsBackupSchedulerServicebackup.schedule.cron, default 0 3 * * *Config
Edge function schedulesEdgeFunctionSchedulerServiceper-function cronEdgeFunction.schedule
Data retentionDataRetentionSchedulerServicedata.retention.cronConfig
Application croninitCron (helpers/cron.helper.ts)per jobApp-defined

All of them are skipped when NODE_ENV=test, so a unit run never spawns real timers.

Pluggable schedulers

apps/server/lib/schedulers.ts defines two tiny interfaces so every worker is deterministic in tests:

interface Scheduler         { schedule(cronExpr: string, fn: () => void): ScheduledTask }
interface IntervalScheduler { every(intervalMs: number, task: () => void): ScheduledTask }

Production uses nodeCronScheduler and nodeIntervalScheduler; tests inject a fake that captures the callback and fires it without real time. The interval scheduler calls timer.unref(), so a poller never keeps the event loop alive on its own.

The realtime outbox worker

The most involved one, and the pattern worth understanding (services/realtime/realtime-outbox.service.ts).

ConstantValue
Poll interval2 s
Worker concurrency1
Max events per run100
Lease60 s, heartbeated every 20 s
Max attempts8
Retention sweephourly
Completed retention7 days
Failed retention30 days

Claiming is a single statement:

… WHERE status = 'pending' OR (status = 'processing' AND "leaseExpiresAt" < NOW())
ORDER BY "createdAt", "id"
FOR UPDATE SKIP LOCKED LIMIT 1

FOR UPDATE SKIP LOCKED makes overlapping claims safe; the lease plus heartbeat means a worker that dies mid-event releases it after 60 s instead of stranding it.

Two wake-up paths coexist. A NOTIFY on the realtime_outbox channel wakes the worker immediately; the 2 s poll is the fallback when LISTEN is unavailable. A trigger that lands mid-drain is coalesced into a follow-up pass rather than dropped — the worst case is one redundant empty drain.

Note

Delete retries are scoped to the socket ids captured before the delete. A reconnect or a process restart produces new ids, so a replayed delete is never sent to a session whose pre-delete visibility cannot be proven. The audience is stored as { mode: 'disabled' | 'all' | 'restricted' } on the outbox row.

Data retention

services/admin/data-retention.service.ts, config group data.retention:

FieldPurges
webhookDeliveryDaysWebhookDelivery rows
edgeFunctionLogDaysEdgeFunctionLog rows
batchSize, maxBatchesPerRunWork cap per run
cron, enabledCadence

Realtime outbox retention is applied too, separately for completed and failed rows (realtime-outbox-retention.ts, each between 0 and 3650 days; an invalid value is rejected rather than coerced).

Every target deletes in small ordered batches and the whole run is capped, so maintenance never opens a long-lived transaction on a live database. A failing target does not stop the others: the run summary reports per-target { deleted, batches, truncated, error? }.

Bucket deletion

A BucketDeletionJob row is claimed with a lease, processes objects in batches, and records deletedObjects against totalObjects with attempts and nextAttemptAt for backoff. Two jobs run in parallel. The job survives a restart, and the row deliberately has no foreign key to the bucket so it stays queryable after the bucket is gone.

Storage cleanup

Every five minutes, expired upload reservations are released (StorageService.cleanupExpiredReservations) so a presign that was never finalized does not hold a slot forever. See storage pipeline.

Edge function schedules

EdgeFunctionSchedulerService reads every function with a schedule and registers one cron task per function, invoking it through the same sandbox as an HTTP call. reschedule() rebuilds the set after a function is created, edited or deleted — no restart.

Backups

Node-cron rather than pg_cron, because pg_dump is a shell command, not SQL. Overlapping runs are skipped and the schedule is rebuilt live on config change. See backups.

Observing all of it

Runs are recorded in the job ledger (JobLedgerService), which raises consecutive_failures, missed_window, dead_letter_depth and backup_older_than_rpo alerts. Background work also writes to the shared log stream through logActivity with sources job, webhook, edge, storage and realtime — without which a job that never ran would be indistinguishable from a job that ran fine. See Observability. Every scheduler also declares itself to JobInventoryService: what it does in one sentence, the file that owns it, the console route that owns its configuration, whether an operator may force a run, and whether its cadence is data or a constant in code. The console builds its list from those declarations joined to the ledger, rather than from the ledger alone — which could only ever show a job that had already run at least once.

POST /admin/jobs/:id/run routes to the scheduler that declared the id and calls the same runOnce() the timer calls, recorded with trigger: 'manual' so a forced run is never mistaken for evidence that the schedule is alive. platform.job_runs keeps each run's output and, for a scheduled edge function, its stdout and returned value — including for a failed run.

Shutdown

installProcessSafety stops the bucket-deletion and realtime-outbox schedulers, then disconnects Prisma, ends the RLS pool and closes Socket.IO — so a deploy drains in-flight work instead of severing it.