Skip to content

Chain Model

This document describes Queuert’s unified job model and the Promise-inspired chain abstraction.

A Job is an individual unit of work with a lifecycle:

(blocked →) pending → running → completed

Each job:

  • Belongs to a Job Type that defines its input/output schema
  • Contains typed input data and (when completed) output data
  • Can continueWith to create a linked follow-up job
  • Can depend on blockers (other chains that must complete first)

A Chain is a series of linked jobs where each job can continue to the next—just like a JavaScript Promise chain.

Job A → Job B → Job C → (completed)

The chain completes when its final job completes without continuing.

The design mirrors JavaScript Promises:

// JavaScript: A Promise chain IS the first promise
const chain = fetch(url) // chain === this promise
.then(processResponse) // continuation
.then(formatResult); // continuation
// Queuert: A Chain IS its head job
const chain = createChain(...) // chain.id === headJob.id
.continueWith(processStep) // continuation
.continueWith(formatStep); // continuation

The fundamental insight: the head job IS the chain. Chains work like Promises but persist across process restarts and distribute across workers.

For the head job in a chain: job.id === job.chainId

This isn’t redundant—it’s a meaningful signal that identifies the chain starter. Continuation jobs have job.id !== job.chainId but share the same chainId as all other jobs in the chain. Each job also carries its position as job.chainIndex: 0 for the head job, incrementing by one for each continuation.

chain · id = X
head jobid = X chainId = X chainIndex = 0
then
continuationid = Y chainId = X chainIndex = 1
then
terminalid = Z chainId = X chainIndex = 2

Having the head job BE the chain (rather than a separate entity) provides:

  • No separate chain table — a chain’s own facts (type name, status, deduplication key, trace context, completion time) live on its head row in the job table, and continuations are further rows in the same table (plus a job_blocker junction table for dependencies)
  • One primary type, one set of operations
  • No synchronization issues

The head job can be:

  • A lightweight “alias” that immediately continues to real work
  • A full job that processes and completes the chain in one step
  • Anything in between
  • No subqueries needed to find chains by type
  • Efficient at scale (millions of jobs)

Chains support various patterns via continueWith:

A → B → C → done
A → B1 (if condition)
→ B2 (else)
A → A → A → done
A → B → A → B → done

Chains can depend on other chains to complete before starting:

┌──────────────┐
│ Blocker A │───┐
└──────────────┘ │
├──→ Main Chain (blocked until A and B complete)
┌──────────────┐ │
│ Blocker B │───┘
└──────────────┘

Blockers are declared at the type level and provided via the blockers array when creating a chain. The main job starts as blocked if any blocker chain is still running (otherwise it starts pending) and transitions to pending when all blockers complete.

Parallel entities use consistent lifecycle terminology to reduce cognitive load:

  • Job: blocked → pending → running → completed
  • Chain: running → completed

Avoid asymmetric naming (e.g., started/finished vs created/completed) even if individual terms seem natural. Consistency across the API produces fewer questions and faster comprehension.

The Chain model:

  1. Mirrors Promises: Familiar mental model for JavaScript developers
  2. Unified identity: The head job IS the chain—no separate entity
  3. Single table: Jobs and chains share storage; chainId links them
  4. Flexible patterns: Linear, branched, looped, or jumping execution
  5. Distributed: Unlike Promises, chains persist and distribute across workers