What

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.

Why

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:

  1. The coderef throws an exception, in which case the exception is caught, a SQL 'ROLLBACK' is issued, and the exception is re-thrown.
  2. The coderef execution completes succesfully, in which case a 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:

  1. The user explicitly commits the transaction by $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.
  2. The code after the guard instantiation runs normally, and eventually perl leaves the scope instantiating the guard, triggering garbage collection. The "DESTROY" in DBIx::Class::Storage::TxnScopeGuard issues a 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)
  3. The code following the guard throws an exception, triggering destruction of the guard before it had a chance to be ->commited 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.

But why not just use the clearly superior txn_do 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;
  }

But this never worked reliably!

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)