The Spring Batch COMPLETED Trap: Why It Doesn't Guarantee Business Success

In Spring Batch, the status COMPLETED sounds reassuring. It indicates that your job ran to completion. But in production systems, this status can mask critical, widespread failures. Here is how swallowing exceptions in batch writers creates monitoring blind spots, and how to design robust batch telemetry.

1. The Rule: Framework Status != Business Success

In Spring Batch, a step is marked as COMPLETED if the reader, processor, and writer complete their execution loops without throwing uncaught exceptions to the framework.

It means the code ran. It does not mean the data was processed successfully.

If your writer catches exceptions internally (e.g., to prevent a single bad database row from aborting a 10,000-row batch), you are hiding failures from the framework. If all rows fail but the writer catches the exceptions and logs them, Spring Batch will still report:

Read Count: 5000 / Write Count: 5000 / Status: COMPLETED

2. The Outage: A Silent 100% Failure

We encountered this pattern during a routine batch run that synchronizes account status records with our main matching database. During this specific run, the matching database was temporarily unavailable.

The batch writer was configured as follows:

fun myWriter(): ItemWriter<UserAccount> = ItemWriter { items ->
    items.forEach { item ->
        try {
            externalDatabaseService.update(item)
        } catch (e: Exception) {
            logger.error("Failed to sync account: ${item.id}", e)
            // Swallow and continue to next item
            return@forEach
        }
    }
}

Because the writer swallowed all exceptions, 87 sync attempts failed. Yet, the batch step successfully finished, and our Slack notifications reported: Status: COMPLETED.

If operators had not happened to check the error logs for that specific hour, the fact that 87 accounts were out of sync would have gone unnoticed, potentially resulting in data discrepancies and compliance issues.

3. The Solution: Explicit Failure Tracking

To ensure that batches do not fail silently, we must track and propagate row-level failures.

Step 1: Track Failures and Store in ExecutionContext

Create a writer class that implements StepExecutionListener to retrieve the StepExecution and record failure counts in the thread-safe ExecutionContext:

@Component
@StepScope
class AccountSyncWriter(
    private val externalDatabaseService: ExternalDatabaseService
) : ItemWriter<UserAccount>, StepExecutionListener {

    private val failedCount = AtomicInteger(0)
    private lateinit var stepExecution: StepExecution

    override fun beforeStep(stepExecution: StepExecution) {
        this.stepExecution = stepExecution
    }

    override fun write(chunk: Chunk<out UserAccount>) {
        chunk.forEach { item ->
            try {
                externalDatabaseService.update(item)
            } catch (e: Exception) {
                failedCount.incrementAndGet()
                stepExecution.executionContext.putInt("failedCount", failedCount.get())
                logger.error("Failed to sync ${item.id}", e)
            }
        }
    }

    override fun afterStep(stepExecution: StepExecution): ExitStatus? {
        val failed = failedCount.get()
        val read = stepExecution.readCount
        
        // Step 3: Force Custom Exit Statuses when failure threshold exceeded
        return when {
            failed > 0 && failed >= (read * 0.1) -> ExitStatus("COMPLETED_WITH_FAILURES")
            else -> stepExecution.exitStatus
        }
    }
}

Step 2: Expose failures in the Step Summary

Add the failure count retrieved from the execution context to your notification layout:

val failedCount = stepExecution.executionContext.getInt("failedCount", 0)
val summaryMessage = """
    *Batch Step Run Summary*
    - Read Count: ${stepExecution.readCount}
    - Write Count: ${stepExecution.writeCount}
    - Failed Count: $failedCount
    - Status: ${stepExecution.status}
""".trimIndent()

4. Conclusion

Resilience structures like try-catch blocks are essential to prevent minor database errors from breaking large batch jobs. However, they must be paired with explicit failure tracking. Without it, you are trading minor job restarts for silent data loss. Ensure that your monitoring systems track business success, not just framework completion.

G

Giri (Dong-gil Nam)

Backend Software Engineer

Passionate about building scalable systems and sharing technical insights. Specializing in JVM internals, distributed systems, and performance optimization.