Skip to content

Transactions

Transactions allow you to perform a sequence of different operations together, and all changes will be applied at once, or not at all. To put it another way, transactions allow you to modify multiple Items without worrying that you’ll end up with an inconsistent state where only one of the changes was applied. Furthermore, you can do reads (Get, List) within the transaction, and use the results of those reads to decide what to write, without worrying about whether the data you read changed between the read and the write. These are called “read-modify-write” transactions and they allow you to perform complex updates that depend on the original state of an Item or even the state of other Items.

Using the Transaction API

The Transaction API is interactive - you pass in a function that takes a Transaction as an argument, and then within that function you can do any sequence of operations you like. If your function returns without an error, all the changes are committed together. If your function throws an error, none of the changes are committed. The result of the transaction contains information about all the Items that were created or updated within the transaction, since their metadata isn’t computed until the transaction commits. If nothing was changed in the transaction, it is effectively a no-op.

If any other concurrent operation (another transaction or other write) modified any of the Items you read in your transaction, the transaction will fail (rollback) and return a specific error. You can retry the transaction, and your function will be called again with a fresh Transaction and have a chance to repeat the sequence of reads and writes with the latest data, potentially resulting in different decisions. Please keep in mind that your transaction function needs to be idempotent if you plan to retry it - if you modify other state in memory or call non-transactional APIs within the function, you won’t get the benefits of transactional consistency. You should also try to keep your transaction functions fast, since the longer the transaction is active, the higher the chance something else will try to modify those same items.

For this example we’ll use the schema defined in Example: Movies Schema, which declares a Movie Item type. The transaction reads a Movie, updates its rating, and saves it back, and also saves a change log entry Item:

15 collapsed lines
1
package main
2
3
import (
4
"context"
5
"fmt"
6
"os"
7
"slices"
8
"strconv"
9
"time"
10
11
"github.com/StatelyCloud/go-sdk/stately"
12
// This is the code you generated from schema
13
"github.com/StatelyCloud/stately/go-sdk-sample/schema"
14
)
15
16
func sampleTransaction(
17
ctx context.Context,
18
client stately.Client,
19
) error {
20
starshipTroopers := &schema.Movie{
21
Title: "Starship Troopers",
22
Rating: "G", // nope
23
Duration: int64(
24
(2*time.Hour + 9*time.Minute).Seconds(),
25
),
26
Genre: "Sci-Fi",
27
Year: 1997,
28
}
29
item, err := client.Put(ctx, starshipTroopers)
30
if err != nil {
31
return err
32
}
33
newMovie := item.(*schema.Movie)
34
35
_, err = client.NewTransaction(
36
ctx,
37
func(txn stately.Transaction) error {
38
// Get the movie we just put
39
item, err := txn.Get(newMovie.KeyPath())
40
if err != nil {
41
return err
42
}
43
44
// Update the rating
45
starshipTroopers = item.(*schema.Movie)
46
starshipTroopers.Rating = "R"
47
_, err = txn.Put(starshipTroopers)
48
if err != nil {
49
return err
50
}
51
52
// And add a change log entry as a child item - this will only exist
53
// if the rating change also succeeds
54
_, err = txn.Put(&schema.Change{
55
Field: "Rated",
56
Description: "G -> R",
57
MovieId: starshipTroopers.Id,
58
})
59
return err
60
},
61
)
62
return err
63
}

Puts in Transactions

The Put API behaves a bit differently inside a transaction than it does outside. Inside a transaction, the Put API will only return an ID, not the whole item. If your item uses an initialValue, the returned ID will be the ID that StatelyDB chose for that field. Otherwise, Put won’t return anything.

You can use this returned ID in subsequent Puts to build heirarchies of items that reference each other. For example you might Put a Customer (returning a new Customer ID), and then use that Customer ID to Put an Address.

You also can’t Get any of the items you’ve Put, until the transaction is committed. That’s because those items haven’t actually been created yet - they all get created together when the transaction commits.

Cross-Group Transactions

A transaction can read and write Items from different Groups. This is a very powerful tool but can be less efficient than isolating updates to a single group. Thus, where possible, we recommend adjusting your data model to ensure that all Items which need to be updated together are in the same Group.

Cross-Store transactions

One current limitation of StatelyDB, which we are working to address, is that transactions are limited to Items in a single Store. A transaction can read and write any Items that are in the same Store, but there is currently no way to specify a separate store to also transact on.

Transaction Isolation

Transactions in StatelyDB are strongly isolated, meaning that you can be sure that no other operations (other transactions or even individual writes) can interfere with the operations in your transaction. If you’re familiar with SQL isolation levels, this corresponds to the SERIALIZABLE isolation level, which is the strongest guarantee of consistency. For example, if you do a read-modify-write transaction, you can be sure that the data you read has not been modified by any other operation before the transaction completes (otherwise, you might have made decisions based on outdated data!). The tradeoff is that StatelyDB will fail your transaction if it detects that another operation has modified the data you’re reading or writing. This is a good thing, because it means you can be sure that your transactional changes are consistent, but it also means that you need to be prepared to handle transaction failures and retry the transaction if necessary.