engnotes.dev
NotebookTopicsAbout

Subscribe

One email when a new post goes up. Nothing else.

one per post · no tracking · also on RSS

Site

  • Notebook
  • Topics
  • About
  • Contact

Topics

Project Loom9Structured Concurrency9Tail Latency & System Behavior6

Elsewhere

  • GitHub
  • X
  • LinkedIn
  • Email
engnotes.dev© 2026 Jagdish Salgotra · written on personal time. not on employer time.
PrivacyTermsCookies
blog/tail-latency-system-behavior/part 6
Tail Latency & System Behavior · Part 6 of 6

SLO Engineering & Designing Predictable Services Under Load

A deterministic Java SLO simulation against a 99%-under-200ms objective. A timeout caps p99 at 200ms yet scores the same 57.36% SLI as doing nothing, because a fast failure is still a failure; a bulkhead holds 99.60% with 60% of the error budget left. Burn rate fires from a single window.

J
Jagdish Salgotra
2026-06-28·7 min read·~1,100 words

Series navigation

← Previous · Part 5Backpressure Design Patterns
Code repositoryproduction-systems-labs
#tail-latency-system-behavior
share
J

Written by

Jagdish Salgotra

Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.

all posts

Keep reading · rest of the series

  • 2026-05-319 min read
    Part 1
    Why Average Latency Lies
  • 2026-06-0710 min read
    Part 2
    Queueing Theory for Engineers
  • 2026-06-147 min read
    Part 3
    Hedged Requests & Speculative Execution
  • 2026-06-216 min read
    Part 4
    The Coordinated Omission Problem
Was this article helpful? or email →
anonymous · no account needed

On this page

Reading progress

0 min of 7 · ~7 left

Ask the post

Any answer points back at the paragraph it came from.

Production System Labs - Series 1, Post 6 Runnable Java experiments on the failure patterns that show up under real load. Deterministic outputs, checked-in CSVs, reproducible on any machine.


Every number so far, and still not a decision

Everything in this series so far has produced a number. Post 1 showed the tail hiding behind the average. Post 2 showed latency exploding past saturation. Post 3 spent capacity to cut the tail, Post 4 made sure the tail was measured honestly, and Post 5 chose where overload pain lands. None of that is a decision yet. It is just data.

A service-level objective is what turns the data into a decision you can defend at three in the morning. The lab states one objective, 99% of requests must complete under 200 ms, and SloScenario.java runs four architectures against the same overload to see which ones keep the promise. The outcomes are in golden/post6/post6-slo-summary.csv:

text
pattern            total_requests  good_requests  bad_requests  achieved_sli_pct  worst_burn_rate  final_budget_remaining_pct  alert_triggered
baseline           5000            2868           2132          57.36             100.00           0.00                        true
bulkhead           5000            4980           20            99.60             1.00             60.00                       false
timeout-budget     5000            2868           2132          57.36             100.00           0.00                        true
concurrency-limit  5000            4960           40            99.20             2.00             20.00                       true

The request rate is deterministic in this experiment: concurrency * 100, so the run command at the end of this post produces 1000 rps for 5s, or 5000 requests per pattern.

I still remember insisting a service was basically fine during an incident because CPU and error rate looked normal, right up until someone asked what fraction of requests had actually finished under our latency target, and not one of us had that number.


A timeout bounds latency and abandons the objective

The most instructive row is timeout-budget, because it looks like a fix and is not one.

The intuition is reasonable: requests are blowing past 200 ms, so cap them at 200 ms with a timeout. And it works, in the narrow sense that its per-second p99 in golden/post6/post6-slo-windows.csv never exceeds 200.0, while the unprotected baseline climbs to 444 ms and 644 ms during the overload:

text
pattern         elapsed_s  good_requests  bad_requests  p99_ms  burn_rate
baseline        3          0              1000          444.0   100.00
baseline        4          0              1000          644.0   100.00
timeout-budget  3          0              1000          200.0   100.00
timeout-budget  4          0              1000          200.0   100.00

Look at the SLI, though. The timeout-budget pattern achieves the same 57.36% as the baseline, with the same 2132 bad requests. The timeout bounded the latency number and changed nothing about the promise. A request that would have taken 400 ms now returns at 200 ms, but it returns as a failure, and the SLI counts a deadline miss as bad whether it comes back slow or comes back fast-but-broken. The user who needed that answer did not get it either way.

That is the trap with latency mechanics in isolation. Bounding p99 is not the same as meeting an SLO. The SLO is defined on good requests, and in this lab a request is good only when it both succeeds and lands under the threshold. The check lives in SloScenario.java:

