Index

Integration Without Illusions

A Cache Must Distinguish Empty from Unavailable

A cached search returns two pickup locations. One location is deleted locally, but the search keeps returning both.

The tempting repair is a shorter time to live. That trades a known correctness bug for a shorter known correctness bug. The system already knows the cached answer is false; waiting five minutes instead of an hour does not make it less false.

The opposite failure looks similar. A remote lookup returns nothing during an outage. Replacing the cache with an empty list would remove locations which may still exist.

Both paths produce “no location,” but they carry different knowledge. A cache must distinguish known invalidity, authoritative emptiness, and unavailable truth.

Freshness starts with what changed

A time to live answers one question:

How long may this entry survive without another event?

It does not answer:

  • what must happen after a local fact changes;
  • whether an empty response is a valid result;
  • whether a failed refresh should erase the previous result;
  • how long stale data may survive an outage; or
  • which cached queries contain a changed record.

Those are consistency decisions. Treating all of them as expiration loses the reason the cache should change.

The retained implementation stores search results as durable enquiry rows. Each row contains a list of locations. Deleting one location therefore invalidates more than a cache key derived from that location’s ID. Every stored enquiry containing its code may now be wrong.

The correction searches enquiries for that embedded code and deletes the matching rows. The next request must fetch a new answer instead of reusing a known false one.

That is dependency invalidation:

location L-2 deleted
        |
        v
find cached enquiries containing L-2
        |
        v
remove those complete cached answers

Removing only location:L-2 would not repair search:country:postal-code, because the stale location is copied inside that result.

Known false and possibly stale need different policies

Local deletion is strong evidence. The application has accepted a state change and knows which cached answers depend on it. Invalidate them.

A remote transport failure provides no replacement fact. A timeout does not mean there are zero locations. Neither does a connection refusal or an unparseable response.

Overwriting a useful answer with an empty list on those failures turns a dependency outage into a customer-visible data outage:

previous answer: [L-1, L-2]
refresh outcome: remote unavailable
bad replacement: []
honest result:    retain [L-1, L-2], record refresh failure

Serving stale data is not automatically safe. An old price, access decision, or revocation list may be worse than no answer. The policy depends on the decision the cache feeds.

For pickup locations, the retained design prefers the last known list during a temporary provider outage. That choice preserves availability, but it carries a clear risk: a removed location may remain visible until a successful refresh. The system accepts that risk only where no stronger local fact says the entry is already wrong.

Empty is data only when the read was authoritative

There is a third case: the remote system responds successfully and the correct answer really is an empty list.

If ordinary lookup code converts both connection failure and valid emptiness to [], its caller cannot tell these states apart:

[] because no locations exist
[] because coordinate lookup failed
[] because the location provider timed out

The retained repair introduces an authoritative refresh path. Live lookups keep their availability behavior. A scheduled repair asks for a stricter contract: transport and upstream failures must escape as failures, while a successful empty response may replace old data.

The distinction is semantic, not merely a flag:

OutcomeWhat is knownCache action
local deletiona cached member is falseinvalidate every dependent answer
successful non-empty refreshnew remote truth is availablereplace with the new list
successful authoritative empty refreshthe current list is emptyreplace with an empty list
timeout, connection failure, or invalid responsecurrent remote truth is unknownretain last-known-good data and record failure

An interface for authoritative reads can make the stronger promise visible:

lookup(...) -> may degrade expected remote failures to []
authoritativeLookup(...) -> returns a valid answer or throws

The first method is suitable only where callers knowingly accept ambiguity. The second is required when an empty result is allowed to delete previously known data.

Refresh cadence is not a freshness guarantee

The retained repair schedules authoritative refreshes nightly. That creates a nominal detection interval for remote changes; it does not guarantee that every entry is at most one day old.

A run can fail. The dependency can remain unavailable for several nights. A single-server, non-overlapping schedule prevents duplicate refresh runs, but it does not make the remote system answer.

Actual staleness is closer to:

time since last successful authoritative refresh

not:

time since the scheduler last started

Persist both outcomes separately. last_refresh_attempt_at explains whether the mechanism ran. last_successful_refresh_at tells consumers how old the known data is. A failure marker and next eligible attempt make the operational state visible.

The retained artifacts prove a nightly repair path, preservation on provider failure, and replacement after an authoritative empty result. They do not retain a published maximum-staleness promise, alert threshold, or measured refresh success rate.

Invalidation can have its own delay

The query which finds every cached enquiry containing a deleted location was recorded at roughly eight seconds without a suitable JSON index. Running it in the request path made deletion wait, so the work moved to a background job.

That avoids blocking the request, but it changes the consistency boundary. Between local deletion and completion of the cleanup job, a reader may still see the stale cached answer. A failed job can extend that window indefinitely.

There are several legitimate responses:

  • add an index and invalidate synchronously;
  • mark the location tombstoned and filter it at read time until cleanup finishes;
  • commit deletion and an invalidation record atomically, then process it asynchronously;
  • accept a bounded stale window and monitor cleanup age; or
  • redesign the cache so results reference locations instead of embedding copies.

The right choice depends on deletion frequency, query cost, tolerated staleness, and the harm of serving a removed value. “We dispatch a job” is an execution detail, not a consistency guarantee.

Write the policy as a state table

A useful cache review begins with sources of knowledge, not a default TTL:

TriggerConfidenceMay replace data?May retain old data?Required follow-up
known local mutationauthoritative local factinvalidate affected answersnocomplete or track invalidation
valid remote responseauthoritative for that readyesonly by explicit product policyrecord successful refresh
remote failureno new factnoif stale-on-error is saferetry and expose age
ambiguous empty resultunknownnoyesuse a stronger read contract
age limit exceededpolicy boundaryperhaps remove or degradeonly if explicitly allowedalert, block, or refresh

This table makes reversal conditions reviewable. If a stale pickup location starts causing costly failed handovers, the stale-on-error choice may need a short hard limit. If remote outages are common and false emptiness blocks every order, preserving old data may remain the safer failure mode.

The public reproduction exercises local invalidation, successful replacement, authoritative emptiness, and unavailable refresh against synthetic entries. It does not measure a database query, scheduler, provider, or production staleness.

A cache is not correct because it eventually expires. It is correct when every reason for changing or retaining an answer has an explicit source of knowledge. Delete what is known to be false, keep old data only when uncertainty is safer, and never let an overloaded empty list decide between the two.