Back in 2008 DBIx::Class introduced an object-guard based RDBMS transaction control in addition to the more widely known ->txn_do( sub { ...
} )
.
With this change to perl5 the object-guard becomes a rather dangerous source of silent bugs.
A bug was filed against perl5 some months ago,
but was rejected (hopefully due to insufficient understanding of the problem).
Read on for details.
Often a programmer needs to make sure a set of RDBMS operations are atomic (the set of operations either completes entirely or not at all).
DBI only provides a manual control of this process via begin_work to issue a SQL 'BEGIN'
transaction start,
and commit / rollback issuing a SQL 'COMMIT'
or SQL 'ROLLBACK'
which finish the transaction with the corresponding outcome.
DBIx::Class provides two ways to automate the handling of these calls.
With txn_do a SQL 'BEGIN'
is issued before execution of the supplied coderef begins.
The only two possible outcomes after this are:
SQL 'ROLLBACK'
is issued,
and the exception is re-thrown.SQL 'COMMIT'
is issued and execution continues.With the object guard almost the entire transaction is under user control.
The BEGIN
is issued at the point of the guard object instantiation.
From this point on there are three possible outcomes:
$guard->commit
,
which issues a SQL 'COMMIT'
and sets a flag on the guard instance,
which turns the "DESTROY" in DBIx::Class::Storage::TxnScopeGuard into a noop.SQL 'ROLLBACK'
while also issuing a warning about an uncommitted guard leaving scope,
alerting the user to a potential unintended ROLLBACK
scenario in his code (e.g.
a rogue return
in a block)->commit
ed and before it reached the end of the scope which keept the object alive.
In this situation the ROLLBACK
is expected and the warning normally emitted by "DESTROY" in DBIx::Class::Storage::TxnScopeGuard is suppressed by the presence of $@
After 96d9b9c it is no longer possible to tell apart case 2
from case 3
above,
since $@ is not visible in DESTROY
.
With the current state of affairs DBIx::Class::Storage::TxnScopeGuard will have no choice but to be adjusted to not warn on case 3
,
which makes it as unwieldy as manual ->txn_begin
/ ->txn_commit
calls,
except much more dangerous: debugging "why is my data gone" due to silent rollbacks will be harder than just writing extra boilerplate any time you need finer transaction control.
Because it is much more flexible to be variable-bound than scope-bound. For example one can easily pass a reference to the guard to an unrelated scope. This in turn allows for much clearer code. Compare the following:
sub with_deferred_constraints { my ($schema, $code, @args) = @_; $schema->txn_do ( sub { # needs to run in the txn # also the un-set needs to run before a commit $schema->_do_query('alter session set constraints = deferred'); my $want = wantarray(); my @res; try { if ($want) { @res = $code->(@args); } elsif (defined $want) { $res[0] = $code->(@args); } else { $code->(@args); } } finally { # unset in either rollback/commit case $self->_do_query('alter session set constraints = immediate'); }; return $want ? @res : $res[0]; }); }
With its sibling:
sub with_deferred_constraints { my ($schema, $code, @args) = @_; my $txn_guard = $schema->txn_scope_guard ( after_begin => sub { $schema->_do_query('alter session set constraints = deferred'); }, before_commit_or_destroy => sub { $schema->_do_query('alter session set constraints = immediate'); }, ); return Context::Preserve::preserve_context ( sub { $code->(@args) }, after => sub { $txn_guard->commit } ); }
Also remember that databases are hateful. In some RDBMS a transaction comes with multiple strings attached (like e.g. refusing to start when multiple resultset handles are active). Thus code striving to be cross-platform must do everything it can to avoid starting a transaction unless absolutely necessary. With a transaction scope guard insertion of "this parts needs to be atomic" markers becomes much more natural, without weird inversion of control flow. Compare:
sub insert { my ($self, $obj) = @_; if (my @rels = $obj->parent_objects) { $self->txn_do ( sub { $_->insert for @rels; $obj->insert }); } else { $obj->insert; } }
To code without any duplication:
sub insert { my ($self, $obj) = @_; my $guard; for ($obj->parent_objects) { $guard ||= $self->txn_scope_guard; $_->insert; } $obj->insert; $guard->commit if $guard; }
It is true that the following case would silently rollback without any indication (warning or otherwise) as to why is the data not in the db:
sub foo { my $guard = $schema->txn_scope_guard; eval { ... die "Hard: With a Vengeance"; }; }
The user did two stupid things here - he did not handle the exception (effectively hiding it), and forgot to handle the instantiated guard. Incidentally this sole corner case was used as justification to reject RT#76426.
It is our hope that a different strategy will be adopted that will allow introspection of the in exception
state, be it via $@
or some new method (e.g. an argument to DESTROY
)