Python · Transactions

Transactions

import smashtablefrom smashtable import Participant, Transaction 1.0.0-rc.1 · c4d34add

§ Optimistic Concurrency

watch makes a transaction fail rather than overwrite a key someone else touched first. ConflictError is the retry signal, and the only exception a correct program is expected to catch routinely:

while True:
    try:
        with st.transaction(accounts) as (view,):
            view.watch("alice")
            view["alice"] = view["alice"] - 100
        break
    except st.ConflictError:
        continue    # nothing was applied, so retrying is safe

§ The Two Phases

with is exactly begin(), the body, stage(), commit(). Both phases are also available directly:

group = st.transaction(by_id, by_name)
ids, names = group.begin()
ids[42] = "carol"; names["carol"] = 42
group.stage()      # validates watches and reserves; still invisible, and may raise ConflictError
group.commit()     # flips visibility

Separating the fallible phase from the applying phase is what lets independent transactions compose into one all-or-nothing unit. stage is where a conflict or an allocation failure normally surfaces. Between the two the participants take no more writes: ids[43] = 'dave' after stage() raises StateError, since staging already reserved and validated what the transaction would change. A read still answers. commit validates again and can still refuse, because a watched key may be committed over while the group sits staged. A group of two or more containers is asked in full before any of them writes, so a refusal publishes nothing and leaves the group staged and retryable. A lone participant commits in one call, having nothing to tear against. Every participant keeps what it validated held until it publishes, so nothing moves between the two passes and the second cannot refuse.

§ Guarantees & Limits

  • A group stages in full or not at all. A body that raises resets every participant. A stage that fails on one participant unwinds them all.
  • A commit publishes in full or not at all. Every participant is asked before any of them writes, and a refusal leaves the group staged, where rollback() or another commit() picks it up.
  • Staged writes are invisible to everyone else, including a transaction opened after the stage.
  • A transaction reads its own writes, until it stages. view[k] sees what view[k] = v put there; after stage() the write is in the store carrying no stamp, so it is invisible to everyone including its own transaction until commit().
  • Commit is not a snapshot, unless the containers share a commit order. It applies each container in turn, so another thread reading two containers while a commit runs may find one of them a step ahead. A reader that needs the pair to agree should take its own transaction or read after the writer’s block returns.
  • Scans are not snapshots either. scan() walks in key order and never yields a key twice or raises mid-walk, but a key inserted behind the cursor is missed. That is the container’s own scan; the same call on a transaction participant records the window it read, so at serializable and above a key committed into that window refuses the commit rather than being silently missed.

API

Types

ConflictErrorBase of the three ways an optimistic validation turns a transaction away.
__init__.pyi:84 ↗
class ConflictError(SmashTableError, RuntimeError)

Never raised on its own - catch it to retry whichever of the three arrived.

WriteConflictErrorAn earlier commit already wrote a key this transaction staged.
__init__.pyi:90 ↗
class WriteConflictError(ConflictError)
ReadConflictErrorA key this transaction read was committed over before it could commit.
__init__.pyi:93 ↗
class ReadConflictError(ConflictError)
StateErrorA transaction was asked for something its current state does not allow.
__init__.pyi:102 ↗
class StateError(SmashTableError, RuntimeError)
ClassParticipantOne container's slice of a transaction.6
__init__.pyi:577 ↗
class Participant

Reads see this transaction's own writes and the state as of when the group opened, never another transaction's staged-but-uncommitted writes. Writes stay invisible to every other reader until the transaction commits, and vanish if it does not. A view stops working once its transaction finishes.

getValue for a key inside this transaction, or default when the key is absent.
__init__.pyi:590 ↗
def get(key: _Key, default: Any = None, /) -> Any

Reads this transaction's own uncommitted writes, and otherwise the state as of when the transaction opened. It does not see writes another transaction has staged but not committed, so a read is never dirty.

Repeating a read is not guaranteed to return the same value: a committed write from elsewhere becomes visible immediately. Call watch(key) to make the transaction refuse to commit if that happens.

Raises

  • TypeError: If key is not of this store's key type, or this is a set.
  • StateError: If the transaction has already finished.
addInsert one member into a set participant, invisibly until the transaction commits.
__init__.pyi:605 ↗
def add(member: _Key, /) -> None

Idempotent, and subject to the same visibility rule as upsert.

Raises

  • TypeError: If this participant is a map, which needs a value.
  • StateError: If the transaction has already finished.
discardRemove a key if it is there, reporting whether it was.
__init__.pyi:614 ↗
def discard(key: _Key, /) -> bool

del participant[key] removes it too; this is the form that answers.

The removal is invisible outside the transaction until commit, and is undone by rollback or by the block raising.

Returns

True when the key was present, so a caller need not look first.

Raises

  • TypeError: If key is not of this store's key type.
  • StateError: If the transaction has already finished.
updateApply every pair of a mapping to this participant.
__init__.pyi:629 ↗
def update(
    other: Mapping[_Key, Any] | Iterable[Any],
    /,
) -> None

