Skip to content

fix(distributed-lock): ownership-checked release (compare-and-delete) - #43462

Merged
villebro merged 1 commit into
gaq-to-gtffrom
villebro/distributed-lock-ownership
Aug 24, 2026
Merged

fix(distributed-lock): ownership-checked release (compare-and-delete)#43462
villebro merged 1 commit into
gaq-to-gtffrom
villebro/distributed-lock-ownership

Conversation

@villebro

Copy link
Copy Markdown
Member

SUMMARY

Addresses the P2 finding from the #43407 review (targets gaq-to-gtf): the distributed lock's release is ownership-less.

AcquireDistributedLock stored a constant value ("1" / {"value": True}) via SET NX, and ReleaseDistributedLock unconditionally deleted the key. So if holder A's TTL lapsed and holder B acquired the same key, A's finally release would delete B's lock. It predates this epic, but the epic makes this primitive central to task submit/cancel (task_lock), so it's worth closing.

Fix: acquire now stores a per-acquisition token (uuid4().hex) as the lock's value; the DistributedLock context manager threads that token to release, which only deletes the key when the stored value still matches — compare-and-delete:

  • Redis: get → compare → delete (a get-then-delete; the residual non-atomic window is bounded by one round-trip and vastly smaller than an unconditional delete's exposure — a Lua CAS could close it fully; noted in a comment).
  • KeyValue DB fallback: ownership-checked read before delete.

Release keeps a token=None mode meaning "delete unconditionally", which preserves the behavior of the one cross-process caller — the Excel export, where the API process acquires and the Celery task releases (they can't share an in-memory token). That path is unchanged.

TESTING INSTRUCTIONS

  • pytest tests/unit_tests/distributed_lock/ — two new regressions (Redis + KV) prove a superseded holder's release leaves the current holder's lock intact; existing happy-path / expiry / fallback tests updated for the token-shaped value (57 passing incl. export-excel which uses the unconditional path).
  • mypy / ruff / pre-commit clean.

ADDITIONAL INFORMATION

  • Has associated issue
  • Required feature flags
  • Changes UI
  • Includes DB Migration
  • Introduces new feature or API
  • Removes existing feature or API

The Redis/KV distributed lock stored a constant value on acquire and deleted the
key unconditionally on release. If a holder A's TTL expired and holder B then
acquired the same key, A's release deleted B's lock — a classic lost-lock bug on
a primitive the Global Task Framework now leans on for task submit/cancel.

Acquire now stores a per-acquisition token (uuid4) as the lock's value; the
DistributedLock context manager threads that token to release, which only deletes
the key when the stored value still matches (compare-and-delete). Both backends:
Redis (get-then-compare-then-delete; residual non-atomic window noted, vs a full
Lua CAS) and the KeyValue DB fallback (ownership-checked before delete).

Release keeps a token=None mode meaning "delete unconditionally", preserving the
existing behavior of the one cross-process caller (the Excel export: the API
process acquires, the Celery task releases — they cannot share an in-memory
token), which is unchanged.

Tests: two new ownership regressions (Redis + KV) prove a superseded holder's
release leaves the current holder's lock intact; existing happy-path/expiry/
fallback tests updated for the token-shaped value.
@dosubot dosubot Bot added the change:backend Requires changing the backend label Aug 24, 2026
@bito-code-review

bito-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped - Branch Excluded

Bito didn't auto-review because the source or target branch is excluded from automatic reviews.
No action is needed if you didn't intend for the agent to review it. Otherwise, to manually trigger a review, type /review in a comment and save.
You can change the branch exclusion settings here, or contact your Bito workspace admin at evan@preset.io.

@netlify

netlify Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

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

@villebro

Copy link
Copy Markdown
Member Author

/review

@bito-code-review

bito-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #3a3f71

Actionable Suggestions - 0
Additional Suggestions - 1
  • superset/commands/distributed_lock/release.py - 1
    • Non-atomic Redis release has race window · Line 83-86
      The get-then-delete in `_release_redis()` is non-atomic. Between the GET and DELETE, another holder could acquire the same key (if our TTL expired). This window is bounded by network round-trip time, but a Lua compare-and-delete script would eliminate it entirely.
Review Details
  • Files reviewed - 4 · Commit Range: 0b9ab1e..0b9ab1e
    • superset/commands/distributed_lock/acquire.py
    • superset/commands/distributed_lock/release.py
    • superset/distributed_lock/__init__.py
    • tests/unit_tests/distributed_lock/distributed_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

# The acquisition token to match on release (see AcquireDistributedLock).
# None means "delete unconditionally" — only for callers that did not
# acquire via the token-aware path.
self.token = token

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.

Suggestion: The default token=None path still performs an unconditional delete, and the dashboard API directly invokes this release after acquiring the lock without retaining or passing the acquisition token. If the enqueueing request's lock expires and another export acquires the same key before enqueue failure cleanup runs, this call deletes the newer holder's lock. Retain the token for same-process acquisition/cleanup paths; use an explicit separate mechanism for the cross-process Excel task handoff. [api mismatch]

Severity Level: Major ⚠️
- ❌ Failed export enqueue cleanup can remove a newer export lock.
- ⚠️ Concurrent exports can bypass dashboard/user throttling.
- ⚠️ Affected path is the dashboard Excel export API.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/distributed_lock/release.py
**Line:** 58:58
**Comment:**
	*Api Mismatch: The default `token=None` path still performs an unconditional delete, and the dashboard API directly invokes this release after acquiring the lock without retaining or passing the acquisition token. If the enqueueing request's lock expires and another export acquires the same key before enqueue failure cleanup runs, this call deletes the newer holder's lock. Retain the token for same-process acquisition/cleanup paths; use an explicit separate mechanism for the cross-process Excel task handoff.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 075b1c1e8c by threading the acquisition token through the whole Excel-export flow, so no caller on that path relies on the unconditional token=None delete anymore:

  • Same-process cleanup: the API now retains acquire = AcquireDistributedLock(...) and the enqueue-failure release passes token=acquire.token, so it only deletes the lock it holds — not one a later export acquired if this one's TTL already expired.
  • Cross-process handoff: the token is passed into the Celery task via apply_async(kwargs={..., "lock_token": acquire.token}); export_dashboard_excel takes a lock_token param and releases with it. Since the token is a plain string it serializes cleanly in the task payload and the ownership check works for both backends (Redis string value / KV {"token": ...}).

The token=None default stays as a documented escape hatch for callers that didn't acquire via the token-aware path, but the throttled Excel path no longer uses it. Extended the existing lock-release tests to assert the token is threaded through (token="lock-tok-1") and the enqueue test to assert apply_async carries lock_token.

@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is correct. The ReleaseDistributedLock command currently defaults token=None, which triggers an unconditional delete. If a process's lock expires and is re-acquired by another process before the original process calls release, the original process will incorrectly delete the new holder's lock.

To resolve this, you should ensure that the dashboard API (and any other direct callers) passes the acquisition token to ReleaseDistributedLock. The DistributedLock context manager already handles this correctly by passing token=acquire.token.

Proposed Fix

Update the dashboard API call to retrieve and pass the token, similar to how the context manager does it:

# Example of how to update the direct invocation:
# 1. Acquire the lock and get the token
lock_command = AcquireDistributedLock(namespace, params)
lock_command.run()
token = lock_command.token

# 2. Pass the token to the release command
ReleaseDistributedLock(namespace, params, token=token).run()

I have reviewed the PR changes and the implementation of the token-based release appears robust for the context manager. Would you like me to fetch all other comments on this PR to validate and implement fixes for them as well?

superset/commands/distributed_lock/release.py

def __init__(
        self,
        namespace: str,
        params: dict[str, Any] | None = None,
        token: str | None = None,
    ) -> None:
        super().__init__(namespace, params)
        # The acquisition token to match on release (see AcquireDistributedLock).
        # None means "delete unconditionally" — only for callers that did not
        # acquire via the token-aware path.
        self.token = token

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.85%. Comparing base (5ab7d5f) to head (0b9ab1e).

Files with missing lines Patch % Lines
superset/commands/distributed_lock/release.py 76.47% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@              Coverage Diff               @@
##           gaq-to-gtf   #43462      +/-   ##
==============================================
- Coverage       78.86%   78.85%   -0.01%     
==============================================
  Files            2883     2883              
  Lines          164782   164784       +2     
  Branches        38083    38087       +4     
==============================================
- Hits           129949   129943       -6     
- Misses          32388    32393       +5     
- Partials         2445     2448       +3     
Flag Coverage Δ
hive 38.13% <13.63%> (-0.02%) ⬇️
mysql 57.69% <45.45%> (-0.02%) ⬇️
postgres 57.72% <45.45%> (-0.02%) ⬇️
presto 40.06% <13.63%> (-0.02%) ⬇️
python 83.52% <81.81%> (-0.01%) ⬇️
sqlite 57.41% <45.45%> (-0.02%) ⬇️
unit 73.61% <81.81%> (-0.03%) ⬇️

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.

@villebro
villebro merged commit a356646 into gaq-to-gtf Aug 24, 2026
70 checks passed
@villebro
villebro deleted the villebro/distributed-lock-ownership branch August 24, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant