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"