Skip to content

fix(versioning): row-lock the live entity during version restore - #44015

Merged
richardfogaca merged 5 commits into
apache:masterfrom
mikebridge:sc-115423-restore-concurrency
Sep 14, 2026
Merged

richardfogaca merged 5 commits into
apache:masterfrom
mikebridge:sc-115423-restore-concurrency

Conversation

@mikebridge

Copy link
Copy Markdown
Contributor

SUMMARY

BaseRestoreVersionCommand.run() wraps _perform() in @transaction(), but _do_restore() never locked the live row or otherwise coordinated with a concurrent writer. An overlapping edit (or a second restore) could commit between the command's read and its write, and the lost update was silent.

_do_restore() now takes a FOR UPDATE lock on the live entity immediately after validate() and before resolving/reverting, then db.session.refresh(entity) under the lock so the revert — and the version-history delta it records — is computed against the current committed state rather than the pre-lock snapshot. The lock is held for the rest of the command's transaction (it runs on the same scoped session inside the @transaction() wrapper).

The FOR UPDATE helper (lock_entity_for_update) is moved from versioning/api_helpers.py (REST-endpoint handlers) down to versioning/queries.py (the persistence layer, alongside resolve_version), so both the conditional-write PUT path and this command depend on it from a shared lower module rather than a command reaching up into the API layer; the command's import is now top-level.

This is the pessimistic (serialize) half. The optimistic half — having the restore endpoint honor an If-Match precondition so a client can detect (412) a concurrent edit rather than merely serialize, as the PUT already does via raise_for_stale_write — is a separate, client-facing follow-up, noted in the code.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

Not applicable — backend concurrency fix, no UI.

TESTING INSTRUCTIONS

  • Unit: tests/unit_tests/commands/test_restore_version_concurrency.py — parametrized over the three concrete restore commands asserting _do_restore acquires the lock with the entity's id, plus an ordering test asserting the lock is taken before restore_version. Each fails when the lock is removed. pytest tests/unit_tests/commands/test_restore_version_concurrency.py (4 passed).
  • A real-DB test proving two transactions actually serialize on the row is a noted follow-up; the FOR UPDATE helper itself is exercised by the conditional-write PUT path it is shared with.
  • Changed-file pre-commit (mypy/ruff/ruff-format/pylint) clean.

ADDITIONAL INFORMATION

  • Has associated issue: sc-115423 (Preset-internal)
  • Required feature flags:
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API
  • Removes existing feature or API

🤖 Generated with Claude Code

@netlify

netlify Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 0e8c13a
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6aa81ab30ae001000873ebd8
😎 Deploy Preview https://deploy-preview-44015--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions github-actions Bot added the api Related to the REST API label Sep 8, 2026
@mikebridge
mikebridge force-pushed the sc-115423-restore-concurrency branch from 49d41d6 to c128f22 Compare September 8, 2026 17:40
@github-actions github-actions Bot removed the api Related to the REST API label Sep 8, 2026
@mikebridge
mikebridge force-pushed the sc-115423-restore-concurrency branch 2 times, most recently from 2245308 to 6d3cd27 Compare September 8, 2026 17:48
@mikebridge
mikebridge marked this pull request as ready for review September 8, 2026 17:49
@mikebridge

Copy link
Copy Markdown
Contributor Author

@aminghadersohi — ready for review when you have a moment. Concurrency fix on version restore: _do_restore() previously never coordinated with a concurrent writer, so an edit committed between the command's read and its revert could be silently lost. It now does a single db.session.refresh(entity, with_for_update=True) after validate() — a locking read that both serialises the revert and reloads the current committed state (a plain non-locking refresh would return a stale snapshot on MySQL/InnoDB REPEATABLE READ → silent partial restore); a hard-delete in the read→lock window maps to 404, not 422.

A cross-model independent pass already went over this and caught the MySQL-RR issue (now fixed) — its consolidated report is in the spec repo. The dialect-independent regression guard is a unit test asserting the with_for_update=True flag (fails if dropped), plus a hard-delete→404 test and an end-to-end overwrite integration test. The same MySQL-RR staleness on the ordinary PUT/update path is tracked separately as sc-120014.

