Course / Transactions & operations / Lesson 28

Lesson 28 · Transactions & operations

Deferred callbacks

Schedule work before and after a commit, block a commit on purpose, and recover when a callback fails.

about 8 minutes · screenplay estimate8 scenesPrerequisites: lessons 22, 27

Development course · Reading edition · API compatibility

Scene 01

Three calls

A hook often knows that something must happen at commit time, and not earlier. Asqueel offers three calls on the database object. deferToCommit registers a callback that runs before the SQL commit. deferAfterCommit registers a callback that runs after a successful SQL commit. deferredRaise registers an exception that makes the next commit fail. All three act on the selected connection name of the current thread.

Example & screen notes

Slide "Deferred callbacks". Bullets:

  • db.deferToCommit(callback, *args, **kwargs): runs before the SQL commit
  • db.deferAfterCommit(callback, *args, **kwargs): runs after the SQL commit
  • db.deferredRaise(exception): makes the next commit fail

Scene 02

The order of a commit

Here CourseDb is a subclass of AsqueelDb that overrides onDbCommitted. We insert North, then register one callback of each kind. Commit runs the deferToCommit callbacks first. While they run, currentEnv holds onCommittingStep set to True. It also holds the connection name being committed. A deferToCommit callback can execute SQL. Its insert joins the same transaction, so North 2 is committed with North. Then the SQL commit runs. Then the deferAfterCommit callbacks run. Last, commit calls db dot onDbCommitted. The base method does nothing, and a subclass overrides it. onDbCommitted runs at every successful commit, also when nothing was pending.

Example & screen notes

Code block on the left; on the right, a vertical diagram with four boxes connected by downward arrows: "deferToCommit callbacks" → "SQL COMMIT" → "deferAfterCommit callbacks" → "onDbCommitted()".

class CourseDb(AsqueelDb):
    def onDbCommitted(self):
        log.append('onDbCommitted')


def before(code):
    log.append(('before', code, db.currentEnv.get('onCommittingStep'),
                db.currentEnv.get('connectionName')))
    region.insert({'code': code + '2', 'name': 'added before commit'})


def after(code):
    log.append(('after', code))


region.insert({'code': 'NO', 'name': 'North'})
db.deferToCommit(before, 'NO')
db.deferAfterCommit(after, 'NO')
db.commit()
# [('before', 'NO', True, '_main_connection'), ('after', 'NO'), 'onDbCommitted']

Scene 03

Arguments, grouping, deduplication

The positional and keyword arguments are passed to the callback when it runs. Two keyword arguments are reserved, and asqueel removes them before the call. underscore deferredId names the registration. The same callback with the same id is registered once. Both calls return the keyword arguments of the registration. A second call receives the dictionary of the first one. Here three calls add three codes to one set, and the callback runs once. Without underscore deferredId, every call registers a new entry. underscore deferredBlock puts the callback in a named group. Groups run in the order of their names, compared in lowercase. The default group is named underscore base underscore. Inside a group, callbacks run in registration order.

Example & screen notes

Two code blocks.

Top, "_deferredId":

def collect(codes=None):
    log.append(('collect', sorted(codes)))

for code in ('A', 'B', 'C'):
    kwargs = db.deferToCommit(collect, codes=set(), _deferredId='collector')
    kwargs['codes'].add(code)
db.commit()
# [('collect', ['A', 'B', 'C']), 'onDbCommitted']

Bottom, "_deferredBlock":

db.deferToCommit(log.append, 'block z', _deferredBlock='z_last')
db.deferToCommit(log.append, 'block a', _deferredBlock='a_first')
db.deferToCommit(log.append, 'block base')
db.commit()
# ['block base', 'block a', 'block z', 'onDbCommitted']

Scene 04

deferredRaise

deferredRaise takes an exception instance. It does not raise it at once. A hook can call it to say that the transaction must not be committed, and let the write continue. At commit, after the deferToCommit callbacks, asqueel raises DeferredCommitError. The message joins the messages of every registered exception. No SQL commit happens. The connection becomes rollback-only, as in lesson 27. A rollback discards the work and the registered exceptions.

