Repository navigation
fix(dataset): lock the If-Match validator's transaction-id read #44091
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a53f3fe
99cc727
4e7ef36
d8730ca
0970543
828a7ed
da0973a
126eed3
ce56d50
c6767b2
181b79e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,9 +32,10 @@ | |
| from flask_babel import gettext as _, ngettext | ||
| from jinja2.exceptions import TemplateError | ||
| from marshmallow import ValidationError | ||
| from sqlalchemy.exc import OperationalError | ||
| from sqlalchemy.orm.exc import MultipleResultsFound | ||
|
|
||
| from superset import event_logger, is_feature_enabled, security_manager | ||
| from superset import db, event_logger, is_feature_enabled, security_manager | ||
| from superset.commands.dataset.create import CreateDatasetCommand | ||
| from superset.commands.dataset.delete import DeleteDatasetCommand | ||
| from superset.commands.dataset.duplicate import DuplicateDatasetCommand | ||
|
|
@@ -112,11 +113,13 @@ | |
| concurrency_token_from, | ||
| current_entity_version_info, | ||
| entity_concurrency_token, | ||
| EntityVersionInfo, | ||
| get_version_endpoint, | ||
| list_versions_endpoint, | ||
| lock_entity_for_update, | ||
| restore_version_endpoint, | ||
| ) | ||
| from superset.versioning.db_errors import is_lock_contention_error | ||
| from superset.versioning.etag import ( | ||
| is_conditional_write, | ||
| raise_for_stale_write, | ||
|
|
@@ -571,6 +574,28 @@ def post(self) -> Response: | |
| logger.exception("Unexpected error in DatasetRestApi.post") | ||
| return self.response_500(message="Fatal error") | ||
|
|
||
| def _lock_contention_response(self) -> Response: | ||
| """The shared retryable 409 for a conditional save losing a lock race. | ||
|
|
||
| In the direct lock-acquisition and validator-read catches, the | ||
| rollback is LOAD-BEARING for lock-wait-timeout: with | ||
| ``innodb_rollback_on_timeout`` OFF (the MySQL default) a 1205 | ||
| rolls back only the failing STATEMENT -- the transaction is still | ||
| alive and still holds any entity row lock already acquired, and | ||
| this rollback is what releases it. For a deadlock (1213) InnoDB | ||
| already rolled the transaction back and this clears the aborted | ||
| session before responding. The decorated command-failure path | ||
| already rolls back before raising; this shared cleanup is a | ||
| harmless no-op on that path, not an additional required rollback. | ||
| """ | ||
| db.session.rollback() # pylint: disable=consider-using-transaction | ||
| return self.response( | ||
| 409, | ||
| message=_( | ||
| "Another save is in progress for this dataset. Retry the same request." | ||
| ), | ||
| ) | ||
|
|
||
| @expose("/<pk>", methods=("PUT",)) | ||
| @protect() | ||
| @statsd_metrics | ||
|
|
@@ -579,7 +604,7 @@ def post(self) -> Response: | |
| log_to_statsd=False, | ||
| ) | ||
| @requires_json | ||
| def put(self, pk: int) -> Response: | ||
| def put(self, pk: int) -> Response: # noqa: C901 | ||
| """Update a dataset. | ||
| --- | ||
| put: | ||
|
|
@@ -598,9 +623,11 @@ def put(self, pk: int) -> Response: | |
| type: string | ||
| name: If-Match | ||
| description: >- | ||
| Optional optimistic-concurrency guard. Pass the ``ETag`` returned | ||
| by a prior read of this dataset; the update is rejected with 412 | ||
| if the dataset has changed since. | ||
| Optional optimistic-concurrency guard. Pass the ``ETag`` | ||
| returned by a prior read of this dataset; the update is | ||
| rejected with 412 if the dataset has changed since, or | ||
| with a retryable 409 if a concurrent save won a lock race | ||
| (see those responses for which action each calls for). | ||
| requestBody: | ||
| description: Dataset schema | ||
| required: true | ||
|
|
@@ -677,10 +704,30 @@ def put(self, pk: int) -> Response: | |
| $ref: '#/components/responses/403' | ||
| 404: | ||
| $ref: '#/components/responses/404' | ||
| 409: | ||
| description: >- | ||
| A concurrent save on this dataset won a database lock | ||
| race, so this conditional request could not be applied. | ||
| The client's ``If-Match`` token is NOT proven stale: | ||
| unlike a 412, the correct action is to retry the SAME | ||
| request (with backoff; a few attempts, then surface the | ||
| conflict) rather than refetch the entity for a new | ||
| token. | ||
| content: | ||
| application/json: | ||
| schema: | ||
| type: object | ||
| properties: | ||
| message: | ||
| type: string | ||
| 412: | ||
| description: >- | ||
| The dataset changed since the version identified by the | ||
| request's ``If-Match`` header; the update was not applied. | ||
| request's ``If-Match`` header; the update was not | ||
| applied. The token is stale: refetch the entity to pick | ||
| up the current version before retrying — retrying the | ||
| same request unchanged will fail again (contrast the | ||
| retryable 409 above). | ||
| content: | ||
| application/json: | ||
| schema: | ||
|
|
@@ -706,22 +753,50 @@ def put(self, pk: int) -> Response: | |
|
|
||
| # Serialise conditional saves on this dataset: the guard below reads | ||
| # the live version, the command writes, and the two must not interleave | ||
| # with another request's. Only a conditional save pays for the lock; an | ||
| # with another request's. Only a conditional save pays for the locks; an | ||
| # unconditional PUT behaves exactly as it did before the guard existed. | ||
| # (On MySQL REPEATABLE READ the version read below is still a plain | ||
| # consistent read and can predate the lock; see the caveats on | ||
| # lock_entity_for_update.) | ||
| if is_conditional_write(): | ||
| # Hold the locked entity for the rest of the request: the | ||
| # session's identity map references clean objects weakly, so | ||
| # discarding the return value could let the refreshed object | ||
| # be collected and the command's find_by_id re-read a stale | ||
| # row on MySQL REPEATABLE READ (see lock_entity_for_update). | ||
| _locked_entity = lock_entity_for_update(SqlaTable, pk) | ||
| # The validator's transaction id uses a locking read. The displayed | ||
| # version number and lazy child reads can still observe an older | ||
| # MySQL REPEATABLE READ snapshot; see lock_entity_for_update. | ||
| conditional: bool = is_conditional_write() | ||
| if conditional: | ||
| # Blocking here is the serialisation doing its job; LOSING the | ||
| # race at this acquisition (deadlock, or lock-wait timeout | ||
| # after ``innodb_lock_wait_timeout``) is the most common | ||
| # contention outcome of all three lock points and maps to the | ||
| # same retryable 409 -- not an uncaught 500. The locked entity | ||
| # is HELD for the rest of the request: the session's identity | ||
| # map references clean objects weakly, so discarding the | ||
| # return value could let the refreshed object be collected and | ||
| # the command's find_by_id re-read a stale row on MySQL | ||
| # REPEATABLE READ (see lock_entity_for_update). | ||
| try: | ||
| _locked_entity: SqlaTable | None = lock_entity_for_update(SqlaTable, pk) | ||
| except OperationalError as ex: | ||
| if not is_lock_contention_error(ex): | ||
| raise | ||
| return self._lock_contention_response() | ||
|
|
||
| # Live version identifiers before the update (empty + query-free when | ||
| # ``ENABLE_VERSIONING_CAPTURE`` is off). | ||
| old_info = current_entity_version_info(SqlaTable, pk) | ||
| # ``ENABLE_VERSIONING_CAPTURE`` is off). On the conditional path the | ||
| # live transaction id is read under an exclusive row lock: a plain | ||
| # read is served from the request's REPEATABLE READ snapshot on MySQL | ||
| # and can miss a concurrent commit, letting a stale If-Match token | ||
| # pass the guard. A lock race lost at that read (deadlock / lock | ||
| # wait) proves concurrent CONTENTION, not that this request's token | ||
| # is stale — so it maps to a retryable 409, and the client should | ||
| # retry the SAME request. (A deadlock at Continuum's version-row | ||
| # insert inside the command is classified by the | ||
| # DatasetUpdateFailedError handler below via ``__cause__`` and | ||
| # returns the same 409.) | ||
| try: | ||
| old_info: EntityVersionInfo = current_entity_version_info( | ||
| SqlaTable, pk, lock_for_stale_check=conditional | ||
| ) | ||
| except OperationalError as ex: | ||
| if not (conditional and is_lock_contention_error(ex)): | ||
| raise | ||
| return self._lock_contention_response() | ||
|
|
||
| try: | ||
| raise_for_stale_write(concurrency_token_from(old_info)) | ||
|
|
@@ -796,6 +871,16 @@ def put(self, pk: int) -> Response: | |
| ) | ||
| response = self.response_422(message=str(ex)) | ||
| except DatasetUpdateFailedError as ex: | ||
| # The gap lock the conditional path's locking read takes on a | ||
| # zero-live-row range can deadlock against another conditional | ||
| # writer at Continuum's version-row INSERT inside the command; | ||
| # on_error chains the driver error as __cause__, and the update | ||
| # transaction has rolled back. (The post-commit override_columns | ||
| # refresh raises its own exception type and cannot reach this | ||
| # branch.) Same retryable classification as the read-point | ||
| # handler above: the token is not proven stale. | ||
| if conditional and is_lock_contention_error(ex.__cause__): | ||
|
mikebridge marked this conversation as resolved.
Resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only negative coverage here: the contaminated-
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in c6767b2: a positive command failure whose cause is OperationalError with driver args (1213, ...), plus an unconditional control that must remain 422 without retry text. Replacing the conditional cause handler with if False in a process-local mutation made the positive fail (422 vs 409) while the unconditional control passed. Final candidate: 59 focused tests and 221 versioning unit tests pass, complete changed-file pre-commit passes, and independent final-snapshot review approves. This is unit verification; fresh live MySQL REPEATABLE READ integration remains a CI gate. |
||
| return self._lock_contention_response() | ||
| logger.error( | ||
| "Error updating model %s: %s", | ||
| self.__class__.__name__, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
db.session.rollback()→passalso survives all 52 tests. fitzee's thread warned a future cleanup trusting the comment would leak the entity lock on a 1205; the docstring now says LOAD-BEARING, but nothing pins it. Asserting it on the 1205 path closes that.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in c6767b2: the 1205 test covers both direct lock points and asserts rollback exactly once before request teardown. Replacing the helper rollback with pass in-process made both cases fail with zero rollback calls. The docstring also distinguishes the required direct-catch rollback from the decorated command path where rollback already occurred. Final focused suite (59) and full versioning unit suite (221) pass; changed-file hooks and independent final-snapshot review pass. No claim of a fresh live-server lock-release test.