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 commitdb.deferAfterCommit(callback, *args, **kwargs): runs after the SQL commitdb.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:
deferToCommitcallbacks, SQL commit,deferAfterCommitcallbacks,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".