Skip to content

Do not revoke a lease that was renewed after it was listed as expired - #22985

Open
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin1/oss-8185-revoke_expired_lease-revokes-a-lease-that-was-renewed-after
Open

Do not revoke a lease that was renewed after it was listed as expired#22985
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin1/oss-8185-revoke_expired_lease-revokes-a-lease-that-was-renewed-after

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

closes #22983

monitor_expired_leases lists the expired leases and schedules one revoke_expired_lease task for each lease. The holder can renew a lease before the task runs. The task read the lease again, but it did not check the expiration. Thus it decremented the slots and deleted a lease that was alive.

This PR adds a check of the current expiration in revoke_expired_lease. If the lease is no longer expired, the task stops and does not change the slots.

The memory lease storage kept the new expiration only in its expiration index. Therefore read_lease gave the old expiration and the new check could not see the renewal. renew_lease now also sets the expiration on the stored lease. The filesystem and Redis backends already do this.

Details
now = datetime.now(timezone.utc)
if expired_lease.expiration > now:
    logger.debug(f"Lease {lease_id} was renewed before revocation, skipping")
    return

Tests:

  • tests/server/services/test_repossessor.py::TestRevokeExpiredLease::test_revoke_expired_lease_renewed_before_revocation — a renewed lease stays, and the active slots do not change. The test fails on main.
  • tests/server/concurrency/test_memory_lease_storage.py::TestMemoryConcurrencyLeaseStorage::test_renew_lease_updates_lease_expiration — the lease expiration agrees with the expiration index after a renewal.

Checklist

  • This pull request references any related issue by including "closes <link to issue>"
  • If this is a complex change, a maintainer has confirmed the proposed approach on the linked issue.
    • The approach follows the fix proposed in the issue.
  • If this pull request adds or changes functionality, it includes tests or explains why tests are not needed.
  • If this pull request changes user-facing behavior, it updates documentation or explains why documentation is not needed.
    • This is a bug fix in server behavior; no documentation change is needed.
  • If this pull request removes docs files, it includes redirect settings in mint.json.
  • If this pull request adds functions or classes, it includes helpful docstrings.

Link to Devin session: https://app.devin.ai/sessions/b61c02f11a41438f81eea5a48a8f737b
Open in Devin Desktop: https://app.devin.ai/desktop/session/b61c02f11a41438f81eea5a48a8f737b?variant=devin

`revoke_expired_lease` now re-checks the lease expiration before it decrements slots, and the memory lease storage keeps the lease expiration in sync with its expiration index on renewal.

Co-authored-by: Alex Streed <desertaxle@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@github-actions github-actions Bot added the bug Something isn't working label Aug 30, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +43 to +48
now = datetime.now(timezone.utc)
if expired_lease.expiration > now:
# The holder renewed the lease between it being listed as expired and
# this task running, so it is alive and must not be revoked.
logger.debug(f"Lease {lease_id} was renewed before revocation, skipping")
return

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.

🔴 Renewal during revocation is lost

A renewal completing after read_lease remains invisible to this one-time expiration check. The task then deletes the renewed lease and releases its slots.

Prompt for agents
Close the remaining TOCTOU race in src/prefect/server/services/repossessor.py. revoke_expired_lease currently reads a lease, checks that snapshot's expiration, performs an asynchronous database decrement, and then unconditionally revokes the lease. A concurrent renew_lease call can succeed after the read/check and before revoke_lease, after which this task deletes the renewed lease and releases its slots. Add a storage-level conditional revocation or equivalent coordination that verifies the lease generation/expiration atomically with deletion, and ensure slot accounting remains consistent when that condition fails. Apply the contract across the memory, filesystem, and Redis storage implementations and add a deterministic test that renews after the task's expiration check but before deletion.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

Correct, and intentionally out of scope here. A renewal that lands after this check but before revoke_lease is still lost, because none of the storage backends offer a conditional (compare-and-delete) revocation. Closing that needs a new contract on ConcurrencyLeaseStorage.revoke_lease (or a generation/expiration token) implemented in the memory, filesystem, and Redis backends, plus rollback of the slot decrement when the condition fails.

This PR shuts the window described in #22983 — the docket scheduling latency between listing and task start, which is the wide one. The residual window is the few milliseconds of the decrement itself. I would rather size the conditional-revocation change on its own issue than expand the API surface here.

Comment thread tests/server/services/test_repossessor.py
@codspeed-hq

codspeed-hq Bot commented Aug 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 8 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing devin1/oss-8185-revoke_expired_lease-revokes-a-lease-that-was-renewed-after (8d4fb78) with main (33ccf3d)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

revoke_expired_lease revokes a lease that was renewed after it was listed as expired

0 participants