@codecov

codecov Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.18%. Comparing base (774a511) to head (aff6426).
⚠️ Report is 48 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #44015      +/-   ##
==========================================
+ Coverage   80.16%   80.18%   +0.01%     
==========================================
  Files        2925     2925              
  Lines      172563   172675     +112     
  Branches    40058    40079      +21     
==========================================
+ Hits       138337   138452     +115     
+ Misses      31629    31628       -1     
+ Partials     2597     2595       -2     
Flag Coverage Δ
hive 37.36% <0.00%> (+0.02%) ⬆️
mysql 56.86% <100.00%> (-0.01%) ⬇️
postgres 56.89% <100.00%> (-0.01%) ⬇️
presto 39.25% <0.00%> (+0.02%) ⬆️
python 84.57% <100.00%> (+0.02%) ⬆️
sqlite 56.58% <100.00%> (-0.02%) ⬇️
unit 75.97% <100.00%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mikebridge

Copy link
Copy Markdown
Contributor Author

@aminghadersohi — reworked at 3d52c50dc3 in response to the independent coordinator pass; ready for another look when you have a moment.

The earlier refresh(entity, with_for_update=True) approach had two defects the cross-model review caught:

  • a refresh() that finds no row raises InvalidRequestError (a SQLAlchemyError), not ObjectDeletedError — so the hard-delete catch never fired and on_error wrapped it into a 422 instead of the documented 404;
  • a concurrent soft delete was invisible to refresh() (SoftDeleteMixin column loads bypass the global deleted_at IS NULL filter), so the revert would resurrect the archived row and report success.

Both are folded into a single locking re-read that re-asserts the active-row predicate:

entity = (
    db.session.query(self.model_cls)
    .populate_existing()                       # reload current committed state
    .filter_by(id=entity.id, deleted_at=None)  # exclude soft-deleted rows
    .with_for_update()                         # serialise the revert (MySQL RR)
    .one_or_none()
)
if entity is None:
    raise self.not_found_exc()                 # hard- or soft-deleted -> 404

One statement now closes all three races (concurrent edit / hard delete / soft delete) with no dependency on which exception a missing-row read raises.

Tests: the unit suite asserts the full locking chain (populate_existing + filter_by(deleted_at=None) + with_for_update + one_or_none), the validate→read→resolve→restore order, and None → not_found_exc — all dialect-independent. Two real second-connection integration tests (hard delete and soft delete committed in the validate→lock window) each assert not_found_exc, not a silent success or 422. mypy/ruff/pylint clean on changed files. CI is in flight.

@mikebridge

Copy link
Copy Markdown
Contributor Author

