skip to content

How do you record or route items that get skipped, and what are the gotchas with SkipListener?

level: seniorimportance: should knowfreq 30%

answer

  1. SkipListener: onSkipInRead(t) / onSkipInProcess(item,t) / onSkipInWrite(item,t)
  2. read callback: no item, only Throwable
  3. register via .listener(...) on faultTolerant step
  4. callback runs outside the rolled-back business tx
  5. listener that throws fails the step — guard it

basics

~10 s

Register a SkipListener with .listener(...) on the fault-tolerant step. It has onSkipInRead(Throwable), onSkipInProcess(item, Throwable), and onSkipInWrite(item, Throwable). Use them to log or write rejected items to a dead-letter table so nothing is lost silently.

solid answer

~40 s

Skips are counted on the StepExecution, but to capture the actual rejected records you register a SkipListener via .faultTolerant()...listener(skipListener). Its three callbacks — onSkipInRead(Throwable t), onSkipInProcess(T item, Throwable t), onSkipInWrite(S item, Throwable t) — let you persist rejects to a dead-letter store or emit metrics. Two gotchas: (1) onSkipInRead gets no item (the read never produced one), only the Throwable. (2) The skip listener callbacks are called *outside* the failed chunk's rolled-back business transaction, near chunk commit, so you shouldn't rely on the same transaction and should be aware ordering can be surprising. Also make the listener resilient: throwing from a listener callback will fail the step. It's the standard way to avoid silently swallowing bad data.

code

java · 26 lines
java
@Component
public class RejectRecordingSkipListener implements SkipListener<Rec, Rec> {
    private static final Logger log =
            LoggerFactory.getLogger(RejectRecordingSkipListener.class);
    private final RejectRepository rejects;

    RejectRecordingSkipListener(RejectRepository rejects) { this.rejects = rejects; }

    @Override
    public void onSkipInRead(Throwable t) {
        String badLine = (t instanceof FlatFileParseException ffpe)
                ? ffpe.getInput() : t.getMessage();
        safeSave("READ", badLine, t);
    }

    @Override
    public void onSkipInProcess(Rec item, Throwable t) { safeSave("PROCESS", item.toString(), t); }

    @Override
    public void onSkipInWrite(Rec item, Throwable t) { safeSave("WRITE", item.toString(), t); }

    private void safeSave(String phase, String payload, Throwable t) {
        try { rejects.save(new Reject(phase, payload, t.getMessage())); }
        catch (Exception e) { log.error("Failed to record reject", e); } // never fail the step
    }
}

go deeper

for a junior

Know SkipListener has three onSkipIn* callbacks used to log skipped items.

for a middle

Explain that read gives only the Throwable and how to register the listener.

for a senior

Handle the transaction/ordering nuance and make the listener resilient to its own failures.

for a principal

Design a dead-letter + reconciliation strategy and standardize reject capture across all fault-tolerant jobs.

**Why you need it.** Skip counts (`StepExecution.getSkipCount()` etc.) tell you *how many* items were skipped but not *which* ones. Silently discarding bad records is a data-loss and auditing problem. `SkipListener<T, S>` (T = read/input type, S = write/output type) is the hook that hands you the rejected item and its exception so you can log it, write it to a *dead-letter table/file*, alert, or increment a metric. **The three callbacks.** - `onSkipInRead(Throwable t)` — fired when a read was skipped. **No item** is passed, because the exception happened inside `read()` before a full item existed. You typically log the raw cause; `FlatFileParseException` even carries the offending input line and line number via `getInput()`/`getLineNumber()`, so you can recover the bad text from the exception itself. - `onSkipInProcess(T item, Throwable t)` — the input item that failed processing, plus the exception. - `onSkipInWrite(S item, Throwable t)` — the (already processed) item that failed to write, plus the exception. **Registration.** On a fault-tolerant step: `new StepBuilder(...).faultTolerant().skip(...).skipLimit(...).listener(mySkipListener)`. You can also implement the callbacks with the `@OnSkipInRead` / `@OnSkipInProcess` / `@OnSkipInWrite` annotations on a POJO and register that. Multiple listeners can be registered. **Transaction/ordering gotcha.** This is the classic interview trap. When a write fails and the chunk rolls back, any DB work the listener does must not be part of that doomed transaction. Spring Batch invokes the skip listener callbacks *after* the rollback, in a way designed so listener side effects aren't undone with the business data — but this also means if your listener writes to the same datasource you should understand it's a separate transactional context and can commit even though the item's business write was rolled back. Historically there were subtleties about listeners being called on both the initial failure and the replay; the framework de-duplicates so each actually-skipped item triggers the callback once, but candidates should know skipping and replay interact. **Resilience gotcha.** If a `SkipListener` callback throws, that exception is *not* itself skippable — it propagates and fails the step. A dead-letter writer that itself hits a DB error can therefore break the whole job. Guard listener code defensively (catch-and-log around the persistence call) so recording a reject can't take the job down. **Idempotency/duplication gotcha.** Because process/write skips involve rollback and single-item replay, be sure your listener persistence isn't invoked in a way that double-writes; rely on the framework's once-per-skip contract rather than hooking lower-level retry callbacks. **When to use.** Any production job with `faultTolerant()` skips should pair with a `SkipListener` — otherwise you've built a silent data-loss machine. The reject store then feeds a reconciliation/repair process. **When it's overkill.** Throwaway/one-off imports where a log line suffices.

  • Why does onSkipInRead not receive the skipped item?
    The read() call threw before producing a complete item object, so there is no item to pass — only the Throwable (which for FlatFileParseException still exposes the raw input line).
  • What happens if your SkipListener callback throws an exception?
    It propagates and fails the step; it is not itself skipped. Dead-letter code should catch and log its own errors so recording a reject can't crash the job.

saying these in an interview costs you the question

  • Expecting an item argument in onSkipInRead
  • Assuming listener DB writes join and roll back with the failed chunk's transaction
  • Not guarding listener code, so a reject-write failure kills the step
  • Relying on skip counts alone and losing the actual rejected records

context