Automatic Idempotency

Warning

Automatic idempotency is currently experimental and not recommended for use in production.

Synopsis

Use the automatic_idempotency transaction option to prevent commits from failing with commit_unknown_result at a small performance cost. transaction_timed_out and cluster_version_changed still indicate an unknown commit status.

Details

Transactions are generally run in retry loops that retry the error commit_unknown_result. When an attempt fails with commit_unknown_result it’s possible that the attempt succeeded, and the retry will perform the effect of the transaction twice! This behavior can be surprising at first, and difficult to reason about.

As an example, consider this simple transaction:

@fdb.transactional
def atomic_increment(tr, key):
    tr.add(key, struct.pack("<q", 1))

This transaction appears to be correct, and behaves as expected most of the time, but incorrectly adds to the key multiple times if commit_unknown_result is thrown.

To mitigate this, it’s common to write something unique to a transaction, so that retries can detect whether or not a commit succeeded by reading the database and looking for the unique change.

For example:

def atomic_increment(db, key):
    tr_id = uuid.UUID(int=random.getrandbits(128)).bytes
    # tr_id can be used to detect whether or not this transaction has already committed
    @fdb.transactional
    def do_it(tr):
        if tr[b"ids/" + tr_id] == None:
            tr[b"ids/" + tr_id] = b""
            tr.add(key, struct.pack("<q", 1))
    do_it(db)
    # Clean up unique state
    del db[b"ids/" + tr_id]

This approach has several problems

  1. We’re now doing about twice the work as before, and we need an extra transaction to clean up.

  2. This will slowly leak space over time if clients fail before cleaning up their unique state.

Automatic Idempotency is an implementation of a conceptually similar pattern, but taking advantage of fdb implementation details to provide better performance and clean up after itself. Here’s our atomic increment with Automatic Idempotency:

@fdb.transactional
def atomic_increment(tr, key):
    tr.options.set_automatic_idempotency()
    tr.add(key, struct.pack("<q", 1))

This will correctly add exactly once, provided you allow it to retry until success.

Caveats

Automatic Idempotency only prevents commit_unknown_result, and it does not prevent transaction_timed_out or cluster_version_changed. Each of these can indicate an unknown commit status. To mitigate this, the current recommendation is do not retry transactions that fail with transaction_timed_out. The default retry loop does not retry transaction_timed_out, so this is mostly relevant if you have a custom retry loop. This feature is not recommended for users of the Multi-version client at this time since it does not prevent cluster_version_changed. Support may be added in the future.