Security Operations Automation Agent Retro, Week 17 - Fixing the Triage Worker Bottleneck
Contents
Also available in Korean: 보안관제자동화 에이전트 회고 17주차 - triage 워커 병목 개선
Background
Triage started falling behind in the dev environment.
The system collects security alerts, groups them into incidents, and uses an LLM to decide whether each incident is a true positive (TP) or a false positive (FP).
That process is triage, and its result is the verdict.
An incident is a group of related alerts. Triage decides whether an incident is a real threat and sets the response order; the verdict is the outcome, TP or FP.
When one new customer was connected, alerts that had been held until the connection were reprocessed, and thousands of incidents appeared at once.
This post calls those incidents the backlog.
New incidents from other customers then piled up without being triaged.
There turned out to be three bottlenecks.
The third only showed up after the queue and threads were fixed.
For 1, the queue was split into a default queue and a backlog queue.
For 2, scheduling was split into 8 shared threads plus a dedicated thread per triage worker.
For 3, only minimal parallelism was applied for now: several items in a batch run at the same time.
The goal of this round was to fix the queue and thread structure, not to push throughput.
Throughput work comes in the next round.
Change 1
Splitting the queue
The triage worker takes incidents without a verdict, lowest ID first, and processes a few at a time as a batch.
Oldest first keeps old incidents from being pushed back forever, so it is the right rule on a normal day.
But when thousands of backlog incidents appeared at once they held the lowest IDs, and new incidents had to wait behind them.
Throughput was about 3 per minute, so clearing the backlog at that rate would have taken more than a day and a half.
A column records which queue each incident belongs to; the existing worker takes only the default queue and a new worker takes only the backlog queue.
Both workers write their results to the same verdict table.
Every downstream step reads the verdict table, so the only thing that differs is which worker picks the incident up.
Each queue got a valve, so concurrency and pause can be changed from the UI.
Pausing the default queue stops live triage, so it goes through a confirmation dialog.
Retries of failed triage were not split into their own queue.
A retry is not a separate worker; each worker picks up its own queue's failed items first in every batch.
Moving retries to a third queue would lose track of which queue they came from, and backlog failures would block live triage again.
Change 2
Splitting the scheduler threads
After deploying the new worker and counting triages per minute, the existing worker did zero while the new one was working, and the other way around.
Each queue had its own lock, so a thread problem seemed likely.
It turned out that 17 scheduled jobs were sharing one thread.
While a triage batch held that thread for about 5 minutes, the alert grouping job, due every 60 seconds, ran only every 150 to 172 seconds.
Each stretched interval ended within a second of a triage batch or a collection run finishing.
The first guess was that the thread count setting (spring.task.scheduling.pool.size) defaults to 1.
That was only half right.
With the virtual-thread option on (spring.threads.virtual.enabled=true), Spring Boot creates a SimpleAsyncTaskScheduler instead of a thread pool.
This scheduler has a single scheduling thread, and fixedDelay tasks run one after another on that thread.
The thread count setting does not apply to this scheduler, so raising it changed nothing.
The scheduling thread pool is now defined in code, with 8 shared threads.
The number was chosen from the production server’s specs.
When a scheduler bean exists, Spring Boot does not create its default one, so every @Scheduled job lands in this pool.
@Bean
fun taskScheduler(): ThreadPoolTaskScheduler =
ThreadPoolTaskScheduler().apply {
poolSize = 8
setThreadNamePrefix("scheduled-")
}
The triage workers are not in this pool.
A triage batch takes minutes and would hold a shared thread for a long time, so each worker creates its own single-thread scheduler and schedules itself.
Registering that dedicated scheduler as a bean would make Spring treat it as the shared scheduler, so it is not registered as a bean.
Change 3
In-batch parallelism
Real throughput work comes in the next round; this time only enough was applied to clear the bottleneck left after the queue and threads were split.
Even after the threads were split, the backlog queue ran at about 2.6 per minute.
Each queue runs one batch at a time under a single lock, and inside a batch items were still triaged one at a time.
One triage calls the LLM twice (verdict, rationale) and takes about 17 seconds.
Actual computation is a little over a second; the rest is waiting for the LLM to respond.
One at a time, the number finished per minute is set by how long one item takes.
At around 17 seconds an item, the ceiling is a little over 3 per minute, so raising the batch size from 5 to 10 did not change throughput.
The easiest fix is to add threads and run triage in parallel without limits.
But the LLM server has limits on concurrent calls and calls per minute, and rejects requests over the limit with 429 (Too Many Requests).
If concurrent calls grow with the thread count, the limit can make triage fail all at once.
So the batch stays, and inside it each queue runs only up to a fixed number at a time.
- The batch size stays the same, and up to the concurrency limit items run together. A new item starts as soon as one finishes, so one slow item does not hold up the rest.
- An item that runs past 60 seconds is cut off and sent to the retry list.
- After 90 seconds a batch starts no new items and only finishes the ones in progress. Items that did not start are not failures; they move to the next batch.
- Starting values are 3 for the default queue and 2 for the backlog queue. The valve now controls concurrency instead of batch size.
The polling interval had to change too.
The next batch starts 60 seconds after the previous one ends (fixedDelay).
Keeping that interval and running 3 at a time gives a 20-second batch plus a 60-second wait, about 13 items in 6 minutes, barely better than one at a time.
So when items remain in the queue the polling interval drops to 5 seconds, and it stays at 60 seconds only when the queue is empty.
That gives about 43 items in the same 6 minutes, about 7 per minute.
Change 4
Why not slots
Another option was to drop batches entirely and always keep N triages in flight.
The worker holds N slots, and as soon as a slot frees up it takes the next item from the queue.
With no batch boundaries, a slow item ties up only its own slot while the others keep going.
On throughput alone, slots are a little better.
With in-batch parallelism the other slots sit idle at the end of a batch while one slow item finishes, and that wait can stretch to the per-item limit of 60 seconds.
The slot approach, however, needs far more changes.
| In-batch parallelism (adopted) | Slots | |
|---|---|---|
| Throughput | Scales with concurrency, small loss at batch end | Scales with concurrency, almost no loss |
| Slow items | The rest wait at batch end | Occupy only their own slot |
| Picking from the queue | Unchanged (one lock per queue) | In-progress marker per incident + FOR UPDATE SKIP LOCKED |
| Multiple pods | Only the pod holding the lock runs | Slots per pod, in-progress markers prevent duplicates |
| When a pod dies | The next batch picks up items without a verdict | Another pod takes over after the expiry time |
| Emergency stop, batch records | Unchanged | Batch-level decisions redesigned around time windows |
Without batches, decisions such as counting consecutive failures for an emergency stop, or “every item in this batch failed”, would all have to be rebuilt around time windows.
The only gain is the idle time at batch end, and the cost is touching everything from how items are picked to failure handling.
The biggest reason was the schedule. (could have used another pair of hands..)
The deadline was fixed and there was too much to change, so making that change before the deadline was too risky.
So the current one-lock-per-queue structure stays, and parallelism happens only inside a batch.
If measurements show batches often stretching to the time limit because of slow items, slots will be revisited.
Overlapping batches were ruled out too.
Incidents still being processed by one batch have no verdict yet, so the next batch picks them up again, and concurrent calls multiply with each overlapping batch and can exceed the limit.
Results
The comparison used the backlog queue with more than 5,000 items waiting, with the batch size fixed at 10, over windows before and after the deploy.
| Metric | Before | After |
|---|---|---|
| Backlog triages per 10 minutes | 21–30 | 49–63 (avg. ~55) |
| Time to clear 5,956 backlog incidents (from throughput) | ~38 h | ~18 h |
| One LLM call, p50 / p90 | 15.8 s / 19.4 s | 16.6 s / 21.1 s |
| LLM calls per minute | 6.0 | 12.2 |
| Rate-limit errors (429) | 0 | 0 |
| Collection interval, top 5% | 100–102 s | 69 s |
| Collection intervals over 90 s | 47–50 | 24–27 |
| Collection intervals over 120 s | 28–29 | 22–26 |
| Scheduling threads | 1, shared by 17 jobs | 8 shared + 2 for triage |
Throughput work is planned as the next task in the remaining development period.
References
- Spring Boot, Task Execution and Scheduling https://docs.spring.io/spring-boot/reference/features/task-execution-and-scheduling.html
- Spring Framework,
SimpleAsyncTaskSchedulerhttps://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/concurrent/SimpleAsyncTaskScheduler.html