Atomic, unlike SortedMap.update: every pair lands with the rest of the transaction or none of them does. A failure part-way leaves the transaction abandonable by rollback with nothing applied.

Raises

  • TypeError: If other is not a mapping, or a key is of the wrong type.
  • ValueError: If an element of other is not a pair.
  • StateError: If the transaction has already finished.
watchRefuse to commit if another writer touches this key first.
__init__.pyi:641 ↗
def watch(key: _Key, /) -> None

This is what turns read-modify-write into a lost-update-free operation. Without it a transaction is atomic but not serializable: two transactions can each read the same value, each write back, and one update is silently lost.

Covers absence as well as presence, so watching a key that does not exist still conflicts if someone inserts it. The conflict surfaces at stage(), not at the write, and nothing is applied when it does.

Raises

  • ConflictError: At stage() time, never here.
  • TypeError: If key is not of this store's key type.
  • StateError: If the transaction has already finished.
scanList of (key, value) pairs in key order over [start, stop), or of members for a set.
__init__.pyi:657 ↗
def scan(
    start: _Key | None = None,
    stop: _Key | None = None,
    *,
    limit: int | None = None,
) -> list[Any]

Sees this transaction's own staged writes, and otherwise the state as of when it opened. Materialized in one span rather than lazily, because a walk records the window it read and nothing else may run inside that record.

Under 'serializable' or stricter, a key another transaction commits into that window makes this transaction's commit raise PhantomConflictError, rather than let a repeat of the walk answer differently.

Raises

  • TypeError: If a bound is not of this store's key type, or this participant is unordered.
  • ValueError: If limit is negative.
  • StateError: If the transaction has already finished.
ClassTransactionA transaction of containers staged together and then published in turn.5
__init__.pyi:674 ↗
class Transaction

Either every store is published or none is: a body that raises resets each participant, a stage that one refuses unwinds the rest, and a commit asks every store before any of them writes. Publication itself walks the containers in turn rather than moving them at one instant, so a thread reading two of them while a commit runs may find one a step ahead - take a transaction of your own if the pair must agree. No transaction ever reads another's uncommitted writes. What more is promised depends on the store: its isolation property reports the level, and below 'snapshot' a value already read may change underneath, so a read-modify-write needs watch() to be safe.

Durability is memory-only: this is not a database and nothing survives the process.

Used as a context manager, the block is exactly begin(), the body, stage(), commit(); an exception discards everything instead.

beginTuple of per-container views, in the order the stores were given.
__init__.pyi:694 ↗
def begin() -> tuple[Participant, ...]

Participants stage in a process-wide canonical order rather than argument order, which is what lets two groups naming the same containers in opposite orders run concurrently without deadlocking. The views you receive ignore that and follow your arguments.

Raises

  • StateError: If the transaction has already finished.
stageValidate every watch and reserve the writes.
__init__.pyi:705 ↗
def stage() -> None

Nothing becomes visible.

This is the first of the two phases. After it succeeds, commit() cannot fail for a reason this transaction could have avoided. It can still refuse over a watched key another transaction published while this one sat staged.

A partial stage is never observable: if one participant refuses, every other is unwound before this returns.

Raises

  • ConflictError: If a watched key changed since the transaction opened.
  • StateError: If the transaction is not open - already staged, or finished.
commitPublish every staged change, or none of it.
__init__.pyi:719 ↗
def commit() -> None

Every container is asked before any of them writes, so a refusal publishes nothing and leaves the group staged, to be retried or rolled back.

Not a snapshot across containers. Each is applied in turn, so a thread reading two of them while this runs may find one a step ahead of the other; a reader that needs the pair to agree should take its own transaction.

Raises

  • StateError: If the transaction was not staged first.
  • ConflictError: If a watched key was published over while the group sat staged.
rollbackPull staged changes back out of the stores, leaving them as they were.
__init__.pyi:733 ↗
def rollback() -> None

Valid only after stage() and before commit(). Since nothing staged was ever visible, no reader can have observed what this undoes.

Raises

  • StateError: If the transaction was not staged.
  • SmashTableError: If a staged version was gone before the rollback reached it, which is a broken invariant rather than a race a retry could win.
resetDiscard everything pending and start the transaction over.
__init__.pyi:744 ↗
def reset() -> None

Unlike rollback, valid at any point before the handle closes - open, staged or already committed. This is what a retry loop calls after catching ConflictError.

Functions

transactionOpen one transaction spanning every given store.
__init__.pyi:758 ↗
def transaction(
    *stores: SortedMap | SortedSet | HashMap | HashSet = (),
) -> Transaction

Used as a context manager, the block either makes every change visible or none of them. Stores may mix maps and sets, ordered and unordered, and may use different key types and isolation levels.

Parameters

storesSortedMap | SortedSet | HashMap | HashSetOne or more SortedMap, SortedSet, HashMap or HashSet, each at most once. A store's own transaction() is this with one argument.

Returns

Whose participants arrive in the order the stores were given.

Raises

  • TypeError: If no store was given, or one is not a store.
  • ValueError: If the same store is given twice.