Example & screen notes

Code block, the error lines highlighted.

from asqueel import DeferredCommitError

region.insert({'code': 'EA', 'name': 'East'})
db.deferredRaise(ValueError('totals do not match'))
db.commit()
# DeferredCommitError: totals do not match
db.commit()
# TransactionStateError: Connection is rollback-only; call rollback before reuse
db.rollback()
# East is not saved

Scene 05

When a callback fails

On the left, a deferToCommit callback raises. The error reaches the caller of commit. No SQL commit happens, and the connection becomes rollback-only. A rollback discards the work and the queues. On the right, a deferAfterCommit callback raises. The SQL commit already happened, and Mountains is saved. The error reaches the caller of commit, and onDbCommitted is not called. The callbacks not reached stay pending. A later commit with new work runs them. A commit with no pending work runs nothing. A rollback discards them. Do not repeat the whole write because a notification failed: the write is committed. A callback that expects an external failure can catch it and return normally.

Example & screen notes

Two code blocks.

Left, "Before the commit":

def failing():
    raise RuntimeError('precommit check failed')

region.insert({'code': 'IS', 'name': 'Islands'})
db.deferToCommit(failing)
db.commit()      # RuntimeError: precommit check failed
db.commit()      # TransactionStateError: rollback-only
db.rollback()    # Islands is not saved

Right, "After the commit":

def broken():
    raise RuntimeError('mail server down')

region.insert({'code': 'MO', 'name': 'Mountains'})
db.deferAfterCommit(broken)
db.deferAfterCommit(log.append, 'second after-commit')
db.commit()      # RuntimeError: mail server down
# Mountains is saved; 'second after-commit' is still pending

Scene 06

Inside a callback

A callback runs while the commit is in progress. It can execute SQL, including table writes. It cannot call commit, rollback or closeConnection on the connection being committed. Asqueel refuses with TransactionStateError.

Example & screen notes

Code block, then bullets.

def recursive():
    db.commit()

region.insert({'code': 'RE', 'name': 'Recursive'})
db.deferToCommit(recursive)
db.commit()
# TransactionStateError: Cannot reenter transaction completion from a commit callback
  • A callback can execute SQL and table writes
  • A callback cannot commit, roll back or close the connection being committed

Scene 07

Callbacks from a hook

This is the usual place for deferred callbacks. The invoice table is bound to InvoiceTable with x underscore table underscore class. Its onInserted hook does not send the invoice. It registers the sending for after the commit. If the caller rolls back, the invoice is never sent. The queues live in db dot currentEnv, one queue per connection name. clearCurrentEnv, and therefore the start of a session, drops them too.

Example & screen notes

Code block, self.db.deferAfterCommit highlighted.

class InvoiceTable(SqlTable):
    def trigger_onInserted(self, record):
        self.db.deferAfterCommit(log.append, f"send invoice {record['inv_number']}")


db.table('invc.invoice').insert({'inv_number': 'INV-1', 'customer_id': customer['id']})
log                  # []
db.commit()
log                  # ['send invoice INV-1', 'onDbCommitted']

Scene 08

Recap

A commit runs the deferToCommit callbacks, the SQL commit, the deferAfterCommit callbacks and onDbCommitted, in this order. A failure before the SQL commit blocks it until rollback, and a failure after it leaves the remaining callbacks pending. A rollback empties the queues and the pending exceptions. The next lesson executes SQL text directly.

Example & screen notes

Slide "Recap". Bullets:

  • Order of a commit: deferToCommit callbacks, SQL commit, deferAfterCommit callbacks, onDbCommitted()
  • A failure before the SQL commit blocks it until rollback(); a failure after it leaves the rest pending
  • rollback() empties the queues and the pending exceptions

Next: "Lesson 29 — Direct SQL".