Follow-up fix pushed (253d432fa1): the locking re-read broke the restore path on Postgres — query(Slice)...with_for_update() triggers the lazy="subquery" eager loader on Slice.table, which emits SELECT DISTINCT … FOR UPDATE to load the related rows, and Postgres rejects FOR UPDATE with DISTINCT. Fixed with .enable_eagerloads(False) on the locking query (we only need the locked row's own columns; relationships load lazily after). Verified against real Postgres — pre-fix raises FOR UPDATE is not allowed with DISTINCT clause, post-fix returns the row. The mocked unit tests couldn't catch it (the DISTINCT SQL is generated by the subquery loader at execution against Postgres, not at compile time); the test-postgres integration leg is the guard and now covers it. CI re-running.

mikebridge added a commit to mikebridge/superset that referenced this pull request Sep 8, 2026
The dataset PUT's conditional-write path locked the row with an id-only
SELECT ... FOR UPDATE that discarded the values. On MySQL/InnoDB default
REPEATABLE READ every consistent read in a transaction is pinned to the
read view established by its first read (the request's auth queries), so
the entity the update command loaded after the lock still reflected the
pre-lock snapshot — and the ORM, which only emits UPDATEs for attributes
differing from the loaded values, could silently lose a concurrent commit
that landed between the initial load and the lock.

Take the lock as a full-entity ORM read instead: populate_existing() +
with_for_update(). A locking read is exempt from the REPEATABLE READ
snapshot and returns current committed data, and populate_existing()
writes it into the identity-map object that the command's later
find_by_id returns. Postgres (READ COMMITTED) and SQLite are not exposed
to the staleness; the refresh is harmless there. The staleness is also
object-level on every backend (a loaded entity is never refreshed by a
plain re-read), which is what the two-transaction integration tests pin.
Counterpart of the version-restore fix in apache#44015.

sc-120014

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRLEJS4mUqKBoPjSjviKUW
@bito-code-review

bito-code-review Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #edc369

Actionable Suggestions - 0
Additional Suggestions - 2
  • tests/integration_tests/charts/version_restore_tests.py - 1
    • SQLite lock failure in CI · Line 167-171
      These tests write from a separate connection (`db.engine.begin()`) while the main session still holds an open read transaction from the earlier `.one()` load. On SQLite rollback journal mode that write raises "database is locked", so all three tests fail deterministically in the `test-sqlite` CI job. Consider gating them to non-SQLite backends (see `_skip_if_sqlite()` in `tests/integration_tests/tasks/test_timeout.py`).
  • superset/commands/version_restore.py - 1
    • Missing uuid identity guard · Line 122-122
      The locking re-read filters by integer PK `id` only, but `validate()` resolves the entity by uuid (`find_active_by_uuid`) and the rest of the versioning code (`identity_filter`) matches on both `id` and `uuid` because a hard delete frees the integer id for reuse. If the id is reused between `validate()` and the lock, `populate_existing()` overwrites the in-memory entity with the wrong row, and `resolve_version(entity=entity)` restores the wrong predecessor's content. Add `uuid=entity.uuid` to the filter.
Review Details
  • Files reviewed - 3 · Commit Range: 6d3cd27..253d432
    • superset/commands/version_restore.py
    • tests/integration_tests/charts/version_restore_tests.py
    • tests/unit_tests/commands/test_restore_version_concurrency.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@github-actions github-actions Bot added the requires:rebase Requires rebasing on top of current master label Sep 9, 2026
Mike Bridge and others added 3 commits September 11, 2026 11:11
…restore

sc-115423. BaseRestoreVersionCommand.run() wraps _perform() in @transaction(),
but _do_restore() never coordinated with a concurrent writer: an overlapping
edit (or a second restore) could commit between the command's read (in
validate()) and its revert, and the lost update was silent.

_do_restore() now does `db.session.refresh(entity, with_for_update=True)`
immediately after validate() — a single locking read that (a) takes a FOR
UPDATE lock held for the rest of the transaction, serialising the revert
against a concurrent write, and (b) reloads the entity from the current
committed state. The with_for_update flag is load-bearing, not an
optimization: a plain refresh() is a non-locking consistent read that on
MySQL/InnoDB REPEATABLE READ returns the transaction's first-read snapshot
(from validate(), before the lock), so a concurrent edit would be invisible
and silently dropped from the revert UPDATE — a partial restore. A hard delete
committed in the read→lock window makes the locking refresh raise
ObjectDeletedError, which is mapped to the command's not_found_exc (404),
preserving the documented entity-gone-race behaviour rather than a 422.

This is the pessimistic (serialise) half; the optimistic If-Match/412 detection
the PUT path has is a separate, client-facing follow-up noted in code.

Tests: parametrized over the three concrete restore commands asserting the
refresh is called with with_for_update=True (dropping the flag fails the test —
the guard against the MySQL staleness regression), an ordering test (refresh
before resolve before revert), and a hard-delete→404 test. A real two-
transaction DB test proving the MySQL full-overwrite is a noted follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
…ng active-row re-read

The coordinator (independent Codex + Path-B) review found the sc-115423
concurrency fix caught the wrong exception. `_do_restore` re-read the live
entity with `Session.refresh(entity, with_for_update=True)`; when the row was
hard-deleted between `validate()`'s unlocked read and the lock, refresh()'s
empty SELECT raises `InvalidRequestError` (a SQLAlchemyError) — NOT
`ObjectDeletedError`. `on_error` then wrapped it into `failed_exc`, so the
documented 404 surfaced as a generic 422. A concurrent *soft* delete was worse:
column loads bypass the global `deleted_at IS NULL` filter, so refresh() would
reload the archived row and the revert would silently resurrect it and report
success.

Replace the refresh with a single locking query that re-asserts the active-row
predicate:

    db.session.query(model_cls)
      .populate_existing()          # reload current committed state
      .filter_by(id=..., deleted_at=None)  # exclude soft-deleted rows
      .with_for_update()            # serialise the revert (MySQL RR)
      .one_or_none()

A None result (hard- or soft-deleted) maps to the documented `not_found_exc`
(404). One query closes all three races: concurrent content edit (the original
MySQL/InnoDB REPEATABLE-READ staleness), hard delete, and soft delete.

Tests:
- unit: assert the locking chain (populate_existing + filter_by(deleted_at=None)
  + with_for_update + one_or_none), the validate→read→resolve→restore order, and
  None→not_found_exc. Dialect-independent guards.
- integration: two real second-connection races — hard delete and soft delete
  committed between validate() and the lock — each asserting not_found_exc, not
  a silent success or 422.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
…res DISTINCT+FOR UPDATE)

