Engineering

Two places that both think they are the truth

A durable source of truth and an in-memory one give the same answers until a process dies. Deciding which is authoritative is a design act, not a default.

MobileByteSensei 10 min read
Two independent timekeepers, stopped for two different reasons
kotlindataarchitecture

A value is held in two places: a database that survives the process, and an in-memory holder that serves it quickly. Both are updated on write. Both are read from, by different callers.

They agree for as long as the process lives, which is long enough for nobody to decide which one is authoritative.

The case this post is about

One piece of state with two homes, which agree until something restarts:

durable      the source of truth; every write goes here first
in-memory    a projection of it, never written to directly
reads        may hit the projection freely
writes       have exactly one path, and it leads to the durable store

Before those four lines, the profile was held in a database and in an in-memory holder, and both had setters. Writes went to whichever one the calling code had a reference to.

They matched for months, because most writes happened to go through the same path. Then a background refresh wrote to one and a user edit wrote to the other, the process was killed, and the version that survived was whichever one had reached disk — with nothing in the code expressing which was meant to.

This post follows that one piece of state, because two writable copies is not a cache.

The symptom

Something is correct until the app is killed and wrong afterwards. A preference, a cached selection, a session-scoped flag — it behaved perfectly through every manual test, and on a cold start it has a different value, or no value, or the value from two sessions ago.

The second thing is that the two stores were never in conflict during development, which is why nobody chose. Both are written on every update, both return the same thing, and the code reads whichever was convenient at each call site.

In perfect agreement, with nothing between them saying which is right The architecture appears to work because the only condition that distinguishes the two is one that does not occur while you are watching.

The third is that the failure is not a stale read in the usual sense. A cache that lags behind is a familiar problem with familiar fixes. This is the opposite: the in-memory copy is often the more current one, because it was updated by a path that never reached the database, and the durable store is the one holding the old value — so the instinct to "trust the database" produces a regression.

The fourth is how it presents. Nobody reports "the two stores disagree". They report that a setting resets, or that a list is briefly empty on launch, or that an action has to be performed twice — symptoms that route to the UI, to lifecycle handling, to anything except the question of which copy of the value is the real one.

The routing is not anybody's fault — it follows the symptom, and the symptom genuinely is in the user interface. What makes this class expensive is the distance between where it shows up and where it lives: hours can go into lifecycle handling before somebody asks the question that takes a minute to answer.

Why the obvious fix failed

Write to both, always. What was already happening, and it does not address the question. Two stores updated in the same function still diverge the moment one write fails, one path is added that only touches one of them, or the process dies between the two.

Read from the database everywhere. Correct-sounding and it discards the in-memory copy's purpose, which was to avoid a disk read on a hot path. It also loses the case where the memory copy is the more recent one, converting a confusing bug into a reproducible data loss.

Keep them in sync with an observer. Closer, and it makes the relationship implicit in a way that is hard to reason about later. An observer that mirrors one store into another has an implied direction, and that direction is the decision this post is about — written in a wiring callback where nobody will look for it.

Make the in-memory one a cache of the durable one. This is very nearly the answer, and it only works if it is actually enforced. Called a cache and written to directly by three code paths, it is not a cache; it is a second source of truth with a misleading name, and the name stops anybody asking.

The actual mechanism

Decide, per piece of state, which store is authoritative, and make every other copy derive from it in one direction only.

durable      the source of truth; every write goes here first
in-memory    a projection of it, never written to directly
reads        may hit the projection freely
writes       have exactly one path, and it leads to the durable store

The fourth line is what makes the rest hold. A projection stops being a projection the moment something writes to it directly, and nothing about its type or name prevents that — so the enforcement has to be structural: the in-memory holder exposes no setter, and is updated only by observing the durable store.

A dial with no movement of its own, showing what the master tells it

The sub-case that has to be decided rather than defaulted is state that should not survive a restart. Not everything belongs in the durable store: a scroll position, an in-progress form, a transient selection. Those are legitimately memory-only, and the decision is the same shape — pick one home, write it down, and do not also persist them "just in case", which is how the second copy appears.

There is a related question about what happens at startup, before the projection has been populated. A reader that arrives in that window gets an empty projection and cannot tell it from a genuinely empty store, which is its own small version of this bug — and it is why the projection's initial state is worth modelling explicitly rather than defaulting to empty.

Three states rather than two is the whole of it: not-loaded, loaded-and-empty, loaded-with-content. Collapsing the first two is what produces a screen that flashes an empty-state message on every cold start, and it is the same mistake as the main one in miniature — a distinction that exists in the world and not in the type.

The fix

choose     one authoritative home per piece of state, and record the choice
derive     every other copy from it, one direction, no setters
restrict   writes to a single path that reaches the authority first
model      the not-yet-loaded state as distinct from empty

