SellVia Docs — menu
DocsTechnical ArchitectureAsync Job Pattern & Idempotency

Async Job Pattern & Idempotency

Technical Architecture/Async Job Pattern & Idempotency.md
sharedNo dated updates

Async Job Pattern & Idempotency

Purpose

The pattern for any user-initiated operation too heavy to run inline in a request (exports, reports, bulk operations) — distinct from [02. Background Jobs'](/technical-architecture/background-jobs) existing system-triggered jobs (payout batching, webhook processing). This is user-triggered, needs a job entity the user can reference, and completes via notification, not polling.

The Rule

No API route ever does heavy processing inline. When a user clicks something expensive (export, bulk report, anything non-trivial), the route does exactly one thing: create a job record and return its ID immediately with status=processing. Actual work happens in a worker, off the request cycle entirely — the request/response is fast regardless of how long the real work takes.

Flow

flowchart TD
    A[User clicks Export] --> B[Client generates idempotency key]
    B --> C[POST /jobs/export with idempotency key]
    C --> D{Job with this key already exists?}
    D -- Yes --> E[Return existing job id + status, no new job created]
    D -- No --> F[Create job record, status=pending]
    F --> G[Enqueue Celery task, return job id, status=processing]
    G --> H[Worker picks up job, does the real work]
    H --> I[Job status updated to completed, result stored]
    I --> J[Notification sent to user - per 01. Notification Logic]
    J --> K[User clicks notification, retrieves result]

Idempotency Keys — Preventing Duplicate Jobs

Every job-creation request carries a client-generated idempotency key (a UUID, generated once per user action — e.g. once when the export button is clicked, not regenerated on a rapid double-click of the same intent). Server checks for an existing job with that key before creating anything:

  • Key already exists → return the existing job's ID and current status. No new job, no duplicate work, no duplicate notification.
  • Key doesn't exist → create the job, enqueue the work.

This is the same principle already required for Swich-touching endpoints (updated 2026-08-23, was Paddle — 07. API → REST Standards' existing idempotency requirement), generalized to every job-creation endpoint, not just payment ones — a double-click, a flaky network retry, or an impatient second click all resolve to exactly one job.

Job Schema

jobs
  id
  type              (e.g. "export_sales_report", "export_creator_earnings")
  status            (pending / processing / completed / failed)
  idempotency_key   (unique, client-generated)
  user_id           (who requested it)
  tenant_id         (per 04. Security → Tenant Isolation Audit — scoped, never cross-tenant visible)
  params            (jsonb — what was requested, e.g. date range, filters)
  result_url        (nullable — signed URL to the output once complete, per 03. Database → File Storage)
  error_message     (nullable — if failed)
  created_at
  completed_at

Worker Pattern

A Celery task registered per job type, triggered when the job record is created — same underlying mechanism as every other background job (02. Background Jobs), same retry-with-backoff discipline. On success: write result_url, set status=completed, trigger notification. On failure: set status=failed, error_message, still notify the user (a silent failure the user never learns about is worse than a visible one) — logged per 06. Error Handling & Logging Pipeline like any other job failure.

Completion: Notification, Not Polling

Users are not shown a spinner waiting on this. A new notification trigger, job_completed (extends 01. Business Logic → Notification Logic's existing trigger list), fires the moment the worker finishes — delivered via the existing notification channels (in-app + email, per 06. Email Infrastructure). A GET /jobs/:id endpoint still exists as a fallback for a user who wants to check manually or if a notification is missed, but it's not the primary UX.

Tenant Scoping

A job's GET /jobs/:id (and the list of a user's own jobs) is scoped exactly like everything else per the Permission Matrix and Tenant Isolation Audit — a Merchant can never see another Merchant's export job, even by guessing an ID.

Open Questions

  • None blocking — this is a straightforward extension of already-decided patterns (Background Jobs, Notification Logic, idempotency keys already required for Swich — updated 2026-08-23, was Paddle). Specific job types get added as actual export/report features are built.