Add batch-llm module: 100,000 synthetic tickets classified by a ChatClient in a Spring Batch 6 chunk step with Resilience4j rate limiting and retry, skip-and-record for malformed JSON, restart after kill -9 in a second JVM, and a per-chunk cost ledger. The model is a deterministic simulated ChatModel; prices are configurable placeholders. No provider called.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
2026-10-29 06:10:22 +00:00
co-authored by Claude Sonnet 5.5
parent aa8b1604ed
commit 6d792f00e0
48 changed files with 2093 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# What is on the classpath and what the builder offers
Spring Boot 4.1.2
Spring Framework 7.0.10
Spring Batch (core) 6.0.6
Spring AI (client) 2.0.2
Resilience4j 2.4.0
Java 25.0.5
Resilience4j config read from application.yml by resilience4j-spring-boot4:
ratelimiter 'llm': limitForPeriod=200 refreshPeriod=PT0.1S timeout=PT1M
retry 'llm': maxAttempts=4
ChunkOrientedStepBuilder methods that matter here (javap of spring-batch-core):
faultTolerant []
taskExecutor [org.springframework.core.task.AsyncTaskExecutor]
retryPolicy [org.springframework.core.retry.RetryPolicy]
retry [[Ljava.lang.Class;]
retryLimit [long]
skip [[Ljava.lang.Class;]
skipLimit [long]
skipListener [org.springframework.batch.core.listener.SkipListener]
processorNonTransactional []
@@ -0,0 +1,15 @@
# The simulated provider: same input, same outcome
Two separate provider instances, same seed, tickets 1..20,000, one call each: identical outcome lists = true
Outcome of the first call per ticket (the plan: 1.5% 429, 0.5% 503, 0.3% never valid, 1.2% invalid on the first reply only):
429 304 1.52%
503 93 0.47%
bad JSON: category not in schema 76 0.38%
bad JSON: missing field 77 0.39%
bad JSON: not JSON 137 0.69%
valid JSON 17,361 86.81%
valid JSON (in a markdown fence, stripped) 1,952 9.76%
Quota check: quota 100 calls/second, 300 calls fired back to back -> 201 rejected with 429 (quota rejections plus any injected 429)
Every number above is a property of the TEST PLAN, not a measurement of any provider.
+11
View File
@@ -0,0 +1,11 @@
# 4,000 tickets against a simulated quota of 800 calls per second
Both runs: 64 virtual threads, Resilience4j retry max 4 attempts, 25 ms x2 backoff, the same fault plan.
Time is indicative only (shared 2-vCPU sandbox, other processes running).
scenario ok bad JSON exhausted calls quota 429s retries seconds
no limiter 3066 13 921 7,596 4,396 3,543 3.5
limiter 600/s 3986 14 0 4,146 0 85 6.9
'exhausted' = tickets whose 4 attempts all met a 429/503: with a step around this call, the step fails.
'calls' counts every attempt the provider received, rejected or not.
+33
View File
@@ -0,0 +1,33 @@
# 3,000 tickets through the real job
job status COMPLETED
step: read=3000 write=2987 processSkip=13 commits=30 rollbacks=0
TICKET_RESULT rows 2987
SKIPPED_TICKET rows 13
classified + skipped 3000 (tickets: 3000)
Skipped tickets and why (first one of each kind):
ticket 46 category not in schema raw reply: {"category":"Account & Payments","priority":1,"confidence":0.74}
ticket 234 not JSON raw reply: Sure! Here is the classification you asked for: {"category":"BILLING","priority":4,"confidence":0.7}
ticket 491 missing field raw reply: {"category":"OTHER","priority":2}
First 3 ledger rows (one per committed chunk):
tickets written skipped attempts 429s 503s bad promptTok complTok cost
1-100 99 1 104 1 1 3 10831 1469 0.007619000
101-200 100 0 100 0 0 0 10574 1453 0.007466500
201-300 99 1 105 2 1 3 10736 1495 0.007610500
Ledger totals vs the provider's own meter (they must agree when nothing was lost):
ledger provider
calls received 3,104 3,104
429 rejections 39 39
503 rejections 14 14
billed calls 3,051 3,051
prompt tokens 324,790 324,790
completion tokens 44,107 44,107
Cost = prompt tokens x input price + completion tokens x output price, per million tokens.
table 'EXAMPLE-NOT-A-REAL-PRICE' (input 0.50, output 1.50): 0.228555500 <- the ledger's total
same tokens under table 'OTHER-EXAMPLE' (input 2.00, output 6.00): 0.914222000
Both tables are invented inputs. Tokens are the measurement; money is tokens x a table you supply.
Tokens spent on replies that were then skipped: bad replies billed = 64 of 3051 billed calls.
+16
View File
@@ -0,0 +1,16 @@
# Which layer retries what: counts taken from the provider's meter
(a) A transient DB failure on the write of chunk 2 (tickets 101-200), 300 tickets, Spring Batch write retry = 2
no failure status=COMPLETED billed calls=304 rollbacks=0
failure + Spring Batch retry status=COMPLETED billed calls=304 rollbacks=0
-> the write was retried inside the same transaction; the model was not asked again.
(b) The provider answers 429 to everything, 1 ticket. Calls the provider received for that ticket:
Resilience4j retry only (max 4 attempts) status=FAILED attempts=4
+ Spring Batch retry of the same exception (2) status=FAILED attempts=12
-> 4 x (1 + 2) = 12. Two retry layers on the same failure multiply, and each attempt also takes a limiter permit.
(c) A poison row at WRITE time (ticket 250): chunk 3 (201-300) rolls back and is scanned one item at a time.
processorNonTransactional (cached) status=COMPLETED billed calls=304 (no-failure baseline: 304) rollbacks=2 ledger rows=100 skipped=3
default (processor runs again per item) status=COMPLETED billed calls=403 (extra over baseline: 99) rollbacks=2 ledger rows=100 skipped=3
-> without processorNonTransactional(), every item of the failed chunk is classified a second time and paid for twice.
@@ -0,0 +1,13 @@
# A model regression, the skip limit, and a restart with a fixed model
Execution 1: simulated model returns invalid JSON for 50% of tickets, no re-ask, skip limit 50, 2,000 tickets.
status / reason FAILED / org.springframework.batch.core.step.FatalStepExecutionException: Unable to process chunk
tickets read 200 of 2000
calls the provider billed before the step stopped: 200 (a full run needs about 2,000)
committed results 52 skipped rows 50
Execution 2: same job parameters, same database, model fixed, skip limit 1000.
status COMPLETED
tickets read this time 1900 (execution 1 had committed 100, so the reader resumed after them)
committed results 1942 skipped rows 58 classified + skipped = 2000
tickets present in BOTH tables: 0 (stale skip rows from execution 1 are deleted when the ticket is classified)
+20
View File
@@ -0,0 +1,20 @@
# One uninterrupted run, 100000 tickets (java -jar, fresh H2 file, fresh provider meter)
START run.id=clean tickets=100000 chunk=100
JOB FINISHED: executionId=1 status=COMPLETED seconds=55.2
---- REPORT ----
tickets total=100000 classified=99691 skipped=309 unaccounted=0
step executions:
{ID=1, JOB=1, STATUS=COMPLETED, READS=100000, WRITES=99691, SKIPS=309, COMMITS=1000, ROLLBACKS=0}
ledger (committed chunks only): {ROWS_=1000, WRITTEN=99691, SKIPPED=309, ATTEMPTS=103519, R429=1531, U503=458, RETRIES=1989, BILLED=101530, BAD=1839, PT=10809111, CT=1467192, COST=7.605343500}
price table 'EXAMPLE-NOT-A-REAL-PRICE': input 0.50 / output 1.50 per million tokens (configurable placeholder)
provider's own meter (survives kill -9):
received 103,519
rejected_429 1,531
window_429 0
unavailable_503 458
billed 101,530
prompt_tokens 10,809,111
completion_tokens 1,467,192
billed calls the ledger does not know about (work lost in a crash or rollback): 0
results checksum (sha-256 over ordered rows): 98bdd29feb6d6f8d
+30
View File
@@ -0,0 +1,30 @@
# Crash and restart, 100000 tickets: JVM 1 killed with kill -9 after 400 committed chunks, JVM 2 resumes
## JVM 1 (progress lines, then SIGKILL)
PROGRESS committed-chunks-this-jvm=250 last-ticket=25000
PROGRESS committed-chunks-this-jvm=300 last-ticket=30000
PROGRESS committed-chunks-this-jvm=350 last-ticket=35000
PROGRESS committed-chunks-this-jvm=400 last-ticket=40000
(process killed by SIGKILL; no shutdown hook ran)
## JVM 2
RECOVER: execution 1 is STARTED but its JVM is gone; marking it FAILED so the instance can restart
START run.id=crash-demo tickets=100000 chunk=100
JOB FINISHED: executionId=33 status=COMPLETED seconds=34.2
---- REPORT ----
tickets total=100000 classified=99691 skipped=309 unaccounted=0
step executions:
{ID=1, JOB=1, STATUS=FAILED, READS=39600, WRITES=39493, SKIPS=107, COMMITS=396, ROLLBACKS=0}
{ID=33, JOB=33, STATUS=COMPLETED, READS=60400, WRITES=60198, SKIPS=202, COMMITS=604, ROLLBACKS=0}
ledger (committed chunks only): {ROWS_=1000, WRITTEN=99691, SKIPPED=309, ATTEMPTS=103519, R429=1531, U503=458, RETRIES=1989, BILLED=101530, BAD=1839, PT=10809111, CT=1467192, COST=7.605343500}
price table 'EXAMPLE-NOT-A-REAL-PRICE': input 0.50 / output 1.50 per million tokens (configurable placeholder)
provider's own meter (survives kill -9):
received 104,350
rejected_429 1,543
window_429 0
unavailable_503 462
billed 102,302
prompt_tokens 10,891,290
completion_tokens 1,478,281
billed calls the ledger does not know about (work lost in a crash or rollback): 772
results checksum (sha-256 over ordered rows): 98bdd29feb6d6f8d
+15
View File
@@ -0,0 +1,15 @@
# Clean run vs crashed-and-resumed run
## target/clean/run.log
tickets total=100000 classified=99691 skipped=309 unaccounted=0
ledger (committed chunks only): {ROWS_=1000, WRITTEN=99691, SKIPPED=309, ATTEMPTS=103519, R429=1531, U503=458, RETRIES=1989, BILLED=101530, BAD=1839, PT=10809111, CT=1467192, COST=7.605343500}
billed calls the ledger does not know about (work lost in a crash or rollback): 0
results checksum (sha-256 over ordered rows): 98bdd29feb6d6f8d
## target/crash/jvm2.log
tickets total=100000 classified=99691 skipped=309 unaccounted=0
ledger (committed chunks only): {ROWS_=1000, WRITTEN=99691, SKIPPED=309, ATTEMPTS=103519, R429=1531, U503=458, RETRIES=1989, BILLED=101530, BAD=1839, PT=10809111, CT=1467192, COST=7.605343500}
billed calls the ledger does not know about (work lost in a crash or rollback): 772
results checksum (sha-256 over ordered rows): 98bdd29feb6d6f8d
checksums identical: 98bdd29feb6d6f8d