skip to content

How does a decider access data to make its decision, and what are the gotchas with the StepExecution argument and restart behavior?

level: seniorimportance: should knowfreq 35%

answer

  1. JobExecutionContext = durable, survives restart
  2. StepExecutionContext = per-step, promote to job via ExecutionContextPromotionListener
  3. stepExecution null when decider is first element
  4. COMPLETED steps skipped on restart
  5. keep decide() pure/deterministic — no wall-clock/mutable external state

basics

~20 s

The decider reads from the JobExecution (job parameters, JobExecutionContext) and the preceding StepExecution (counts, its own step context). The StepExecution can be null if no step ran before the decider, so guard for it. Deciders should be deterministic so restarts route the same way.

solid answer

~40 s

decide() receives the JobExecution and the StepExecution of the immediately preceding step. From the JobExecution you get job parameters and the JobExecutionContext — the durable place to stash values a prior step computed (via a StepExecutionListener or ExecutionContextPromotionListener that promotes step-context keys to the job context). From the StepExecution you read counts (read/write/skip/commit) and its transient step-scoped context. The big gotcha: StepExecution is null when the decider is the first flow element, so null-check it. The second gotcha is restartability — on a restart, already-COMPLETED steps aren't re-run, so any data the decider relied on must live in the persisted JobExecutionContext, not in a bean field or step-scoped state that won't be rebuilt. Keep deciders pure/deterministic; a decider that reads wall-clock time or mutable external state can route differently on restart and break idempotency.

code

java · 16 lines
java
public class SizeDecider implements JobExecutionDecider {
    @Override
    public FlowExecutionStatus decide(JobExecution job, StepExecution step) {
        // Restart-safe: read the promoted value from the JOB context.
        long rows = job.getExecutionContext().getLong("importedRows", -1);

        // Fallback to the immediate step's write count, guarding null.
        if (rows < 0) {
            rows = (step != null) ? step.getWriteCount() : 0;
        }
        return new FlowExecutionStatus(rows > 1000 ? "LARGE" : "SMALL");
    }
}

// The preceding step promotes 'importedRows' from step-context to job-context:
// stepBuilder...listener(promotionListener())  // ExecutionContextPromotionListener with keys={"importedRows"}

go deeper

for a junior

Know the decider reads job parameters and the JobExecutionContext, and that stepExecution may be null.

for a middle

Explain the step-context vs job-context distinction and the ExecutionContextPromotionListener promotion pattern.

for a senior

Reason about restart determinism, skipped COMPLETED steps, and keeping decide() pure and cheap.

for a principal

Set team conventions: deciders as fast pure functions over persisted context, all heavy computation in steps, no external mutable reads at decision points.

## What the decider can see `decide(JobExecution jobExecution, StepExecution stepExecution)` gives two windows into state: ### From JobExecution - **getJobParameters()** — the immutable parameters the job was launched with (dates, ids, flags). - **getExecutionContext()** — the **JobExecutionContext**, a persisted `ExecutionContext` (key/value map) that lives for the whole job and **survives restarts**. This is the correct channel for passing computed values into a decider. - Access to prior StepExecutions via `getStepExecutions()` if you need more than the immediate one. ### From StepExecution (the preceding step) - **counts**: `getReadCount()`, `getWriteCount()`, `getSkipCount()`, `getCommitCount()`, `getRollbackCount()`. - **getExitStatus()** — what that step reported. - **getExecutionContext()** — the **StepExecutionContext**, scoped to that one step. NOTE: step-context values are NOT automatically visible in the job context; a step context is per-step. ## Promoting data step -> job A classic pattern: a step computes a value and stores it in its **step** context; you then need it in the **job** context so a later decider/step can read it. Use `ExecutionContextPromotionListener` with the keys to promote: ```java @Bean ExecutionContextPromotionListener promotionListener() { ExecutionContextPromotionListener l = new ExecutionContextPromotionListener(); l.setKeys(new String[]{"importedRows"}); return l; } ``` Attach it to the step; after the step completes, `importedRows` moves into the JobExecutionContext where the decider reads it with `jobExecution.getExecutionContext().getLong("importedRows")`. ## Gotcha 1: null StepExecution If the decider is `.start(decider)` (first element) or otherwise not preceded by a step, `stepExecution` is **null**. Always guard: ```java long rows = stepExecution != null ? stepExecution.getWriteCount() : 0; ``` When preceded by a step, Spring Batch passes the **last** StepExecution of the current flow. ## Gotcha 2: restarts On restart of a failed/stopped job: - Steps already **COMPLETED** are **skipped** (unless `allowStartIfComplete(true)`), so a decider must not depend on side effects of re-running them. - The **JobExecutionContext is reloaded** from the repository, so values you promoted there are still available — this is why the promotion pattern is restart-safe. - A decider reading **volatile external state** (current time, a DB row that changed, a config flag toggled between runs) can pick a **different branch** on restart than on the first run. That can corrupt an in-flight job or violate idempotency. Prefer reading immutable job parameters or promoted context values captured at first run. ## Gotcha 3: statelessness of the bean Deciders are typically singletons. Storing decision state in instance fields is unsafe across concurrent job executions and won't survive restart. Compute purely from the arguments each call. ## Gotcha 4: exceptions in decide() If `decide()` throws, the flow fails. Deciders should be cheap, deterministic, and defensive (null checks, safe context lookups with defaults like `getLong(key, defaultValue)`). ## When you need heavy logic Don't do expensive I/O in a decider. Have a preceding **Step/Tasklet** do the work, persist the result to the JobExecutionContext, and let the decider make a fast branch decision from that. This keeps the decision point deterministic and observable.

  • A step wrote a value into its step ExecutionContext but the decider can't see it. Why, and how do you fix it?
    The step ExecutionContext is scoped to that step and isn't visible in the JobExecutionContext the decider reads. Attach an ExecutionContextPromotionListener to the step with that key so it's promoted to the job context after the step completes.
  • Why is reading System.currentTimeMillis() inside a decider risky?
    It makes the decision non-deterministic. On a restart the decider could route down a different branch than the original run, potentially skipping or re-running work inconsistently. Capture time-based decisions as job parameters or promoted context values instead.

saying these in an interview costs you the question

  • Assuming step ExecutionContext values are automatically in the job context.
  • Storing decision state in decider instance fields.
  • Doing heavy I/O inside decide() instead of in a preceding step.
  • Ignoring that stepExecution may be null.
  • Reading mutable external state, breaking restart determinism.

context