java
if (outcome.success() && outcome.latencyMs() <= target.latencyThresholdMs()) {
    good++;
} else {
    bad++;
}

A timeout that fires moves a request from the slow-success column to the failure column. It protects the latency graph and abandons the objective. The bulkhead and concurrency-limit patterns, which actually cap the overload the requests experience rather than just truncating the wait, keep the SLI at 99.60% and 99.20%.


Burn rate fires from a single window

You do not need thirty days of data to know you are in trouble. You need the burn rate, and the burn rate is computable from a single window.

The objective of 99% under 200 ms defines an error budget of 1%. SloTarget.java supplies that target, and burn rate is just how fast the current request stream is spending that budget relative to the rate that would exactly exhaust it over the SLO window:

java
public static double burnRate(long badEvents, long totalEvents, SloTarget target) {
    if (totalEvents == 0L) {
        return 0.0;
    }
    double badRatio = badEvents / (double) totalEvents;
    return badRatio / target.errorBudgetRatio();
}

BurnRateCalculator.java is intentionally that small. A burn rate of 1.0 means you are spending budget exactly as fast as the SLO allows. The baseline's second second shows a 5.2% bad ratio against a 1% budget, which is a burn rate of 5.2x, and that is enough to act on immediately:

text
pattern   elapsed_s  bad_event_pct  burn_rate  budget_remaining_pct  alerting
baseline  1          0.00           0.00       100.00                false
baseline  2          5.20           5.20       0.00                  true
baseline  3          100.00         100.00     0.00                  true

By the end of the second second the baseline has already consumed its entire window budget and the burn rate hits 100x once every request is bad. The alert does not wait for a month of accounting; the instantaneous burn rate fires the moment the stream is unsustainable. The protective patterns keep their burn rate low enough to survive: the bulkhead's worst burn rate over the whole run is 1.0, and it ends with 60% of its error budget still in hand.

The patterns differ in how they protect the budget, and the trade-offs map straight back onto Post 5. The bulkhead isolates the overloaded slice so failure in one place does not consume the budget everywhere, and it spends almost nothing. The concurrency limit caps in-flight work, which keeps the SLI at 99.20% but does shed a thin slice of requests and trips the alert briefly at a 2.0 burn rate before recovering, ending with 20% budget remaining. Both keep the promise. The difference is how much margin they leave for the next surprise.


The budget is the contract you defend

An SLO is the contract that makes every earlier measurement actionable; without it you have graphs, with it you have a budget you can defend.

The SLI has to describe what the user experiences, not what is easy to graph. In this lab, good means succeeded and under 200 ms. CPU, heap, and error rate can all look normal while the SLI sits at 57.36%, which is exactly the gap that makes incidents feel inexplicable.

Bounding latency is not meeting the objective. The timeout-budget pattern held p99 at 200 ms and still failed 2132 requests, the same as doing nothing, because a fast failure is still a failure against the budget. Burn rate belongs in the same conversation, computed per window, because a 5.2x burn rate in the second second was enough to know the baseline was lost. A month of data is not required to see an unsustainable stream.

Architecture should protect the budget, not decorate the graph. The bulkhead and concurrency limit kept the SLI above 99%; the difference between them was the 60% versus 20% budget they left for the next event.

The whole series collapses into this one contract. Tail measurement from Post 1, honest load generation from Post 4, queueing pressure from Post 2, variance reduction from Post 3, and overload placement from Post 5 all become one number you are willing to be paged on.

The point of the budget is not to hit 100%. It is to know, at any moment, how much room you have left before the promise breaks, and to have already decided what you will give up to keep it.

The command that regenerates the numbers used here is:

bash
./gradlew :latency-lab:runSloPolicy \
  -Pargs="--deterministic --duration 5s --concurrency 10 --output-dir ./results/slo-policy --snapshot-interval 1s --slo-target p99<200ms"

The generated artifacts used in this post are:

text
golden/post6/post6-slo-summary.csv
golden/post6/post6-slo-windows.csv
golden/post6/post6-burn-rate.png
golden/post6/post6-error-budget.png

GoldenOutputTest.java runs this deterministic Article 6 path and compares post6-slo-summary.csv and post6-slo-windows.csv against the checked-in golden files. A fresh run for this post wrote results/article-grounding/post6/manifest.json; both Article 6 CSV artifacts had golden_match: true, with SHA-256 values matching their golden SHA-256 values.

References used for the underlying SLO ideas, not for the generated lab numbers:

  • Google SRE, Service Level Objectives.
  • Google SRE, Alerting on SLOs.