skip to content

Why does an open-addressed cache with a flat entry count show p95 lookup latency climbing over days?

level: seniorimportance: should knowfreq 38%

answer

  1. Size is flat, but what else grew?
  2. Deletes leave something behind
  3. Hits or misses — which slows first?
  4. The resize trigger counts the wrong thing
  5. Live plus dead slots is the real load

basics

~20 s

Tombstones accumulate. Each delete leaves a marker that every probe must step over, so paths lengthen while the live count stays flat — and a load-factor test that counts only live entries never trips a rebuild, so the dead space is never reclaimed.

solid answer

~50 s

This is the signature of a delete-heavy workload on an open-addressed table: an order cache where each order is inserted, looked up, then removed keeps its live count steady around, say, 200k, while every removal converts a slot from occupied to tombstoned. Non-empty slots only ever grow, so probe paths lengthen day after day. Misses degrade first and hardest, because a successful lookup stops at its key while an unsuccessful one must run to the first truly empty slot. The trap is that the resize trigger compares live entries to capacity and therefore never fires. Two things fix it: make the load-factor test count non-empty slots (live plus tombstones), and rebuild by reinserting live entries only into a fresh slot array — which drops every tombstone as a side effect, and can reuse the same capacity since the live count never grew.

go deeper

for a junior

Know that removing an entry from an open-addressed table does not give the slot back, and that a table can slow down even while the number of stored items stays constant.

for a middle

Explain the arithmetic: non-empty slots only ever grow, probe cost tracks the non-empty fraction, and a rebuild that reinserts live entries drops the tombstones for free.

for a senior

Show the investigation, not just the answer: the occupancy split and probe-length histogram you would ask for, the hit-versus-miss latency split that confirms it, and the fixed threshold you would ship.

for a principal

Own the observability gap behind it — a structure whose internal occupancy is unmeasurable will always cost days of investigation. Decide whether compaction is scheduled, incremental or a signal to change the structure for this workload.

## The workload that produces this shape A payments order cache: each order is inserted when it arrives, read a handful of times while it is in flight, then deleted when it settles. Inserts and deletes run at roughly equal rates, so the number of live entries hovers around a fixed value and every capacity dashboard looks calm. Over days, p95 lookup latency drifts upward with no deploy, no traffic change and no growth in stored entries. ## The mechanism In an open-addressed table, deletion cannot free the slot — clearing it would cut the probe path for any key that collided with the deleted one. So delete writes a **tombstone**: a marker meaning "occupied once, keep probing". The arithmetic that follows is unforgiving: - Insert of a new key on a path with no tombstone consumes one EMPTY slot. - Delete turns OCCUPIED into DELETED: non-empty count unchanged. - Reuse of a tombstone turns DELETED back into OCCUPIED: non-empty count unchanged. So **the number of non-empty slots only ever grows**, no matter how balanced the insert/delete rates are. Tombstone reuse helps, but only opportunistically — it recycles a tombstone only when some future key's probe path happens to cross it. Under churn with a wide key space, most tombstones lie on paths nothing revisits. Probe length is governed by the non-empty fraction, because a step over a tombstone costs the same slot read and state test as a step over a live entry. At 20% live occupancy but 90% non-empty occupancy, the table performs like a table that is 90% full. ## Why misses degrade first A successful lookup terminates the instant it matches its key, frequently within one or two probes. An unsuccessful lookup has no early exit: it must reach the first EMPTY slot to conclude the key is absent. As EMPTY slots grow scarce, miss cost rises far faster than hit cost. This is diagnostic gold — split your latency metric by hit and miss, and a widening gap points straight at the dead space rather than at anything upstream. ## Confirming it rather than guessing Latency drifting with flat volume invites the usual suspects — garbage collection, a noisy neighbour, a slow downstream call — and teams burn days there. Two measurements settle it quickly: 1. **Occupancy split**: live entries, tombstones and empty slots as fractions of capacity. Live at 0.3 with non-empty at 0.85 is conclusive. 2. **Probe-length histogram**: average and p99 probes per operation, ideally separated by hit and miss. A mean drifting from 1.4 to 9 over a week is the same story told in the units that matter. Both are cheap counters. Their absence is the actual root cause of most long investigations here — the table's internal state was never observable. ## The fix **Change what the load-factor test counts.** Compare non-empty slots to capacity, not live entries to capacity. That single change makes this table trip its rebuild threshold in hours rather than never. **Rebuild by reinserting live entries only.** Allocate a fresh slot array, walk the old one, and re-insert every OCCUPIED entry by probing it into the new array from its home slot. Tombstones are simply not carried across — they were only ever a repair for the *old* layout's probe paths, and the new layout's paths are built correctly from scratch. Cleanup is therefore free: it is not a separate compaction pass, it is a property of rebuilding. **Capacity is a separate decision from cleanup.** Because the live count never grew, the rebuild can legitimately target the same capacity — the goal is to compact, not to grow. Rebuilding into a larger array when only tombstones were the problem wastes memory permanently and postpones rather than solves the next occurrence. ## Operating it A rebuild is a bulk operation, so on a latency-sensitive path it has to be scheduled rather than triggered blindly at p99 time: run it on a maintenance tick, at a low-traffic window, or migrate a fixed number of slots per operation into the new array while reads consult both. Where the workload's deletes are extreme and pauses are unacceptable at all, the deeper answer may be that the structure is wrong for the workload rather than that the threshold is wrong. ## What interviewers listen for The tell is whether the candidate distinguishes *live size* from *space consumed*. Almost everyone knows tombstones exist; the senior signal is saying "the size metric is flat because size is the wrong metric — show me the non-empty fraction", naming misses as the first casualty, and then choosing to compact at the same capacity rather than reflexively doubling.

  • Which single metric confirms this diagnosis fastest?
    The occupancy split: live entries, tombstones and empty slots as fractions of capacity. Thirty percent live against eighty-five percent non-empty is conclusive on its own. A probe-length histogram is the strong second, and splitting lookup latency by hit versus miss corroborates it — misses run to the first empty slot, so they degrade first and hardest.
  • Why did this table never resize on its own?
    Because its growth test compared live entries to capacity, and balanced churn keeps live entries flat forever. Tombstones are invisible to that test even though they cost a probe step each. Changing the test to compare non-empty slots against capacity makes the same table trip its threshold within hours, with no other code change.
  • When the rebuild runs, which entries move and at what capacity?
    Live entries only. Each is re-probed into a fresh slot array from its home slot; tombstones are not carried over, since they only patched the old layout's probe paths. Because the live count never grew, the rebuild can reuse the same capacity — the point is to compact the dead space, not to buy more memory.
  • What if the service cannot afford a bulk rebuild pause on this path?
    Migrate incrementally: allocate the new array and move a fixed number of slots per operation, serving reads from both arrays until the old one is drained, so the cost is spread instead of spiked. Scheduling the rebuild on a maintenance tick or a low-traffic window is the simpler option when the workload has one. Enabling tombstone reuse on insert slows the decay but does not remove the need.

saying these in an interview costs you the question

  • Blames the entry count that never actually grew
  • Says deletes free their slot immediately
  • Assumes only a bigger table can fix it
  • Computes load factor from live entries only
  • Chases garbage collection before checking probe lengths
  • Thinks hits and misses degrade at the same rate

context