Skip to content

fix(dataset): refresh the entity under the conditional-write row lock - #44027

Merged
rusackas merged 5 commits into
apache:masterfrom
mikebridge:sc-120014-put-lock-stale-entity
Sep 14, 2026
Merged

rusackas merged 5 commits into
apache:masterfrom
mikebridge:sc-120014-put-lock-stale-entity

Conversation

@mikebridge

@mikebridge mikebridge commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

SUMMARY

The dataset PUT's conditional-write path (If-Match saves) serialises check-and-write by row-locking the entity first (lock_entity_for_update, called from superset/datasets/api.py). But the lock was an id-only SELECT id ... FOR UPDATE that discarded the row's values — it serialised writers without refreshing what the transaction can see:

  • 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 run well before the lock). So the entity loaded after that id-only lock still reflected the pre-lock snapshot, and the ORM — which only emits UPDATEs for attributes that differ from the loaded values — could silently lose a concurrent commit that landed between the initial load and the lock. This is the same class as the version-restore partial-write fixed in fix(versioning): row-lock the live entity during version restore #44015, on the ordinary update path.
  • The staleness is also object-level on every backend: SQLAlchemy never refreshes an already-loaded entity from a later plain SELECT, so even where the row read is fresh (Postgres READ COMMITTED), the identity-map object the update command operates on stayed stale.