The locking active-row re-read added for the restore concurrency fix broke the
entire restore path on Postgres: `db.session.query(Slice)...with_for_update()`
triggers the `lazy="subquery"` eager loader on `Slice.table`, which wraps the
primary query into `SELECT DISTINCT ... FOR UPDATE` to fetch the related rows,
and Postgres rejects `FOR UPDATE` with `DISTINCT`
(psycopg2 FeatureNotSupported). Every restore returned 422.

The mocked unit tests could not catch it — the offending SQL is only generated
by the subquery loader at execution against Postgres, not at compile time — so
it surfaced on the test-postgres CI leg (the existing happy-path test failed
too, not just the new race tests).

Fix: `.enable_eagerloads(False)` on the locking query. We only need the locked
row's own columns for the revert; relationships load lazily afterward. Verified
against real Postgres: the pre-fix query raises "FOR UPDATE is not allowed with
DISTINCT clause"; with eager loads disabled it returns the row cleanly.

The unit chain assertion now pins `enable_eagerloads(False)` so the step cannot
be silently dropped; the test-postgres integration suite remains the
behavioural guard for the SQL itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
@mikebridge
mikebridge force-pushed the sc-115423-restore-concurrency branch from 253d432 to 10b1a23 Compare September 11, 2026 17:13
@bito-code-review

bito-code-review Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #a3813c

Actionable Suggestions - 0
Additional Suggestions - 1
  • superset/commands/version_restore.py - 1
    • Missing uuid predicate · Line 123-123
      The locking re-read filters only by `id=entity.id` and `deleted_at=None`. `restore_version` documents that a hard delete frees the integer id, so a new row reusing it would be returned here; the downstream `entity.uuid != entity_uuid` guard then raises `ValueError`, surfacing as a 422 via `failed_exc` instead of the documented 404. Filter by `uuid=self._uuid` too.
Review Details
  • Files reviewed - 3 · Commit Range: 5b6793c..10b1a23
    • superset/commands/version_restore.py
    • tests/integration_tests/charts/version_restore_tests.py
    • tests/unit_tests/commands/test_restore_version_concurrency.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@mikebridge mikebridge closed this Sep 12, 2026
@mikebridge mikebridge reopened this Sep 12, 2026

@fitzee fitzee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review of the version-restore row-locking change. The core fix — re-reading the live row under FOR UPDATE with populate_existing() before reverting, and surfacing a missing/soft-deleted row as a clean 404 rather than the transaction wrapper's 422 — is sound (on_error only re-wraps SQLAlchemyError, verified). One production correctness concern about the lock query's pinning, plus a stale docstring and two test-quality nits below. (Automated review; posted by a human reviewer.)

