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 |
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.