Fix (mirroring #44015's locking-refresh): take the lock as a full-entity ORM read — db.session.query(model).populate_existing().filter(id).with_for_update().one_or_none(). A locking read is exempt from the REPEATABLE READ snapshot and returns current committed data; populate_existing() writes it into the identity-map object, which the update command's later find_by_id returns without re-hydrating from its own (possibly stale) row.

Two halves of one protection — merge-order note: this PR closes the entity-read half of the MySQL REPEATABLE READ conditional-write race. The validator-read half (current_entity_version_info still being a plain snapshot read, so a stale If-Match token can pass) is closed by #44091 — the race is fully shut only with both merged, in either order (whichever lands second takes a small rebase in datasets/api.py; #44091's body carries the same note and the _locked_entity-preservation warning).

Lifetime contract (from the adversarial review round): the helper returns the locked entity and the caller must hold it — the identity map references clean persistent objects weakly, so a discarded result is collectible the moment the helper returns, after which find_by_id would re-hydrate from the stale snapshot and silently undo the fix on the production path (which loads nothing of its own before the lock). The dataset PUT binds the return value for the rest of the request. Missing-row semantics are unchanged: None is returned and existence keeps being decided by the command's own lookup (404 as before). Chart/dashboard PUTs have no conditional-write lock path, so the fix is dataset-scoped at the single shared helper.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — concurrency fix with no UI or response-shape change.

TESTING INSTRUCTIONS

  • pytest tests/unit_tests/versioning/test_lock_entity.py — 3 tests: the dialect-independent regression guard pins the statement shape (populate_existing() and with_for_update() both chained, result consumed via one_or_none(); dropping either fails on every backend — the analogue of fix(versioning): row-lock the live entity during version restore #44015's refresh(..., with_for_update=True) pin), plus the non-numeric/None id early-outs that keep the /<pk> string route from raising a SQL cast error. Reverting the helper flips the shape test.
  • pytest tests/integration_tests/versioning/conditional_write_lock_tests.py — the lifetime pin (held, DatasetDAO.find_by_id returns the very same refreshed instance; discarded, a weakref proves the object is collected — the identity map alone does not keep it alive) plus the two-transaction behavioral proof: a second engine-level connection commits a change between this session's entity load and the lock; after lock_entity_for_update the loaded entity must show the concurrently committed value (fails pre-fix on every backend, since the staleness is object-level), plus a quiet-path control asserting the refresh is a no-op (and dirties nothing) when nothing changed underneath. Skipped on SQLite, where a second writer connection deadlocks against the open read transaction instead of modelling a concurrent request — CI's Postgres/MySQL runs execute them (local env here is SQLite-backed, so these two are CI-verified).
  • Manual (MySQL): open a dataset edit in one tab; from a second session commit a change to another field; save the first tab with If-Match. Before the fix the first save's flush could quietly discard the concurrent change on MySQL; after, the command diffs against committed state.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

🤖 Generated with Claude Code

https://claude.ai/code/session_01JRLEJS4mUqKBoPjSjviKUW

@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.04%. Comparing base (bc35151) to head (158a38f).
⚠️ Report is 138 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #44027      +/-   ##
==========================================
+ Coverage   79.85%   80.04%   +0.18%     
==========================================
  Files        2915     2920       +5     
  Lines      170702   172008    +1306     
  Branches    39561    39827     +266     
==========================================
+ Hits       136317   137676    +1359     
+ Misses      31868    31735     -133     
- Partials     2517     2597      +80     
Flag Coverage Δ
hive 37.34% <25.00%> (-0.26%) ⬇️
mysql 56.87% <50.00%> (-0.50%) ⬇️
postgres 56.89% <50.00%> (-0.51%) ⬇️
presto 39.22% <25.00%> (-0.25%) ⬇️
python 84.55% <75.00%> (+0.29%) ⬆️
sqlite 56.59% <25.00%> (-0.50%) ⬇️
unit 75.87% <75.00%> (+0.55%) ⬆️

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.

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
@mikebridge
mikebridge force-pushed the sc-120014-put-lock-stale-entity branch from 98847ad to ac59e41 Compare September 8, 2026 21:38
@github-actions github-actions Bot added the api Related to the REST API label Sep 8, 2026
@netlify

netlify Bot commented Sep 8, 2026

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit ac59e41
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6aa08056dde064000751082d
😎 Deploy Preview https://deploy-preview-44027--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.

mikebridge and others added 2 commits September 8, 2026 15:59
… command

Cross-model review blocker: the locking refresh discarded its result, but
the session's identity map references clean persistent objects weakly, so
with no strong reference the refreshed object is collectible the moment
the helper returns. The command's find_by_id would then re-hydrate a new
object from a plain consistent read — on MySQL REPEATABLE READ the
pre-lock snapshot again — silently reducing the fix to a no-op on the
production path (which, unlike the original test, holds no reference of
its own between the lock and the lookup).

lock_entity_for_update returns the locked entity and the dataset PUT
binds it for the rest of the request. New integration test pins both
halves: held, DatasetDAO.find_by_id returns the very same refreshed
instance; discarded, a weakref proves the object is collected. The unit
shape pin asserts the return contract.

sc-120014

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRLEJS4mUqKBoPjSjviKUW
DatasetDAO.find_by_id applies base filters that read g.user; the two
tests calling it from the test process (rather than through a request)
failed on the CI Postgres/MySQL lanes with AttributeError: user. Wrap
the lookups in override_user(admin), matching the suite idiom for
direct DAO calls.

sc-120014

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRLEJS4mUqKBoPjSjviKUW
@mikebridge
mikebridge marked this pull request as ready for review September 8, 2026 23:47
@mikebridge

Copy link
Copy Markdown
Contributor Author

@aminghadersohi — requesting review on the conditional-write lock fix (sc-120014), the update-path sibling of #44015's restore fix and squarely in your versioning lane.

The dataset PUT's If-Match path row-locked the entity with an id-only SELECT ... FOR UPDATE that discarded the values. On MySQL/InnoDB REPEATABLE READ, the transaction's read view is pinned by its first consistent read (the request's auth queries), so the entity the update command loaded after that 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 load and the lock.

Fix: the lock is now a full-entity ORM read — populate_existing() + with_for_update() — so the locking read (exempt from the RR snapshot) writes current committed data into the identity-map object the command's find_by_id returns. Two hardenings from the adversarial review rounds worth knowing:

  • Lifetime contract: the helper returns the locked entity and the dataset PUT binds it for the rest of the request — the identity map references clean objects weakly, so a discarded result was collectible immediately, which would have silently reduced the fix to a production no-op (caught by the cross-model committer pass). Pinned both ways: held → find_by_id returns the very same refreshed instance; discarded → a weakref proves collection.
  • Scoped honestly: the refresh covers the entity row; the If-Match validator's own version-info reads (and lazy child-collection loads) have the same RR staleness one layer up and are tracked as a separate follow-up rather than folded in — the docstrings and the endpoint comment say exactly what is and isn't covered.

Tests: 3 unit statement-shape pins (dropping either flag fails on every backend; mirrors #44015's refresh-flag guard) + 3 two-transaction integration tests (object-level staleness, so the interleave test fails pre-fix on every backend it runs on; SQLite skipped — a second writer connection can't model a concurrent request there). CI green including the full DB matrix.

🤖 Generated with Claude Code

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 #afcb23

Actionable Suggestions - 0
Additional Suggestions - 1
  • superset/versioning/api_helpers.py - 1
    • Stale If-Match check · Line 252-258
      The lock refreshes the entity row, but the `If-Match` decision reads `current_entity_version_info` (a plain consistent read at `datasets/api.py:720`), which on MySQL REPEATABLE READ is pinned to the pre-lock snapshot. Two concurrent conditional writes can still both pass the stale check and clobber — the lost update this fix claims to prevent. Consider making the version read a locking read or deriving the token from the returned entity.
Review Details
  • Files reviewed - 4 · Commit Range: ac59e41..15b9634
    • superset/datasets/api.py
    • superset/versioning/api_helpers.py
    • tests/integration_tests/versioning/conditional_write_lock_tests.py
    • tests/unit_tests/versioning/test_lock_entity.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

Copy link
Copy Markdown
Contributor Author

Dependency note for reviewers: this PR closes the entity-read half of the MySQL REPEATABLE READ conditional-write race; #44091 (in review) closes the validator-read half (current_entity_version_info otherwise still serves a snapshot transaction id, so a stale If-Match can pass). The race is fully shut only with both merged — either order works; the PR bodies carry the rebase caveats.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JRLEJS4mUqKBoPjSjviKUW

mikebridge and others added 2 commits September 11, 2026 11:14
The MySQL CI lane runs InnoDB's default REPEATABLE READ (no
isolation_level is configured anywhere, and set_db_default_isolation's
READ COMMITTED default is generative-and-discarded, i.e. inert), so the
interleave test also flips at the row level there if with_for_update()
is dropped. Documented so a future fix making the isolation default
effective doesn't silently weaken what this file proves; the unit shape
pin stays the every-backend guard.

sc-120014

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

Correction round: CI pins the MySQL server to READ COMMITTED
(.github/workflows/bashlib.sh:97-98) — the previous docstring note had
this backwards. Under CI's RC, a mutant keeping populate_existing() but
dropping with_for_update() passes every lane, because an RC re-read sees
the concurrent commit without any lock. The new MySQL-lane test forces
REPEATABLE READ on the session's connection so that mutant fails
behaviorally: the unlocked re-read serves the pre-commit snapshot while
the locking read is exempt and returns committed data. (PostgreSQL's RR
raises a serialization failure on such a locking read instead — a
different, also safe, outcome — so the forced-RR proof is MySQL-scoped.)

sc-120014

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

@gabotorresruiz gabotorresruiz 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.

Thanks Mike, this is a careful fix and the test story is unusually strong.

I verified it empirically on this branch against a real MySQL 8.0.46 at the server default REPEATABLE READ (stricter than CI, which pins READ COMMITTED in bashlib.sh):

  • All 4 integration tests and the 3 unit pins pass at head. At the parent commit (bc35151) the three behavioral tests fail exactly as the PR body describes, and the quiet path control keeps passing.
  • Both mutants are killed: dropping with_for_update() fails the forced RR test and the interleave test, and dropping populate_existing() fails both as well, plus the unit shape pin in each case.
  • I also drove the real PUT route live on that MySQL: a fresh If-Match save returns 200 and bumps the version, a replayed stale token returns 412, and an unconditional PUT is untouched.
  • The lifetime coupling holds as documented: DatasetDAO never sets force_fetch, so BaseDAO._query returns the refreshed identity map instance to the update command, and find_by_id handed back the very same object in my runs.

One note for whoever lands second between this and #44091: the #44091 diff rewrites the same hunk in datasets/api.py and currently calls lock_entity_for_update without binding the return, so the rebase must keep the _locked_entity binding or the refresh quietly dies with the discarded reference, exactly as the helper docstring warns. Both PR bodies already carry the caveat, so this is just a reminder at merge time.

@bito-code-review

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

Copy link
Copy Markdown
Contributor

Code Review Agent Run #d75c47

Actionable Suggestions - 0
Review Details
  • Files reviewed - 1 · Commit Range: 15b9634..158a38f
    • tests/integration_tests/versioning/conditional_write_lock_tests.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

@rusackas rusackas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirmed @gabotorresruiz's note is a coordination reminder for #44091, not a blocker here, that one's still open, so there's nothing to rebase against yet. Reproduced the core mechanism myself independently of the MySQL testing already done: a plain SQLAlchemy re-query returns the same stale identity-mapped object after a concurrent commit, populate_existing() correctly overwrites it in place. Binding _locked_entity in datasets/api.py matches the helper's own lifetime contract.

LGTM, merging.

@rusackas
rusackas merged commit 818857d into apache:master Sep 14, 2026
75 checks passed
mikebridge added a commit to mikebridge/superset that referenced this pull request Sep 14, 2026
…SHARE

The conditional-write guard compared the client's If-Match token against
a version-info lookup made with plain consistent reads. On MySQL/InnoDB
REPEATABLE READ those are served from the snapshot pinned by the
request's first read, so a version row committed by a concurrent writer
between that first read and this request's row lock stayed invisible —
the stale token matched, raise_for_stale_write passed, and the second
writer overwrote the first: the lost update the lock + If-Match pair
exists to prevent, surviving on MySQL only. Validator-layer sibling of
the entity refresh in apache#44027.

Add current_live_transaction_id_for_share — a plain live-row query (not
the aggregate: locking clauses and aggregates don't combine reliably
across dialects) under with_for_update(read=True), exempt from the RR
snapshot without blocking readers — and thread lock_for_stale_check
through current_entity_version_info; the dataset PUT passes it only for
conditional writes. The displayed version number stays a plain aggregate
read: a concurrent commit can leave response metadata one behind, but
the guard never consults it.

sc-120050

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JRLEJS4mUqKBoPjSjviKUW
villebro pushed a commit that referenced this pull request Sep 16, 2026
…#44027)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 818857d)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the REST API size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants