When an operation with its own declared boundary is called inside an existing transaction, what propagation choices exist?
answer
- what happens when one is already open
- join, suspend, demand, forbid, nest
- join means one shared fate
- independent unit means two live transactions
- nested scope can be discarded alone
basics
~20 sPropagation decides what an inner bounded call does when a transaction is already running: join it, suspend it for an independent one, refuse to run unbounded, or open a nested scope that can be discarded on its own.
solid answer
~50 sThe usual choices are: **join** the caller's transaction, which is the default in most layers and means one commit decided by the outermost caller; **require a new one**, which suspends the caller's transaction and runs an independent unit that commits or rolls back on its own; **require an existing one**, which fails fast if there is none, guarding code that must never self-commit; **run without one**, suspending anything open; and **nested**, a sub-scope inside the caller's transaction that can be discarded without abandoning the whole thing. The practical question is which failures the caller can survive. If the inner call joined, its failure normally dooms the shared transaction even if the caller catches the exception. Only an independent unit or a nested scope gives the inner work a fate of its own - and an independent unit costs a second live transaction that can block on rows the caller still holds.
go deeper
Know that an inner call normally joins the transaction already running rather than starting a second one, so the whole use case commits or rolls back together.
Describe each mode by what it does in both cases - transaction open and none open - and explain why only an independent or nested unit lets the caller survive an inner failure.
Bring the operational costs: a suspended caller still holds its locks, so an independent inner unit that touches the same rows stalls until something times out.
Treat propagation as policy. A default of joining, with independent units allowed only for work that must outlive a rollback, keeps failure semantics reviewable instead of per-method folklore.
## What propagation decides **Propagation** is the rule that answers one question: an operation that declares it needs a boundary is invoked, and a transaction is already in progress on this thread - now what? Every data-access layer that supports declarative demarcation needs an answer, and the names differ between layers while the underlying set of behaviours is remarkably stable. ## The modes | Behaviour | With a transaction already open | With none open | |---|---|---| | Join | run inside the caller's transaction | start one | | Require a new one | suspend the caller's, run an independent one | start one | | Require an existing one | run inside the caller's | fail immediately | | Optional | run inside the caller's | run with no boundary | | Run without one | suspend the caller's, run unbounded | run unbounded | | Forbid | fail immediately | run unbounded | | Nested | open a sub-scope inside the caller's | start one | **Join** is the default nearly everywhere and is the right answer nearly everywhere: a use case is one unit, and code called along the way should be part of it. ## Which failures the caller can survive This is what interviews actually probe. Consider a use case that writes its main change and then records an attempt in a log table, and the log write fails. - If the log write **joined**, the failure normally dooms the shared transaction. Catching the exception in the caller does not restore it - the shared unit is already destined to roll back, and the commit at the end fails or silently discards everything. - If the log write ran in an **independent** unit, it failed and rolled back on its own. The caller's transaction is untouched and can still commit. - If it ran in a **nested** sub-scope, it can be discarded back to where the sub-scope began and the caller carries on within the same transaction, still deciding one commit at the end. So the choice is not stylistic. It determines whether the phrase "best effort" is implementable at all. ## The cost of an independent unit Suspending a transaction to run another one is more expensive than it looks: - **Two live transactions at once**, each holding a connection, for the duration of the inner call. Under load that doubles the demand from this code path. - **Self-contention.** If the inner unit touches a row the suspended caller has already written, it blocks on a lock the caller cannot release until the inner call returns. That is a deadlock in all but name, usually resolved only by a timeout. - **Asymmetric fate.** The inner commit is final. If the caller later rolls back, the inner work stays. That is exactly what you want for an audit or attempt record and exactly wrong for anything the main change depends on. - **Ordering surprises.** The inner unit cannot see the caller's uncommitted writes, so an inner read of a row the caller just modified returns the older state. The nested sub-scope avoids the extra connection and the self-contention, since there is still only one transaction; the trade is that the whole thing still commits or rolls back together at the end, and support depends on the underlying engine and driver offering the sub-scope mechanism at all. ## Choosing 1. Default to **join**. One use case, one unit. 2. Use **require an existing one** on low-level write helpers that must never be called unbounded - it turns a silent per-statement commit into an immediate, obvious failure. 3. Use an **independent unit** only for work that genuinely must survive the caller's rollback, and check it does not touch rows the caller has locked. 4. Use a **nested sub-scope** for a step whose failure is tolerable but whose writes must not outlive a caller rollback. 5. Avoid **forbid** and **run without one** except at well-understood edges; suspending a transaction to do database work is rarely what anyone intended. ## What propagation does not decide Propagation says which transaction the work runs in. It does not decide the isolation semantics that transaction runs under, how a conflict between concurrent writers is detected, or what happens to a layer's tracked objects after a failed write. Those are separate concerns; keeping them separate is what makes a propagation choice explainable in one sentence.
- Why is a mode that refuses to run without an open transaction useful?It converts a silent bug into a loud one. A write helper called with no boundary would otherwise commit each statement individually, which looks fine until a later step fails. Declaring that it must have a transaction already means any call path that forgot the boundary fails immediately, at the point of the mistake.
- Does an independent inner unit see writes the suspended caller has already made?No. It is a separate transaction, so the caller's uncommitted writes are invisible to it, and it reads the last committed state of those rows instead. If it tries to write one of them it blocks on the caller's lock, which the caller cannot release until the inner call returns.
- How does propagation interact with a boundary declaration that is never applied?It does not apply at all. Propagation is evaluated by the mechanism that reads the declaration, so a call path that bypasses that mechanism gets neither the mode nor the boundary - the work simply runs in whatever context the caller had, which is why a bypassed declaration is so much worse than none.
saying these in an interview costs you the question
- Thinks catching the exception from a joined inner call saves the caller's transaction
- Uses an independent unit routinely without noticing two connections are held
- Believes an independent inner unit sees the caller's uncommitted writes
- Assumes an independent inner commit is undone when the caller rolls back
- Cannot name any mode other than the layer's default