Typorithm · medium

Optimistic version checks and deliberate retries

pythonsqlalchemyalembicdatabasesindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

Optimistic version checks and deliberate retries

0%

Concept and contract

Reject an update based on an old mapped version and reread current state before applying a reviewed merge policy.

Two requests can base changes on the same old object state even when their writes occur at different times. A mapper version counter lets an ORM flush include the version it previously loaded in its UPDATE condition. If that version no longer matches a row, the flush can report StaleDataError instead of silently overwriting a later change.

The fixture uses an owned file database and a non-null version column. It releases the stale Session’s read transaction before another Session commits, while retaining the old object with expire_on_commit=False. This is a deliberate serialized stale-object experiment, not a claim to simulate every database’s concurrent isolation behavior.

Mechanism and worked example

The row starts with score 10 and version one.

The row starts with score 10 and version one. A second Session changes the score to 20, advancing the version. The stale object then attempts a different score using its old version and the flush is rejected. The handler rolls back before reading the final state, which remains score 20 at version two.

If the intervening writer made no actual change, there may be no version advance or conflict. The cases include that distinction. Version checking concerns actual ORM writes; assigning an attribute its unchanged value need not emit an UPDATE simply because the Python assignment statement ran.

Complete worked example

python

from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy.orm.exc import StaleDataError

class Base(DeclarativeBase):
    pass

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    score: Mapped[int]
    version: Mapped[int] = mapped_column(default=1, nullable=False)
    __mapper_args__ = {"version_id_col": version}

def inspect_example(initial, committed, late_value):
    with TemporaryDirectory() as folder:
        engine = create_engine("sqlite+pysqlite:///" + str(Path(folder) / "owned.sqlite3"),
                               connect_args={"autocommit": False})
        try:
            Base.metadata.create_all(engine)
            with Session(engine) as seed, seed.begin():
                seed.add(Role(id=1, score=initial))
            with Session(engine, expire_on_commit=False) as stale:
                role = stale.get(Role, 1)
                stale.commit()  # release the read transaction, retain this old version
                with Session(engine) as writer, writer.begin():
                    writer.get(Role, 1).score = committed
                conflict = False
                try:
                    role.score = late_value
                    stale.commit()
                except StaleDataError:
                    stale.rollback()
                    conflict = True
            with Session(engine) as check:
                current = check.get(Role, 1)
                return {"score": current.score, "version": current.version, "conflict": conflict}
        finally:
            engine.dispose()
Reasoning
The row starts with score 10 and version one. A second Session changes the score to 20, advancing the version. The stale object then attempts a different score using its old version and the flush is rejected. The handler rolls back before reading the final state, which remains score 20 at version two. If the intervening writer made no actual change, there may be no version advance or conflict. The cases include that distinction. Version checking concerns actual ORM writes; assigning an attribute its unchanged value need not emit an UPDATE simply because the Python assignment statement ran.
Download the SQLAlchemy and Alembic computer lab ↗

Focused decision: context is in the complete example

python

version: Mapped[int] = mapped_column(default=1, nullable=False)
__mapper_args__ = {"version_id_col": version}
Database decision
A mapped version adds a stale-state predicate to the ORM flush. The practice retries only after rollback and a fresh read, with a specifically stated delta policy.
Download the SQLAlchemy and Alembic computer lab ↗

Counterexamples and common mistakes

The controlled bug removes version_id_col from the mapper.

The controlled bug removes version_id_col from the mapper. The stale write can then overwrite the committed value without the intended version predicate, and the version does not behave like the reviewed optimistic counter. Checking only that commit returned normally would miss the lost-update behavior.

Do not promise that mapper versioning guards arbitrary bulk SQL updates or replaces the database’s own isolation and locking rules. Also avoid retrying the same stale absolute assignment indefinitely. A retry policy needs an explicit domain meaning and a fresh read; it is not a generic instruction to ignore every concurrency error until a commit succeeds.

Controlled bug: observe the rejected database contract

python

