Transactions
If you want the SharedTree to treat a set of changes atomically, then you can wrap these changes in a transaction.
Using a transaction guarantees that (if applied) all of the changes will be applied together synchronously and no other changes (either from this client or from a remote client) can be interleaved with those changes.
Note that the Fluid Framework guarantees this already for any sequence of changes that are submitted synchronously.
However, using a transaction has the following additional implications:
- If reverted (e.g. via an "undo" operation), all the changes in the transaction are reverted together.
- It is also more efficient for SharedTree to process and transmit a large number of changes as a transaction rather than as changes submitted separately.
- It is possible to specify constraints on a transaction so that the transaction will be ignored if one or more of these constraints are not met.
To create a transaction use Tree.runTransaction.
You can cancel a transaction from within the callback function by returning the special "rollback object", available via Tree.runTransaction.rollback.
If an error occurs within the callback, the transaction will be canceled automatically before propagating the error.
In this example, myNode can be any node in the SharedTree. It will be optionally passed into the callback function.
Tree.runTransaction(myNode, (node) => {
// Make multiple changes to the tree.
// This can be changes to the referenced node but is not limited to that scope.
if (
// Something is wrong here!
) return Tree.runTransaction.rollback;
})
You can also pass a TreeView object to runTransaction().
Tree.runTransaction(myTreeView, (treeView) => {
// Make multiple changes to the tree.
});
There are example transactions here: Shared Tree Demo.
New Transaction API
The APIs described in this section are currently @alpha and may change in future releases.
Unlike Tree.runTransaction, the Alpha runTransaction does not automatically roll back the transaction if the callback throws an error.
If your callback may throw, you should handle errors explicitly and return { rollback: true } when required.
The alpha APIs introduce TreeBranchAlpha.runTransaction as a replacement for Tree.runTransaction.
This new API exists to solve correctness issues where errors being thrown in runTransaction could result in an undefined state.
This new API enables developers to customize how their application handles this error case.
Running a Transaction
Transactions are started via runTransaction on any TreeBranchAlpha
import { TreeAlpha } from "@fluidframework/tree/alpha";
// Using a branch:
const branch = TreeAlpha.branch(myNode);
if (branch !== undefined) {
branch.runTransaction(/* ... */);
}
// Using a view directly:
const view = tree.viewWith(config);
view.runTransaction(/* ... */);
The transaction Callback Function
The callback function passed to runTransaction implements the transaction logic. It can optionally return a status object indicating success or failure, and include a user-defined value.
Simple form (no return value):
const result = view.runTransaction(() => {
view.root.name = "Alice";
view.root.score = 100;
// If something goes wrong, we can roll back this transaction by returning an object with rollback: true
// return { rollback: true };
});
if (result.success) {
// Transaction was committed
} else {
// Transaction was rolled back
}
With return values:
const result = view.runTransaction(() => {
const oldName = view.root.name;
view.root.name = "Bob";
return { value: oldName };
});
if (result.success) {
console.log("Previous name was:", result.value);
}
Asynchronous Transactions
runTransactionAsync works like runTransaction but accepts an async callback.
const result = await view.runTransactionAsync(async () => {
const data = await fetchExternalData();
view.root.externalValue = data;
});
Transaction Labels
You can attach a label to a transaction via the params argument.
The label is surfaced through ChangeMetadata, and can be useful for grouping related actions.
view.runTransaction(
() => {
view.root.name = "Dave";
},
{ label: "rename-user" },
);
// Label "rename-user" will now be associated with the commit for this transaction and will appear in relevant metadata
// Here's an example of accessing the label from a change event:
view.checkout.events.on("changed", (meta) => {
console.log(meta.label); // will log "rename-user" if this event handler is applied before the above transaction is run
});
If transactions are nested, only the outermost transaction's label is used.
Nested Transactions
runTransaction and runTransactionAsync can be called from within the callback of another transaction, with some limitations. Nested transactions have the following behavior:
- If the inner transaction rolls back, only the inner transaction's changes are discarded. The outer transaction continues.
- Constraints are applied to the outermost transaction. A single commit is generated for the outermost transaction, encompassing all inner transactions.
- Undo reverts the outermost transaction and all of its inner transactions together.
- Only the outermost transaction's label is used; inner labels are ignored.
- Constraints from nested transactions become part of the final overall transaction
An asynchronous transaction cannot be started inside a synchronous transaction.
The other three nesting combinations (sync-in-sync, sync-in-async, async-in-async) are all supported.
The system will throw a UsageError when an asynchronous transaction is started inside of a synchronous transaction.
Constraints
Constraints let you opt in to stricter validation when your application needs it. A violated constraint causes the entire transaction to be dropped. Constraints can be applied as preconditions to a transaction (or as preconditions on revert, to be applied if the transaction is reverted) thereby ensuring that the transaction's changes will only be applied if some specific conditions are met.
This is useful when the transaction's effect may be rendered undesirable by the effects of concurrent edits that are sequenced before the transaction.
Specifying Constraints
Constraints are specified via the preconditions field in the params argument to runTransaction, or returned from the transaction callback via the preconditionsOnRevert field:
view.runTransaction(
() => {
view.root.content = "new value";
},
{
// `preconditions` applies to the transaction itself.
// Here we add a `nodeInDocument` constraint which will ensure that this transaction will only be applied if someNode is still in the document at the time this transaction is sequenced.
preconditions: [
{ type: "nodeInDocument", node: someNode },
],
},
);
view.runTransaction(() => {
view.root.content = "updated";
return {
// `preconditionsOnRevert` only applies if the transaction is reverted (e.g. via an undo operation).
// Here we add a `nodeInDocument` constraint which will ensure that the changes made by this transaction can only be reverted if view.root is still in the document at the time of revert.
preconditionsOnRevert: [
{ type: "nodeInDocument", node: view.root },
],
};
});
Supported Constraint Types
nodeInDocument
Requires that a specific node exists in the document.
const importantNode = view.root;
view.runTransaction(
() => {
view.root.value = 42;
},
{
preconditions: [
{ type: "nodeInDocument", node: importantNode },
],
},
);
If importantNode is removed by a concurrent edit that is sequenced before this transaction, the transaction will be rolled back.
The nodeInDocument constraint is also available for use in the old transaction API via Tree.runTransaction.
This is the only constraint which can be applied via Tree.runTransaction.
noChange (Alpha)
Requires that the document is in the exact same state when the transaction is applied as it was when the transaction was created. If any concurrent edit is sequenced before the transaction, the constraint is violated and the transaction is rolled back.
In some cases, SharedTree will attempt reapply transactions whose noChange constraint was violated. This happens when we detect that no visible change has occurred - eg, if an element was added and then removed, the add would violate noChange, but then with the remove happening directly after, we will unviolate the noChange constraint.
view.runTransaction(
() => {
view.root.items.insertAtEnd("new item");
},
{
preconditions: [{ type: "noChange" }],
},
);