You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2026/09/16 20:00:37 UTC

(superset) 08/34: fix(versioning): close the two retention-interaction races (#44012)

This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch 7.0
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 58895ee15184c3c03988ef335aae05da8d595069
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Mon Sep 14 10:58:55 2026 -0600

    fix(versioning): close the two retention-interaction races (#44012)
    
    Co-authored-by: Claude Fable 5 <no...@anthropic.com>
    Co-authored-by: Mike Bridge <mi...@ext.preset.io>
    (cherry picked from commit 96ef982d24e2e1c3cc1f17dde676142ae8a4f8e1)
---
 superset/tasks/utils.py                            |  20 +-
 superset/tasks/version_history_retention.py        |  14 +-
 superset/utils/dates.py                            |  22 +-
 superset/versioning/baseline/insertion.py          |  29 +-
 superset/versioning/queries.py                     |  39 ++-
 superset/versioning/restore.py                     |   7 +
 .../unit_tests/versioning/test_retention_races.py  | 353 +++++++++++++++++++++
 7 files changed, 440 insertions(+), 44 deletions(-)

diff --git a/superset/tasks/utils.py b/superset/tasks/utils.py
index fa5fb99c388..8cf97bf0d0d 100644
--- a/superset/tasks/utils.py
+++ b/superset/tasks/utils.py
@@ -19,7 +19,7 @@ from __future__ import annotations
 
 import logging
 import traceback
-from datetime import datetime, timezone
+from datetime import datetime
 from http.client import HTTPResponse
 from typing import Any, cast, Final, TYPE_CHECKING
 from urllib import request
@@ -37,6 +37,10 @@ from superset.tasks.types import (
     FixedExecutor,
 )
 from superset.utils import json
+
+# Re-exported for the task modules that import it from here; the canonical
+# definition lives in superset.utils.dates so every naive-UTC writer shares it.
+from superset.utils.dates import naive_utcnow as naive_utcnow
 from superset.utils.hashing import hash_from_str
 from superset.utils.urls import get_url_path
 
@@ -182,20 +186,6 @@ def fetch_csrf_token(
     return {}
 
 
-def naive_utcnow() -> datetime:
-    """Return the current UTC time with the tzinfo stripped.
-
-    Task timestamp columns (``started_at``, ``ended_at``, ``subscribed_at``) are
-    naive ``DateTime`` columns holding UTC. Writing a tz-aware value to a naive
-    column lets some DB drivers convert it to the session-local timezone, which
-    would skew every duration computed from those columns by the local UTC
-    offset — so the offset is dropped here, once, before the value reaches the DB.
-
-    :returns: Current UTC time as a naive ``datetime``
-    """
-    return datetime.now(timezone.utc).replace(tzinfo=None)
-
-
 def floored_status_cursor() -> datetime:
     """Return the current wall-clock time floored to whole seconds.
 
diff --git a/superset/tasks/version_history_retention.py b/superset/tasks/version_history_retention.py
index b9aca62fa84..6fa345f2b65 100644
--- a/superset/tasks/version_history_retention.py
+++ b/superset/tasks/version_history_retention.py
@@ -49,7 +49,7 @@ import logging
 import time
 from collections.abc import Iterator
 from dataclasses import dataclass
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timedelta
 from typing import Any
 
 import sqlalchemy as sa
@@ -57,6 +57,7 @@ from flask import current_app
 from sqlalchemy.exc import OperationalError
 
 from superset.extensions import celery_app, db, stats_logger_manager
+from superset.utils.dates import naive_utcnow
 
 logger: logging.Logger = logging.getLogger(__name__)
 
@@ -488,11 +489,12 @@ def _prune_old_versions_impl(retention_days: int) -> dict[str, Any]:
 
     tables = _resolve_shadow_tables(versioning_manager.transaction_cls.__table__)
     # Naive-UTC to match ``version_transaction.issued_at`` (Continuum stores
-    # it tz-naive via ``utc_now()``); ``datetime.utcnow()`` is deprecated on
-    # 3.12+, so derive the same value from the tz-aware clock and drop tzinfo.
-    cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
-        days=retention_days
-    )
+    # it tz-naive via ``utc_now()``), derived from the shared clock helper —
+    # the baseline capture stamp uses the same one, so both sides share one
+    # UTC reference and derivation. (They still read their own process's
+    # wall clock — web/worker vs Celery beat — so NTP-scale skew between
+    # hosts remains possible; immaterial against a windows-of-days cutoff.)
+    cutoff = naive_utcnow() - timedelta(days=retention_days)
 
     # Drain the backlog one bounded, id-ordered window at a time. Each
     # window is its own retried SERIALIZABLE pass, so memory and
diff --git a/superset/utils/dates.py b/superset/utils/dates.py
index af38c68d381..1d1efcf6b76 100644
--- a/superset/utils/dates.py
+++ b/superset/utils/dates.py
@@ -31,4 +31,24 @@ def datetime_to_epoch(dttm: datetime) -> float:
 
 
 def now_as_float() -> float:
-    return datetime_to_epoch(datetime.now(timezone.utc).replace(tzinfo=None))
+    return datetime_to_epoch(naive_utcnow())
+
+
+def naive_utcnow() -> datetime:
+    """Naive-UTC now — the single clock for naive-UTC datetime columns.
+
+    Continuum stores ``version_transaction.issued_at`` tz-naive in UTC, and
+    the task timestamp columns (``started_at``, ``ended_at``,
+    ``subscribed_at``) are likewise naive ``DateTime`` columns holding UTC.
+    Writing a tz-aware value to such a column lets some DB drivers convert
+    it to the session-local timezone, skewing every duration computed from
+    it by the local UTC offset — so the offset is dropped here, once, before
+    the value reaches the DB. Every writer and comparator of these columns
+    (the baseline capture stamp, the retention prune's cutoff, the task
+    lifecycle stamps) must derive its value from this one helper so their
+    agreement is structural, not comment-enforced. Callers in different
+    processes still read their own host's wall clock — the guarantee is a
+    shared UTC reference and derivation, not cross-process monotonic
+    ordering.
+    """
+    return datetime.now(timezone.utc).replace(tzinfo=None)
diff --git a/superset/versioning/baseline/insertion.py b/superset/versioning/baseline/insertion.py
index 2482d5aec1b..33733c05233 100644
--- a/superset/versioning/baseline/insertion.py
+++ b/superset/versioning/baseline/insertion.py
@@ -38,6 +38,7 @@ from typing import Any
 import sqlalchemy as sa
 from sqlalchemy.orm import Session
 
+from superset.utils.dates import naive_utcnow
 from superset.versioning.baseline.children import CHILD_BASELINE_HANDLERS
 from superset.versioning.baseline.shadow import insert_baseline_shadow_row
 from superset.versioning.utils import read_row_outside_flush
@@ -109,15 +110,25 @@ def _insert_baseline_row(
 
     # Insert a version_transaction row for the baseline.
     #
-    # ``issued_at`` and ``user_id`` are sourced from the entity's audit fields
-    # (``changed_on`` / ``changed_by_fk``, falling back to ``created_on`` /
-    # ``created_by_fk`` if the row was never edited), so the baseline reads
-    # in the version-history UI as "this is the state at the time of the
-    # last pre-versioning edit, by that user." Using ``now()`` and the
-    # current user would have made the baseline look chronologically newer
-    # than subsequent edits and attributed historical content to the user
-    # who happened to trigger the first save under versioning.
-    baseline_issued_at = row.get("changed_on") or row.get("created_on") or sa.func.now()
+    # ``issued_at`` is CAPTURE time, on the one shared clock
+    # (``naive_utcnow`` — the same helper the retention prune's cutoff
+    # uses). Retention keys on this column, so an audit-field timestamp
+    # (the entity's ``changed_on``) would let a freshly captured baseline
+    # expire at the very next prune whenever the last pre-versioning edit
+    # predated the retention window — permanently emptying the entity's
+    # pre-edit history; a server-side ``now()`` would mix the database
+    # server's local clock into an otherwise-UTC column. Panel ordering
+    # is unaffected (operation_type sorts the baseline first, not
+    # ``issued_at``). The visible trade-off, accepted for retention
+    # correctness: the baseline row pairs the pre-versioning author with
+    # a capture-time timestamp (≈ the first save under versioning), not
+    # the historical edit moment.
+    #
+    # ``user_id`` stays sourced from the audit fields (``changed_by_fk``,
+    # falling back to ``created_by_fk``) so the baseline remains attributed
+    # to the author of the pre-versioning state, not whoever happened to
+    # trigger the first save under versioning.
+    baseline_issued_at = naive_utcnow()
     baseline_user_id = row.get("changed_by_fk") or row.get("created_by_fk")
     tx_table = versioning_manager.transaction_cls.__table__
     result = conn.execute(
diff --git a/superset/versioning/queries.py b/superset/versioning/queries.py
index b47e38d0b1f..91cc5073dcc 100644
--- a/superset/versioning/queries.py
+++ b/superset/versioning/queries.py
@@ -469,7 +469,11 @@ def resolve_version_uuid(
     """Translate a ``version_uuid`` into its 0-based ``version_number``.
 
     Thin wrapper over :func:`resolve_version` for read-side callers that
-    only need the display index.
+    only need the display index. No in-repo caller remains (the snapshot
+    fetch addresses rows by ``transaction_id``); retained as DAO façade
+    surface — anything that must survive a concurrent retention prune
+    should use :func:`resolve_version` and address by transaction id,
+    never by this prune-unstable index.
     """
     resolved = resolve_version(model_cls, entity_uuid, version_uuid, entity=entity)
     return None if resolved is None else resolved[0]
@@ -496,7 +500,7 @@ def get_version(
 
     Pass *entity* to skip the ``find_active_by_uuid`` lookup; see
     :func:`list_versions` for the rationale. The same *entity* is threaded
-    into :func:`resolve_version_uuid` to eliminate a second redundant
+    into :func:`resolve_version` to eliminate a second redundant
     lookup on the same request.
     """
     # pylint: disable=import-outside-toplevel
@@ -507,11 +511,10 @@ def get_version(
         if entity is None:
             return None
 
-    version_num = resolve_version_uuid(
-        model_cls, entity_uuid, version_uuid, entity=entity
-    )
-    if version_num is None:
+    resolved = resolve_version(model_cls, entity_uuid, version_uuid, entity=entity)
+    if resolved is None:
         return None
+    version_num, transaction_id = resolved
 
     ver_tbl, tx_tbl, user_tbl = _resolve_version_tables(model_cls)
     stmt = (
@@ -521,14 +524,16 @@ def get_version(
             *_user_select_cols(user_tbl),
         )
         .select_from(_version_with_tx_user_join(ver_tbl, tx_tbl, user_tbl))
-        # Must pin identically to the count ``resolve_version_uuid`` derived
-        # ``version_num`` from: an offset counted over one row set and applied
-        # to a wider one addresses the wrong row. Pinned there but not here,
-        # a recycled id would surface a predecessor's snapshot under the
-        # successor's version uuid.
+        # Address the snapshot by the ``transaction_id`` that
+        # ``resolve_version`` already pinned — never by positional OFFSET:
+        # a retention prune committing between resolution and this fetch
+        # shifts the offset and silently surfaces a different version's
+        # snapshot under the requested version uuid. The identity filter
+        # stays so a transaction id recycled on another entity can never
+        # match. (The display ``version_number`` resolved above may still
+        # lag a concurrent prune; the snapshot itself cannot.)
         .where(identity_filter(ver_tbl.c, entity.id, entity_uuid))
-        .order_by(*_baseline_first_ordering(ver_tbl))
-        .offset(version_num)
+        .where(ver_tbl.c.transaction_id == transaction_id)
         .limit(1)
     )
     row = db.session.execute(stmt).mappings().first()
@@ -570,6 +575,14 @@ def get_version(
     # (``table_columns_version`` / ``sql_metrics_version``). Empty lists
     # when the dataset had no children at this tx.
     if model_cls is SqlaTable:
+        # Residual read-consistency window: these child fetches run after
+        # the parent snapshot fetch, so a prune committing in between can
+        # age out closed child shadow rows that were valid at target_tx —
+        # columns/metrics silently missing rather than a 404. Pre-existing
+        # retention policy behavior (the prune can also erase closed child
+        # history while the parent survives) — tracked as SC-120012
+        # (snapshot-isolated reads + child-history retention policy), which
+        # also covers the restore command's use of the same child path.
         # pylint: disable=import-outside-toplevel
         from superset.connectors.sqla.models import SqlMetric, TableColumn
         from superset.versioning.changes import shadow_rows_valid_at
diff --git a/superset/versioning/restore.py b/superset/versioning/restore.py
index b56bafe08c6..d38a4911462 100644
--- a/superset/versioning/restore.py
+++ b/superset/versioning/restore.py
@@ -161,6 +161,13 @@ def restore_version(
     # race, and so the change-records listener sees the complete state in
     # one ``after_flush`` pass. See ``single_flush_scope`` for the full
     # rationale.
+    # SC-120012: ``revert(relations=...)`` reconstructs children from the
+    # closed child shadow rows valid at the target transaction. Retention
+    # can legitimately prune closed child history while the parent
+    # transaction survives the window, in which case this WRITE persists an
+    # incomplete column/metric set for a SqlaTable — the same child-path
+    # gap noted read-only in ``queries.get_version``, worse here because it
+    # is durable. Tracked there; not closed in this change.
     skipped_slice_ids: list[int] = []
     try:
         with single_flush_scope(db.session):
diff --git a/tests/unit_tests/versioning/test_retention_races.py b/tests/unit_tests/versioning/test_retention_races.py
new file mode 100644
index 00000000000..aa5f83b1419
--- /dev/null
+++ b/tests/unit_tests/versioning/test_retention_races.py
@@ -0,0 +1,353 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Regression tests for the two retention-interaction races (SC-115615).
+
+1. A freshly captured baseline must survive a full retention window from
+   CAPTURE time: stamping the baseline transaction with the entity's
+   historical ``changed_on`` let the very next prune delete it whenever
+   the last pre-versioning edit predated the window.
+2. The version snapshot must be fetched by the resolved
+   ``transaction_id``: an OFFSET re-fetch silently returns a different
+   version's snapshot when a concurrent prune removes rows between
+   resolution and fetch.
+
+Shared scaffolding: a minimal in-memory version store (transaction,
+version-shadow, and user tables) shaped like Continuum's, driven through
+the real production functions.
+"""
+
+from __future__ import annotations
+
+import uuid as uuid_lib
+from collections.abc import Iterator
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+import sqlalchemy as sa
+from pytest_mock import MockerFixture
+from sqlalchemy.orm import sessionmaker
+
+
+def _naive_utc_now() -> datetime:
+    return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+@pytest.fixture
+def version_store() -> Iterator[SimpleNamespace]:
+    """Minimal Continuum-shaped store: transaction + shadow + user tables."""
+    engine = sa.create_engine("sqlite://")
+    metadata = sa.MetaData()
+    tx_table = sa.Table(
+        "version_transaction",
+        metadata,
+        sa.Column("id", sa.Integer, primary_key=True),
+        sa.Column("issued_at", sa.DateTime),
+        sa.Column("user_id", sa.Integer),
+        sa.Column("remote_addr", sa.String(100)),
+    )
+    ver_table = sa.Table(
+        "things_version",
+        metadata,
+        sa.Column("id", sa.Integer),
+        sa.Column("uuid", sa.Uuid),
+        sa.Column("value", sa.String(50)),
+        sa.Column("transaction_id", sa.Integer),
+        sa.Column("end_transaction_id", sa.Integer),
+        sa.Column("operation_type", sa.Integer),
+    )
+    user_table = sa.Table(
+        "ab_user",
+        metadata,
+        sa.Column("id", sa.Integer, primary_key=True),
+        sa.Column("first_name", sa.String(64)),
+        sa.Column("last_name", sa.String(64)),
+    )
+    metadata.create_all(engine)
+    yield SimpleNamespace(engine=engine, tx=tx_table, ver=ver_table, user=user_table)
+    engine.dispose()
+
+
+# ---------------------------------------------------------------------------
+# Race 1: baseline retention timestamp (superset/versioning/baseline/insertion.py)
+# ---------------------------------------------------------------------------
+
+
+class _Owner:
+    """Stand-in entity; its live table is never read (the row read is
+    patched), only ``type(obj).__table__`` must exist."""
+
+    __table__ = None
+    id = 1
+
+
+def _insert_baseline(
+    version_store: SimpleNamespace, mocker: MockerFixture, changed_on: datetime
+) -> tuple[Any, int]:
+    """Drive the real ``_insert_baseline_row`` against the in-memory store
+    for an entity whose last pre-versioning edit happened at *changed_on*."""
+    # pylint: disable=import-outside-toplevel
+    from superset.versioning.baseline import insertion
+
+    conn = version_store.engine.connect()
+    outer = conn.begin()
+    mocker.patch(
+        "sqlalchemy_continuum.versioning_manager",
+        SimpleNamespace(transaction_cls=SimpleNamespace(__table__=version_store.tx)),
+    )
+    mocker.patch(
+        "superset.versioning.baseline.insertion.read_row_outside_flush",
+        return_value={
+            "id": 1,
+            "uuid": uuid_lib.uuid4(),
+            "value": "pre-edit state",
+            "changed_on": changed_on,
+            "created_on": changed_on,
+            "changed_by_fk": 7,
+        },
+    )
+    session = SimpleNamespace(connection=lambda: conn)
+    tx_id = insertion._insert_baseline_row(session, _Owner(), version_store.ver)
+    outer.commit()
+    assert tx_id is not None
+    return conn, tx_id
+
+
+def test_baseline_survives_a_full_retention_window_from_capture(
+    version_store: SimpleNamespace, mocker: MockerFixture
+) -> None:
+    """The retention prune deletes transactions with ``issued_at`` older
+    than its cutoff. A baseline captured NOW for an entity last edited 100
+    days ago must survive a 90-day window — stamped with capture time; an
+    audit-field timestamp would let the very next prune erase the entity's
+    only pre-edit history."""
+    historical = _naive_utc_now() - timedelta(days=100)
+    before = _naive_utc_now()
+    conn, tx_id = _insert_baseline(version_store, mocker, historical)
+    after = _naive_utc_now()
+
+    issued_at = conn.execute(
+        sa.select(version_store.tx.c.issued_at).where(version_store.tx.c.id == tx_id)
+    ).scalar_one()
+
+    cutoff = _naive_utc_now() - timedelta(days=90)
+    assert issued_at >= cutoff, "freshly captured baseline expired immediately"
+    assert before <= issued_at <= after
+    assert issued_at != historical
+    # One clock: naive-UTC, matching Continuum's storage and the prune's
+    # cutoff derivation. (SQLite round-trips DATETIME naive regardless —
+    # the capture-window bracket above is the load-bearing assertion; the
+    # clock agreement itself is structural via the shared naive_utcnow.)
+    assert issued_at.tzinfo is None
+
+
+def test_baseline_keeps_historical_attribution_and_shadow_shape(
+    version_store: SimpleNamespace, mocker: MockerFixture
+) -> None:
+    """Only the retention clock moves to capture time: the baseline stays
+    attributed to the pre-versioning author, and the shadow row still
+    reads as a live op=0 row at the allocated transaction."""
+    historical = _naive_utc_now() - timedelta(days=100)
+    conn, tx_id = _insert_baseline(version_store, mocker, historical)
+
+    tx_row = (
+        conn.execute(sa.select(version_store.tx).where(version_store.tx.c.id == tx_id))
+        .mappings()
+        .one()
+    )
+    assert tx_row["user_id"] == 7
+
+    shadow = conn.execute(sa.select(version_store.ver)).mappings().one()
+    assert shadow["transaction_id"] == tx_id
+    assert shadow["end_transaction_id"] is None
+    assert shadow["operation_type"] == 0
+    assert shadow["value"] == "pre-edit state"
+
+
+def test_fresh_baseline_is_not_in_the_prune_candidate_window(
+    version_store: SimpleNamespace, mocker: MockerFixture
+) -> None:
+    """End-to-end lock on the two callers' clock agreement: a baseline
+    captured now via the real ``_insert_baseline_row`` — then closed, as
+    the same flush's real edit closes it in production, so the live-row
+    preservation rule cannot be what saves it — is not even a CANDIDATE
+    for the real ``_resolve_prune_window`` at a 90-day cutoff. With an
+    audit-field stamp the baseline would be both candidate and prunable
+    on the spot."""
+    # pylint: disable=import-outside-toplevel
+    from superset.tasks.version_history_retention import _resolve_prune_window
+
+    historical = _naive_utc_now() - timedelta(days=100)
+    conn, tx_id = _insert_baseline(version_store, mocker, historical)
+    conn.close()
+    with version_store.engine.begin() as setup_conn:
+        setup_conn.execute(
+            version_store.ver.update()
+            .where(version_store.ver.c.transaction_id == tx_id)
+            .values(end_transaction_id=tx_id + 1)
+        )
+
+    cutoff = _naive_utc_now() - timedelta(days=90)
+    with version_store.engine.connect() as prune_conn:
+        window = _resolve_prune_window(prune_conn, cutoff, [version_store.ver], 0, 100)
+
+    assert window.candidate_count == 0
+    assert tx_id not in window.prunable
+
+
+# ---------------------------------------------------------------------------
+# Race 2: snapshot fetch under concurrent prune (superset/versioning/queries.py)
+# ---------------------------------------------------------------------------
+
+
+class _DummyModel:
+    """Placeholder model class; every model-derived lookup is patched."""
+
+
+def _seed_history(version_store: SimpleNamespace, entity_uuid: uuid_lib.UUID) -> None:
+    """Three versions of entity id=1: baseline v0 (tx 100), edit v1
+    (tx 101), live edit v2 (tx 102)."""
+    now = _naive_utc_now()
+    with version_store.engine.begin() as conn:
+        conn.execute(
+            version_store.tx.insert(),
+            [
+                {"id": 100, "issued_at": now - timedelta(days=3)},
+                {"id": 101, "issued_at": now - timedelta(days=2)},
+                {"id": 102, "issued_at": now - timedelta(days=1)},
+            ],
+        )
+        conn.execute(
+            version_store.ver.insert(),
+            [
+                {
+                    "id": 1,
+                    "uuid": entity_uuid,
+                    "value": "v0",
+                    "transaction_id": 100,
+                    "end_transaction_id": 101,
+                    "operation_type": 0,
+                },
+                {
+                    "id": 1,
+                    "uuid": entity_uuid,
+                    "value": "v1",
+                    "transaction_id": 101,
+                    "end_transaction_id": 102,
+                    "operation_type": 1,
+                },
+                {
+                    "id": 1,
+                    "uuid": entity_uuid,
+                    "value": "v2",
+                    "transaction_id": 102,
+                    "end_transaction_id": None,
+                    "operation_type": 1,
+                },
+            ],
+        )
+
+
+def _patched_get_version(
+    version_store: SimpleNamespace,
+    mocker: MockerFixture,
+    entity_uuid: uuid_lib.UUID,
+    resolved: tuple[int, int],
+) -> dict[str, Any] | None:
+    """Run the real ``get_version`` against the in-memory store with the
+    resolution step pinned to *resolved* — the state a request captured
+    BEFORE a concurrent prune commits."""
+    # pylint: disable=import-outside-toplevel
+    from superset.versioning import queries
+
+    session = sessionmaker(bind=version_store.engine)()
+    mocker.patch.object(queries, "db", SimpleNamespace(session=session))
+    mocker.patch.object(
+        queries,
+        "_resolve_version_tables",
+        return_value=(version_store.ver, version_store.tx, version_store.user),
+    )
+    mocker.patch.object(queries, "resolve_version", return_value=resolved)
+    mocker.patch.object(queries, "_entity_kind_for", return_value=None)
+    try:
+        return queries.get_version(
+            _DummyModel,
+            entity_uuid,
+            uuid_lib.uuid4(),
+            entity=SimpleNamespace(id=1),
+        )
+    finally:
+        session.close()
+
+
+def test_get_version_returns_resolved_tx_after_concurrent_prune(
+    version_store: SimpleNamespace, mocker: MockerFixture
+) -> None:
+    """The request resolved v1 (display index 1, tx 101); a retention prune
+    then removes the baseline before the snapshot fetch. Addressed by
+    transaction_id the fetch still returns v1 — an OFFSET fetch would
+    count index 1 over the shrunken row set and silently return v2's
+    snapshot under v1's version uuid."""
+    entity_uuid = uuid_lib.uuid4()
+    _seed_history(version_store, entity_uuid)
+
+    with version_store.engine.begin() as conn:
+        conn.execute(
+            version_store.ver.delete().where(version_store.ver.c.transaction_id == 100)
+        )
+        conn.execute(version_store.tx.delete().where(version_store.tx.c.id == 100))
+
+    result = _patched_get_version(version_store, mocker, entity_uuid, (1, 101))
+    assert result is not None
+    assert result["_version"]["transaction_id"] == 101
+    assert result["value"] == "v1"
+
+
+def test_get_version_returns_none_when_resolved_version_is_pruned(
+    version_store: SimpleNamespace, mocker: MockerFixture
+) -> None:
+    """Third outcome: the prune removes the REQUESTED version itself
+    between resolution and fetch. The honest answer is None (a 404),
+    never a neighbouring snapshot served under the requested uuid."""
+    entity_uuid = uuid_lib.uuid4()
+    _seed_history(version_store, entity_uuid)
+
+    with version_store.engine.begin() as conn:
+        conn.execute(
+            version_store.ver.delete().where(version_store.ver.c.transaction_id == 101)
+        )
+        conn.execute(version_store.tx.delete().where(version_store.tx.c.id == 101))
+
+    result = _patched_get_version(version_store, mocker, entity_uuid, (1, 101))
+    assert result is None
+
+
+def test_get_version_without_prune_is_unchanged(
+    version_store: SimpleNamespace, mocker: MockerFixture
+) -> None:
+    """Sanity control: with no concurrent prune, the tx-addressed fetch
+    returns the same snapshot the OFFSET fetch used to."""
+    entity_uuid = uuid_lib.uuid4()
+    _seed_history(version_store, entity_uuid)
+
+    result = _patched_get_version(version_store, mocker, entity_uuid, (1, 101))
+    assert result is not None
+    assert result["_version"]["transaction_id"] == 101
+    assert result["_version"]["version_number"] == 1
+    assert result["value"] == "v1"