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:
17 collapsed lines
package main
import ( "context" "fmt" "os" "slices" "strconv" "time"
"github.com/google/uuid"
"github.com/StatelyCloud/go-sdk/stately" // This is the code you generated from schema "github.com/StatelyCloud/stately/go-sdk-sample/schema")
func sampleTransaction( ctx context.Context, client stately.Client,) error { starshipTroopers := &schema.Movie{ Title: "Starship Troopers", Rating: "G", // nope Duration: 2*time.Hour + 9*time.Minute, Genre: "Sci-Fi", Year: 1997, } item, err := client.Put(ctx, starshipTroopers) if err != nil { return err } newMovie := item.(*schema.Movie)
_, err = client.NewTransaction( ctx, func(txn stately.Transaction) error { // Get the movie we just put item, err := txn.Get(newMovie.KeyPath()) if err != nil { return err }
// Update the rating starshipTroopers = item.(*schema.Movie) starshipTroopers.Rating = "R" _, err = txn.Put(starshipTroopers) if err != nil { return err }
// And add a change log entry as a child item - this will only exist // if the rating change also succeeds _, err = txn.Put(&schema.Change{ Field: "Rated", Description: "G -> R", MovieId: starshipTroopers.Id, }) return err }, ) return err}
5 collapsed lines
require 'bundler/setup'require_relative 'schema/stately'require 'byebug'
def sample_transaction(client) movie = StatelyDB::Types::Movie.new( title: 'Nightmare on Elm Street', year: 1984, genre: 'Horror', duration: 6060, rating: 'G' # nope ) result = client.put(movie)
result = client.transaction do |tx| # Get the movie item key_path = StatelyDB::KeyPath.with('movie', result.id) movie = tx.get(key_path)
# Update the rating movie.rating = 'R' tx.put(movie)
# And add a change log entry as a child item - this will only exist if # the rating change also succeeds tx.put(StatelyDB::Types::Change.new( movie_id: movie.id, field: 'rating', description: 'Updated rating from G to R' )) end
# Get the Change log entry out of the transaction result. Note that we're # grabbing the second item in the result.puts portion of the response # since it is the second item we put in the transaction. change_log_entry = result.puts.at(1)
# Display the Change log entry puts "Change Log Entry:" puts " ID: #{change_log_entry.id}" puts " Field: #{change_log_entry.field}" puts " Description: #{change_log_entry.description}"end
9 collapsed lines
from __future__ import annotations
from typing import TYPE_CHECKING
from statelydb import ListToken, SyncChangedItem, SyncDeletedItem, SyncReset, key_path
from .schema import Actor, Change, Character, Client, Movie
async def sample_transaction(client: Client) -> None: movie = Movie( title="Nightmare on Elm Street", year=1984, genre="Horror", duration=6060, rating="G", # nope ) result = await client.put(movie)
txn = await client.transaction() async with txn: # Get the movie item kp = key_path("movie-{id}", id=result.id) movie = await txn.get(Movie, kp)
# Update the rating movie.rating = "R" await txn.put(movie)
# And add a change log entry as a child item - this will only # exist if the rating change also succeeds await txn.put( Change( movie_id=movie.id, field="rating", description="Updated rating from G to R", ), )
# Get the Change log entry out of the transaction result. Note that # we're grabbing the second item in the result.puts portion of the # response since it is the second item we put in the transaction. change_log_entry = txn.result.puts[1]
# Display the Change log entry print("Change Log Entry:") print(f" ID: {change_log_entry.id}") print(f" Field: {change_log_entry.field}") print(f" Description: {change_log_entry.description}")
3 collapsed lines
import { createClient, DatabaseClient, Movie } from "./schema/index.js";import { keyPath, ListToken } from "@stately-cloud/client";
async function sampleTransaction(client: DatabaseClient) { const originalMovie = await client.put( client.create("Movie", { genre: "action", year: 1997n, title: "Starship Troopers", rating: "G", // nope duration: 7_740n, }), );
const result = await client.transaction(async (txn) => { // Don't forget to await each of the operations! const movie = await txn.get( "Movie", keyPath`/movie-${originalMovie.id}`, );
// Fix the rating if (movie && movie.rating != "R") { movie.rating = "R"; await txn.put(movie);
// And add a change log entry as a child item - this will only // exist if the rating change also succeeds await txn.put( client.create("Change", { movieId: movie.id, field: "Rated", description: "G -> R", }), ); }
// No error means the transaction will be committed, if nothing else // changed that movie in the meantime });
// Get the Change log entry out of the transaction result. Note that // we're grabbing the second item in the result.puts portion of the // response since it is the second item we put in the transaction. const changeLogEntry = result.puts[1];
console.log(changeLogEntry);}
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 hierarchies 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.