The database is the authority and everything else is derived from it — which means the in-memory copy has no setter at all, and the fourth line is in the type rather than in a comment:

sealed interface Loaded<out T> {
    data object NotLoaded : Loaded<Nothing>          // distinct from empty, on purpose
    data class Present<T>(val value: T) : Loaded<T>
}

class ProfileRepository(private val dao: ProfileDao, scope: CoroutineScope) {

    // DERIVED. One direction, from the authority. No way to publish into this.
    val profile: StateFlow<Loaded<Profile?>> =
        dao.observe().map { Loaded.Present(it) as Loaded<Profile?> }
            .stateIn(scope, SharingStarted.WhileSubscribed(5_000), Loaded.NotLoaded)

    // The ONLY write path, and it reaches the authority first. The cache updates
    // because the database changed, never because a caller also remembered to.
    suspend fun save(p: Profile) {
        dao.upsert(p)
    }
}

The distinction the fourth line buys, on a cold start:

t=0ms    NotLoaded              -> show a skeleton
t=18ms   Present(null)          -> show "no profile yet", the empty state
t=22ms   Present(Profile(...))  -> show it

Collapsing NotLoaded into null makes the first two frames identical, so the screen flashes its empty state for 18 milliseconds on every launch. That flash is the entire symptom, and it is a type distinction rather than a timing fix.

The first line is the one that survives the longest, because it is the only one that is still true after the code is refactored. A decision recorded next to the state — this is durable, this is memory-only — is the thing a future reader needs, and it is almost never written down because at the time it feels self-evident.

It feels self-evident because at the moment of writing there is only one plausible reading, and the author is holding it. Six months later there are two plausible readings and nobody is holding either, which is the general condition under which architectural decisions have to survive — and the reason a one-line comment outperforms a diagram almost every time.

What makes the enforcement cheap is that it is mostly subtraction. Removing the setter from the in-memory holder is a small change that makes an entire class of bug impossible, and the compiler finds every place that was relying on it.

That list is itself the useful output. Each call site the compiler rejects is a write path somebody added to the projection directly, and reading them tells you why — usually a hot path where the durable write felt too slow, which is a real constraint that deserved a design decision rather than a quiet second writer.

How we proved it

The divergence is reproducible on demand. Update the value through the path that writes only to memory, kill the process, and read again. The old value is there. That takes a minute and it converts a vague "sometimes settings reset" into a specific mechanism.

The newer copy lost on restart, then both moving together

The direction of staleness is the surprising part, and it is worth checking rather than assuming. In the cases I traced, the in-memory copy was more current more often than the durable one, because the convenient write path was the one that did not involve a database call. Anybody debugging this on the assumption that the database is right will be wrong about half the time.

What I cannot tell you is how many values are in this condition across a codebase. Finding them means identifying every piece of state with two homes, which is not something I know how to enumerate mechanically — a durable store and a holder are both ordinary code, and their relationship is a fact about intent rather than about structure.

What I have not solved: the multi-process case. Everything above assumes one process with one in-memory copy. A widget, a background service or a second process has its own memory, observing the same durable store, and "one authority with projections" is still right while the propagation delay becomes real and visible in a way it is not within a single process.

And a limitation worth stating: making the projection read-only pushes every write through the durable store, which is slower. On a hot path that is a real cost, and the honest answer is that you measure it rather than assuming either way — the architecture here is about correctness, and if it turns out to be too slow, the fix is a different design rather than a second writable copy.

The designs that are available when you need them are worth knowing in advance: batching writes, debouncing them, or accepting a bounded window where the durable store lags and saying so explicitly. All three keep a single authority. What none of them is, is a second place that anything may write to.

The thing that reframed it for me was realising this is not a caching problem at all. Caching has a clear authority and a known staleness window; what I had was two peers with no stated relationship, which is a different thing wearing the same shape. Once I stopped reaching for cache vocabulary, the question became obvious — which of these is the truth — and it turned out nobody had ever answered it.

The vocabulary mattered more than I expected. Calling the holder a cache had closed the question: caches are allowed to be stale, so staleness read as normal rather than as a symptom of there being no authority at all. The name was doing the work of a decision nobody made.

What this generalizes to

Two copies of a value with no stated authority will agree until the moment it matters, because the conditions that separate them are exactly the conditions you do not test in.

The shape recurs well past local storage. A configuration held in a file and in environment variables. A user's state in a session and in a token. A feature flag in a service and in a local default. A count maintained incrementally and derivable from the rows. In each case both are correct, both are consulted, and the divergence waits for a restart, a failover, or a deploy.

So the question worth asking of any duplicated state is which one wins, and where is that written down — because if the answer to the second half is "nowhere", then the answer to the first half is "whichever one the next person happens to read".

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS