Background Jobs
TimeTiles uses Payload CMS’s built-in job queue and workflow system for all asynchronous processing. Jobs and workflows are defined in lib/jobs/ and registered through ALL_JOBS in lib/jobs/ingest-jobs.ts and the workflow definitions in lib/jobs/workflows/. See the Payload docs for tasks , workflows , and queues .
Key Behavior
- Auto-deletion: Completed jobs are automatically deleted (
deleteJobOnComplete: true) - Workflow-based orchestration: The ingestion pipeline uses 4 Payload Workflows, queued by collection hooks and trigger services
- 3-queue architecture:
ingest(user-facing workflows),default(trigger jobs),maintenance(scheduled system jobs) - Production: One Docker worker container per queue runs Payload’s
jobs:runcommand; see the worker settings below - Development: Payload’s
autoRunschedules and processes all queues within the Next.js process after Payload initializes; no polling process or seeded admin login is required
Native job execution, scheduling, queueing and cancellation are admin-only through jobs.access.
The payload-jobs collection’s CRUD permissions are separate and do not guard native job
endpoints. Trusted server-side workers use the Local API’s default access override.
The generated payload-jobs-stats global is admin-readable and not manually writable, including
by admins. It holds scheduling state; Payload’s scheduler maintains it through its database adapter.
Production Workers
deployment/docker-compose.prod.yml is the source of truth for worker commands. Each worker
consumes exactly one queue:
| Queue | Polling cron (--cron) | Batch limit (--limit) | --handle-schedules |
|---|---|---|---|
ingest | */10 * * * * * (every 10 seconds) | 10 | No |
default | */1 * * * * (every minute) | 20 | Yes |
maintenance | */1 * * * * (every minute) | 20 | Yes |
--cron needs a quoted cron expression. Polling consumes queued work; --handle-schedules
also queues due tasks from their native Payload schedule definitions. Scheduling is filtered
by --queue, so both default and maintenance need the flag. Enabling it only on default
does not schedule maintenance tasks. The ingest queue receives workflows from application triggers.
For example, the equivalent default-queue worker command from the repository root is:
pnpm --filter web payload jobs:run --cron '*/1 * * * *' --queue default --limit 20 --handle-schedulesIngest Workflows
The ingestion pipeline is orchestrated by 4 Payload Workflows. Each workflow sequences multiple task handlers into a linear pipeline. See Data Ingestion Pipeline for detailed stage documentation.
| Workflow | Trigger | Pipeline |
|---|---|---|
manual-ingest | ingest-files afterChange hook | dataset-detection, then per-sheet: analyze, detect-schema, validate, create-schema-version, geocode, create-events |
scheduled-ingest | Shared trigger service: scheduler, webhook, manual run, wizard, or data-package activation | url-fetch, dataset-detection, then per-sheet pipeline |
scraper-ingest | Scheduler, webhook, or manual run | scraper-execution, dataset-detection, then per-sheet pipeline |
ingest-process | Review approval hook or retry/reset routes | Resume from the selected stage |
All ingest workflows run on the ingest queue with per-resource concurrency keys (e.g., ingest:manual:{id}, ingest:scheduled:{id}).
Error Model
- Throw for transient failures — Payload retries the task
- Return
{ needsReview: true }for human review — pipeline pauses for that sheet - Return data for success — pipeline continues to next task
- Multi-sheet files use
Promise.allSettledwith per-sheet try/catch; individual sheet failures do not block other sheets
For scheduled-ingest, Payload owns the retry decision. The workflow propagates failures without
incrementing the schedule’s failure counter. On its next pass, schedule-manager reconciles the
current run’s latest Payload job when hasError is final, then plans new runs. This normally happens
on the next minute tick while scheduled execution is enabled and workers are processing the queue.
The reconciliation locks and re-reads the schedule, ignores jobs older than its current lastRun,
and records the failed run only once. Jobs marked error.cancelled are excluded: an explicit
cancellation must not consume the schedule’s retry budget. Do not duplicate Payload’s retry
calculation in workflow catches.
Stuck-run cleanup cancels old queued jobs through payload.jobs.cancel(), with a
processing: false filter applied at cancellation time. Payload preserves completed and already
failed jobs; cancelled jobs have error.cancelled: true and no completion timestamp. They are
removed by the existing seven-day failed-job retention, not the three-day completed-job retention.
Task Definitions and Scheduling
The current task inventory is ALL_JOBS in lib/jobs/ingest-jobs.ts. Each handler owns its task slug, retry policy, and any native Payload schedule configuration. Inspect those definitions for exact cron expressions and queue assignments instead of copying a schedule table.
Workflow task slugs can differ from filenames: schema-detection-job.ts registers detect-schema, and create-events-batch-job.ts registers create-events. Use the registered slug when invoking a task.
Post-review processing is not always a schema-version restart. ingest-process supports analyze-duplicates, detect-schema, create-schema-version (default), and create-events as resume points.
Adding a New Job
- Create handler in
lib/jobs/handlers/my-job.ts - Import and register the job config in
ALL_JOBSinlib/jobs/ingest-jobs.ts - Configure its queue, retry policy, and schedule if applicable
- If it is a workflow task, add it to the appropriate workflow in
lib/jobs/workflows/ - Generate a migration with
make migrate-createif the job needs schema changes
Testing Jobs
See Integration Testing Patterns for job testing. Key points:
- Query pending jobs before running (
completedAt: { exists: false }) - Verify side effects after successful runs (completed job records are deleted). Final failures remain in
payload-jobsand can be inspected. - Use
describe.sequential()for tests that interact with the job queue - Use a bounded drain loop with
payload.jobs.run({ allQueues: true })to process chained workflow tasks in tests