Introduction

DQL transactions allow developers to execute multiple DQL statements in a single atomic operation. Using transactions ensures either all statements are executed successfully or none are executed at all, and provides consistency guarantees so that consecutive statements within a transaction operate on the same snapshot of the data. DQL read-write transactions are executed at serializable isolation level, which makes them suitable for use cases that require strong consistency guarantees.

Usage

Use the transaction() method on DittoStore to run a given block in a read-write transaction:

let completionAction = try await ditto.store.transaction(hint: "transaction example") { transaction in
    let queryResult1 = try await transaction.execute(/* query 1 */)
    let queryResult2 = try await transaction.execute(/* query 2 */)
    // ...
    let queryResultN = try await transaction.execute(/* query N */)

    // Complete by returning a `TransactionCompletionAction`
    return .commit // Or .rollback
}

The transaction is committed if the block returns .commit and rolled back if it returns .rollback. The transaction will also be implicitly rolled back if an error is thrown at any point inside the block.

You can alternatively return a value from a transaction using the second variant of the method. Returning any value will commit the transaction and throwing an error will roll it back:

let result: DittoQueryResult? = try await ditto.store.transaction(hint: "transaction returning a value") { transaction in
    let queryResult1 = try await transaction.execute(/* query 1 */)
    let queryResult2 = try await transaction.execute(/* query 2 */)
    // ...
    let queryResultN = try await transaction.execute(/* query N */)
    return queryResultN
}

Concurrency and Read-Only Transactions

Transactions are initiated as read-write transactions by default, so only a single read-write transaction is being executed at any given time. Any other read-write transaction started concurrently will wait until the current transaction has been committed or rolled back. Therefore, it is crucial to make sure a transaction finishes as early as possible so other read-write transactions aren’t blocked for a long time. We recommend setting the hint parameter for transactions, which improves the readability of log output related to transactions and is especially helpful for debugging conflicts between multiple transactions. Also see section Deadlock Prevention for more information.

A transaction can be configured to be read-only using the options parameter. Multiple read-only transactions can be executed concurrently, including concurrently with a read-write transaction. However, executing a mutating DQL statement in a read-only transaction will throw an error.

// Read-only transaction with hint:
try await ditto.store.transaction(hint: "Read-only transaction", isReadOnly: true) { transaction in
    // ...
    return .commit
}

Transaction Info

The closure passed to transaction() receives a DittoTransaction object. In addition to the execute() method, it also provides an info property that returns a DittoTransactionInfo object. This object contains information about the transaction, such as the transaction’s unique ID, the transaction’s hint, and the transaction’s options.

try await ditto.store.transaction { transaction in
    let id = transaction.info.id
    let hint = transaction.info.hint
    let isReadOnly = transaction.info.isReadOnly
    // ...
    return .commit
}

Closing Ditto with Pending Transactions

Ditto waits for all pending transactions to complete before closing. Ditto is closing when the close() method is called, when the instance is garbage collected, or when it goes out of scope.

The Flutter SDK does not support waiting for pending transactions to complete. Make sure to await all transactions’ completion before closing the Ditto instance.

Nested Non-Transaction Operations

Be aware that Ditto does not prevent running operations inside a transaction block without using the transaction object. This can lead to unexpected behavior and deadlocks and must be avoided.

try await ditto.store.transaction { transaction in
    try await transaction.execute(/* query 1 */) // ✅ OK
    try await ditto.store.execute(/* query 2 */) // ⚠️ Avoid this
    return .commit
}

Deadlock Prevention

Read-write transactions should only be used when necessary, as they can lead to deadlocks if not implemented correctly. This section provides some information on how to avoid and debug deadlocks and other performance issues when using read-write transactions. Note that the following does not apply to read-only transactions.

When a read-write transaction is started, a lock on the Ditto store is acquired and held until the transaction is committed or rolled back. Any attempt to start a new read-write transaction concurrently will wait until the current transaction is completed. It is important to avoid long-running operations inside a transaction block, as these prevent other transactions from starting which can lead to performance issues.

The transaction’s lock can also lead to deadlocks if the current transaction is waiting for the new transaction to complete. In particular, a read-write transaction must never be initiated from within another read-write transaction block. In this case the inner transaction will not start until the outer transaction is completed and the outer transaction will never complete because it is waiting for the inner transaction.

This caveat also applies to executing mutating DQL statements in a transaction block without using the transaction object. As each mutating DQL statement is run in its own transaction, this can lead to a deadlock. See the previous section Nested Non-Transaction Operations for an example.

Ditto keeps track of how long a transaction has been running and will start logging warnings annotated with the transaction’s hint every 5 seconds if a transaction runs for more than 10 seconds. These time intervals can be configured using the following system parameters:

  • transaction_duration_before_logging_ms: The amount of time in milliseconds that a transaction can be running for before logging should begin.
  • transaction_trace_interval_ms: The interval of time in milliseconds between progressive trace statements

These warnings will be emitted with a progressively escalating log level, starting at Debug.

Replication of Changes from Transactions

In a Ditto mesh where peers share identical subscriptions, transaction atomicity and causal consistency is ensured by the replication process. However, if a peer subscribes to only a subset of the changes within a transaction, that peer will receive only the subscribed subset. This can lead to unexpected consequences if the peer later broadens its subscriptions or replicates these partial changes to other peers with broader subscriptions.

For the first scenario, consider a mesh with two peers. If Peer 1 modifies documents A and B, but Peer 2 subscribes only to changes on document A, then Peer 2 will receive only the changes to document A. If Peer 2 subsequently expands its subscriptions to include document B, it will not immediately have access to changes to B from the transaction; these changes will only become available once replication catches up with the extended subscription set.

A similar situation arises when replication occurs in stages across peers with varying subscription scopes. Suppose Peer 1 modifies both A and B, and Peer 2 subscribes only to document A. Peer 2 receives only changes to A. If Peer 3 is connected only to Peer 2 but subscribes to both A and B, it will still receive only the changes to A from the transaction since Peer 2 did not have access to changes on B. This can lead to unexpected behavior because Peer 3’s subscriptions includes both documents, but changes to B are missing due to Peer 2’s limited subscriptions. This issue can be mitigated by ensuring sufficient connectivity with peers that have broader subscription sets.

Error Handling

When an error is thrown at any point inside the transaction block or while committing the transaction, the transaction is implicitly rolled back and the error is propagated to the caller of transaction().

If errors occur in an execute() call within a transaction block and the error is caught and handled within the block, the transaction will continue to run and not be rolled back.

When a transaction is configured to be read-only, the execution of mutating DQL statements (UPDATE, DELETE, etc.) will throw an error. Unless this error is caught, it will be propagated to the caller of transaction(:) and the transaction will be rolled back.

Limitations

Concurrent read-write transactions are not supported. If a read-write transaction is already in progress, any attempt to start a new transaction will wait until the current transaction is completed. See section Deadlock Prevention for more information. Transaction atomicity may be temporarily compromised if a mesh is set up such that changes are replicated to a peer with subscriptions covering only a portion of a transaction’s changes, and that peer then replicates to other peers with broader subscriptions or full coverage of the transaction. Additionally, if a peer initially subscribes to only part of a transaction’s changes and later extends its subscriptions, it won’t immediately receive the remaining changes from the original transaction. These additional changes will only become available once replication aligns with the new subscription scope. For more details, refer to Replication of Changes from Transactions section.