In a layer whose cache outlives one unit of work, why invalidate a row's entry both before the write and after the commit?
answer
- two instants, not one
- the pre-image is correct until commit
- a miss during the write repopulates
- rollback would publish a value that never existed
- removal is safe in both outcomes
basics
~20 sRemoving before the write stops further hits on a copy about to become wrong; removing again after the commit drops the pre-image that a concurrent reader reloaded and put back during the write. Populate only after a real commit.
solid answer
~40 sBetween the write and the commit the old value is still the correct answer for everyone else, so a reader that misses in that window loads the pre-image and repopulates the entry with it. That copy then survives the commit forever. The first removal stops further hits on a copy you are about to falsify; the second removes whatever in-flight readers put back. You also never write the new value into the cache before the commit: if the transaction rolls back, the cache is left publishing a value no committed state ever had, and nothing will correct it. Prefer removal to replacement, because removal is safe under both commit and rollback. A narrow race still survives, so a bounded maximum entry age is the backstop.
code
pseudocode · 7 linesbegin()
invalidate(cache, key(Order, 42)) // 1: before the write
update(Order, 42, status = 'CLOSED') // 2: rows change, not yet visible
commit() // 3: rows become visible
invalidate(cache, key(Order, 42)) // 4: drop anything reloaded in between
// populate(cache, key(Order, 42), value) only ever after step 3go deeper
Remember the order as a phrase: remove before the write, remove again after the commit, and never put a value in before the transaction has actually committed.
Explain the window: during the write the old value is still correct for other readers, so a miss reloads it and puts it back. That repopulated copy is what the second removal exists to delete.
Point out where the hook must be attached in real code — the outermost commit, not method exit and not flush — and name the backstop, a bounded entry age, for the race that the ordering cannot close.
Weigh whether the residual window is acceptable per dataset, and when to pay for version-stamped entries or a shared copy instead of accepting milliseconds of possible wrongness.
## The window an ordering has to close When a transaction changes a row that a longer-lived cache holds a copy of, three things happen at different instants: the write hits the engine, the transaction commits, and the cached copy is dealt with. Between the first two instants the row's new value exists but is not visible to anyone else; after the second it is visible to everyone. Any reader that misses the cache during that period loads the **pre-image** — the old, still-committed value, which is the correct answer for that reader at that moment — and, because it missed, it will usually put that value back into the cache. That is the whole problem. A reader repopulating the entry with a value that is correct *now* leaves an entry that becomes wrong the instant the commit lands, and nothing in the commit touches caches. ## Why one removal is not enough Take the two single-removal orderings in turn: - **Remove only before the write.** From that point readers go to the database, which is good, but the pre-image is still what the database returns until the commit. The first miss repopulates the entry with the old value and it survives the commit indefinitely. - **Remove only after the commit.** Every reader between the write and the commit is served from the entry you have not touched yet — and, worse, a reader that misses in that window repopulates with the pre-image, and can do so *after* your removal if its read started before it. Removing twice closes the ordinary case: the first removal stops further hits on a copy you are about to falsify, and the second removes whatever the in-flight readers put back. ## Why you must not publish before the commit The temptation is to skip the invalidation entirely and write the new value straight into the entry, since you know what it is. Do not: the transaction may still roll back — on a constraint violation, on a deadlock victim, on an application error three lines later, on a lost connection. If it does, the database returns to the old state and the cache is left holding a value **no committed state ever had**. Nothing will correct it, because a rollback notifies nobody. That is worse than staleness: a stale entry at least matches some past reality, and a dirty one matches none. The rule is therefore one-directional — **remove before, remove after, populate only after a real commit**. | Ordering | Behaviour after a commit | Behaviour after a rollback | |---|---|---| | Populate the entry with the new value before commit | correct only if nothing else committed in between | serves a value that never existed | | Remove only before the write | may serve the pre-image indefinitely | correct (entry simply reloads) | | Remove only after the commit | serves the old value during the write, and can be raced | correct | | Remove before, remove again after commit | correct in the ordinary case | correct | ## The race that survives Double removal narrows the window; it does not prove anything. A reader can load the pre-image before your second removal and be descheduled long enough to write it back afterwards. The window is now microseconds to milliseconds wide instead of unbounded, but it is not zero, and no purely local ordering closes it. The usual answers are to give entries a bounded maximum age so a lost race self-heals within a known time, to stamp entries with the row's version and refuse to overwrite a newer stamp with an older one, or to accept the window explicitly for data where a brief wrong value is tolerable. Which of those you choose is a judgment about the data, not a property of the ordering. ## What this means in code you review 1. The invalidation must be tied to the **commit**, not to the end of the method that did the write, and not to the flush that sent the statements. A flush is not a commit; statements can be sent and still rolled back. 2. If the write happens inside a wider transaction started by a caller, the entry must be dealt with when *that* transaction commits, not when the inner method returns. 3. A failed transaction must still leave the entry removed rather than repopulated — removal is safe in both outcomes, which is exactly why the rule prefers removal over replacement. 4. Anything that must happen only after a genuine commit — a message published, a downstream call — belongs on the same hook for the same reason.
- Why prefer removing the entry over writing the new value into it after the commit?Removal is correct under every outcome, including ones you did not model, and it costs one later miss. Replacement asserts a value that may already be out of date if another transaction committed in between, and it turns any mistake in the ordering into a published wrong value rather than a reload.
- Is it enough to invalidate when the flush sends the statements?No. A flush only sends statements; the transaction can still roll back afterwards, so the rows may never change. The hook must fire on the commit itself, and on the outermost transaction if the write is nested inside a caller's transaction.
- What race survives even with both removals in place?A reader can load the pre-image before your second removal and be delayed long enough to write it back afterwards. The window shrinks from unbounded to milliseconds. Closing it further needs a bounded maximum entry age so a lost race self-heals, or version-stamped entries that refuse an older stamp.
saying these in an interview costs you the question
- Says one invalidation after commit is always sufficient
- Writes the new value into the cache before committing
- Treats the flush as the commit point for invalidation
- Thinks a rollback also rolls back cache writes
- Claims double invalidation eliminates the race entirely
- Invalidates in the inner method while an outer transaction is still open