Skip to content

Fix lost expiration index updates in the filesystem lease storage - #22971

Open
afonsojanu wants to merge 5 commits into
PrefectHQ:mainfrom
afonsojanu:fix/lease-expiration-index-race-22935
Open

Fix lost expiration index updates in the filesystem lease storage#22971
afonsojanu wants to merge 5 commits into
PrefectHQ:mainfrom
afonsojanu:fix/lease-expiration-index-race-22935

Conversation

@afonsojanu

Copy link
Copy Markdown

Closes #22935

Two lease renewals can land close enough together that they both read the same snapshot of the shared expirations.json index before either one writes back. Whichever one saves last wins, and the other's update just disappears. Since the reaper only ever looks at that index to decide what's expired, losing a single update is enough to have it revoke a lease that's still perfectly alive. The reporter hit this in production on a long-running flow that got killed with a bare 410 about half an hour in, with every renewal up to that point having succeeded.

I added an asyncio.Lock around the read, modify, and write of the index in _update_expiration_index and _remove_from_expiration_index, so two updates can't interleave anymore.

I also changed read_expired_lease_ids to check the actual lease file before reporting something as expired. The lease file is what renew_lease stamps directly, so it's the source of truth, and the index is only supposed to be a shortcut to it. This means that if the index ever ends up stale for some other reason down the road, a stale entry becomes harmless instead of fatal. If the lease file is missing entirely, that entry really is orphaned, so it still gets reported as expired and cleaned up.

For tests, I added one that forces the interleaving deterministically (delaying the first index read just long enough for a second concurrent update to finish first, instead of hoping the event loop schedules it unluckily), and one that backdates an index entry while leaving the lease file untouched, to confirm the reaper no longer trusts a stale index on its own. Both fail against the code on main and pass with the fix.

Ran the full tests/server/concurrency/ and tests/server/services/test_repossessor.py suites locally (61 tests, all passing), plus ruff and mypy on the changed files with nothing new flagged.

Two lease renewals landing close together could interleave inside
_update_expiration_index: both read the same version of the shared
expirations.json, and whichever one saved last silently threw away
the other's write. The reaper only ever looks at that index to decide
what's expired, so a single lost update was enough to revoke a lease
that was still very much alive.

This adds an asyncio.Lock around the read, modify, and write of the
index so two updates can no longer race each other. It also has
read_expired_lease_ids check the actual lease file before reporting
something as expired, since that file is what renew_lease really
stamps and the index is only meant to be a shortcut to it. If the
index is ever stale for some other reason down the line, this keeps
that stale entry from being fatal instead of harmless.

Added a test that forces the interleaving deterministically (delaying
the first read just enough for the second update to finish first) and
one that backdates an index entry while leaving the lease file alone,
confirming the reaper no longer trusts it blindly. Both fail against
the old code and pass with the fix.

Closes PrefectHQ#22935
@github-actions github-actions Bot added the bug Something isn't working label Aug 28, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@codspeed-hq

codspeed-hq Bot commented Aug 28, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 8 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing afonsojanu:fix/lease-expiration-index-race-22935 (4910d29) 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.

get_concurrency_lease_storage() builds a fresh ConcurrencyLeaseStorage
per call, so the lock added to serialize concurrent index renewals was
an instance attribute that never actually served that purpose - each
caller held its own lock and could still interleave with another
request's read-modify-write of expirations.json.

Moving the lock to a class attribute means every instance in the
process shares it, which is what the guard was meant to do.
devin-ai-integration[bot]

This comment was marked as resolved.

The lock guarding the expiration index's read-modify-write cycle was a
single asyncio.Lock created once at class definition time. An
asyncio.Lock only binds to an event loop once something actually
contends on it, and raises RuntimeError if a different loop later
contends on it too. That's exactly what happens across an embedded
server restart or a second asyncio.run() call in the same process:
the first loop's contention binds the lock, and any later loop that
also needs to serialize concurrent renewals blows up instead.

The lock is now looked up from a WeakKeyDictionary keyed by the
running event loop, so each loop gets its own lock and old ones are
freed once their loop is gone, while still serializing renewals across
the fresh ConcurrencyLeaseStorage instances that
get_concurrency_lease_storage() builds per call.

Added a regression test that forces genuine lock contention once on
the fixture's own event loop and then again on a freshly created one,
confirming the second round no longer raises.
devin-ai-integration[bot]

This comment was marked as resolved.

…le lock

A per-event-loop asyncio.Lock registry fixed the RuntimeError from an
embedded server restart binding a stale lock, but it never actually
serialized concurrent access: two loops running at the same time (two
worker threads, each with its own loop) each get a different lock from
the registry, so their read-modify-write cycles on the expiration index
still race and one write clobbers the other.

Replace the registry with the same FileLock used elsewhere in this
codebase (GitRepository.pull_code, the server startup migration lock).
It's not bound to any event loop or thread, so a single lock file next
to the index serializes renewals across loops, threads, and even
separate processes sharing the same storage_path, which this backend
already needs to tolerate given it stores everything on disk. Its
aacquire() polls instead of blocking the event loop while waiting.

Added a regression test that runs two updates concurrently on separate
threads, each with its own asyncio.run() loop, and confirms both index
entries survive instead of one clobbering the other.

@devin-ai-integration devin-ai-integration Bot 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.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +692 to +700
async def load_with_a_delay_on_the_first_call():
nonlocal call_count
call_count += 1
result = await original_load()
if call_count == 1:
# Give the second update a chance to read, write, and finish
# before this one acts on what it already read.
await asyncio.sleep(0.05)
return result

@devin-ai-integration devin-ai-integration Bot Aug 30, 2026

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.

🔍 Concurrency tests rely on scheduler timing

Fixed sleeps do not guarantee the intended interleaving, and timed joins never verify thread termination. Replace them with explicit synchronization to satisfy the deterministic-test rule in the test guidance.

Devin Review

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

join(timeout=...) returning tells you nothing about whether the
thread finished or just timed out, so a real deadlock would slip
through as a silent pass. Assert not is_alive() after each join
instead.

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 0 new potential issues.

Devin Review

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.

FileSystem lease storage: unsynchronised expiration-index update revokes live concurrency leases (410 Gone)

1 participant