jaunty Architecture › Connection and Transaction Ownership

Connection and Transaction Ownership

Jaunty accepts your IDbConnection and gives it back in the state it found it. Two booleans carry that promise through the write paths, and every acquire/release pair is derived from them:

C#
bool wasClosed = connection.State == ConnectionState.Closed;
bool ownTransaction = transaction is null;

Under src/Jaunty/Write, wasClosed appears in eight files and ownTransaction in six: the six Bulk* files have both, and Upsert/UpsertAsync have only wasClosed because they issue one statement and own no transaction. The acquire decisions read the booleans in the try; the release decisions read them in the finally.

What that buys you

For the six bulk-write paths:

you pass Jaunty opens Jaunty closes Jaunty commits
a closed connection, no transaction yes yes yes, its own
an open connection, no transaction no no yes, its own
a connection inside your transaction no no no — yours to commit

An open connection is left open because you opened it, and closing it would break the next statement in your own unit of work. A supplied transaction is never committed or rolled back by Jaunty, so a bulk write composes into a larger transaction without an argument.

Upsert differs in the last column only: with no transaction supplied its single statement runs in autocommit, and with one supplied it joins yours. The connection columns are the same.

The nesting order

There are two nested try blocks. The outer one exists for the finally that disposes and closes; the inner one exists for the catch that puts session state back.

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

yes

no

wasClosed = state is Closed

If the connection is a DbConnection,
validate the supplied transaction
is a DbTransaction

ownTransaction = transaction is null

outer try

wasClosed?

Open

inner try

constraints off,
autocommit dialect?

Disable FK enforcement
outside any transaction

ownTransaction?

BeginTransaction

constraints off,
in-transaction dialect?

Disable FK enforcement
inside the transaction

Write the rows

Re-enable, in-transaction case

ownTransaction?

Commit

Re-enable, autocommit case

finally

catch

Re-enable, in-transaction case

ownTransaction?

Rollback

Re-enable, autocommit case

Rethrow

ownTransaction?

Dispose the transaction

wasClosed and
still open?

Close

Return

The re-enable appears twice on each path because the disable does. Which of the two placements is live depends on the dialect, and they bracket the commit from opposite sides: the in-transaction one is undone before the commit, the autocommit-dialect one after it.

The async paths are the same shape with OpenAsync, BeginTransactionAsync, CommitAsync, RollbackAsync, DisposeAsync and CloseAsync substituted, all of them NET8_0_OR_GREATER with the synchronous call as the fallback on older targets. Every restore in the catch passes CancellationToken.None — the rollback and both foreign-key re-enables. An operation cancelled mid-write still has to put things back, and passing the cancelled token would abandon the transaction instead of undoing it.

Three rules the shape encodes

Everything that can throw goes inside the try that restores state. The foreign-key disable and the BeginTransaction call used to sit outside it. A throw from either skipped the catch, and since the finally only disposes and closes, the connection went back to the pool with foreign-key enforcement still off — where the next unrelated caller inherited it. Anything that changes session state must be inside the block that puts it back.

Re-enabling is best effort and never masks the original exception. Each restore in the catch is wrapped in its own empty catch. The failure you want to read is the one that started it, and a secondary failure while cleaning up would otherwise replace it.

The close is conditional twice. wasClosed && connection.State != ConnectionState.Closed. The first check is ownership. The second is defensive: nothing in Jaunty closes the connection between those two points, so it guards against the provider having done so.

Why the transaction is validated before use

A DbConnection's IDbCommand.Transaction setter is DbCommand's explicit interface implementation, and it casts to DbTransaction internally. Assigning a non-DbTransaction IDbTransaction through it throws an InvalidCastException with no useful message. The write paths run the supplied transaction through AsyncTransactionValidator.RequireDbTransaction first, so an incompatible transaction produces an ArgumentException naming the problem instead. On the synchronous paths this happens only when the connection is a DbConnection; anything else takes the transaction as given, because the cast that would fail is not in play.

The foreign-key toggle has two placements

Which one applies is a dialect fact, not a preference. ForeignKeyToggleCoordinator.RequiresPreTransactionToggle is true when constraint-skipping was asked for and the dialect requires autocommit for the toggle; those engines toggle before BeginTransaction and are undone after the commit, and the rest toggle within the transaction and are undone before it.

ValidateTransactionCompatibility rejects the one combination that cannot work, throwing NotSupportedException when all three hold: constraint-skipping requested, a dialect that requires autocommit for the toggle, and a transaction supplied by the caller. There is nowhere to put the toggle in that case, because the transaction is already open before Jaunty is involved.

See also