Purpose of self-conflicting transactions

Can anyone give me an example where making a transaction self-conflicting is necessary for correctness or otherwise useful?

I think one of the main reasons to have a transaction be self-conflicting is to preserve the causal relationship of your writes. For example, imagine you have a cluster with a single writer and you commit a transaction that isn’t self-conflicting. Your client sends the commit request to the cluster and the request disappears for a while. The client tries to send the request again and succeeds, getting back a success response from the cluster. You go on committing more transactions that modify the same keys, and then your lost request reappears and gets delivered to the cluster.

If that lost request is allowed to commit, it will overwrite changes that you made in those other transactions after you thought your first request had committed. This means you can’t guarantee that if you commit two transactions in some order, that they will be applied in the same order.

If your transactions are self-conflicting, on the other hand, then the second time your request gets committed it will be rejected due to a conflict with the other commit of the same transaction. In this case, you don’t run the risk of the transaction being reordered with later transactions.

Ok I think I understand. So the guarantee is that if the client gets an acknowledgement for a particular retry, then no earlier retry will commit after the first acknowledged retry.

So the following ASSERT would never fail with a self conflicting transaction (assuming a single writer), but might fail if the transaction were not self conflicting.

    state Transaction tx(db);
    state int attempts = 0;
    tx.makeSelfConflicting();
    loop
    {
        ++attempts;
        try {
            tx.set("attempts", attempts);
            Void _uvar = wait(tx.commit());
            break;
        } catch (Error& e) {
            Void _uvar = wait(tx.onError(e));
        }
    }
    tx.reset();
    int _attempts = wait(tx.read("attempts"));
    ASSERT(_attempts.get() == attempts);

But if someone else were reading the attempts key concurrently, they might see some earlier value for attempts. Does that make sense?

Having looked at the code a bit more, there are a couple aspects of this I’m uncertain about. Let me take a deeper look through it and then see if I can give a more concrete answer afterward.

Ok, so I did a little more research into this. By default, all transactions that do any writes will make themselves self-conflicting if they are not already so [1].

Additionally, any commit that fails with commit_unknown_result, which is the error you get if the result of the commit could not be determined and could potentially happen in the future, will automatically commit a second “dummy” transaction [2]. This transaction sets some conflict ranges that will cause your transaction to fail with a conflict if the commit request does get delivered later.

Both of these behaviors are disabled if you set the transaction option CAUSAL_WRITE_RISKY. If you do this, then making your transaction self-conflicting by calling tr.makeSelfConflicting() won’t by itself save you because you must call it on each retry and each time will generate different conflict ranges. Instead, you would either need your transaction to have a stable self-conflict or you would need to commit a transaction like above in response to the commit_unknown_result error that does conflict with your transaction.

There are some places in our source code where we create transactions and call tr.makeSelfConflicting() on them, but as far as I can tell this doesn’t seem to provide any benefit given the default behavior of transactions.

[1] https://github.com/apple/foundationdb/blob/62d437810998880da8c88e0acd0c30f98ae026de/fdbclient/NativeAPI.actor.cpp#L2556
[2] https://github.com/apple/foundationdb/blob/62d437810998880da8c88e0acd0c30f98ae026de/fdbclient/NativeAPI.actor.cpp#L2496

3 Likes

@ajbeamon
I’m wondering what is the purpose of CAUSAL_WRITE_RISKY?

Allowing the same transaction committing again does not seem bring any performance benefit either.

What are the scenarios that motivates CAUSAL_WRITE_RISKY feature?

I’ve never known anybody to use it, so I don’t have a really compelling use-case off-hand. The main change I can see from it, though, is that you would not be required to wait for the commit of a dummy transaction when a commit fails with unknown result and you would get the error sooner.