Skip to main content

Transactions

transact(): several writes that commit together or not at all, with TransactionBusy, TransactionAborted and retry.

Every write is already its own transaction, atomic across every thread and process using the directory. Sometimes one write at a time isn’t enough. A request bumps a total, bumps a per-path counter and records when it happened, and a reader that sees two of those three has seen something that was never true. transact() groups several calls into one: they all commit together when the block exits, or none of them do.

with cache.transact() as tx:
    tx.incr('requests')
    tx.incr(f'requests:{path}')
    tx.set('last_request', now)

That’s also faster. Each commit costs a disk sync, so grouping writes pays for one instead of several: three counter bumps measured 79 µs one by one and 48 µs in one transaction.

What the block does

  • Commit on exit, roll back on an exception. If the block raises, none of its writes are kept, and the exception carries on as usual.
  • tx is the cache itself. On the thread that entered the block, every call on that cache directory inside it runs in the transaction, whether you call it on tx, on cache, or on another Cache handle for the same directory. diskcache’s transact() yields None, so code written for it keeps working.
  • The block reads its own writes. Other threads and processes see nothing until it commits, and their writes to this directory wait for it, up to their own timeout. Keep blocks short.
  • Nesting joins the outer block. A transact() inside another one on the same directory, on the same thread, joins the outer transaction instead of starting its own. As in diskcache, the inner block’s exit commits nothing, and an exception you catch inside the outer block doesn’t undo the inner block’s writes. Only the outer block’s exit commits.
with cache.transact():
    cache.set('a', 1)
    try:
        with cache.transact():
            cache.set('b', 2)
            raise ValueError
    except ValueError:
        pass
# Both 'a' and 'b' are committed: the inner block had no savepoint of its own.

The object transact() returns is used once: entering it a second time, or while another thread is entering it, raises RuntimeError.

Beginning one: timeout and retry=

Beginning a transaction takes the directory’s write lock, and waits for it up to the cache’s timeout, then raises Timeout, as a write does. With retry=True it keeps trying instead. Each attempt waits up to timeout with the interpreter released, and Ctrl-C is checked between attempts, so a KeyboardInterrupt gets through within one timeout.

with cache.transact(retry=True) as tx:
    ...

Two caches at once: TransactionBusy

Inside one cache’s transaction, beginning a transaction on a second cache takes that cache’s write lock if it’s free. If another thread holds it, transact() raises TransactionBusy at once instead of waiting: that thread could be waiting for the lock you hold, and then neither would ever finish. TransactionBusy is a subclass of Timeout, so existing handlers still catch it, and retry=True never retries it, since trying again while you still hold the first lock can’t help.

To use two caches in one block, always nest them in the same order everywhere in your app, and if TransactionBusy reaches you, leave the outer block and try the whole thing again.

When a block doesn’t commit

A with block never exits cleanly when its writes are gone. Its exit raises TransactionAborted, a RuntimeError, when the transaction didn’t commit:

  • an operation inside it failed in a way that left the transaction unusable (a full disk, an I/O error). Later writes in the block fail at once, and nothing in it commits;
  • it was ended from another thread, which rolled it back (below);
  • it was ended from inside one of its own operations, such as a pickle hook that calls the block’s __exit__. That raises RuntimeError where it’s called, and the transaction rolls back when that operation returns.

A commit that can’t finish raises too: a busy or failed COMMIT raises Timeout or an OSError, and nothing is kept.

Ending it from another thread

diskcache says a transaction “may not be shared between threads”. In diskcache-oxide, any thread may call the transaction’s __exit__, to commit (no exception) or roll back the owner’s transaction. The rules:

  • While the owner is inside one of its own operations (decoding a value, say), a rollback is left to that operation, and happens when it returns. A commit waits for it, up to the cache’s timeout. A commit that can’t run by then raises Timeout and rolls back.
  • The owner’s own exit afterwards raises TransactionAborted unless the transaction committed.
  • An owner’s exit that comes while the other thread is still committing waits for the outcome, up to the cache’s timeout, and reports it. Past that, it raises Timeout (“whether it committed is unknown”). An exit with an exception passing doesn’t wait.
  • Writes the owner makes after another thread ended the transaction run on their own, outside it.

durable=True

On a durable=True cache, beginning a transaction still raises, and so does any failure inside the block: dropping one write and committing the rest would break the transaction’s atomicity. Only calls outside a transaction are answered as misses.

A transaction belongs to a thread, not a task

The calls that join a transaction are the ones made on the thread that entered the block. In asyncio, every task on a loop runs on that same thread. So if you await inside the block, another coroutine that runs meanwhile and writes to the same cache joins your transaction, and rolls back with it if your block raises. Don’t await inside a transact() block. An async with cache.transact_async() form is planned for AsyncCache (see the roadmap), where waiting for the write lock won’t block the event loop.

Transactions and forks

A fork waits for every transaction other threads hold, with no timeout, so the child never inherits half of one (D-012). A long transaction holds up every fork in the process while it’s open, and calls behind that fork time out (see How long a call waits for a fork). Forking from inside your own transaction can’t be waited for: the child’s copy is poisoned and raises RuntimeError (see Forking from inside your own transaction).

Typing: TransactionalCacheLike

Cache and NullCache both have transact(). Annotate with TransactionalCacheLike to accept either and call it:

from diskcache_oxide import TransactionalCacheLike

def count(cache: TransactionalCacheLike, path: str) -> None:
    with cache.transact() as tx:
        tx.incr('requests')
        tx.incr(f'requests:{path}')

On a NullCache, transact() is a no-op that yields the NullCache. A FanoutCache is not a TransactionalCacheLike: its transact(*, key) covers only the shard that key lives in, and refuses calls for keys on other shards inside the block. Annotate with ShardedCacheLike to accept a FanoutCache or a NullCache. CacheLike itself has no transact(), so a cache whose transactions need more than retry= (a sharded cache’s transact(key=...)) can still be a CacheLike. A Caches lookup is a TransactionalCacheLike, so caches['general'].transact() type-checks, and a disabled cache’s transact() is the NullCache one.