skip to content

In a mapper with a tracked set, why is catching a failed flush and continuing with the same unit unsafe?

level: middleimportance: must knowfreq 57%

answer

  1. changes are recorded, not written
  2. the batch fails, not the line
  3. in-memory copies now describe nothing
  4. unit is condemned at commit
  5. abandon and rebuild, never repair

basics

~20 s

A rejected flush leaves the tracked objects out of step with the stored rows and normally condemns the unit, so anything written afterwards is discarded at commit. The unit has to be abandoned and rebuilt, not repaired in place.

solid answer

~40 s

A flush turns the changes accumulated in the tracked set into statements. When one of them is rejected, part of the batch may already have been sent, generated values and version numbers may be half-applied, and the in-memory objects no longer describe any state the database holds. Most layers also mark the unit **rollback-only** at that point, so every later save is thrown away when the boundary tries to commit - and the caller who swallowed the original failure is handed a rollback error instead. Recovery by carrying on therefore writes nothing while looking like it worked. The correct shape is to let the failure propagate, discard the unit together with its tracked set, and if you want to recover, do it in a fresh unit that reloads what it needs.

go deeper

for a junior

Remember that changing a tracked object only records intent and that the write happens at a flush. If a flush is rejected, the whole unit is finished - nothing you do afterwards inside it is stored.

for a middle

Explain the three consequences: statements already sent, in-memory copies that match no stored row, and a unit marked rollback-only. Then explain why a flush can be triggered by a read.

for a senior

Demonstrate the recovery shape - propagate, discard the unit, decide from the failure kind, and do any compensating write in a fresh unit built from values captured beforehand.

for a principal

Judge where the boundary belongs so that partial-success requirements are met by transaction scoping rather than by catch blocks, and say what that costs in throughput and complexity.

## What a flush is doing when it fails A mapper does not write when you change an object. It records the change in a **tracked set** - the loaded objects it is watching, plus the inserts and deletes registered against it - and turns that into statements later, at a **flush**. The flush orders the statements, sends them, and reads back whatever the engine generates: keys, defaults, version numbers. A failure at that point is therefore not a failure of one line of your method. It is a failure somewhere in a **batch of statements derived from many earlier lines**. ## Three things become true the moment it is rejected 1. **Part of the work may already be in the transaction.** Statements before the rejected one were accepted. The unit is not back where it started. 2. **The tracked set is now fiction.** Some objects carry values the engine accepted, some carry values it refused, and objects waiting on generated keys or bumped versions may hold neither the old value nor a real new one. Nothing in memory describes a state the database holds. 3. **The unit is normally condemned.** Most layers mark the transaction rollback-only when a failure reaches the database, so the boundary will roll back whatever happens next. ## Why the failure arrives late This is what makes the bug hard to read: the line you blame is not the line that failed. | What the code looks like it says | What actually holds | |---|---| | `order.status = PAID` succeeded, so that change is stored | it only recorded intent; no statement has been sent | | the failure came from the call it was thrown by | it came from a flush triggered by that call, carrying every pending change | | the earlier saves in this method are safe | they are in the same unit and share its fate | | catching here contains the damage | the damage is already recorded on the transaction | A flush can be triggered by an explicit request, by a query the layer wants to see current data for, or by the commit itself - so a failure can surface at a line that only reads. ## Carrying on writes nothing The tempting recovery is: catch the failure, skip the bad object, keep saving the rest, report a partial success. Every part of that is wrong. - The later saves join a unit that is already condemned, so at commit they are discarded together with everything else. - The caller is told the work succeeded, and then the boundary raises a rollback error the caller never anticipated. - Even if the layer let the writes through, they would be derived from a tracked set that no longer agrees with the rows. - Reads through the same unit are no better: the objects handed back are the same distrusted in-memory copies. ## The shape that works 1. **Let the failure propagate** out of the unit. The boundary rolls back; that is the point of the boundary. 2. **Discard the unit and its tracked set** with it. Nothing in there is reusable. 3. **Decide from the failure kind** whether this is worth another attempt or is a permanent outcome the caller should be told about. 4. **Recover in a fresh unit** if you need to write something - an audit row, a rejected-item record - reloading whatever that write needs rather than reusing objects from the dead attempt. If the requirement is that good rows land while bad ones are rejected, that is a question about how the transaction is scoped in the first place, and it is answered by choosing the boundary, not by catching inside one. ## Where layers and engines differ Data-access layers differ in strictness: some refuse outright to do any further work through a unit whose flush failed, others let calls through and only reveal the problem at commit. Engines differ too in whether a rejected statement leaves the surrounding transaction able to execute further statements at all. Neither difference makes the recovery-in-place pattern safe - it only changes whether you find out immediately or at the boundary - and code that works on one combination and not the other is precisely the kind of accident this rule avoids. A layer with no tracked set has a smaller version of the same problem: there is no distrusted object graph to worry about, but the transaction itself is still in whatever state the rejected statement left it, so the decision to abandon and re-run is unchanged.

  • Why can a failure surface at a line that only reads?
    Because a query can trigger a flush first, so that the query sees the changes made so far in the unit. The statements sent at that moment carry every pending change, including one made much earlier. The read is the trigger, not the cause - which is why the stack trace points somewhere unhelpful and the fix lives further up the method.
  • You need to record that an item was rejected. Where does that write go?
    In a new unit opened after the failed one has rolled back, using values captured before the flush - the submitted data, the failure kind, the constraint name. Anything read or written through the condemned unit is lost with it, so the record has to be assembled from what is already in hand rather than by loading through the dead tracked set.
  • Does clearing the tracked set make the unit usable again?
    No. Clearing throws away the distrusted in-memory copies, which removes one of the three problems, but the transaction is still marked rollback-only and the statements already sent are still part of it. The flag lives on the transaction, so only ending it - and starting another - genuinely resets the situation.

It is like a checkout where the till rejects one item mid-scan and voids the sale: the basket in your hand no longer matches what was rung up, and adding more items to that basket changes nothing about what you leave with.

saying these in an interview costs you the question

  • Catching the flush failure, skipping the bad object and continuing to save
  • Assuming the failing line is the line where the value was changed
  • Believing earlier saves in the same unit survive a later failure
  • Clearing the tracked set and treating the unit as healthy again
  • Reporting partial success while the boundary is about to roll everything back