Observability · Debugging Production

Reading a Diff for the Mechanism It Changed

A bisected revision is a pointer to a change, not a cause. Separating default-value, ordering, and resource-lifecycle differences from cosmetic ones is what turns a pointer into a mechanism.

A bisection over the version range ends with a commit identifier and the commit contains four hundred changed lines across eleven files. The investigation now has a bounded region to read, which is progress, and no statement yet about how the change produces the symptom, which is the actual goal. The gap is closed by reading the diff as a set of behavioural differences and asking, for each one, what observable consequence it would have.

Three categories of change that alter behaviour

Most lines in a large diff do not change what the program does. Renames, comment edits, formatting, test updates, and documentation produce no new observable behaviour, though they can hide one inside the noise. The difficulty is not reading the diff but deciding which of its parts could interact with the symptom.

Changes that alter a value are the first category and the largest source of unremarkable-looking regressions. A default timeout reduced from thirty seconds to five, a retry count raised from two to five, a buffer size halved, a page size changed, a maximum connection count lowered, a batch size increased: none of these look like logic, and each changes behaviour under load in a way that is invisible in a functional test. A timeout reduction turns slow-but-successful responses into errors, which turns a latency problem into an availability problem. A retry-count increase multiplies the load offered to an already-saturated dependency, converting a partial outage into a total one.

Changes that alter ordering are the second. Initialisation order determines which subsystem’s failure aborts the rest; registration order determines which handler wins when two match; the order in which resources are acquired determines which deadlock is possible and which lock-order inversion appears. A diff that moves a startup step after a connection is opened has changed the set of states the process can be in when that connection fails, and the failure will appear as an error message pointing at a component that was not touched.

Changes that alter resource lifetime are the third. A pooled object that is no longer returned to the pool, a cache entry whose eviction predicate lost a clause, a file descriptor whose close was moved inside a branch, a context whose timeout is cancelled earlier than before: these produce gradual degradation rather than immediate failure. Unbounded growth of a call site that runs once per request shows up as memory growth measured in hours, which pushes the onset far from the deploy that caused it and makes the change correlation harder to detect.

What the symptom says about which category applies

The symptom’s shape is evidence, and it narrows the categories before the diff is read. A step change in behaviour at a known moment points at a value change or an ordering change. A gradual change in behaviour over hours points at a lifetime change, with the rate determined by how often the leaked resource is acquired. An intermittent failure that depends on concurrency points at an ordering change in the synchronisation structure, though it can also be produced by a value change that alters the timing window.

The magnitude matters too. If the symptom is proportional to load, the change likely affects a per-request resource or a shared pool whose contention grows with concurrency. If the symptom is independent of load, the change likely affects a path taken once per process, per connection, or per configuration reload, or affects the treatment of an error that occurs at a roughly constant rate.

The onset time relative to the deploy is the third input, and it must be read with care. The deploy finishes when the last host is updated, and old processes may continue serving for minutes or hours afterwards, so measurements that average across both versions smear the boundary. A per-version breakdown, keyed on a value the application reports at startup, is the standard way to make the transition visible, and it is the same trick that makes a canary comparison meaningful.

The mechanism has to differ from the previous behaviour

The strongest tool when reading a diff is the requirement to state the mechanism in a form that predicts a difference from the previous version. “The new parameter is passed to the constructor” is not a mechanism. “The constructor now uses this value as the idle deadline, so a connection that is idle for longer than five seconds is closed by the client while the server still believes it is open, and the next request on that connection fails with a reset” is a mechanism: it names the state, the transition, and the observable consequence, and it can be checked against logs.

That statement is falsifiable and cheap to test. If the hypothesis is right, the failing requests should be the first request issued on a connection after an idle period exceeding the new deadline, and the distribution of idle times before failure should show a boundary near that value. If the boundary is elsewhere, the value is not the operative one, which is itself informative.

-    HoldTimeout: 30 * time.Second,
+    HoldTimeout: 5 * time.Second,
     DialTimeout: 2 * time.Second,

Reading a diff like this one as a numeric change is easy; the work is in asking which component consumes the value, what it compares it against, and what happens to the state when the comparison succeeds.

Configuration is part of the change even when the code is not

Configuration is a larger source of failures than its share of changed lines suggests, and a configuration change is often not in the version history at all. Analyses of failures in large distributed systems have consistently put configuration and operational mistakes among the leading causes, alongside software defects. An operator edits a value in a control plane, a feature flag toggles, an autoscaler changes a limit, a certificate rotates. When the investigation has established an onset time and no code change correlates with it, the configuration and cluster state at that time become the object of study.

The awkward property of these changes is that they are frequently unversioned and unreviewed, so there is no diff to read. Reconstruction has to use the API audit log of the control plane, the timestamps of configuration reload events in the application’s own log, and the effective configuration reported by a running process. The last is the most reliable and the least often recorded: a process that logs its effective limits at startup makes every later investigation cheaper, because the state that produced the incident is captured at the moment it was true rather than inferred afterwards.

Defaults are the subtle case. A change that does not mention a field can still change its value, because the default moved. This is why a dependency upgrade deserves the same treatment as an application change: a library that changes a default timeout, a default buffer size, or a default retry policy changes the behaviour of every caller, and the change is not visible in the application’s diff at all.

From candidate to conclusion

The end state of this reading is a small set of candidate mechanisms, each with a prediction. The verification step is usually cheap once the prediction exists, and it is the point at which the investigation can stop guessing. If the prediction matches, the mechanism is established within the resolution of the evidence; if it does not, the mechanism is eliminated, and eliminating candidates is as valuable as confirming one.

Two failure modes are worth guarding against. The first is accepting a mechanism that explains the symptom without explaining the timing: a change that would produce constant errors cannot explain errors that began at 14:20 and occur in bursts. The second is accepting a mechanism that requires an unusual state without showing that the state was reachable, such as a race that requires two requests to arrive within a window narrower than the observed request spacing.

When the candidates are exhausted without a match, the change is probably not in the diff, and the search should return to establishing onset and correlating changes rather than reading the same lines again. The residue that most often remains is an interaction between two systems, each of which is correct in isolation, a class of fault for which the diff of either one contains no sufficient explanation.

References

  1. Git documentation — git-diff — Git project
  2. Simple Testing Can Prevent Most Critical Failures — Yuan, Luo, Zhuang, Meng, Lee, Bhuta, and Stumm, USENIX OSDI 2014
  3. Site Reliability Engineering — O'Reilly, Google SRE team