from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy.orm.exc import StaleDataError

class Base(DeclarativeBase):
    pass

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    score: Mapped[int]
    version: Mapped[int] = mapped_column(default=1, nullable=False)
    # Missing mapper version checking

def inspect_example(initial, committed, late_value):
    with TemporaryDirectory() as folder:
        engine = create_engine("sqlite+pysqlite:///" + str(Path(folder) / "owned.sqlite3"),
                               connect_args={"autocommit": False})
        try:
            Base.metadata.create_all(engine)
            with Session(engine) as seed, seed.begin():
                seed.add(Role(id=1, score=initial))
            with Session(engine, expire_on_commit=False) as stale:
                role = stale.get(Role, 1)
                stale.commit()  # release the read transaction, retain this old version
                with Session(engine) as writer, writer.begin():
                    writer.get(Role, 1).score = committed
                conflict = False
                try:
                    role.score = late_value
                    stale.commit()
                except StaleDataError:
                    stale.rollback()
                    conflict = True
            with Session(engine) as check:
                current = check.get(Role, 1)
                return {"score": current.score, "version": current.version, "conflict": conflict}
        finally:
            engine.dispose()
Reasoning
The controlled bug removes version_id_col from the mapper. The stale write can then overwrite the committed value without the intended version predicate, and the version does not behave like the reviewed optimistic counter. Checking only that commit returned normally would miss the lost-update behavior. Do not promise that mapper versioning guards arbitrary bulk SQL updates or replaces the database’s own isolation and locking rules. Also avoid retrying the same stale absolute assignment indefinitely. A retry policy needs an explicit domain meaning and a fresh read; it is not a generic instruction to ignore every concurrency error until a commit succeeds.
Download the SQLAlchemy and Alembic computer lab ↗

Independent practice

Type the version column and mapper configuration, then predict the winner of the documented interleaving.

Type the version column and mapper configuration, then predict the winner of the documented interleaving. For independent practice, after a real conflict and rollback reread the current row and apply late_value as a delta. Keep the no-conflict path’s original absolute-assignment behavior unchanged.

This asymmetry is intentional in the teaching contract: it makes the merge policy explicit rather than pretending all retries mean the same thing. Use the supplied [10, 20, 5] case, then a zero-valued starting row and a case with no intervening version change. Do not reuse the expired stale attributes as the fresh source.

Step 1: After a conflict and rollback, reread the current row and apply late_value as a delta in a new transaction. Step 2: Preserve the original assignment path when no conflict occurred.

Practice starter: solve before reading the solution

python

from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy.orm.exc import StaleDataError

class Base(DeclarativeBase):
    pass

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    score: Mapped[int]
    version: Mapped[int] = mapped_column(default=1, nullable=False)
    __mapper_args__ = {"version_id_col": version}

def inspect_example(initial, committed, late_value):
    with TemporaryDirectory() as folder:
        engine = create_engine("sqlite+pysqlite:///" + str(Path(folder) / "owned.sqlite3"),
                               connect_args={"autocommit": False})
        try:
            Base.metadata.create_all(engine)
            with Session(engine) as seed, seed.begin():
                seed.add(Role(id=1, score=initial))
            with Session(engine, expire_on_commit=False) as stale:
                role = stale.get(Role, 1)
                stale.commit()  # release the read transaction, retain this old version
                with Session(engine) as writer, writer.begin():
                    writer.get(Role, 1).score = committed
                conflict = False
                try:
                    role.score = late_value
                    stale.commit()
                except StaleDataError:
                    stale.rollback()
                    conflict = True
            with Session(engine) as check:
                current = check.get(Role, 1)
                return {"score": current.score, "version": current.version, "conflict": conflict}
        finally:
            engine.dispose()

# Task: After a conflict and rollback, reread the current row and apply late_value as a delta in a new transaction.
Reasoning
After a conflict and rollback, reread the current row and apply late_value as a delta in a new transaction.
Download the SQLAlchemy and Alembic computer lab ↗
  • Hint 1: After rollback, call get again before computing the delta result.
  • Hint 2: The conflicting [10, 20, 5] case should finish at 25 and version three.

