Transactions
Starcounter uses database transactions to ensure that database operations are ACID compliant. When writing Starcounter application code, we must wrap database reads and writes in transactions, so it's important to know how they work in Starcounter.
Achieving ACID compliance
Atomicity
As defined by Wikipedia, an atomic transaction "is an indivisible and irreducible series of database operations such that either all occur, or nothing occurs." Starcounter ensures atomicity by wrapping changes of one transaction within a transaction scope. The changes commit simultaneously at the end of the scope. If something interrupts the transaction before the end of the scope is reached, none of the changes will commit to the database.
Consistency
A consistent DBMS ensures that all the data written to the database follow the defined contraints of the database. In Starcounter, this is solved by raising exceptions when an illegal action is carried out within a transaction, such as commiting non-unique values to a field that requires unique values. The exception will in turn make the transaction roll back so that none of the changes are applied.
Isolation
To make transaction isolated, Starcounter uses snapshot isolation. This means that when a transaction initializes, it takes a snapshot of the database and stores it in a transactional memory. Every transaction operates on its own snapshot of the database. For example, an SQL query that executes before a parallel transaction commits will not be able to see the changes made by the transaction because the changes are isolated to that transaction's snapshot of the database. This works no matter how large the database is.
Durability
Durability ensures that committed transactions will have their results recorded permanently. Starcounter solves this by writing transactions to a transaction log after they are committed.
Concurrency Control
When multiple users write to the database at the same time, the database engine must ensure that the data is consistent by using atomicity and isolation. For example, imagine the value of a database table cell is 100
, and you update it to 110
in a transaction. At the same time, another transaction is also reading the value of the cell as 100
and updates it to 120
. What should the end result be?
To resolve the problem with multiple transactions accessing the same data, the transaction must be able to handle conflicts. The easiest way to do this is to use locking. If you want your database engine to serve large numbers of users and transactions, locking is slow and expensive and can lead to deadlocks. Locking is efficient when conflicts are likely, but is otherwise slow because it always consumes time, even if there are no conflicts. Another word for locking is "pessimistic concurrency control".
A more efficient way of providing concurrency than "pessimistic concurrency control" is "optimistic concurrency control". As the name implies, this concurrency mechanism assumes that conflicts are unlikely, but if conflicts happen, they are still handled. Starcounter uses optimistic concurrency control. Thus, the Starcounter database handles transactions without locking the modified objects. If there are conflicts, the developer can either provide a delegate to execute on conflict or use the ITransactor.TryTransact
method to retry when there is a conflict.
ITransactor.Transact
ITransactor.Transact
ITransactor.Transact
is the simplest way to create a transaction in Starcounter. It declares a transactional scope and runs synchronously, as described above. The argument passed to the ITransactor.Transact
method is a delegate containing the code to run within the transaction. We can get the ITransactor
instance from the application service provider:
Then we can use the ITransactor
to create new database transactions.
Since ITransactor.Transact
is synchronous, it blocks the executing thread until the transaction completes. Thus, if the transaction takes more than a few milliseconds to run, it might prevent your application's performance from scaling with CPU core counts. In those cases, it's better to use ITransactor.TransactAsync
. ITransactor.TransactAsync
returns a Task
that completes when the transaction commits or rolls back, which lets you avoid thread blocking.
ITransactor.TransactAsync
ITransactor.TransactAsync
ITransactor.TransactAsync
is the asynchronous counterpart of ITransactor.Transact
. It gives the developer more control of balance throughput and latency. The function returns a Task
that is marked as completed and successful with the property IsCompletedSuccessfully
when the database operations are written to the transaction log which persists the changes.
ITransactor.Transact
and ITransactor.TransactAsync
are syntactically identical, but semantically different since ITransactor.TransactAsync
is used with await
. While awaiting the transaction log write operation, it's possible to do other things:
This is more flexible and performant than ITransactor.Transact
, but it comes with certain risks. For example, if there's a power outage or other hardware failure after the email is sent but before writing to the log, the email will be incorrect. Even if the user got a confirmation, the order will not be in the database since it was never written to the transaction log.
ITransactor.TransactAsync
is also useful when creating many transactions in sequence:
This speeds up the application since the thread is free to handle the next transaction even if the database operations in the previous transactions are not written to the transaction log yet.
Nested transactions
A nested transaction is a transaction that is run within the scope of another transaction. Starcounter does not support such transactions. Attempting to start a new transaction within an active transaction's scope will throw a NotSupportedException
exception.
Instead, in the common cases where such an inner transaction would be used, we should instead reuse the outer transaction or move the inner transaction logic to a new transaction that is started before or after the outer transaction.
Transaction size
The logic we provide in delegates in calls to ITransactor.Transact
and ITransactor.TransactAsync
should execute in as short time as possible, since conflicts are more likely to occur the longer the transaction is. And retrying long transactions due to conflicts can be expensive resource-wise. Therefore it's best practice to break big transactions into smaller ones.
Threading
Transactions serialize database access because the database kernel is not thread-safe. Since database access is serialized, database operations in the same transaction can't be parallelized.
For example, if you try to use PLINQ to parallelize database operations in a transaction, it will fail with ScErrNoTransactionAttached
:
Even if the database operations of a transaction aren't parallelized, database operations from multiple transactions are executed either concurrently or in parallel, depending on the available database resources.
Async operations within a transaction
It is not recommended to execute any asynchronous operations within a database transaction, especially not those which have side effects. Still, Starcounter supports asynchronous transaction delegates.
The following example demonstrates how to asynchronously save a query result into a file.
Note: Starcounter uses a custom synchronization context to serialize database access, that is why awaiter has to always continue on the captured context.
Last updated