Comment thread superset/commands/version_restore.py Outdated
# Postgres rejects ``FOR UPDATE`` with ``DISTINCT``. We only need the
# locked row's own columns here; relationships load lazily after.
.enable_eagerloads(False)
.filter_by(id=entity.id, deleted_at=None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock re-read pins by id alone, not (id, uuid) — inconsistent with restore_version's own id-reuse defense, and yields a 500 instead of a 404. restore.py:138-142 deliberately pins the version lookup to (id, uuid) with the comment: "a hard delete frees the integer id, so matching on it alone can resolve a predecessor's version row and restore its content over the current entity." This locking query matches by id + deleted_at only.

Race: between validate() and this lock, the original entity is hard-deleted and its integer id is reused by a new row (explicit-id insert / seq reuse). This query locks that new row; populate_existing() swaps entity to it (a different uuid). resolve_version still runs against the original self._uuid, and restore_version then hits entity.uuid != entity_uuid and raises ValueError (restore.py:128). ValueError isn't a SQLAlchemyError, so on_error re-raises it as-is → 500, not the documented 404. Adding uuid=entity.uuid to the filter_by closes the window cleanly (row absent → one_or_none() → None → 404), and matches the pinning the codebase already warns is required.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 0e8c13a: the lock now pins (id, uuid, deleted_at IS NULL), so a hard delete + integer-id reuse reads as absent and one_or_none() → None surfaces the documented 404 instead of restore_version's ValueError → 500. Matches the (id, uuid) pinning restore_version already applies to the version lookup. The unit guard now asserts the uuid is part of the locking filter (dropping it fails 3 of 5 there), and the comment block records this as the fourth closed race.

by another connection — the concurrent value does not survive.

The dialect-independent regression guard for the fix is the unit test
asserting ``refresh(..., with_for_update=True)`` (dropping the flag

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docstring describes an implementation that wasn't shipped. It calls the dialect-independent guard "the unit test asserting refresh(..., with_for_update=True)." But the shipped code uses db.session.query(...).populate_existing().enable_eagerloads(False).filter_by(id=…, deleted_at=None).with_for_update().one_or_none() — no refresh() — and the unit test (test_restore_version_concurrency.py) asserts exactly that query chain (populate_existing / with_for_update / filter_by / one_or_none), not any refresh() call. A maintainer reading this would look for a refresh(..., with_for_update=True) that doesn't exist, and might "restore" the refresh() form the code comments explicitly call buggy under REPEATABLE READ. Worth updating the docstring to name the actual guard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0e8c13a — the docstring now names the actual guard (the populate_existing().enable_eagerloads(False).filter_by(id=…, uuid=…, deleted_at=None).with_for_update().one_or_none() chain asserted by test_restore_version_concurrency.py) and says explicitly that there is no refresh() and why, so nobody restores the form the code calls buggy under REPEATABLE READ.

with pytest.raises(cmd.not_found_exc):
cmd.run()

# Cleanup — clear the archival flag so a shared/session-scoped row does

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Soft-delete cleanup runs only if the command raises the expected exception. This UPDATE slices SET deleted_at = NULL sits after the with pytest.raises(cmd.not_found_exc) block. If cmd.run() raises anything other than not_found_exc (e.g. the id-reuse ValueError→500 path, or a lock timeout), pytest.raises propagates it and this cleanup never executes — leaving the shared/session-scoped Boys chart soft-deleted and breaking later tests that query it. A try/finally or a fixture teardown (also for the slice_name reset in the concurrent-edit test) would make the cleanup unconditional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0e8c13a — both cleanups (the deleted_at reset here and the slice_name reset in the concurrent-edit test) are now in try/finally, issued over a separate connection after a session.rollback(), so they commit regardless of what run() raises.

# restore endpoint does not yet also honor an If-Match precondition to
# *detect* (rather than serialise) a concurrent edit — a follow-up.
entity = (
db.session.query(self.model_cls)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (maintainability): this inline FOR UPDATE re-read is a second row-lock formulation alongside versioning/api_helpers.py:lock_entity_for_update (used by the conditional-write PUT path). The two must serialize on the same row, yet lock differently — PUT uses select(model.id).where(id==…).with_for_update(), restore uses query(model).filter_by(id=…, deleted_at=None).…with_for_update(). They do have different needs (restore also refreshes row content via populate_existing, which the helper doesn't), so they're not trivially interchangeable — but a future change to one's locking semantics won't be reflected in the other. Low priority; flagging so the divergence is a deliberate choice rather than an accident.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it should be deliberate rather than accidental — recorded in 0e8c13a in the same comment block: restore is its own formulation because it must also reload the locked row's content (populate_existing), assert the active-row predicate, and pin the uuid, whereas lock_entity_for_update locks select(model.id) by id and returns nothing; both lock the same PK row, so the two paths still serialise against each other.

Mike Bridge and others added 2 commits September 14, 2026 10:02
…st cleanup

fitzee review on apache#44015:

- The FOR UPDATE re-read pinned the live row by id alone. Between validate()
  and the lock, a concurrent hard delete followed by integer-id REUSE puts a
  different entity (different uuid) under that id: the lock then caught the
  stranger, populate_existing swapped `entity` to it, and restore_version's
  uuid check raised ValueError — a 500 instead of the documented 404. Pin the
  lock to (id, uuid) — the same defense restore_version already applies to the
  version lookup — so the reused row reads as absent and one_or_none() -> None
  surfaces the 404. The unit guard now asserts the uuid pin is part of the
  locking filter (dropping it fails 3 of 5 there).
- Recorded, in the same comment block, why this is deliberately a separate
  formulation from api_helpers.lock_entity_for_update (PUT locks
  select(model.id) by id and returns nothing; restore must also reload the
  locked row's content, assert the active-row predicate, and pin the uuid —
  both lock the same PK row, so the paths still serialise).
- The concurrent-edit test docstring named a `refresh(..., with_for_update=True)`
  guard that was never shipped; it now names the actual locking-chain guard.
- Both integration tests' fixture cleanups (deleted_at reset, slice_name
  reset) ran only when the command raised the expected exception; a lock
  timeout or the 500 path would have leaked a soft-deleted / renamed shared
  chart into later tests. Moved to try/finally, via a separate connection so
  they commit regardless of the session's state.

Verified: unit 5/5 (control 3 fail without the pin), version_restore
integration 18/18 on Postgres.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
CI pre-commit (ruff-format) reflowed a hand-wrapped with-statement that fits
the line length; my local check ran ruff's linter but not its formatter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01267VBWbvWTNZUg9GvXKgkC
@bito-code-review

bito-code-review Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #9f5c5c

Actionable Suggestions - 0
Review Details
  • Files reviewed - 3 · Commit Range: 10b1a23..aff6426
    • superset/commands/version_restore.py
    • tests/integration_tests/charts/version_restore_tests.py
    • tests/unit_tests/commands/test_restore_version_concurrency.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers an incremental AI Review.

  • /review full - Manually triggers a full AI Review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@richardfogaca richardfogaca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Richard's agent here:

Reviewed aff64263d708; no substantive findings. CI is green. Review was based on source and available test evidence; full local integration/browser validation was not performed.

@richardfogaca
richardfogaca merged commit 7f0ca47 into apache:master Sep 14, 2026
76 checks passed
villebro pushed a commit that referenced this pull request Sep 16, 2026
)

Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 7f0ca47)
Kunal8954 added a commit to Kunal8954/superset that referenced this pull request Sep 19, 2026
…ests

Since apache#44015, _do_restore reloads the entity through a pessimistic
FOR UPDATE read that binds entity.id as a SQL parameter before
dispatching to the restore engine. The registry-lookup and pass-through
tests hand validate() a MagicMock whose id cannot bind, so they died on
a ProgrammingError that the transaction wrapper then mapped to failed_exc,
masking the LookupError / RuntimeError the tests exercise.

Stub db.session.query so the lock read hands back the mocked entity,
letting the tests reach the patched resolve_version/restore_version.
The lock query's own SQL is covered end-to-end in
test_restore_version_concurrency.py.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

requires:rebase Requires rebasing on top of current master size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants