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
@@ -0,0 +1,20 @@
package com.ankurm.batchllm;
/**
* The model answered, and the answer is not usable JSON for our schema. Deterministic from the
* pipeline's point of view: asking the same question again will usually give the same answer, so
* this is a skip, not a retry. The tokens for the bad answer were still billed.
*/
public class BadModelOutputException extends RuntimeException {
private final String rawReply;
public BadModelOutputException(String reason, String rawReply) {
super(reason);
this.rawReply = rawReply;
}
public String rawReply() {
return rawReply;
}
}
@@ -0,0 +1,14 @@
package com.ankurm.batchllm;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class BatchLlmApplication {
public static void main(String[] args) {
System.exit(SpringApplication.exit(SpringApplication.run(BatchLlmApplication.class, args)));
}
}
@@ -0,0 +1,10 @@
package com.ankurm.batchllm;
import java.util.Set;
/** What the model is asked to return, and what the writer stores. */
public record Classification(long ticketId, String category, int priority, double confidence) {
public static final Set<String> CATEGORIES =
Set.of("BILLING", "TECHNICAL", "ACCOUNT", "SHIPPING", "FEATURE_REQUEST", "OTHER");
}
@@ -0,0 +1,80 @@
package com.ankurm.batchllm;
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.batch.infrastructure.item.Chunk;
import org.springframework.batch.infrastructure.item.ItemWriter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* Writes the results AND the cost ledger row for the chunk in one transaction, so a ledger row
* exists if and only if its tickets do. The reader position, the results and the ledger either
* all commit or all roll back.
*/
@Component
public class ClassificationWriter implements ItemWriter<Classification> {
private final JdbcTemplate jdbc;
private final UsageMeter meter;
private final Pricing pricing;
private final AtomicLong committedLedgerRows = new AtomicLong();
public ClassificationWriter(JdbcTemplate jdbc, UsageMeter meter, Pricing pricing) {
this.jdbc = jdbc;
this.meter = meter;
this.pricing = pricing;
}
@Override
public void write(Chunk<? extends Classification> chunk) {
List<? extends Classification> items = chunk.getItems();
jdbc.batchUpdate("INSERT INTO TICKET_RESULT (TICKET_ID, CATEGORY, PRIORITY, CONFIDENCE) VALUES (?,?,?,?)",
items, items.size(), (ps, c) -> {
ps.setLong(1, c.ticketId());
ps.setString(2, c.category());
ps.setInt(3, c.priority());
ps.setDouble(4, c.confidence());
});
// A ticket skipped in an earlier, uncommitted attempt (the skip listener commits on its own) and
// classified now must not stay in the skipped table.
jdbc.batchUpdate("DELETE FROM SKIPPED_TICKET WHERE TICKET_ID = ?", items, items.size(),
(ps, c) -> ps.setLong(1, c.ticketId()));
long first = items.stream().mapToLong(Classification::ticketId).min().orElse(0);
long last = items.stream().mapToLong(Classification::ticketId).max().orElse(0);
UsageMeter.Snapshot spent = meter.drain();
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_COMMITTED) {
long rows = committedLedgerRows.incrementAndGet();
if (rows % 50 == 0) {
System.out.println("PROGRESS committed-chunks-this-jvm=" + rows + " last-ticket=" + last);
}
} else {
meter.restore(spent); // rolled back: the money was still spent
}
}
});
}
BigDecimal cost = pricing.cost(spent.promptTokens(), spent.completionTokens());
jdbc.update("""
INSERT INTO COST_LEDGER (FIRST_TICKET, LAST_TICKET, TICKETS_WRITTEN, TICKETS_SKIPPED, ATTEMPTS,
REJECTED_429, UNAVAILABLE_503, RETRIES, BILLED_CALLS, BAD_REPLIES, PROMPT_TOKENS, COMPLETION_TOKENS,
COST, PRICE_VERSION) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
first, last, items.size(), spent.skipped(), spent.attempts(), spent.rejected429(),
spent.unavailable503(), spent.retries(), spent.billedCalls(), spent.badReplies(),
spent.promptTokens(), spent.completionTokens(), cost, pricing.version());
}
public long committedLedgerRows() {
return committedLedgerRows.get();
}
}
@@ -0,0 +1,101 @@
package com.ankurm.batchllm;
import java.time.Duration;
import java.util.concurrent.Executors;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.step.builder.ChunkOrientedStepBuilder;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.infrastructure.item.ItemWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
import org.springframework.dao.TransientDataAccessException;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class LlmJobConfig {
@Bean
ProviderMeter providerMeter(ProviderFaults faults) {
return new ProviderMeter(faults.meterFile());
}
/** The "provider". Replace this one bean with a real ChatModel and nothing else changes. */
@Bean
SimulatedProvider chatModel(ProviderFaults faults, ProviderMeter meter) {
return new SimulatedProvider(faults, meter);
}
@Bean
ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel).defaultSystem(TicketClassifier.SYSTEM).build();
}
@Bean
TicketReader ticketReader(LlmJobProperties props) {
return new TicketReader(props.tickets());
}
/** Virtual threads, at most {@code concurrency} model calls in flight inside one chunk. */
@Bean
AsyncTaskExecutor llmExecutor(LlmJobProperties props) {
var semaphore = new java.util.concurrent.Semaphore(props.concurrency());
var virtual = Executors.newVirtualThreadPerTaskExecutor();
return new TaskExecutorAdapter(task -> virtual.execute(() -> {
semaphore.acquireUninterruptibly();
try {
task.run();
} finally {
semaphore.release();
}
}));
}
@Bean
Step classifyStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
TicketReader reader, TicketClassifier classifier, ItemWriter<Classification> writer,
SkippedTicketListener skipListener, AsyncTaskExecutor llmExecutor, LlmJobProperties props) {
ChunkOrientedStepBuilder<Ticket, Classification> step = new StepBuilder("classifyStep", jobRepository)
.<Ticket, Classification>chunk(props.chunkSize())
.transactionManager(transactionManager)
.reader(reader)
.processor(classifier)
.writer(writer)
.taskExecutor(llmExecutor)
.faultTolerant()
// Deterministic bad output: skip it, remember it, keep going -- up to a limit.
.skip(BadModelOutputException.class)
// ... and a row the database refuses (too long, constraint) is just as deterministic.
.skip(org.springframework.dao.DataIntegrityViolationException.class)
.skipLimit(props.skipLimit())
.skipListener(skipListener);
if (!props.processorTransactional()) {
step.processorNonTransactional();
}
// Spring Batch retry is for the DATABASE side of the chunk. The model call has its own
// Resilience4j retry. Registering the 429 exception here as well multiplies the two.
if (props.writeRetries() > 0 || props.batchRetriesCalls()) {
var policy = org.springframework.core.retry.RetryPolicy.builder()
.maxRetries(Math.max(1, props.writeRetries()))
.delay(Duration.ofMillis(20))
.includes(props.batchRetriesCalls()
? java.util.Set.of(TransientDataAccessException.class, ProviderRateLimitException.class)
: java.util.Set.of(TransientDataAccessException.class))
.build();
step.retryPolicy(policy);
}
return step.build();
}
@Bean
Job classifyJob(JobRepository jobRepository, Step classifyStep) {
return new JobBuilder("classifyTickets", jobRepository).start(classifyStep).build();
}
}
@@ -0,0 +1,28 @@
package com.ankurm.batchllm;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* @param tickets number of synthetic tickets to classify
* @param chunkSize items per transaction; also the most work a crash can lose
* @param concurrency threads (virtual) calling the model at once inside one chunk
* @param reask extra model calls allowed when a reply is not valid JSON
* @param skipLimit the step fails when more than this many tickets are skipped
* @param writeRetries Spring Batch retries of a failed WRITE (0 = none)
* @param batchRetriesCalls also let Spring Batch retry the rate-limit exception (the mistake the post warns about)
* @param processorTransactional re-run the model for every item when a chunk is scanned after a failed write
* @param runId identifying job parameter: same value = same JobInstance = restart target
*/
@ConfigurationProperties("batch.llm")
public record LlmJobProperties(
@DefaultValue("100000") long tickets,
@DefaultValue("100") int chunkSize,
@DefaultValue("64") int concurrency,
@DefaultValue("1") int reask,
@DefaultValue("1000") long skipLimit,
@DefaultValue("0") int writeRetries,
@DefaultValue("false") boolean batchRetriesCalls,
@DefaultValue("false") boolean processorTransactional,
@DefaultValue("run-1") String runId) {
}
@@ -0,0 +1,109 @@
package com.ankurm.batchllm;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.core.job.parameters.JobParameters;
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* {@code --mode=run} (default) starts or restarts the job; {@code --mode=report} only prints what
* is in the database. Starting is the same call whether it is the first run or the tenth: the
* identifying parameter {@code run.id} names the JobInstance.
*
* <p>If the previous JVM was killed, its JobExecution is still STARTED in the repository and
* Spring Batch refuses to start another ("already running"). {@code JobOperator.recover} marks it
* FAILED so the instance becomes restartable. Only do that when you know the old process is dead.
*/
@Component
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = "batch.llm.autorun", havingValue = "true", matchIfMissing = true)
public class LlmJobRunner implements ApplicationRunner {
private final JobOperator operator;
private final JobRepository repository;
private final Job job;
private final JdbcTemplate jdbc;
private final LlmJobProperties props;
private final Pricing pricing;
private final ProviderMeter providerMeter;
public LlmJobRunner(JobOperator operator, JobRepository repository, Job job, JdbcTemplate jdbc,
LlmJobProperties props, Pricing pricing, ProviderMeter providerMeter) {
this.operator = operator;
this.repository = repository;
this.job = job;
this.jdbc = jdbc;
this.props = props;
this.pricing = pricing;
this.providerMeter = providerMeter;
}
@Override
public void run(ApplicationArguments args) throws Exception {
List<String> mode = args.getOptionValues("mode");
if (mode != null && mode.contains("report")) {
report();
return;
}
JobParameters params = new JobParametersBuilder().addString("run.id", props.runId()).toJobParameters();
var instance = repository.getJobInstance(job.getName(), params);
if (instance != null) {
var last = repository.getLastJobExecution(instance);
if (last != null && last.getStatus().isRunning()) {
System.out.println("RECOVER: execution " + last.getId() + " is " + last.getStatus()
+ " but its JVM is gone; marking it FAILED so the instance can restart");
operator.recover(last);
}
}
long t0 = System.nanoTime();
System.out.println("START run.id=" + props.runId() + " tickets=" + props.tickets() + " chunk=" + props.chunkSize());
JobExecution execution = operator.start(job, params);
System.out.printf("JOB FINISHED: executionId=%d status=%s seconds=%.1f%n", execution.getId(),
execution.getStatus(), (System.nanoTime() - t0) / 1e9);
report();
}
void report() throws Exception {
System.out.println("---- REPORT ----");
long results = jdbc.queryForObject("SELECT COUNT(*) FROM TICKET_RESULT", Long.class);
long skipped = jdbc.queryForObject("SELECT COUNT(*) FROM SKIPPED_TICKET", Long.class);
System.out.printf("tickets total=%d classified=%d skipped=%d unaccounted=%d%n", props.tickets(), results, skipped,
props.tickets() - results - skipped);
System.out.println("step executions:");
for (Map<String, Object> r : jdbc.queryForList("""
SELECT se.STEP_EXECUTION_ID AS ID, se.JOB_EXECUTION_ID AS JOB, se.STATUS, se.READ_COUNT AS READS,
se.WRITE_COUNT AS WRITES, se.PROCESS_SKIP_COUNT AS SKIPS, se.COMMIT_COUNT AS COMMITS, se.ROLLBACK_COUNT AS ROLLBACKS
FROM BATCH_STEP_EXECUTION se ORDER BY se.STEP_EXECUTION_ID""")) {
System.out.println(" " + r);
}
Map<String, Object> l = jdbc.queryForMap("""
SELECT COUNT(*) AS ROWS_, COALESCE(SUM(TICKETS_WRITTEN),0) AS WRITTEN, COALESCE(SUM(TICKETS_SKIPPED),0) AS SKIPPED,
COALESCE(SUM(ATTEMPTS),0) AS ATTEMPTS, COALESCE(SUM(REJECTED_429),0) AS R429, COALESCE(SUM(UNAVAILABLE_503),0) AS U503,
COALESCE(SUM(RETRIES),0) AS RETRIES, COALESCE(SUM(BILLED_CALLS),0) AS BILLED, COALESCE(SUM(BAD_REPLIES),0) AS BAD,
COALESCE(SUM(PROMPT_TOKENS),0) AS PT, COALESCE(SUM(COMPLETION_TOKENS),0) AS CT, COALESCE(SUM(COST),0) AS COST
FROM COST_LEDGER""");
System.out.println("ledger (committed chunks only): " + l);
System.out.printf("price table '%s': input %s / output %s per million tokens (configurable placeholder)%n",
pricing.version(), pricing.inputPerMillion(), pricing.outputPerMillion());
long billed = providerMeter.get(ProviderMeter.Slot.BILLED);
long ledgerBilled = ((Number) l.get("BILLED")).longValue();
System.out.println("provider's own meter (survives kill -9):");
System.out.print(providerMeter.summary().indent(2));
System.out.printf("billed calls the ledger does not know about (work lost in a crash or rollback): %d%n", billed - ledgerBilled);
var md = MessageDigest.getInstance("SHA-256");
jdbc.query("SELECT TICKET_ID, CATEGORY, PRIORITY, CONFIDENCE FROM TICKET_RESULT ORDER BY TICKET_ID", rs -> {
md.update((rs.getLong(1) + ":" + rs.getString(2) + ":" + rs.getInt(3) + ":" + rs.getDouble(4) + "\n").getBytes());
});
System.out.println("results checksum (sha-256 over ordered rows): " + HexFormat.of().formatHex(md.digest()).substring(0, 16));
}
}
@@ -0,0 +1,31 @@
package com.ankurm.batchllm;
import java.math.BigDecimal;
import java.math.RoundingMode;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* A price table you configure. The defaults are PLACEHOLDERS chosen to make the arithmetic easy to
* follow; they are not the price of any provider or model. Put your own contract prices in
* {@code batch.pricing.*} and the ledger recomputes with them.
*
* @param version a label stored on every ledger row, so a price change is visible in the data
* @param inputPerMillion currency units per million prompt tokens
* @param outputPerMillion currency units per million completion tokens
*/
@ConfigurationProperties("batch.pricing")
public record Pricing(
@DefaultValue("EXAMPLE-NOT-A-REAL-PRICE") String version,
@DefaultValue("0.50") BigDecimal inputPerMillion,
@DefaultValue("1.50") BigDecimal outputPerMillion) {
private static final BigDecimal MILLION = BigDecimal.valueOf(1_000_000L);
public BigDecimal cost(long promptTokens, long completionTokens) {
return inputPerMillion.multiply(BigDecimal.valueOf(promptTokens))
.add(outputPerMillion.multiply(BigDecimal.valueOf(completionTokens)))
.divide(MILLION, 9, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,32 @@
package com.ankurm.batchllm;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Knobs of the SIMULATED provider. Every number here is an invented test input, not a measurement
* of any real service.
*
* @param seed changes which tickets and calls are unlucky
* @param limitPerSecond the simulated provider's own quota: more calls than this in any sliding
* second get a 429
* @param random429 chance that any single call is rejected with a 429 regardless of load
* @param random503 chance that any single call gets a 503
* @param permanentBad share of tickets whose reply is never valid JSON, however often asked
* @param flakyBad share of tickets whose FIRST billed reply is invalid and the second is fine
* @param slowShare share of calls that take {@code slowMillis} instead of 1-3 ms
* @param slowMillis latency of a slow call
* @param meterFile memory-mapped counters kept by the "provider", so they survive kill -9
*/
@ConfigurationProperties("sim.provider")
public record ProviderFaults(
@DefaultValue("7") long seed,
@DefaultValue("2400") int limitPerSecond,
@DefaultValue("0.015") double random429,
@DefaultValue("0.005") double random503,
@DefaultValue("0.003") double permanentBad,
@DefaultValue("0.012") double flakyBad,
@DefaultValue("0.01") double slowShare,
@DefaultValue("40") int slowMillis,
@DefaultValue("target/provider-meter.bin") String meterFile) {
}
@@ -0,0 +1,57 @@
package com.ankurm.batchllm;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* The simulated provider's own invoice: counters in a memory-mapped file. The operating system
* keeps a mapped page after the process is killed, so the counters survive {@code kill -9}, which
* is what lets the crash demo say how many billed calls the dead JVM had made.
*/
public final class ProviderMeter {
public enum Slot { RECEIVED, REJECTED_429, WINDOW_429, UNAVAILABLE_503, BILLED, PROMPT_TOKENS, COMPLETION_TOKENS }
private final MappedByteBuffer buffer;
public ProviderMeter(String file) {
try {
Path path = Path.of(file);
if (path.getParent() != null) {
Files.createDirectories(path.getParent());
}
try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw")) {
buffer = raf.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, 8L * Slot.values().length);
}
} catch (IOException e) {
throw new IllegalStateException("cannot map " + file, e);
}
}
public synchronized void add(Slot slot, long n) {
int pos = slot.ordinal() * 8;
buffer.putLong(pos, buffer.getLong(pos) + n);
}
public synchronized long get(Slot slot) {
return buffer.getLong(slot.ordinal() * 8);
}
public synchronized void reset() {
for (Slot s : Slot.values()) {
buffer.putLong(s.ordinal() * 8, 0L);
}
}
public synchronized String summary() {
StringBuilder sb = new StringBuilder();
for (Slot s : Slot.values()) {
sb.append(String.format("%-18s %,d%n", s.name().toLowerCase(), get(s)));
}
return sb.toString();
}
}
@@ -0,0 +1,8 @@
package com.ankurm.batchllm;
/** The provider said 429 Too Many Requests. Transient: retrying later can succeed. */
public class ProviderRateLimitException extends RuntimeException {
public ProviderRateLimitException(String message) {
super(message);
}
}
@@ -0,0 +1,8 @@
package com.ankurm.batchllm;
/** The provider said 503. Transient, like {@link ProviderRateLimitException}. */
public class ProviderUnavailableException extends RuntimeException {
public ProviderUnavailableException(String message) {
super(message);
}
}
@@ -0,0 +1,150 @@
package com.ankurm.batchllm;
import java.util.ArrayDeque;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import com.ankurm.batchllm.ProviderMeter.Slot;
/**
* A hand-written, deterministic {@link ChatModel} -- the same idea as the ScriptedChatModel in the
* other modules of this repository, but driven by a fault plan instead of a list of replies.
* It is NOT a language model: it reads the ticket number out of the prompt and decides, from a
* hash of (seed, ticket, call number), whether this call returns valid JSON, malformed JSON, a
* 429, a 503 or a slow answer. No network, no key, no vendor.
*
* <p>It also enforces its own quota: more than {@code limitPerSecond} calls in any sliding second
* get a 429, which is what a client-side rate limiter exists to avoid.
*
* <p>Token counts are ESTIMATED as characters / 4 and returned through the same
* {@code ChatResponseMetadata.usage} field a real provider fills in.
*/
public class SimulatedProvider implements ChatModel {
private static final Pattern TICKET_ID = Pattern.compile("Ticket #(\\d+)");
private static final List<String> CATEGORIES = List.of("BILLING", "TECHNICAL", "ACCOUNT", "SHIPPING", "FEATURE_REQUEST", "OTHER");
private final ProviderFaults faults;
private final ProviderMeter meter;
private final ConcurrentHashMap<Long, AtomicInteger> callsPerTicket = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, AtomicInteger> billedPerTicket = new ConcurrentHashMap<>();
private final ArrayDeque<Long> window = new ArrayDeque<>();
private volatile int latencyDivisor = 1;
public SimulatedProvider(ProviderFaults faults, ProviderMeter meter) {
this.faults = faults;
this.meter = meter;
}
/** Test hook: make every sleep this many times shorter. */
public void setLatencyDivisor(int divisor) {
this.latencyDivisor = Math.max(1, divisor);
}
public ProviderMeter meter() {
return meter;
}
/** Total calls the model has seen for one ticket, rejected ones included. */
public int callsFor(long ticketId) {
AtomicInteger n = callsPerTicket.get(ticketId);
return n == null ? 0 : n.get();
}
private double unit(long ticket, long salt) {
long h = TicketGenerator.mix(TicketGenerator.mix(faults.seed() * 31 + salt) ^ ticket);
return (h >>> 11) * 0x1.0p-53;
}
@Override
public ChatResponse call(Prompt prompt) {
String text = prompt.getContents();
Matcher m = TICKET_ID.matcher(text);
if (!m.find()) {
throw new IllegalArgumentException("simulated provider expects 'Ticket #<id>' in the prompt");
}
long id = Long.parseLong(m.group(1));
int callNo = callsPerTicket.computeIfAbsent(id, k -> new AtomicInteger()).incrementAndGet();
meter.add(Slot.RECEIVED, 1);
if (overQuota()) {
meter.add(Slot.REJECTED_429, 1);
meter.add(Slot.WINDOW_429, 1);
throw new ProviderRateLimitException("429 Too Many Requests (quota: " + faults.limitPerSecond() + " calls per second)");
}
if (unit(id, 1000L + callNo) < faults.random429()) {
meter.add(Slot.REJECTED_429, 1);
throw new ProviderRateLimitException("429 Too Many Requests (injected)");
}
if (unit(id, 2000L + callNo) < faults.random503()) {
meter.add(Slot.UNAVAILABLE_503, 1);
throw new ProviderUnavailableException("503 Service Unavailable (injected)");
}
boolean slow = unit(id, 3000L + callNo) < faults.slowShare();
long millis = slow ? faults.slowMillis() : 1 + (long) (unit(id, 4000L + callNo) * 3);
try {
Thread.sleep(Math.max(0, millis / latencyDivisor));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
int billedNo = billedPerTicket.computeIfAbsent(id, k -> new AtomicInteger()).incrementAndGet();
String reply = reply(id, billedNo);
int promptTokens = (text.length() + 3) / 4;
int completionTokens = (reply.length() + 3) / 4;
meter.add(Slot.BILLED, 1);
meter.add(Slot.PROMPT_TOKENS, promptTokens);
meter.add(Slot.COMPLETION_TOKENS, completionTokens);
ChatResponseMetadata metadata = ChatResponseMetadata.builder()
.model("simulated-classifier")
.usage(new DefaultUsage(promptTokens, completionTokens))
.build();
return new ChatResponse(List.of(new Generation(new AssistantMessage(reply))), metadata);
}
private synchronized boolean overQuota() {
long now = System.nanoTime();
while (!window.isEmpty() && now - window.peekFirst() > 1_000_000_000L) {
window.pollFirst();
}
if (window.size() >= faults.limitPerSecond()) {
return true;
}
window.addLast(now);
return false;
}
/** The reply for ticket {@code id} on its {@code billedNo}-th billed answer. */
String reply(long id, int billedNo) {
String category = CATEGORIES.get((int) Math.floorMod(TicketGenerator.mix(id * 1_000_003L + 17), 6L));
int priority = 1 + (int) (unit(id, 5000L) * 4);
double confidence = Math.round((0.60 + unit(id, 6000L) * 0.39) * 100) / 100.0;
String good = "{\"category\":\"" + category + "\",\"priority\":" + priority + ",\"confidence\":" + confidence + "}";
boolean permanent = unit(id, 7000L) < faults.permanentBad();
boolean flaky = !permanent && billedNo == 1 && unit(id, 8000L) < faults.flakyBad();
if (!permanent && !flaky) {
// one reply in ten comes wrapped in a markdown fence: valid after the fence is stripped
return unit(id, 9000L) < 0.10 ? "```json\n" + good + "\n```" : good;
}
return switch ((int) (unit(id, 10_000L) * 4)) {
case 0 -> good.substring(0, good.length() - 14); // truncated
case 1 -> "Sure! Here is the classification you asked for: " + good; // prose in front
case 2 -> good.replace(category, category.charAt(0) + category.substring(1).toLowerCase() + " & Payments"); // enum value not in the schema
default -> "{\"category\":\"" + category + "\",\"priority\":" + priority + "}"; // field missing
};
}
}
@@ -0,0 +1,49 @@
package com.ankurm.batchllm;
import org.springframework.batch.core.listener.SkipListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Records every skipped ticket with the reason and the raw reply, so a skipped ticket is a row
* you can look at and re-run, not a number in a counter.
*
* <p>Both callbacks write in a REQUIRES_NEW transaction. {@code onSkipInWrite} is called while the
* chunk's own transaction is about to be rolled back (that is how the scan finds the bad item); an
* insert that joined that transaction would be rolled back with it and the skipped ticket would
* leave no trace. This was found by a test that counted tickets: 299 of 300 were accounted for.
* {@code MERGE} makes the insert idempotent, because after a crash the same ticket can be skipped twice.
*/
@Component
public class SkippedTicketListener implements SkipListener<Ticket, Classification> {
private final JdbcTemplate jdbc;
private final TransactionTemplate ownTransaction;
public SkippedTicketListener(JdbcTemplate jdbc, PlatformTransactionManager transactionManager) {
this.jdbc = jdbc;
this.ownTransaction = new TransactionTemplate(transactionManager);
this.ownTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
@Override
public void onSkipInProcess(Ticket ticket, Throwable t) {
String raw = t instanceof BadModelOutputException b ? b.rawReply() : null;
record(ticket.id(), t.getMessage(), raw);
}
@Override
public void onSkipInWrite(Classification item, Throwable t) {
record(item.ticketId(), "write rejected: " + t.getClass().getSimpleName(), item.toString());
}
private void record(long ticketId, String reason, String raw) {
String r = reason == null ? null : reason.substring(0, Math.min(reason.length(), 200));
String rawReply = raw == null ? null : raw.substring(0, Math.min(raw.length(), 400));
ownTransaction.executeWithoutResult(s -> jdbc.update(
"MERGE INTO SKIPPED_TICKET (TICKET_ID, REASON, RAW_REPLY) KEY (TICKET_ID) VALUES (?,?,?)", ticketId, r, rawReply));
}
}
@@ -0,0 +1,5 @@
package com.ankurm.batchllm;
/** One support ticket. Synthetic: see {@link TicketGenerator}. */
public record Ticket(long id, String subject, String body) {
}
@@ -0,0 +1,122 @@
package com.ankurm.batchllm;
import java.util.function.Supplier;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.batch.infrastructure.item.ItemProcessor;
import org.springframework.stereotype.Component;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryRegistry;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* The ItemProcessor. One ticket in, one {@link Classification} out.
*
* <p>Three layers wrap the single model call, innermost first:
* <ol>
* <li>the call itself, through {@link ChatClient};</li>
* <li>a Resilience4j {@link RateLimiter} that takes a permit BEFORE every attempt;</li>
* <li>a Resilience4j {@link Retry} outside it, so a retried attempt takes a fresh permit.</li>
* </ol>
* Parsing is outside the retry on purpose: an answer that arrived and is not JSON is not a
* transient fault, and a Resilience4j retry would pay for the same bad answer again.
*/
@Component
public class TicketClassifier implements ItemProcessor<Ticket, Classification> {
static final String SYSTEM = """
You classify customer support tickets. Reply with ONLY a JSON object:
{"category": one of BILLING, TECHNICAL, ACCOUNT, SHIPPING, FEATURE_REQUEST, OTHER,
"priority": integer 1 (low) to 4 (urgent), "confidence": number between 0 and 1}
""";
private final ChatClient chatClient;
private final RateLimiter limiter;
private final Retry retry;
private final UsageMeter meter;
private final LlmJobProperties props;
private final JsonMapper json = JsonMapper.builder().build();
public TicketClassifier(ChatClient chatClient, RateLimiterRegistry limiters, RetryRegistry retries,
UsageMeter meter, LlmJobProperties props) {
this.chatClient = chatClient;
this.limiter = limiters.rateLimiter("llm");
this.retry = retries.retry("llm");
this.meter = meter;
this.props = props;
this.retry.getEventPublisher().onRetry(e -> meter.retries.incrementAndGet());
}
@Override
public Classification process(Ticket ticket) {
BadModelOutputException last = null;
for (int ask = 0; ask <= props.reask(); ask++) {
ChatResponse response = callModel(ticket);
Usage usage = response.getMetadata().getUsage();
meter.billedCalls.incrementAndGet();
meter.promptTokens.addAndGet(usage.getPromptTokens());
meter.completionTokens.addAndGet(usage.getCompletionTokens());
String text = response.getResult().getOutput().getText();
try {
return parse(ticket.id(), text);
} catch (BadModelOutputException e) {
meter.badReplies.incrementAndGet();
last = e;
}
}
meter.skipped.incrementAndGet();
throw last;
}
private ChatResponse callModel(Ticket ticket) {
Supplier<ChatResponse> attempt = () -> {
meter.attempts.incrementAndGet();
try {
return chatClient.prompt()
.user("Ticket #" + ticket.id() + "\nSubject: " + ticket.subject() + "\nBody: " + ticket.body())
.call()
.chatResponse();
} catch (ProviderRateLimitException e) {
meter.rejected429.incrementAndGet();
throw e;
} catch (ProviderUnavailableException e) {
meter.unavailable503.incrementAndGet();
throw e;
}
};
// Retry( RateLimiter( call ) ): every retry goes back through the limiter.
return Retry.decorateSupplier(retry, RateLimiter.decorateSupplier(limiter, attempt)).get();
}
Classification parse(long ticketId, String raw) {
String text = raw == null ? "" : raw.strip();
if (text.startsWith("```")) {
text = text.replaceFirst("^```[a-zA-Z]*\\s*", "").replaceFirst("\\s*```$", "").strip();
}
JsonNode node;
try {
node = json.readTree(text);
} catch (RuntimeException e) {
throw new BadModelOutputException("not JSON: " + e.getClass().getSimpleName(), raw);
}
JsonNode category = node.get("category");
JsonNode priority = node.get("priority");
JsonNode confidence = node.get("confidence");
if (category == null || priority == null || confidence == null) {
throw new BadModelOutputException("missing field", raw);
}
if (!Classification.CATEGORIES.contains(category.asString())) {
throw new BadModelOutputException("category not in schema: " + category.asString(), raw);
}
if (!priority.isInt() || priority.asInt() < 1 || priority.asInt() > 4) {
throw new BadModelOutputException("priority out of range", raw);
}
return new Classification(ticketId, category.asString(), priority.asInt(), confidence.asDouble());
}
}
@@ -0,0 +1,54 @@
package com.ankurm.batchllm;
/**
* Builds ticket number {@code id} from nothing but the number, so 100,000 tickets exist without a
* file or a table, and the same id is the same ticket in every JVM. SYNTHETIC text assembled from
* phrase lists; it is not real customer data.
*/
public final class TicketGenerator {
private static final String[] OPENERS = {
"Hello,", "Hi team,", "Urgent:", "Quick question,", "Following up,", "To whom it may concern,", "Hey,"};
private static final String[][] TOPICS = {
{"I was charged twice for invoice", "my refund has not arrived for order", "the VAT on my last invoice looks wrong for plan",
"please update the card on file for subscription", "the discount code was not applied to order"},
{"the export job crashes with a timeout in workspace", "the API returns 500 for endpoint", "login loops forever on device",
"the dashboard shows stale numbers for project", "webhook deliveries stopped for integration"},
{"I cannot reset the password for user", "please merge the duplicate accounts for team", "the 2FA code never arrives for",
"remove my former colleague from workspace", "I need to change the owner email on account"},
{"my parcel is stuck in customs for order", "the tracking page says delivered but nothing arrived for order",
"wrong size shipped on order", "the courier left order", "I need to change the delivery address of order"},
{"it would be great to have dark mode in", "can you add CSV import to", "please support single sign-on for",
"a bulk edit option for", "we would love an API endpoint for"},
{"thanks for the quick help with", "I am writing about something else entirely regarding", "just checking the status of",
"can someone call me about", "feedback on the onboarding of"}};
private static final String[] TAILS = {
"This is the third time I am asking.", "It started yesterday after the update.", "Our whole team is blocked.",
"No rush, but please reply this week.", "I attached a screenshot earlier.", "Please escalate if needed.",
"Happy to jump on a call.", "This affects roughly twenty users.", "I already tried the steps in your help centre."};
private TicketGenerator() {
}
public static long mix(long x) {
x += 0x9E3779B97F4A7C15L;
x = (x ^ (x >>> 30)) * 0xBF58476D1CE4E5B9L;
x = (x ^ (x >>> 27)) * 0x94D049BB133111EBL;
return x ^ (x >>> 31);
}
public static Ticket ticket(long id) {
long h = mix(id * 1_000_003L + 17);
int topic = (int) Math.floorMod(h, (long) TOPICS.length);
String[] phrases = TOPICS[topic];
String lead = phrases[(int) Math.floorMod(h >>> 8, (long) phrases.length)];
String opener = OPENERS[(int) Math.floorMod(h >>> 16, (long) OPENERS.length)];
StringBuilder body = new StringBuilder(opener).append(' ').append(lead).append(" #")
.append(Math.floorMod(h >>> 24, 90_000L) + 10_000).append(". ");
int tails = 1 + (int) Math.floorMod(h >>> 40, 4L);
for (int i = 0; i < tails; i++) {
body.append(TAILS[(int) Math.floorMod(mix(h + i) >>> 5, (long) TAILS.length)]).append(' ');
}
return new Ticket(id, "Ticket about " + lead.split(" ")[Math.min(3, lead.split(" ").length - 1)], body.toString().trim());
}
}
@@ -0,0 +1,36 @@
package com.ankurm.batchllm;
import org.springframework.batch.infrastructure.item.ExecutionContext;
import org.springframework.batch.infrastructure.item.ItemStreamReader;
/**
* Reads ticket 1..total. It is restartable for the same reason the CSV reader in the earlier
* Spring Batch article is: it saves its position in the step's ExecutionContext, and Spring Batch
* persists that context in the SAME transaction as the chunk's writes.
*/
public class TicketReader implements ItemStreamReader<Ticket> {
static final String KEY = "ticket.reader.next";
private final long total;
private long next = 1;
public TicketReader(long total) {
this.total = total;
}
@Override
public void open(ExecutionContext executionContext) {
next = executionContext.getLong(KEY, 1L);
}
@Override
public void update(ExecutionContext executionContext) {
executionContext.putLong(KEY, next);
}
@Override
public Ticket read() {
return next > total ? null : TicketGenerator.ticket(next++);
}
}
@@ -0,0 +1,52 @@
package com.ankurm.batchllm;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
/**
* Money and calls spent since the last ledger row was committed. The classifier adds to it from
* many threads; the writer drains it inside the chunk transaction and, if that transaction rolls
* back, puts the amounts back -- the calls were still made and billed.
*/
@Component
public class UsageMeter {
public record Snapshot(long attempts, long rejected429, long unavailable503, long retries, long billedCalls,
long promptTokens, long completionTokens, long badReplies, long skipped) {
public Snapshot plus(Snapshot o) {
return new Snapshot(attempts + o.attempts, rejected429 + o.rejected429, unavailable503 + o.unavailable503,
retries + o.retries, billedCalls + o.billedCalls, promptTokens + o.promptTokens,
completionTokens + o.completionTokens, badReplies + o.badReplies, skipped + o.skipped);
}
}
final AtomicLong attempts = new AtomicLong();
final AtomicLong rejected429 = new AtomicLong();
final AtomicLong unavailable503 = new AtomicLong();
final AtomicLong retries = new AtomicLong();
final AtomicLong billedCalls = new AtomicLong();
final AtomicLong promptTokens = new AtomicLong();
final AtomicLong completionTokens = new AtomicLong();
final AtomicLong badReplies = new AtomicLong();
final AtomicLong skipped = new AtomicLong();
public Snapshot drain() {
return new Snapshot(attempts.getAndSet(0), rejected429.getAndSet(0), unavailable503.getAndSet(0),
retries.getAndSet(0), billedCalls.getAndSet(0), promptTokens.getAndSet(0),
completionTokens.getAndSet(0), badReplies.getAndSet(0), skipped.getAndSet(0));
}
public void restore(Snapshot s) {
attempts.addAndGet(s.attempts());
rejected429.addAndGet(s.rejected429());
unavailable503.addAndGet(s.unavailable503());
retries.addAndGet(s.retries());
billedCalls.addAndGet(s.billedCalls());
promptTokens.addAndGet(s.promptTokens());
completionTokens.addAndGet(s.completionTokens());
badReplies.addAndGet(s.badReplies());
skipped.addAndGet(s.skipped());
}
}