ExerciseProduction incident
Production incident
The tenant that leaked across requests
45 minsenior3–12 yrs
A real incident: symptom first, cause hidden, tradeoff at the end.
The incident
The gateway puts the caller's tenant into a ThreadLocal at the start of a
request so every layer below can read it without threading it through forty
signatures. A filter clears it at the end.
Two tickets were raised three weeks apart, and only the first was taken
seriously:
1. SECURITY. A customer of one tenant saw a report belonging to another.
Intermittent, only under load, never reproducible locally. The report
code is correct — it reads the tenant from the context, as designed.
2. MEMORY. The heap floor creeps up over days. The retained set is
dominated by objects reachable from Thread instances.
They are the same bug, seen from two sides. Find it, fix it, and then
answer the design question: what removes this class of bug rather than
this instance of it?
What this teaches
- A ThreadLocal is cleaned up when its thread dies, and pooled threads do not die
- remove() belongs in a finally, because the failing path is the one that matters
- A leaked context is a correctness and security bug before it is a memory one
- Only a request with no context of its own reveals it — which is why it looks intermittent
- Virtual threads and ScopedValue remove the class of bug, not just the instance
Starter
Starter.javaOpen in playground
import java.util.*;
import java.util.concurrent.*;
/**
* Incident reproduction: the tenant that leaked across requests.
*
* The gateway puts the caller's tenant into a ThreadLocal at the start of a
* request so that every layer below can read it without threading it through
* forty signatures. A filter clears it at the end.
*
* Two tickets, and only the first was taken seriously:
*
* 1. SECURITY: a customer of tenant-b saw a report belonging to tenant-a.
* Intermittent, only under load, never reproducible locally. The report
* code is correct and reads the tenant from the context, as designed.
*
* 2. MEMORY: the service's heap floor creeps up over days. The retained set
* is dominated by objects reachable from Thread instances.
*
* They are the same bug. Find it, fix it, and then answer the design
* question: what would remove this class of bug rather than this instance.
*
* TASKS
* 1. Run it. Which request saw the wrong tenant, and where did that value
* come from?
* 2. Find the request that made it possible. It is not the one that saw
* the wrong data.
* 3. Fix it so no request can observe another's context.
* 4. In a comment: the pool has one thread here for reproducibility. Does a
* pool of fifty threads fix this, reduce it, or just make it rarer?
*/
public class Starter {
/** Set per request. Read by every layer below. */
static final ThreadLocal<String> TENANT = new ThreadLocal<>();
/** Stands in for whatever the request actually retained. */
static final ThreadLocal<byte[]> REQUEST_BUFFER = new ThreadLocal<>();
record Report(String forTenant, String servedTenant) {
boolean leaked() {
return !forTenant.equals(servedTenant);
}
}
/**
* A layer far below the filter. It does the right thing: it does not
* take the tenant as a parameter, it reads it from the context.
*/
static String currentTenant() {
String tenant = TENANT.get();
return tenant == null ? "<none>" : tenant;
}
/**
* DEFECT: the context is set here and cleared at the end — but only when
* the request completes normally. The clear is not in a finally block,
* so a request that throws leaves its tenant behind on a pooled thread
* that will never die.
*/
static Report handleRequest(String tenant, boolean willFail) {
TENANT.set(tenant);
REQUEST_BUFFER.set(new byte[512 * 1024]);
if (willFail) {
throw new IllegalStateException("downstream timeout for " + tenant);
}
Report report = new Report(tenant, currentTenant());
TENANT.remove();
REQUEST_BUFFER.remove();
return report;
}
/**
* An internal health check. It has no tenant — that is correct and
* intended — and it reads the context only to label its audit line.
*
* This is the request that gets hurt, and it does nothing wrong.
*/
static Report handleInternalRequest() {
return new Report("<none>", currentTenant());
}
public static void main(String[] args) throws Exception {
// One thread, so the interleaving is deterministic. In production it
// is fifty, which only changes how often you hit it.
ExecutorService pool = Executors.newFixedThreadPool(1);
List<Report> served = new ArrayList<>();
System.out.println("── four requests, one pooled thread ──");
// Request 1: tenant-a, and it fails downstream.
try {
pool.submit(() -> handleRequest("tenant-a", true)).get();
} catch (ExecutionException expected) {
System.out.println(" 1 tenant-a failed: " + expected.getCause().getMessage());
}
// Request 2: the health check. No tenant of its own. Reads the
// context exactly as every other layer does.
Report internal = pool.submit(Starter::handleInternalRequest).get();
served.add(internal);
System.out.println(" 2 internal attributed to: " + internal.servedTenant()
+ (internal.leaked() ? " <-- it has no tenant" : ""));
// Request 3: tenant-c, completes normally.
Report third = pool.submit(() -> handleRequest("tenant-c", false)).get();
served.add(third);
System.out.println(" 3 tenant-c served: " + third.servedTenant());
// Request 4: tenant-d, fails too.
try {
pool.submit(() -> handleRequest("tenant-d", true)).get();
} catch (ExecutionException expected) {
System.out.println(" 4 tenant-d failed: " + expected.getCause().getMessage());
}
// What is still attached to the pooled thread once everything is done?
String strandedTenant = pool.submit(TENANT::get).get();
byte[] strandedBuffer = pool.submit(REQUEST_BUFFER::get).get();
pool.shutdown();
System.out.println();
boolean noLeakAcrossRequests = served.stream().noneMatch(Report::leaked);
boolean threadLeftClean = strandedTenant == null && strandedBuffer == null;
System.out.println("stranded on the thread : tenant=" + strandedTenant
+ ", buffer=" + (strandedBuffer == null ? "none"
: (strandedBuffer.length / 1024) + " KB"));
System.out.println();
System.out.println("no request saw another's tenant : " + noLeakAcrossRequests);
System.out.println("thread left clean for the pool : " + threadLeftClean);
System.out.println(noLeakAcrossRequests && threadLeftClean ? "PASS" : "FAIL");
}
}Run it locally:
cd exercises/java/jvm/memory-leaks/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out StarterHints
Hint 1
Which request in the run has no tenant of its own? Why does that make it the one that gets hurt?
Hint 2
Look at where remove() sits relative to the throw. Which path reaches it?
Hint 3
A request that sets its own tenant overwrites the stale value before reading it. Does that make it safe, or just quiet?
Hint 4
For the design question: what is different about a virtual thread's lifetime, and what does ScopedValue do that a ThreadLocal cannot?
Done when
- The internal request is attributed to <none>, not to a previous tenant
- Nothing is left attached to the pooled thread after the run
- The fix survives the request that throws
- A comment explains why a pool of fifty threads would not have fixed this
- A comment names what would remove the class of bug
Solution
Show the solution — try it yourself first
Solution.javaOpen in playground
import java.util.*;
import java.util.concurrent.*;
/**
* Solution: the tenant that leaked across requests.
*
* The gateway puts the caller's tenant into a ThreadLocal at the start of a
* request so that every layer below can read it without threading it through
* forty signatures. A filter clears it at the end.
*
* Two tickets, and only the first was taken seriously:
*
* 1. SECURITY: a customer of tenant-b saw a report belonging to tenant-a.
* Intermittent, only under load, never reproducible locally. The report
* code is correct and reads the tenant from the context, as designed.
*
* 2. MEMORY: the service's heap floor creeps up over days. The retained set
* is dominated by objects reachable from Thread instances.
*
* They are the same bug. Find it, fix it, and then answer the design
* question: what would remove this class of bug rather than this instance.
*
* WHAT WAS WRONG
* The two tickets were one bug. remove() sat at the end of the happy path
* instead of in a finally, so any request that threw left its tenant and
* its 512 KB buffer attached to a pooled thread.
*
* The memory ticket is the obvious consequence: pooled threads never die,
* so the value is retained for the life of the application, once per
* thread that ever failed.
*
* The security ticket is the same fact seen from the other side. The next
* request to land on that thread inherits the stale context. A request
* that SETS a tenant hides this by overwriting it, which is why it looked
* intermittent — only a request with no tenant of its own, like the health
* check here, actually reads the leftover.
*
* WHAT WOULD REMOVE THE CLASS OF BUG
* Virtual threads are not pooled: the thread really does die after its
* task, so the entry goes with it. ScopedValue (Java 21+) is the intended
* replacement for this pattern outright — the value is bound for the
* duration of a call and unbound when it returns, with no remove() to
* forget. Where neither is available, the filter that sets the context
* should be the only code that can, and it should use try/finally once
* rather than trusting every handler to.
*/
public class Solution {
/** Set per request. Read by every layer below. */
static final ThreadLocal<String> TENANT = new ThreadLocal<>();
/** Stands in for whatever the request actually retained. */
static final ThreadLocal<byte[]> REQUEST_BUFFER = new ThreadLocal<>();
record Report(String forTenant, String servedTenant) {
boolean leaked() {
return !forTenant.equals(servedTenant);
}
}
/**
* A layer far below the filter. It does the right thing: it does not
* take the tenant as a parameter, it reads it from the context.
*/
static String currentTenant() {
String tenant = TENANT.get();
return tenant == null ? "<none>" : tenant;
}
/**
* FIX: remove() in a finally.
*
* The clear has to run on the path that threw, because that is the only
* path where it was ever going to matter. A ThreadLocal is cleaned up
* when its thread dies, and a pooled thread is built not to die — so
* "the next request will overwrite it" is not a cleanup strategy. It is
* also not true for a request that has no tenant of its own.
*/
static Report handleRequest(String tenant, boolean willFail) {
TENANT.set(tenant);
REQUEST_BUFFER.set(new byte[512 * 1024]);
try {
if (willFail) {
throw new IllegalStateException("downstream timeout for " + tenant);
}
return new Report(tenant, currentTenant());
} finally {
TENANT.remove();
REQUEST_BUFFER.remove();
}
}
/**
* An internal health check. It has no tenant — that is correct and
* intended — and it reads the context only to label its audit line.
*
* This is the request that gets hurt, and it does nothing wrong.
*/
static Report handleInternalRequest() {
return new Report("<none>", currentTenant());
}
public static void main(String[] args) throws Exception {
// One thread, so the interleaving is deterministic. In production it
// is fifty, which only changes how often you hit it.
ExecutorService pool = Executors.newFixedThreadPool(1);
List<Report> served = new ArrayList<>();
System.out.println("── four requests, one pooled thread ──");
// Request 1: tenant-a, and it fails downstream.
try {
pool.submit(() -> handleRequest("tenant-a", true)).get();
} catch (ExecutionException expected) {
System.out.println(" 1 tenant-a failed: " + expected.getCause().getMessage());
}
// Request 2: the health check. No tenant of its own. Reads the
// context exactly as every other layer does.
Report internal = pool.submit(Solution::handleInternalRequest).get();
served.add(internal);
System.out.println(" 2 internal attributed to: " + internal.servedTenant()
+ (internal.leaked() ? " <-- it has no tenant" : ""));
// Request 3: tenant-c, completes normally.
Report third = pool.submit(() -> handleRequest("tenant-c", false)).get();
served.add(third);
System.out.println(" 3 tenant-c served: " + third.servedTenant());
// Request 4: tenant-d, fails too.
try {
pool.submit(() -> handleRequest("tenant-d", true)).get();
} catch (ExecutionException expected) {
System.out.println(" 4 tenant-d failed: " + expected.getCause().getMessage());
}
// What is still attached to the pooled thread once everything is done?
String strandedTenant = pool.submit(TENANT::get).get();
byte[] strandedBuffer = pool.submit(REQUEST_BUFFER::get).get();
pool.shutdown();
System.out.println();
boolean noLeakAcrossRequests = served.stream().noneMatch(Report::leaked);
boolean threadLeftClean = strandedTenant == null && strandedBuffer == null;
System.out.println("stranded on the thread : tenant=" + strandedTenant
+ ", buffer=" + (strandedBuffer == null ? "none"
: (strandedBuffer.length / 1024) + " KB"));
System.out.println();
System.out.println("no request saw another's tenant : " + noLeakAcrossRequests);
System.out.println("thread left clean for the pool : " + threadLeftClean);
System.out.println(noLeakAcrossRequests && threadLeftClean ? "PASS" : "FAIL");
}
}Stretch
Move the set/remove out of the handler and into a single filter that wraps
every request, so a handler cannot forget. Then write the test that would
have caught the original bug — it has to involve a request that throws and
a following request that reads without writing, which is a shape most
test suites never produce by accident.
← Back to How do you get a memory leak in a garbage-collected language?