Solution, tests and trade-offs

The solution rolls back, obtains the current Role and adds the late delta in a new transaction.

The solution rolls back, obtains the current Role and adds the late delta in a new transaction. The first fixture finishes with score 25 and version three, while the no-conflict fixture still follows its original assignment path. The checks compare score, version and the observed conflict flag together.

The owned fixture has no competing writer during the retry, so one retry is enough to illustrate the rule. A real service may need bounded retries, idempotency and a different merge or rejection policy. The lesson establishes a stale-state observation and a reviewed decision, not an unconditional guarantee that every conflicting write should be merged.

Practice solution and acceptance checks

python

from pathlib import Path
from tempfile import TemporaryDirectory
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy.orm.exc import StaleDataError

class Base(DeclarativeBase):
    pass

class Role(Base):
    __tablename__ = "roles"
    id: Mapped[int] = mapped_column(primary_key=True)
    score: Mapped[int]
    version: Mapped[int] = mapped_column(default=1, nullable=False)
    __mapper_args__ = {"version_id_col": version}

def inspect_example(initial, committed, late_value):
    with TemporaryDirectory() as folder:
        engine = create_engine("sqlite+pysqlite:///" + str(Path(folder) / "owned.sqlite3"),
                               connect_args={"autocommit": False})
        try:
            Base.metadata.create_all(engine)
            with Session(engine) as seed, seed.begin():
                seed.add(Role(id=1, score=initial))
            with Session(engine, expire_on_commit=False) as stale:
                role = stale.get(Role, 1)
                stale.commit()  # release the read transaction, retain this old version
                with Session(engine) as writer, writer.begin():
                    writer.get(Role, 1).score = committed
                conflict = False
                try:
                    role.score = late_value
                    stale.commit()
                except StaleDataError:
                    stale.rollback()
                    conflict = True
                    current = stale.get(Role, 1)
                    current.score += late_value
                    stale.commit()
            with Session(engine) as check:
                current = check.get(Role, 1)
                return {"score": current.score, "version": current.version, "conflict": conflict}
        finally:
            engine.dispose()
Reasoning
The solution rolls back, obtains the current Role and adds the late delta in a new transaction. The first fixture finishes with score 25 and version three, while the no-conflict fixture still follows its original assignment path. The checks compare score, version and the observed conflict flag together. The owned fixture has no competing writer during the retry, so one retry is enough to illustrate the rule. A real service may need bounded retries, idempotency and a different merge or rejection policy. The lesson establishes a stale-state observation and a reviewed decision, not an unconditional guarantee that every conflicting write should be merged.
Download the SQLAlchemy and Alembic computer lab ↗

Interview reasoning and next step

Explain what version value appears in the UPDATE condition and why the stale flush affects no matching row.

Explain what version value appears in the UPDATE condition and why the stale flush affects no matching row. Describe the required rollback before reusing the Session, and distinguish an absolute replacement from adding a delta to newly read state. Use the final report to show which writer actually won.

When applying the idea elsewhere, consider isolation level, locking and write style alongside the mapper feature. SQLite results do not prove PostgreSQL’s concurrent scheduling behavior. Next, move to asynchronous Sessions, where transaction ownership and unexpected attribute-triggered IO also need explicit boundaries.

Optional stretch — after the core task works: Define a bounded conflict rejection policy for another owned workflow where merging is inappropriate.

Computer practice: https://applydjinn.com/typorithm/sqlalchemy-lab-20261107.zip. Install the pinned requirements in an isolated Python 3.13 environment and run verify.py. Edit the working exercise baseline, then run --practice sa-versioning. The lab uses disposable SQLite databases and actual SQLAlchemy/Alembic calls; it does not connect to application accounts, production databases or paid services. The app includes prepared results and explained questions without an editor or Python runtime.

Reject an update based on an old mapped version and reread current state before applying a reviewed merge policy.