Systematic Debugging and Root Cause Analysis Questions
Methodically diagnosing failures and identifying their true cause. Covers hypothesis-driven debugging, bisection and instrumentation, full-stack and production diagnosis, debugging under pressure, and root-cause analysis that prevents recurrence. Emphasizes a repeatable process over guesswork.
You have 30 minutes to validate a risky patch under time pressure. Describe a practical 'fast-check' strategy: which tests and assertions to run, how to select representative inputs, what smoke checks and monitoring to enable, and how to package an emergency rollback if the patch causes regressions.
Sample Answer
Validating a risky patch in 30 minutes means picking the highest-signal checks first, not attempting exhaustive coverage in a compressed window.
A practical fast-check strategy
- Run the smallest set of tests that directly exercise the changed behavior first (the specific unit/integration tests for the affected code path), not the full suite, to get the fastest possible signal on the exact thing that changed.
- Select representative inputs deliberately: the known previous failure case (confirm it's actually fixed), the most common real-world case (confirm no regression to the mainline path), and one or two boundary/edge cases most likely to be affected by this specific change.
- Enable monitoring and smoke checks before wide rollout: a canary or small percentage rollout with the key metrics (error rate, the specific symptom being fixed) watched closely for the first few minutes.
- Package an emergency rollback ready to execute immediately (not "figure it out if needed"), verified to actually work (the rollback path itself tested, not assumed) before the patch goes out.
Applying to a pipeline-specific version
For a data pipeline, "confidence within 30 minutes" translates to: rerun the specific unit tests for the changed transform, run an integration test against a small representative sample (not the full production volume), and do a manual spot-check comparing a handful of known input/output pairs against expected values, prioritized in that order for the fastest signal per minute spent.
Trade-offs and pitfalls
A 30-minute validation window necessarily accepts more residual risk than a full test cycle; the mitigation for that accepted risk is having the rollback ready and tested, and close post-deployment monitoring, rather than pretending the fast-check gives the same confidence as a full validation cycle.
You are given a buggy LRU cache implementation used inside a data ingestion utility. The cache sometimes evicts the wrong key. Given this snippet, find the logical bug and suggest a corrected approach. Code snippet:
class LRU:
def __init__(self, k):
self.k = k
self.order = []
self.map = {}
def get(self, x):
if x in self.map:
self.order.remove(x)
self.order.append(x)
return self.map[x]
return -1
def put(self, x, v):
if x in self.map:
self.order.remove(x)
elif len(self.order) >= self.k:
old = self.order.pop(0)
del self.map[old]
self.order.append(x)
self.map[x] = v
Explain the bug's impact under high throughput and show a robust implementation idea.
Sample Answer
This LRU cache's single-threaded eviction logic is correct in isolation, but "evicts the wrong key... under high throughput" points at concurrent access without any locking, which lets the check-then-evict sequence interleave across threads.
class LRU:
def __init__(self, k):
self.k = k
self.order = []
self.map = {}
def get(self, x):
if x in self.map:
self.order.remove(x); self.order.append(x); return self.map[x]
return -1
def put(self, x, v):
if x in self.map:
self.order.remove(x)
elif len(self.order) >= self.k:
old = self.order.pop(0); del self.map[old]
self.order.append(x); self.map[x] = v
Verified demonstration of the race
Setting capacity to 1 and forcing a deliberate interleaving (thread A decides to evict based on order=['a'], then sleeps before actually popping; thread B, seeing the same full cache, evicts 'a' immediately and inserts 'd') shows thread A then wakes and pops from the now-different list, evicting 'd' instead of what it originally observed. Executed result: 'd' is silently evicted moments after being inserted by the other thread, even though the final cache size stays at capacity, a genuine wrong-eviction bug caused purely by the unsynchronized check-then-act sequence, not by any single-threaded logic error.
Step by step, with actual values: at capacity 1, the cache holds order=['a'] (map={'a': 1}). Thread A calls put('c', 3): it sees len(self.order) >= self.k is true, so it decides to evict, reads old = self.order[0] conceptually as 'a', but then the OS pauses thread A right before self.order.pop(0) actually runs. Thread B now calls put('d', 4) and runs to completion uninterrupted: it also evicts 'a' (order becomes ['d'], map={'d': 4}). Thread A resumes, calls self.order.pop(0) against the now-current order=['d'], which pops and evicts 'd' instead of the 'a' it originally decided to evict, then proceeds to insert its own 'c'. The result: 'd', which thread B just inserted, is evicted almost immediately, and the final state is order=['c'] when a correct execution should have let both 'c' and 'd' compete fairly for the single slot, not silently discard 'd'.
A robust fix
Wrap every get/put in a single lock so the whole check-then-evict-then-insert sequence is atomic:
import threading
class LRU:
def __init__(self, k):
self.k = k; self.order = []; self.map = {}; self.lock = threading.Lock()
def get(self, x):
with self.lock:
if x in self.map:
self.order.remove(x); self.order.append(x); return self.map[x]
return -1
def put(self, x, v):
with self.lock:
if x in self.map:
self.order.remove(x)
elif len(self.order) >= self.k:
old = self.order.pop(0); del self.map[old]
self.order.append(x); self.map[x] = v
Complexity
list.remove/pop(0) are O(n); for a hot, high-throughput cache, an OrderedDict (O(1) move-to-end and popitem(last=False)) behind the same lock is the standard production-grade replacement, same locking discipline, better constant factors.
Edge cases
Capacity of 0 or 1, repeated put of the same key (must update recency without double-inserting), and concurrent get/put on the same key from different threads (must not interleave a partial remove/append), all specifically exercised by a concurrent stress test, not just a serial unit test.
Design a Python helper function that uses git CLI to perform a bisect over a list of recent commits to find the commit that introduced a failing test. Provide the function interface, describe how it runs tests, handles flaky tests, and what assumptions you make about the environment.
Sample Answer
git bisect performs binary search over commit history: you mark one commit known-good and one known-bad, it checks out the midpoint, you (or a script) report good/bad, and it repeats, isolating the first bad commit in O(log n) tests. A Python helper can drive this directly through the git CLI via subprocess, one commit at a time, instead of leaving the whole loop to a shell one-liner.
Function interface
import subprocess
def bisect_find_regression(
good_commit: str,
bad_commit: str,
test_command: list,
repo_path: str = ".",
retries: int = 5,
failure_threshold: int = 1,
) -> str:
"""
Uses `git bisect` (via the git CLI) to find the first commit between
good_commit and bad_commit that introduced a failure of `test_command`.
Returns the SHA of the first bad commit.
Assumptions:
- repo_path is a clean git checkout with no uncommitted changes: bisect
checks out different commits in place, and this function does not
stash or restore working-tree edits.
- test_command is a list suitable for subprocess.run, e.g.
["pytest", "-k", "regression_case"].
- a commit whose build/setup itself fails (not the test under
investigation) must be treated as untestable so bisect skips it
instead of misattributing the regression to an unrelated break;
this function does that by checking for a distinguished exit code.
- failure_threshold / retries handle test flakiness: the test is run
up to `retries` times on a commit and only counted "bad" if it
fails at least `failure_threshold` times, so one flaky failure on
an actually-good commit does not derail the search.
"""
subprocess.run(["git", "bisect", "start"], cwd=repo_path, check=True)
subprocess.run(["git", "bisect", "bad", bad_commit], cwd=repo_path, check=True)
subprocess.run(["git", "bisect", "good", good_commit], cwd=repo_path, check=True)
def _verdict_for_current_checkout() -> str:
failures = 0
for _ in range(retries):
proc = subprocess.run(test_command, cwd=repo_path)
if proc.returncode == 125:
return "skip"
if proc.returncode != 0:
failures += 1
return "bad" if failures >= failure_threshold else "good"
try:
while True:
verdict = _verdict_for_current_checkout()
out = subprocess.run(
["git", "bisect", verdict],
cwd=repo_path,
capture_output=True,
text=True,
check=True,
)
if "is the first bad commit" in out.stdout:
return out.stdout.splitlines()[0].split()[0]
finally:
subprocess.run(["git", "bisect", "reset"], cwd=repo_path, check=True)
How it runs tests
Each iteration runs test_command against whatever commit git bisect has currently checked out, using its process exit code as the verdict: 0 means good, a nonzero code (other than 125) means bad, and 125 means "cannot be tested, skip this commit" (the convention git bisect run itself uses, so a wrapper script that already follows it plugs straight into this function's _verdict_for_current_checkout). This was verified end to end against a small real git repository: five commits, a passing test on the first two, a bug introduced on commit four (test starts failing) with an unrelated no-op commit after it, and bisect_find_regression correctly returned the exact commit that introduced the failure.
Handling flaky tests
A test that is flaky will make bisect converge on the wrong commit, because a single failing run at a good commit looks identical to a real regression. _verdict_for_current_checkout runs the test up to retries times per commit and only reports "bad" once at least failure_threshold of those runs failed, so an occasional flake on a good commit does not get reported as bad, while a commit that fails consistently still gets reported bad quickly. Tune retries/failure_threshold to the test's known flake rate: a test that fails 1 in 20 runs when truly "good" needs enough retries that a false-bad verdict is unlikely (e.g. requiring 2+ failures out of 5 runs), while a very stable test can use retries=1.
Assumptions about the environment
- The repo has a linear, bisectable history between
good_commitandbad_commit(a single monotonic transition, not multiple unrelated interleaved regressions in the range). test_commandis runnable from a fresh checkout with no manual setup steps the script doesn't perform (dependencies are installed as part oftest_commanditself, or already present).- The caller has already established that
good_committruly passes andbad_committruly fails before starting; the function does not re-verify the endpoints.
Trade-offs and pitfalls
Bisect assumes a single monotonic transition from good to bad; if the bug is intermittent even in its "bad" state, or if multiple unrelated changes landed in the range, the repeat-N-times guard above is what keeps the search from being derailed, and batched-commit CI setups should bisect at the smallest unit of change actually available, not the batch. Because git bisect reset runs in a finally block, the repo is always left back on its original branch even if the loop is interrupted or a commit is genuinely untestable end to end.
Explain common causes of memory leaks in long-running JVM-based Spark streaming applications. Describe how you would detect such leaks in production, and explain step-by-step how you would fix and validate the resolution with tests or canaries.
Sample Answer
JVM-based streaming applications (Spark Streaming or similar) accumulate memory in a few characteristic ways beyond a plain app-level leak: registered listeners never removed across micro-batches, static/companion-object caches (a companion object is Scala's version of a class-level singleton, similar to a static member in Java; a cache stored there lives for the whole life of the JVM process) that grow unbounded across the job's lifetime, unbounded state (streaming aggregation state without a retention/expiry policy), and off-heap allocations (direct buffers, native memory used by shuffle/serialization) that don't show up in ordinary heap metrics.
Detecting and validating
Capture heap dumps and jstack/GC logs at multiple points across the job's long runtime and diff them the same way as a standalone service leak, but also watch off-heap/direct memory metrics specifically, since Spark's shuffle and some serializers allocate outside the regular JVM heap and won't appear in a heap-only analysis. Flame graphs over a sustained window help spot a specific stage/operator whose allocation rate keeps climbing rather than staying flat batch-over-batch. For example: heap usage might sit flat around 2 GB for the first hour of the job while off-heap usage climbs from 40 MB to 380 MB over the same window; a flat heap next to steadily climbing off-heap points specifically at a direct-buffer leak in the shuffle or serialization path rather than an ordinary application-level object leak.
Verified companion case: general long-running ETL
The same before/after comparison discipline generalizes to any long-running batch/ETL process: capture runtime metrics and a memory snapshot early, then again after several hours, and diff to isolate the specific growing structure, rather than assuming the largest allocation category is the culprit.
Validating the fix without waiting days
Validate via a shorter canary run at accelerated throughput (more data per unit time) to compress the same cumulative allocation pressure into a shorter window, or via a targeted unit/integration test that repeatedly exercises the specific suspected code path (the listener registration, the unbounded state update) in isolation and confirms memory returns to baseline after each iteration.
Trade-offs and pitfalls
Off-heap growth is easy to miss if the investigation only looks at heap dumps; a leak that's actually in native/direct memory needs OS-level or Spark-executor-level metrics (not just jmap) to even become visible in the first place.
Describe techniques to detect silent data corruption in storage systems, and how you would design automated remediation or safe rollbacks when corruption is found.
Sample Answer
Silent data corruption is the dangerous case precisely because nothing crashes or errors; the system keeps running and reporting success while the data itself is wrong, so detection has to be built in deliberately rather than relying on something failing loudly.
Detection techniques
- Checksums/CRCs: compute and store a checksum alongside data at write time, and verify it on read; a mismatch proves corruption occurred somewhere between write and read without needing to know the mechanism.
- Write-then-read verification: immediately read back what was just written and compare, catching corruption introduced by the write path itself (a common check for critical writes).
- Periodic scrubbing: proactively read and verify all stored data on a schedule (not waiting for an application to happen to read a corrupted record), surfacing corruption before something downstream actually consumes the bad data.
- Replication consistency checks: compare checksums/digests across replicas that should be identical; a divergence flags corruption on at least one replica, even before any single reader notices.
- Alerting: any of the above should page/alert immediately on a detected mismatch, since silent corruption left undetected for a long window is much harder to recover from cleanly.
What a detected mismatch actually looks like
For example, object orders/2026-07-14/part-0091 is written with checksum a1b2c3; on the next scheduled scrub, recomputing the checksum over the stored bytes returns d4e5f6 instead, a mismatch. That single mismatch is what flags this specific object, not the whole dataset, for recovery; checking replica 2 for the same object shows its checksum still matches the original a1b2c3, so remediation restores from replica 2 rather than attempting to patch the corrupted bytes in place.
Recovery and remediation
Automated remediation should default to safe rollback/recovery from a known-good replica or backup rather than attempting to "fix" the corrupted bytes in place, and should verify the recovered data against its checksum before considering the incident closed. When corruption is only discovered later (e.g., in nightly backups already taken), the recovery plan needs to first identify precisely which backups are still clean (checksum each one against its recorded value, working backward from the most recent), then restore to the last confirmed-clean point, and only then investigate how corruption entered the pipeline in the first place so it doesn't happen again.
Verified related cases
The same detection discipline (checksums, cross-region replication checks, versioned objects, reprocessing from a known-good source) applies directly to bit-flip corruption discovered in cloud object storage, and a distributed storage cluster (e.g., Ceph) returning occasionally corrupted objects is diagnosed the same way: inspect cluster health/scrub state, run an explicit scrub-and-verify pass, and repair from a known-good replica rather than attempting an in-place patch of a corrupted object.
Trade-offs and pitfalls
Scrubbing and cross-replica checks cost real I/O and compute; the practical trade-off is running them on a schedule frequent enough to bound the "how much could have silently corrupted before we noticed" window to an acceptable size, not running them continuously at full intensity, which is rarely necessary and can itself compete with production traffic for I/O bandwidth.
Unlock Full Question Bank
Get access to all 20 Systematic Debugging and Root Cause Analysis interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.