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