Which writes escape a version guard, and how do you keep the guard meaningful when such writes are needed?
answer
- the guard covers one write path
- many rows changed, nothing compared
- advancing matters more than checking
- stale holders revert the batch
- touch the parent to protect its rule
basics
~20 sAny statement not emitted for a tracked object - a set-based update, a hand-written fix, another service - neither compares nor advances the version unless it says so. Write the increment into those statements and re-read anything already loaded.
solid answer
~50 sThe guard covers exactly the writes the layer emits for objects it is tracking. A set-based `UPDATE ... WHERE status = 'PENDING'` changes many rows without reading any of them, so it compares no version and, unless the statement itself carries `version = version + 1`, leaves the column where it was - and every reader holding those rows now has a version that matches a row whose contents changed. The same is true of statements written by hand, of another service writing the table, and of engine-side rules. Three habits keep the guard honest: include the increment in every statement that changes guarded rows; run such writes before loading anything, or discard the loaded copies afterwards; and where an invariant lives on a parent but the write touches only children, advance the parent's version deliberately so two concurrent child writes collide.
go deeper
A version only helps for writes that go through the layer for a loaded object. A statement that updates many rows at once compares nothing and, unless it says so, changes no version.
Explain both halves of a set-based write's damage - it is itself unguarded, and it strands other readers on a version that still matches - and give the increment as the fix.
Show the operational symptoms: edits reappearing after a batch, conflict spikes after an import, an invariant that only breaks under concurrency, and the tracked copies you must discard.
Own the rule across systems - every writer advances the version - and judge where a deliberate parent increment is worth the serialisation it buys, versus where it just creates contention.
## What the guard covers, precisely An optimistic version protects a row only through the path that maintains it: the layer emits an update for an object it is tracking, binds the loaded version into the `WHERE` clause, and advances the column in the same statement. Everything outside that path is unguarded by construction. This is not a defect of the mechanism - it is the boundary of it, and knowing where the boundary runs is most of the senior-level content here. ## Set-based writes A statement that changes many rows in one go - archive everything older than a date, reprice a category, flip a status - never loads those rows into memory, so there is nothing to compare against. Two distinct problems follow. 1. **The write itself is unguarded.** It overwrites whatever is there, including an edit someone committed a second ago. That is often exactly what was wanted, but it should be a decision, not an accident. 2. **The column is left stale.** If the statement does not advance the version, a reader who loaded a row before the bulk write still holds a version that matches the row - so their later write passes the guard and quietly reverts the bulk change. The second problem is the nastier one, because it turns a set-based write into a *silent* lost update for anyone who was already holding the row. ## Writers that never touch the layer at all The same reasoning covers a widening circle: - Statements written by hand for a data fix or a report-driven correction. - Another service, a scheduled job, or an import writing the same tables. - Engine-side rules that modify a row as a side effect of another write. - Anything restoring rows from a copy of the data. Each of these can change a row's contents while leaving its version untouched, and each therefore blinds every optimistic reader that was already holding it. ## Keeping the guard honest | Situation | What to do | |---|---| | Set-based update of guarded rows | Include `version = version + 1` in the statement so holders of old versions are invalidated | | Loaded copies exist in the same unit | Run the set-based write first, or drop the tracked copies afterwards and re-read | | A write path outside the application | Give it the same increment rule, or accept and document that it is a break in the guard | | A data fix by hand | Advance the version too; it costs one clause and prevents a phantom success later | The underlying rule is one sentence: **every writer of a guarded row must advance its version, whether or not it checks it.** Checking is optional per writer; advancing is not, because other people's checks depend on it. ## The deliberate increment There is a second, constructive use of the same lever. Sometimes the rule being protected does not live on the row being written. An order's total must not exceed a limit, but the write inserts a line; a folder's item count must stay bounded, but the write adds an item. Two such writes can each be valid against the parent they read and invalid together, and neither one touches the parent row, so no guard fires. The fix is to bring the parent into the write on purpose: read it, and force its version forward as part of the same transaction. Now two concurrent child writes both try to advance the same parent version, one of them finds it moved, and the invariant is protected without locking anything for the duration of a user's editing session. Some layers expose this as a request made when loading the parent; where they do not, changing any field on the parent has the same effect. The cost is deliberate contention: every child write now serialises against every other child write under that parent, which is the point, but also the reason not to apply it to parents whose children are genuinely independent. ## Symptoms in the wild - Edits that "come back" after a batch job runs - a stale holder wrote over it, passing the guard. - Conflict rates that spike right after an import, because the import advanced versions on rows people had open. - An invariant that is violated only under concurrency and cannot be reproduced by hand - usually a rule on a parent that no write ever touches. ## What not to conclude None of this means set-based writes are wrong; they are the right tool for changing many rows and reading none. It means they are a **second write path** with its own contract with the version column. Treat the increment as part of that contract, keep the two paths from overlapping inside one unit of work, and reserve the deliberate parent increment for invariants that genuinely span rows.
- Why is failing to advance the version in a set-based write worse than failing to check one?Because checking protects only that statement, while advancing protects everyone else. A set-based write that skips the check simply wins; one that skips the increment leaves every reader holding a version that still matches a changed row, so their next write passes the guard and reverts the change.
- How does forcing a parent's version forward protect a rule that spans its children?It turns independent child writes into competitors for one version. Each transaction reads the parent, validates the rule, and advances the parent as it writes its child; the second to commit finds the parent's version moved and is rejected, so the two writes cannot both be applied on the same stale view.
- What should happen to objects already loaded when a set-based write changes the same rows?They should be considered worthless and re-read. Their fields no longer match the rows and their versions may or may not, so writing them back either fails confusingly or succeeds and undoes the bulk change. Ordering the bulk write before any loading avoids the question entirely.
saying these in an interview costs you the question
- Believes every write to the table is version-checked automatically
- Runs a set-based update without advancing the version column
- Keeps using tracked copies of rows a bulk statement just changed
- Thinks a rule spanning child rows is protected by versions on the children
- Adds a forced parent increment everywhere, serialising unrelated writes
- Treats an external writer as harmless because it does not read versions