# 5. The diagnostic endpoint: proving four threads ran, not just configuring them [← Previous](04-the-writer-and-the-beanmapped-trap.md) | [README](../README.md) | [Next: Why CPU-bound, not I/O-bound →](06-why-cpu-bound-not-io-bound.md) Configuration says how many partitions *should* run and on how many threads. It does not say what actually happened on a given execution — whether the pool was saturated, whether two partitions landed on the same thread, how long each one actually took relative to the others. This module makes that observable instead of assumed. ## PartitionStatsListener [`PartitionStatsListener`](../src/main/java/com/ankurm/batchpartition/partition/PartitionStatsListener.java) is a `StepExecutionListener` attached to the worker step. `beforeStep` records a start timestamp on a `ThreadLocal` (each partition runs on its own thread, so there is no cross-talk); `afterStep` computes the duration and inserts one row into `PARTITION_STATS`: which step (which partition), which OS thread name, how many rows it read, when it started and finished, how long it took, and its exit code. ## PartitionInsightController [`PartitionInsightController`](../src/main/java/com/ankurm/batchpartition/web/PartitionInsightController.java) exposes that table over HTTP: ```console $ curl -s http://localhost:8081/batch/partitions/1 | python3 -m json.tool [ { "PARTITION_NAME": "ordersWorkerStep:partition0", "THREAD_NAME": "order-partition-2", "READ_COUNT": 5000, "STARTED_AT": "2026-09-14T09:29:56.892Z", "FINISHED_AT": "2026-09-14T09:29:57.968Z", "DURATION_MS": 1075, "EXIT_CODE": "COMPLETED" }, ... ] ``` Full transcript, all four partitions: [`docs/output/05-happy-path-4-partitions.txt`](output/05-happy-path-4-partitions.txt). Two things this makes concrete that configuration alone does not: partition-to-thread assignment is not 1:1 with partition *number* (`partition0` landed on thread `order-partition-2` here, not `order-partition-1` — `TaskExecutorPartitionHandler` submits tasks in the order the `Partitioner`'s map iterates, and `ThreadPoolTaskExecutor` hands them to whichever pooled thread is free first); and all four really did start within milliseconds of each other, which is the difference between "partitioned" as a configuration property and "partitioned" as an observed fact. This same table is what makes [chapter 7](07-the-rejectedexecutionexception.md) and [chapter 8](08-restart-reruns-only-the-failed-partition.md) possible to write with confidence: querying `PARTITION_STATS` (or, for those chapters, the framework's own `BATCH_STEP_EXECUTION` table directly) is how this article found that three rejected partitions do not fail, they hang. **Delete this before shipping.** An unauthenticated endpoint that dumps job-internal timing data belongs behind whatever authentication and authorization the rest of the service already has, at minimum, and arguably behind nothing publicly reachable at all — it exists here to make the mechanism visible for this article, not because a production batch service should expose it. ## Going deeper - `StepExecutionListener` and the other listener interfaces available: [Spring Batch reference — Listeners](https://docs.spring.io/spring-batch/reference/step/chunk-oriented-processing/configuring-a-step.html) (`rel="nofollow"`). [Next: Why CPU-bound, not I/O-bound →](06-why-cpu-bound-not-io-bound.md)