Scenario Lab
Real production situations mapped to your actual experience. Read the scenario, think through your answer, then reveal the expert framing — including the exact vocabulary and follow-up questions an interviewer will use.
Family 1 — Thread Pool Saturation & Upstream API Timeout
P95 latency jumps from 200ms to 28s. You're on-call.
It's 2am. Your alerting fires — P95 response time has spiked from 200ms to 28s. Error rate is low (under 2%). CPU is normal. Memory is normal. Your service fetches data from an upstream API per request and processes it in a thread pool. What do you do in the next 15 minutes, and what's your hypothesis?
- Why is latency high but error rate low? What does that tell you about the failure mode?
- What metrics would you check first — and in what order?
- The thread pool is involved. What does "saturation" look like in metrics?
- Why would CPU and memory be normal during this incident?
Expert Answer
Hypothesis: thread pool saturation caused by upstream latency increase. Low error rate + high latency = requests are completing, just slowly. CPU normal = threads are blocking (waiting on I/O), not computing. This is the signature of pool saturation from a slow dependency.
Diagnosis in order:
- 1. Check upstream response time — your APM (Dynatrace) will show outbound HTTP call duration. If upstream P95 jumped from 50ms to 25s, you have your cause.
- 2. Check thread pool metrics — active threads, queue size, pool utilization. If active = max and queue > 0, the pool is saturated. New requests are queuing behind existing ones waiting for the upstream.
- 3. Check rejection rate — if queue is also full, new tasks are being rejected. This is when error rate starts climbing.
Why CPU is normal: Blocked threads (waiting on network I/O) consume no CPU. 100 threads all waiting on HTTP responses = 0% CPU but 100% pool utilization. This is the key insight: saturation ≠ high CPU.
Immediate mitigation: If you can't fix upstream — reduce connection-timeout on the HTTP client so threads fail fast instead of waiting 30s each. This sacrifices some requests to free the pool for others.
Vocabulary to use
thread pool saturation blocking I/O pool utilization queue depth fail-fast timeout upstream dependency degradationInterviewer follow-ups
- "Your circuit breaker was configured but didn't help. Why?" → Because the upstream was slow, not failing. Circuit breakers open on error rate or slow call rate — if you only configured
failureRateThresholdand notslowCallRateThreshold, slow responses never triggered it. - "Why is slow worse than down?" → A down upstream fails fast — threads are freed immediately. A slow upstream holds threads for the full timeout duration. 100 threads × 28s timeout = the pool stays saturated for 28 seconds per wave. Down fails in milliseconds.
- "How would you size the thread pool to handle this gracefully?" → Pool size should reflect your upstream SLA, not your traffic volume. If upstream p99 is 500ms and you want 200 concurrent requests: pool = 200 × (500ms / 1000ms) = 100 threads. Adding more threads just delays the saturation point.
Your Story Hook
Circuit breaker is configured. Upstream is slow. It never opens. Your tech lead wants to know why.
Your Resilience4j circuit breaker has failureRateThreshold=50. The upstream API is responding in 25–30 seconds but returning HTTP 200. After 10 minutes of degradation, the circuit breaker has never opened. Your tech lead asks you to explain why and propose a fix.
- What does
failureRateThresholdactually measure — errors, or slowness? - What config would make the circuit breaker aware of slow calls?
- What's the difference between a "failure" and a "slow call" in Resilience4j's model?
Expert Answer
Root cause: failureRateThreshold only counts calls that throw an exception or return a configured failure result. HTTP 200 with a 28s response time is not a "failure" — it's a successful slow call. The circuit breaker has no evidence anything is wrong.
Fix — add slow call configuration:
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
// NEW: treat calls over 2s as "slow"
.slowCallDurationThreshold(Duration.ofSeconds(2))
// NEW: open circuit when 80% of calls are slow
.slowCallRateThreshold(80)
.slidingWindowSize(20)
.build();Now: when 80% of the last 20 calls take over 2 seconds, the circuit opens — even though the upstream is returning 200 OK. Callers get a fast failure (fallback or error) instead of waiting 28 seconds each.
The deeper insight: A circuit breaker without slow call configuration is blind to the most common production failure mode. Downstream services rarely crash — they degrade. Slow = the new down.
Vocabulary to use
slowCallRateThreshold slowCallDurationThreshold sliding window open / half-open / closed states fast failure fallbackInterviewer follow-ups
- "What happens when the circuit is open?" → Calls fail immediately with CallNotPermittedException — no network call made. Your fallback handles it (cached result, degraded response, or error). This is the "fast failure" that protects your thread pool.
- "How does the circuit know when the upstream has recovered?" → HALF_OPEN state: after
waitDurationInOpenState, a limited number of probe calls are allowed. If they succeed (under slowCallDurationThreshold), circuit closes. If they're still slow, it re-opens. - "You eventually resolved this by getting a better API from the upstream team. What made the new API better?" → This is your real story — the interviewer wants to hear what "better" meant technically (async callback, bulk fetch, reduced payload, dedicated SLA).
Your Story Hook
slowCallRateThreshold and slowCallDurationThreshold as a short-term fix. But the real solution was working with the upstream team to provide an API that met our latency requirements — because no amount of circuit breaker tuning fixes a fundamentally slow dependency."
"Just increase the thread pool size." Why this makes it worse.
Your PM and a backend colleague both suggest: "If the thread pool is full, just make it bigger — add more threads so more tasks can run in parallel." You need to explain why this is the wrong solution and what the right approach is.
- What is the actual resource being exhausted — threads, or the upstream's capacity?
- If you add more threads, what happens to the upstream?
- What does each thread cost in terms of JVM resources?
- What's the right abstraction for "I want to limit how many concurrent upstream calls I make"?
Expert Answer
Why adding threads makes it worse:
- Each thread costs ~1MB of stack memory. 500 threads = 500MB just in stacks, before any heap allocation.
- More threads = more concurrent requests to the upstream. If the upstream is slow because it's overloaded, sending it 2x more requests makes it slower. You're amplifying the problem.
- OS context-switching overhead increases with thread count. Beyond the CPU core count, threads compete for CPU time and performance degrades non-linearly.
- The saturation point moves but doesn't disappear. You've bought time, not a fix.
The right solutions:
- Bulkhead pattern — limit concurrent calls to the upstream to X (e.g. 20), reject or queue others. Protects the upstream and gives you a predictable failure mode instead of cascading degradation.
- Timeout + fast fail — set aggressive timeouts on the upstream call. Free threads quickly so the pool can serve new requests, at the cost of some failures.
- Fix the upstream — the real answer. Adding threads is treating the symptom, not the cause.
// Bulkhead: max 20 concurrent calls to upstream BulkheadConfig config = BulkheadConfig.custom() .maxConcurrentCalls(20) .maxWaitDuration(Duration.ofMillis(100)) .build();
Vocabulary to use
bulkhead pattern thread stack memory (~1MB) context switching overhead cascading failure back-pressure treating symptoms vs root causeInterviewer follow-ups
- "What's the difference between a thread-pool bulkhead and a semaphore bulkhead in Resilience4j?" → Thread-pool: dedicated executor, caller's thread is freed immediately (async). Semaphore: limits concurrent callers using permits, caller's thread is blocked. For synchronous upstream calls, semaphore bulkhead is simpler. Thread-pool bulkhead is better for async/reactive.
- "You mentioned Virtual Threads (Project Loom) — would they have solved this?" → Partially. Virtual threads are cheap to block, so you can have thousands without the 1MB stack overhead. But they don't solve the upstream overload problem — you'd still be sending too many requests to a struggling upstream. Bulkhead is still needed.
Your Story Hook
Family 2 — Concurrent Task State & Distributed Race Conditions
Some workflows are generating duplicate task batches. Intermittent. Hard to reproduce.
Your workflow engine processes tasks in batches. When all tasks in a batch complete, the service generates the next batch. In production you notice some workflows have two copies of the next batch — duplicate tasks running. It happens maybe 1 in 50 batch completions, only under load, and never in staging. Describe the race condition and how you'd fix it.
- Why does it only happen under load and not in staging?
- What is the check-then-act sequence that two instances could both execute?
- What makes "is this the last task?" a race condition?
- What's the smallest atomic operation that would prevent this?
Expert Answer
The race condition — check-then-act on task count:
- Batch has 10 tasks. Tasks 1–8 complete. Two instances (A and B) both pick task 9 and task 10 simultaneously.
- Instance A completes task 9. Counts remaining = 1. "Not the last — do nothing."
- Instance B completes task 10. Counts remaining = 0. "Last task — generate next batch!" ✓
- But also: Instance A completes task 9 and counts remaining = 1 at the same time Instance B completes task 10 and counts remaining = 1 (stale read). Both see "1 remaining after mine" → both think they're the second-to-last → neither generates the next batch. Silent failure.
- Or both read 0 remaining at the same moment → both generate the next batch. Duplicate.
Why only under load: The race window is milliseconds — two tasks completing at almost exactly the same time. Under low load, tasks complete sequentially. Under high load with multiple instances, the overlap probability increases.
Fix — atomic "last task" check using DB update:
-- Atomic: decrement and check in one statement UPDATE batch SET remaining_tasks = remaining_tasks - 1 WHERE batch_id = :batchId RETURNING remaining_tasks; -- Only the instance that sees remaining_tasks = 0 -- in the RETURNING result generates the next batch. -- DB row-level lock on UPDATE ensures only one wins.
The key: the check and the decrement are a single atomic DB operation. Only one instance gets remaining_tasks = 0 back — the one whose UPDATE brought it to zero.
Vocabulary to use
check-then-act race condition TOCTOU (time-of-check time-of-use) atomic decrement row-level lock compare-and-swap stale readInterviewer follow-ups
- "You also added a DB-based distributed lease lock for task picking. Walk me through how it works." → This is your exact implementation:
locked_by(instance name) +locked_at(timestamp). Pick =UPDATE task SET locked_by=:instance, locked_at=NOW() WHERE id=:id AND locked_by IS NULL. Scheduler resets stale locks:locked_at < NOW() - INTERVAL timeout. The WHERE condition makes it atomic — only one instance can win the UPDATE. - "What happens if an instance crashes while holding a task lock?" → The scheduler's stale lock cleanup handles it. After
timeout(e.g. 5 minutes), the lock is automatically released and another instance can pick the task. This is whylocked_atexists — to detect abandoned locks. - "Why not use Redis for the distributed lock instead of the DB?" → Redis Redlock is faster but adds infrastructure complexity and has subtle failure modes (clock drift, network partition). For task picking where the task state already lives in the DB, a DB-native lock is simpler, consistent with the task's own transaction, and one fewer system to fail.
Your Story Hook
A junior dev cached workflow progress. You spot a bug in the PR.
You're reviewing a PR. The developer added a cache to avoid repeated DB reads of workflow progress — total tasks, completed tasks, current status. The cache key is workflowId. TTL is 60 seconds. The intent is performance. What's wrong with this and what do you tell them?
- Workflow progress changes constantly as tasks complete. What does caching it for 60 seconds mean?
- In a multi-instance deployment, which instance's cache has the "true" value?
- What kind of data IS safe to cache for a workflow?
- What's the general rule about what belongs in cache?
Expert Answer
The bug: Workflow progress is mutable state — it changes every time a task completes. Caching it creates stale reads:
- Instance A caches workflow W progress: 8/10 complete.
- Instance B completes task 9 → updates DB → W is now 9/10.
- Instance A reads cache → still sees 8/10 → makes decisions based on stale data (e.g., doesn't trigger the "almost done" notification).
- With 60s TTL and fast task processing, this window is wide enough to cause real bugs.
Your actual experience: This is exactly the cache bug you fixed — mutable workflow state cached across instances leads to wrong decisions. You removed this cache and kept only static workflow definition data (the workflow template, step definitions, configuration).
The rule to tell the junior dev:
- Cache: static / immutable data — workflow definitions, configuration, lookup tables, user profiles that rarely change.
- Don't cache: mutable operational state — task counts, completion status, anything that multiple writers can change concurrently.
- If you must cache mutable data: use write-through invalidation (invalidate on every write, not TTL expiry), and accept that cross-instance consistency requires a shared cache (Redis), not local cache (Caffeine).
Vocabulary to use
cache coherence stale read mutable vs immutable data write-through invalidation local cache vs distributed cache TTL expiry vs event-driven invalidationInterviewer follow-ups
- "What data in a workflow system IS safe to cache?" → The workflow definition/template: step sequence, task types, timeout config, metadata. This is written once at design time and read many times during execution. Perfect cache candidate — high read/write ratio, immutable during execution.
- "You replaced mutable cache with DB reads. Didn't that hurt performance?" → Yes, slightly — but correctness > performance. For mutable state, you can optimize DB reads (covering indexes, read replicas) without introducing cache coherence bugs. The right answer is to make the DB read fast, not to introduce a stale cache.
Your Story Hook
Tech lead asks: "What if the instance crashes while holding a task lock?"
You've explained your DB-based distributed lease lock (locked_by + locked_at columns, scheduler resets stale locks). Your tech lead pushes back: "What happens to the task if the instance crashes after picking it but before completing it? And what's the worst-case recovery time?"
- What state is the task in after a crash? Is it in a safe state?
- What determines how long before another instance can pick it up?
- Is the task idempotent? What happens if it gets re-executed?
- How does this compare to alternatives like Redis locks or ShedLock?
Expert Answer
What happens on crash: The task remains with locked_by = 'instance-A' and locked_at = T. Instance A is gone. No one releases the lock. The task is stuck — invisible to other instances because locked_by IS NOT NULL.
Recovery mechanism — the scheduler: A separate scheduler job runs periodically (e.g., every 30 seconds) and resets stale locks:
@Scheduled(fixedDelay = 30_000) public void resetStaleLocks() { taskRepo.resetLocksOlderThan( Instant.now().minus(lockTimeout) ); // UPDATE task SET locked_by=NULL, locked_at=NULL // WHERE locked_by IS NOT NULL // AND locked_at < NOW() - INTERVAL '5 minutes' }
Worst-case recovery time = lockTimeout + schedulerInterval. If lockTimeout = 5 min and scheduler runs every 30s, a crashed instance's tasks are freed within 5m30s.
The idempotency question: When the task is re-picked and re-executed, is it safe? If the task was partially complete (e.g., wrote to DB, then crashed before marking done), re-execution may duplicate work. This is why task execution must be idempotent — designed so re-running it produces the same result. This is a critical design constraint your tech lead is probing for.
Why this is still a good design vs Redis: The task state lives in the DB. The lock lives in the DB. They're in the same transaction boundary. A Redis lock would add a second system to fail — you could lose the Redis lock without the DB knowing, or vice versa.
Vocabulary to use
lease / heartbeat lock lock timeout stale lock recovery idempotent execution at-least-once processing ShedLock (industry equivalent)Interviewer follow-ups
- "How do you choose the lockTimeout value?" → It must be longer than the maximum legitimate task execution time, with headroom. If tasks normally complete in under 2 minutes, set timeout to 5-10 minutes. Too short = healthy instances lose their locks mid-execution. Too long = stuck tasks block workflows for too long after a crash.
- "Are you aware of ShedLock? How does it compare to what you built?" → ShedLock does exactly this pattern — DB-backed distributed lock with a scheduler-based cleanup, configurable timeout. It's a well-tested library version of what we implemented. We built it ourselves because we needed it integrated with our specific task entity, not as a standalone lock. In hindsight, ShedLock would have been a good starting point.
Your Story Hook
Family 3 — Service Right-sizing & Performance Profiling
Walk me through exactly how you right-sized your k8s service — from start to 30% reduction.
An interviewer asks you to describe your right-sizing process in detail. They want to understand: what you measured, what tools you used, how you found the saturation point, what you changed, and how you validated the result without breaking production.
- What was the starting point — how over-provisioned were you, and how did you know?
- What does "saturation point" mean concretely — the metric that tells you you've hit it?
- What did Dynatrace tell you that you couldn't see from k8s metrics alone?
- What did you actually change — JVM flags, pool sizes, thread counts, k8s limits?
Expert Answer — Full Story Structure
Step 1 — Establish baseline (what you had): Check current k8s resource requests/limits vs actual usage. If your pod requests 4GB RAM and Dynatrace shows it never exceeds 2.5GB under peak load, you're wasting 1.5GB per pod × N pods. K8s requests directly affect scheduling and cluster cost.
Step 2 — Instrument with meaningful metrics: k8s metrics (CPU millicores, memory bytes) show consumption but not why. Dynatrace custom metrics add the application layer: thread pool utilization, GC frequency and pause time, connection pool wait time, heap usage breakdown (old gen vs young gen). These tell you what's driving the resource usage.
Step 3 — Find the saturation point via load test: Gradually increase load (requests/sec) while watching: latency P95/P99, error rate, thread pool utilization, GC pause duration, heap usage. The saturation point is where latency starts climbing non-linearly — that's the ceiling of your current config. Beyond it, adding more load causes disproportionate degradation.
Step 4 — Identify the actual bottleneck:
- If CPU spikes first → compute-bound (algorithmic work, JSON parsing, serialization)
- If heap fills / GC pauses spike first → memory pressure, object allocation rate too high
- If thread pool saturates first → blocking I/O (your case), need bulkhead or async
- If connection pool wait time spikes first → DB connection pool too small or queries too slow
Step 5 — Tune, then right-size: Fix the bottleneck first (connection pool size, JVM heap tuning, thread pool sizing). Then re-run the load test. The new saturation point is higher. Now set k8s resources based on the real peak usage at your target throughput, with a safety buffer (~20%).
Step 6 — Validate in production: Deploy to a canary pod with reduced resources. Compare metrics against non-canary pods under the same traffic. If latency and error rate are identical, roll out. Monitor for a week before locking in the new limits.
Vocabulary to use
resource requests vs limits saturation point load testing GC pause time heap pressure canary deployment JVM ergonomics P95/P99 latencyInterviewer follow-ups
- "What was the actual bottleneck you found — what drove the 30% reduction?" → This is where your specific answer lives. Common answers: heap was over-allocated (JVM uses up to -Xmx but actual live set was much smaller), thread count was too high (each thread = ~1MB stack, 200 threads = 200MB), connection pool too large (each idle connection holds memory on both sides).
- "How did you set the JVM heap size for a container?" → Use
-XX:MaxRAMPercentage=75instead of a fixed-Xmx. This makes heap a percentage of the container's memory limit — it scales correctly when you change the k8s memory limit without updating the JVM flag. Fixed-Xmxin containers causes OOMKill when the container limit is lower than Xmx. - "What's the risk of setting k8s requests too low?" → Pod can be scheduled on a node without enough resources. Under load, it competes with other pods for CPU (throttling) or gets OOMKilled. Requests should reflect the steady-state usage, not the absolute minimum.
Your Story Hook
After reducing resources 30%, the service OOMs during a traffic spike. What went wrong?
Three weeks after your successful right-sizing, a flash sale causes 3x normal traffic. Two pods OOMKill in sequence. The surviving pods handle the load fine. Post-mortem: what went wrong in your sizing process, and how do you prevent it next time?
- You sized based on "normal" load — but what load profile should you size for?
- Why did only 2 pods OOM and not all of them?
- What's the difference between k8s resource requests and limits — which one caused the OOMKill?
- What should you have done differently in the load test?
Expert Answer
What went wrong — 3 likely causes:
- 1. Load test didn't cover spike scenarios. You tested steady-state load and found the saturation point at normal traffic. But 3x spike = different memory pressure: more concurrent requests = more heap objects alive simultaneously. The saturation point at 3x is higher than at 1x.
- 2. Memory limit set too close to steady-state usage. If steady-state heap is 1.2GB and you set memory limit to 1.5GB (25% buffer), a spike that needs 1.8GB hits the limit → OOMKill. Rule: memory limit should be 50%+ above the p99 usage under peak load, not steady-state.
- 3. No HPA (Horizontal Pod Autoscaler) configured. At 3x traffic, the right response is more pods, not bigger pods. HPA scales out pods when CPU or custom metrics exceed threshold. Without it, the fixed pod count must absorb all spike traffic.
Why only 2 pods OOMed: Load balancing isn't perfectly even. Two pods received disproportionate traffic (hot pods), hit their memory limit first. Surviving pods had slightly less traffic and stayed under the limit.
Prevention:
- Load test at 2x and 3x expected peak, not just normal load
- Set memory limit with a generous buffer above p99 spike usage
- Configure HPA to scale out on CPU% or custom throughput metric
- Set a PodDisruptionBudget so at most N pods can be unavailable at once
Vocabulary to use
OOMKill memory limit vs request HPA (Horizontal Pod Autoscaler) spike load vs steady-state PodDisruptionBudget safety bufferInterviewer follow-ups
- "What's the difference between memory request and memory limit in k8s?" → Request: guaranteed reservation — k8s won't schedule the pod on a node without this much free memory. Limit: hard ceiling — if the container exceeds this, it's OOMKilled immediately. A pod running at its limit has no headroom for spikes.
- "How would you configure HPA for this service?" → HPA based on CPU% is simple but lags (CPU climbs after the spike hits). Better: HPA on a custom metric like requests-per-second or thread-pool-utilization via Prometheus adapter. This scales out proactively before the pods are saturated.
Your Story Hook
Your manager asks: document the right-sizing process for the other 5 services.
After your success, your manager asks you to write a runbook so other teams can repeat the right-sizing process for their services. What are the key steps, decisions, and guardrails you'd include? This is also exactly the kind of "take ownership" question interviewers ask.
- What are the steps that must happen in order — what can't you skip?
- What are the failure modes if someone skips a step?
- What are the guardrails — how do you prevent a team from making the service worse?
- How does this differ between a stateless API service vs a stateful task-processing service?
Expert Answer — Runbook Structure
1. Instrument before you touch anything
Add metrics if they don't exist: heap usage, GC pause time, thread pool utilization, connection pool wait time, P95/P99 latency. You cannot right-size what you cannot measure. Dynatrace auto-instruments JVM and HTTP — add custom metrics for business-relevant pools.
2. Establish a production baseline
Record: avg/p95/p99 CPU and memory over 7 days (cover weekday + weekend patterns). Note the peak usage windows. This is your "do not go below" floor.
3. Load test to find saturation
Run at: 1x (normal), 1.5x, 2x, 3x expected peak. Record where P95 latency starts climbing non-linearly. That's the saturation point. Use realistic traffic — same request mix as production, not just synthetic pings.
4. Identify the primary bottleneck
At saturation, which metric spikes first? CPU → compute-bound. Heap/GC → memory-bound. Thread pool → I/O-bound. Connection pool → DB-bound. Fix the bottleneck before reducing resources.
5. Set new limits with safety margins
Memory limit = p99 peak usage × 1.5 (50% headroom). CPU limit = p99 peak × 1.3. k8s request = steady-state average × 1.2. Never set limit = request — that eliminates burst headroom.
6. Canary validate
Deploy one pod with new resources. Compare its metrics against unchanged pods under same traffic for 24–48h. No latency regression → safe to roll out.
7. Monitor post-rollout for 1 week
Watch for GC pressure increase, latency p99 drift, OOMKill events. Set alerts at 80% of memory limit.
Vocabulary to use
runbook baseline canary validation safety margin / headroom request vs limit load profileInterviewer follow-ups
- "How does right-sizing differ for a stateful service vs a stateless API?" → Stateful (your task processor): must account for in-flight task state in heap, cannot be terminated mid-task without data loss, needs graceful shutdown. Stateless: can be scaled/terminated freely, right-sizing is simpler. Stateful services need more conservative memory limits and must handle SIGTERM gracefully.
- "What would you automate in this process?" → The baseline collection and load test execution can be scripted (k6 or Gatling scripts parameterized by service name). The canary deployment can be automated via a pipeline stage. Manual steps: identifying the bottleneck type and setting the safety margins — those require human judgment.
Your Story Hook
Family 4 — Kafka Bridge & Event Pipeline Design
Your back-pressure controller uses two thresholds. Your colleague says one is enough. How do you defend it?
Your Kafka consumer has a back-pressure controller that only slows intake when both conditions are true: thread pool utilization > 80% AND task queue saturation > 85%. Your colleague argues this is over-engineered — "just check the queue size." You need to explain why the two-threshold design is correct.
- What does a full queue with idle threads actually mean? Is that real back-pressure?
- What does a busy thread pool with an empty queue mean?
- What failure mode does the two-threshold design prevent that a single-threshold design misses?
- What happens to upstream events if back-pressure incorrectly fires when the system is actually fine?
Expert Answer
Why one threshold is wrong — the two scenarios it misreads:
- Full queue + idle threads: Queue depth is high, but threads are all waiting on I/O (e.g. enrichment API calls). The threads will drain the queue soon — no real back-pressure needed. A single queue-size check fires and incorrectly slows intake, creating unnecessary lag.
- Busy threads + empty queue: All threads are actively processing, queue is low. The system is healthy — no back-pressure needed. A single thread-utilization check might fire based on momentary CPU spike. False positive.
Why both conditions together are meaningful: When both the queue is saturated AND threads are fully utilized, the system genuinely cannot absorb more work. Every new event arriving will either be rejected or compound the existing saturation. That is the only state where slowing intake is the right response.
Implementation — what back-pressure actually does: The back-pressure controller throws a retryable SubscribeException instead of accepting the batch. The event stays unacknowledged. The scheduler picks it up on the next tick — no message loss, just a pause. The system recovers naturally as threads finish their current work.
if (threadPoolUtil > 0.80 && queueSaturation > 0.85) { throw new SubscribeException("back-pressure: system saturated"); // event stays in Kafka, scheduler retries next tick }
The principle: back-pressure should fire on genuine saturation, not on noisy individual metrics. Combining signals reduces false positives, which is important — false back-pressure creates unnecessary lag and makes the system appear slower than it is.
Vocabulary to use
back-pressure two-threshold design false positive retryable exception queue saturation thread pool utilization signal correlationInterviewer follow-ups
- "What happens to the event that triggered back-pressure — is it lost?" → No. The consumer throws a retryable exception without committing the offset. The event stays at the same Kafka offset. On the next scheduler tick, the consumer tries again. At-least-once delivery is preserved.
- "How do you tune the thresholds — 80% and 85% are arbitrary?" → They're starting points validated under load testing. Too low → frequent false back-pressure, unnecessary lag. Too high → back-pressure fires too late, queue already overflowing. Tune by running load tests, observing where saturation actually begins, and setting thresholds with a 15–20% margin before that point.
- "Is back-pressure the same as a circuit breaker?" → No. Circuit breaker protects against a degraded downstream dependency — it opens when the downstream fails or is slow. Back-pressure protects against overwhelming your own processing capacity. They solve different problems and you may need both: circuit breaker on enrichment API calls, back-pressure on the consumer's own worker pool.
Your Story Hook
A tenant reports records are missing after the initial load. Walk me through your diagnosis.
A tenant opens a support ticket: "After the initial data load completed, 3 specific records are missing from the downstream system. The load lifecycle event showed completed." Your service is the Kafka-to-Kafka bridge that orchestrated the load. Where do you look, and in what order?
- The lifecycle event says "completed" — does that prove records reached the downstream system?
- At how many points in the pipeline could a record go missing?
- How do you distinguish "TDOS didn't publish it" from "consumer didn't process it"?
- What does the dead-letter topic tell you that consumer lag doesn't?
Expert Answer — 6-Step Pipeline Walk
Rule: never assume the bug is in your component. Eliminate each layer with evidence.
Step 1 — Confirm the scope. Is it exactly these 3 records? Only this resource type (e.g. only objectives)? Only this tenant? Narrowing scope tells you whether it's a systemic bug or a record-specific edge case (e.g. malformed data on those specific records).
Step 2 — Verify the lifecycle event. A completed lifecycle event means TDOS finished the orchestration — it does not guarantee every record reached the downstream consumer. Check the lifecycle topic for this tenant's loadId: was completed published, or failed? If failed, the problem is in TDOS orchestration.
Step 3 — Check the dataload topic (bridge output). Query the dataload topic for this tenant and loadId. Count published events. Compare against the record count from the source API for this resource type. If event count < API record count → TDOS dropped records during publishing. If counts match → issue is downstream.
Step 4 — Check consumer lag. If TDOS published all records, check whether the downstream consumer has processed them all. Lag > 0 means it's still catching up — not a bug, just slow. Wait and recheck.
Step 5 — Check the dead-letter topic. If lag is 0 but records are missing, check the DLT for the downstream consumer group during the load window. DLT messages mean the consumer received the events but failed to process them (e.g. schema validation error, DB constraint violation). The DLT entry includes the exception and original event — diagnose from there.
Step 6 — Check the source API directly. Fetch the 3 missing records from the source API and compare with what TDOS published. If TDOS published different data — enrichment bug or race condition (record changed between the API call and the event publish). If the source API also shows different data — the upstream system is the root cause.
Vocabulary to use
pipeline walk dead-letter topic consumer lag lifecycle event correlation ID source of truth event count vs record countInterviewer follow-ups
- "How do you query Kafka for specific events in production?" → We use a CLI tool (
qadebquery) that filters by topic, event type, tenant ID, and time window. The constraint is a 1-hour query window per invocation — for a 7-day replay you'd need to script 168 sequential queries. - "What if the source API returns the record but TDOS didn't publish it — how do you detect that?" → Compare: total records paginated from the source API (can be fetched by re-running the initial load query in dry-run mode) vs. event count on the dataload topic for that tenant and loadId. A mismatch narrows the bug to TDOS's publish step specifically.
- "How do you use the correlation ID in this diagnosis?" → Every event carries
xsapsfcorrelationid. For a specific missing record, you can search logs across all hops — inbound event receipt, API call, outbound publish — using the same correlation ID. Without it you're correlating timestamps manually across three systems.
Your Story Hook
"Why at-least-once and not exactly-once? Aren't duplicates dangerous?"
Your tech lead reviews the system design and asks: "Kafka supports exactly-once semantics (EOS). You chose at-least-once. Duplicates seem risky in a data sync pipeline — why not use EOS?" You need to explain the trade-off and defend the choice.
- What does "exactly-once" in Kafka actually guarantee — and what does it NOT cover?
- What makes a consumer safe to receive duplicate events?
- How does
sequentialValueprotect consumers from acting on stale replays? - What's the operational cost of EOS vs at-least-once?
Expert Answer
What EOS actually guarantees — and its limits: Kafka's exactly-once semantics (transactional producer + idempotent consumer) guarantees that a message is written to a Kafka topic exactly once end-to-end within the Kafka cluster. It does NOT guarantee exactly-once processing at the consumer's external side effects — if your consumer writes to a database or calls an API, those external calls are outside Kafka's transaction scope. A consumer crash after writing to the DB but before committing the Kafka offset will re-process the event and call the API again. EOS is Kafka-to-Kafka, not Kafka-to-world.
Why at-least-once is sufficient here: All consumers are designed to be idempotent at the application level. The downstream upsert operation is INSERT OR UPDATE ON CONFLICT DO UPDATE — replaying an event produces the same state as processing it once. At-least-once with idempotent consumers is functionally equivalent to exactly-once, at a fraction of the complexity.
How sequentialValue handles stale replays: Every event carries a monotonically increasing sequentialValue scoped to [tenantImmutableId, resourceType, recordId]. Consumers track the last seen value per stream. If a replay delivers an event with a lower sequentialValue than already processed, it is discarded — no side effects, no duplicate write. This is application-level deduplication on top of at-least-once.
// Consumer checks before processing: long lastSeen = stateStore.get(event.sequentialKeys()); if (event.sequentialValue() <= lastSeen) { result.skip(event); // stale replay — discard return; } // process and update lastSeen
The cost of EOS you avoid: Transactional producers require a transactional.id, two-phase commit coordination with the broker, and a 20–30% throughput penalty. For a high-volume event pipeline, this is a significant trade-off for a guarantee that doesn't even cover your real risk (external side effects).
Vocabulary to use
at-least-once delivery idempotent consumer exactly-once semantics (EOS) sequentialValue application-level deduplication transactional producer EOS scope (Kafka-to-Kafka only)Interviewer follow-ups
- "What if a consumer is NOT idempotent — e.g. it sends an email on each event?" → Then at-least-once is dangerous and you need deduplication logic before the side effect. Options: deduplicate by event ID in a DB/Redis before sending, or use the outbox pattern (persist the intent transactionally, then send exactly once). EOS alone still doesn't help — it doesn't stop the email send from being repeated after a crash.
- "What does sequentialValue protect against that Kafka partition ordering already provides?" → Kafka ordering holds within a partition under normal conditions, but breaks during: consumer rebalances (a partition reassigned to a new instance replays from its last committed offset), retries (an event retried out of order), and manual replays. sequentialValue is application-level insurance for those edge cases.
- "How do you choose between exactly-once and at-least-once in a new system?" → Default to at-least-once + idempotent consumers. It's simpler, higher throughput, and sufficient for most use cases. Reach for EOS only when: (1) you cannot make consumers idempotent, AND (2) duplicates have real business consequences (e.g. financial transactions). Even then, EOS won't protect you at the external side-effect boundary.
Your Story Hook
A junior asks: "We already have tenantId — why do we need immutableTenantId as the partition key?"
You're reviewing your multi-tenant Kafka architecture with a new team member. The partition key is immutableTenantId (a UUID), not tenantId (the human-readable company identifier). They ask: "Both identify the same tenant. Why not use the readable one — it would make debugging easier?" Explain the decision.
- What happens to partition assignment if the partition key changes for a tenant?
- When does a company's
tenantIdactually change in an enterprise SaaS product? - What breaks for events published before the rename vs after, if you use the mutable key?
- Beyond partition keys — where else does the immutable ID matter for correctness?
Expert Answer
The problem with mutable partition keys — three failure modes:
- 1. Partition ordering breaks on rename. Kafka assigns partitions by
hash(key) % numPartitions. IftenantIdchanges fromACME_CORPtoGLOBEX_INC, historical events are on partition X (hashed fromACME_CORP) and new events land on partition Y (hashed fromGLOBEX_INC). Consumers reading partition Y never see the history on partition X. Ordering guarantee is broken — new events appear without prior context. - 2. Consumer state is split across partitions. If a consumer tracks
lastSeenSequentialValueper tenant, the rename creates two separate state entries. The consumer can't tell they're the same tenant. Deduplication logic fails. Potential double-processing of events that arrive under the old ID during a migration window. - 3. Cross-system correlation breaks over time. Systems store the
tenantIdthey received at event time. After a rename, joining historical records from system A (old name) with current records from system B (new name) requires knowing the rename history. WithimmutableTenantId, the join key is always valid.
When tenantId actually changes in enterprise SaaS:
- Company rebranding (common — happens every few years)
- Mergers & acquisitions (two tenant IDs merge into one)
- Administrative corrections (typos in the original provisioning)
The design principle: any identifier used as a partition key, join key, or ordering anchor must be stable for the lifetime of the data it identifies. Human-readable identifiers are convenient but inherently mutable. Use a surrogate key (UUID assigned at creation, never changed) for technical stability, and preserve the human-readable identifier for display and filtering only.
Why keep both: tenantId is still valuable for logging, debugging, and UI display — "which tenant is this?" is easier to answer with ACME_CORP than a UUID. The pairing of both identifiers in every event gives you both stability and readability.
Vocabulary to use
immutable surrogate key partition stability mutable vs immutable identifier partition key = hash(key) % partitions tenant rename / M&A ordering guarantee cross-system correlationInterviewer follow-ups
- "Could you use a database row ID as the immutable tenant identifier instead of a UUID?" → Yes — any value that is assigned once and never changes works. UUID is preferred because it's generation-independent (no central counter needed), globally unique across systems, and doesn't leak information about insertion order or total tenant count.
- "You said M&A merges two tenants. How would that work at the Kafka level?" → The two tenants had separate
immutableTenantIdvalues and separate partition assignments. After merge, you'd typically: publish a lifecycle event signaling the merge, remap one immutable ID to the other in consuming systems, and run a re-sync to consolidate data. The partition split in historical events is a known migration cost — that's why the merge event model needs to be designed upfront, not retrofitted. - "What other things in your system use immutableTenantId for the same reason?" → sequentialKeys for ordering streams, audit log join keys, data-layer row isolation (foreign key to the immutable ID), correlation across different SF modules that each have their own representation of the tenant. Anywhere you need a stable reference across time, you use the immutable ID.