Java Bindings: Additional methods on the ReadTransaction interface to manage read conflict ranges

The ReadTransaction interface currently only offers methods that allow the user to read data. However, it doesn’t offer any control over which conflict ranges get added. I propose that the ReadTransaction interface should offer the following additional methods:

  • snapshot() - This would allow the user to get a snapshot view of the database, i.e., one that doesn’t add any read conflict ranges automatically. This method currently exists in the Transaction interface, and read-write transactions implement this by producing a new ReadTransaction that doesn’t add read conflict ranges. That implementation could stay the same, but snapshot transactions would implement it by returning this. The benefit, though, is that the user could call snapshot on a ReadTransaction and know that they are getting a version of the transaction that doesn’t add conflicts.
  • addReadConflictRange() and addReadConflictKey() - These methods already exist in the transaction interface. In a ReadTransaction, I would think that they should only add a conflict range if the transaction is not actually a snapshot transaction. It’s also possible that the ReadTransaction interface should have a method that is called something like addReadConflictRangeIfNotSnapshot or something more verbose so that it is explicit what’s going on, though it would add cruft. Regular transactions would implement that to add the conflict range. Snapshot transactions would not.
  • isSnapshot() - This probably isn’t strictly necessary, but it would be nice to have. It would let the user make decisions about whether they are going to manually add conflict ranges before having to go and compute them.

If one wanted to get into the weeds of the type system, one might be able to do something better with something like a SnapshotTransaction interface. But I think the thing that we actually want to support at the end of the day is for someone to write a method that looks like:

void doFoo(ReadTransaction rtr) {
   // logic

Then the “right” conflict ranges get added by the doFoo method, whatever that means for the application. Like, in theory, you could imagine a method that is something like:

CompletableFuture<Optional<KeyValue>> getRandomKey(ReadTransaction rtr, Subspace subspace, int limit) {
    return rtr.snapshot().getRange(subspace.range(), limit).asList().thenApply(list -> {
        it (list.isEmpty()) return Optional.empty();
        int index = (int)(Math.random() * list.size());
        KeyValue kv = list.get(index);
        rtr.addReadConflictKey(kv.getKey());  // or addReadConflictKeyIfNotSnapshot
        return Optional.of(kv.getKey());

This would then let someone pick a random element from a subspace and only add a read conflict key on the one that they actually got. (This is essentially our canonical example of why someone would manually add a read conflict range.) But this has the added benefit that if someone wants to read that key at snapshot isolation level, they can do so by just passing in a snapshot transaction. For example, the user might grab this random key and then check to see if some other thing in the database is still at work, and then want to add a read conflict key only if that second operation returns a particular result. (Maybe this is a little contrived.)

One other benefit of this interface change is that, right now, if the user wants to be particular about conflict ranges, then they have to declare that their functions take Transactions (or TransactionContexts), which means that there are no compiler-enforced guarantees about whether the operation can (incidentally) perform a mutation, which might be bad for users who are sensitive to what can turn a read-only transaction into a read-write transaction.

1 Like