← All notes

// ENGINEERING NOTES

Make state transitions explicit

A small state model makes unexpected behavior easier to find.

A background job often starts with a few flags: started, finished, and failed. Each flag looks harmless. Together, they let you describe combinations that do not make sense.

What does it mean for a job to be both finished and failed? Which flag should a caller check first?

Start with the states

Give each stage a name. For a small job runner, four states are enough:

State Meaning Allowed next state
Queued Waiting for a worker Running
Running The worker owns the job Succeeded or Failed
Succeeded The work completed None
Failed The work stopped with an error None

Job lifecycle: Queued becomes Running; Running becomes Succeeded or Failed. Both outcomes are terminal states.

The diagram follows one attempt from the queue to its final outcome. There are no transitions out of a terminal state.

The table is a design decision. A retry could create a new attempt instead of moving a failed attempt back to queued.

Put the rule in one place

A transition function describes the allowed changes directly:

const allowed = {
  queued: new Set(['running']),
  running: new Set(['succeeded', 'failed']),
  succeeded: new Set(),
  failed: new Set(),
};

function transition(job, nextState) {
  if (!allowed[job.state]?.has(nextState)) {
    throw new Error(`Invalid transition: ${job.state} -> ${nextState}`);
  }

  return { ...job, state: nextState };
}

Returning a new value keeps the original job available for comparison. The function also gives tests one place to verify the rules.

A state name describes where the work is. A transition describes what is allowed to happen next.

Keep the storage check with the write

A function protects one in-memory value. It does not coordinate two workers. If multiple workers can update the same job, make the database write conditional on the expected state.

For example, the operation “claim this job” should update it only while it is still queued. Check the affected row count before starting the work. If another worker already claimed it, the caller must stop.

What to test

  • A queued job can become running.
  • A running job can succeed or fail.
  • A queued job cannot succeed without running.
  • A completed attempt cannot change state.
  • Two workers cannot both claim the same queued job.

The useful outcome is a model you can read without guessing which flags take priority. Start small, name the states, and keep the allowed changes close to the code that applies them.

// END OF NOTEMore engineering notes →