Skip to content

fix: do not return stopped service instances from the instance cache - #22995

Open
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1788190860-fix-stopped-service-instance-cache
Open

fix: do not return stopped service instances from the instance cache#22995
devin-ai-integration[bot] wants to merge 3 commits into
mainfrom
devin/1788190860-fix-stopped-service-instance-cache

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

tests/test_assets.py::test_nested_materialization failed on main (run, group 10/18, python 3.12 / postgres 14) with:

RuntimeError: Cannot put items in a stopped service instance.   (during event emission)
AttributeError: 'AssertingEventsClient' object has no attribute 'events'

Root cause

QueueService.instance() can keep a stopped service in the class-level _instances cache:

  1. instance() calls _new_instance(), which starts the service on the global loop thread.
  2. If the service stops immediately (for example, _lifespan fails during startup), _run calls _remove_instance(), which removes the key from _instances.
  3. That removal can happen before the caller of instance() stores the new instance in _instances, so the caller writes the stopped instance into the cache.

The stopped instance stays in the cache. Each later instance() call gets that dead service, send() raises RuntimeError, and no items are processed. For the EventsWorker, the client also never enters its async context, so AssertingEventsClient.events does not exist and the test fails with the AttributeError.

Fix

instance() now replaces a cached instance that is stopped, and does not cache a new instance that is already stopped. A stopped service can never accept items again, so the cache must not keep it.

Reproduction before the fix (a delay between the start of the service and the cache write makes the race deterministic):

first stopped: True
second is first: True stopped: True
send failed: Cannot put items in a stopped service instance.

After the fix, instance() gives a new, running service.

Verification

  • tests/_internal/concurrency/test_services.py: 39 passed
  • tests/test_assets.py and tests/events/client/test_events_worker.py: 78 passed
  • tests/test_assets.py::test_nested_materialization: 20 consecutive passes
  • tests/_internal/concurrency, tests/events/client, tests/test_logging.py: only tests/events/client/test_automations_server_compatibility.py::test_trigger_round_tripping fails, and it fails the same way on main without this change

Checklist

  • This pull request references any related issue by including "closes <link to issue>"
    • No issue exists; this is a small fix for a flaky test on main.
  • If this is a complex change, a maintainer has confirmed the proposed approach on the linked 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.
    • The change is internal, so no documentation change is necessary.
  • 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/fa2f90b080764e1ba44805f21217bda3
Open in Devin Desktop: https://app.devin.ai/desktop/session/fa2f90b080764e1ba44805f21217bda3?variant=devin

A queue service that stops during startup removes itself from the instance
cache before the caller of instance() stores it there, so the stopped instance
stays cached and all later callers get a service that cannot accept items.

Co-Authored-By: bot_apk <apk@cognition.ai>
@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

@devin-ai-integration devin-ai-integration Bot added the development Tech debt, refactors, CI, tests, and other related work. label Aug 31, 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.

Note

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

Devin Review found 3 potential issues.

Devin Review

Comment on lines +323 to +324
if instance._stopped:
cls._instances.pop(key, None)

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

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.

🟡 Stopped replacement still reaches callers

If _new_instance stops before returning, instance uncaches it but still returns it. The first submission still fails and can lose an event or log.

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.

This is intentional and out of scope here. If a service cannot start, the caller must see that failure; a retry loop inside instance() would hide a persistent startup problem. The bug this PR fixes is that the failure was permanent for every later caller, because the stopped service stayed in the cache. After this change the first caller can still fail, but the next instance() call gets a working service.

Comment thread src/prefect/_internal/concurrency/services.py
Comment thread tests/_internal/concurrency/test_services.py
…lacement

Co-Authored-By: bot_apk <apk@cognition.ai>
devin-ai-integration[bot]

This comment was marked as resolved.

…instance

Co-Authored-By: bot_apk <apk@cognition.ai>

@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 new potential issues.

Devin Review

Comment on lines +317 to +326
# A stopped service never accepts items again, so a cached instance that
# is stopped must be replaced instead of returned
if instance is None or instance._stopped:
instance = cls._new_instance(*args)
cls._instances[key] = instance

# A service that stops while it is created removes itself from
# `_instances` before it is stored there, so the caller must remove it
if instance._stopped:
cls._instances.pop(key, None)

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.

🔍 Bug fix lacks tracked issue

The contribution guide requires an issue before bug fixes. This PR explicitly states that no issue exists.

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.

This fix comes from a flaky test on main (workflow run 33405376931), not from a user report, so there is no issue to link. The failing run is the tracking record and is described in the PR body.

Comment on lines +106 to +118
@classmethod
def _new_instance(cls, *args):
instance = super()._new_instance(*args)
if cls.fail:
# Wait for the failed service to finish so that it removes itself from
# the instance cache before `instance()` stores it there
instance.drain()
return instance

try:
failed_instance = FailingStartupService.instance()
assert failed_instance._stopped
assert FailingStartupService._instances.get(failed_instance._key) is None

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.

🔍 Regression tests mirror private internals

The tests override _new_instance and mutate _instances directly. The testing standard prefers public behavior over private implementation details.

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.

QueueService has no public surface for its lifecycle: instance, _instances, _new_instance, _stop and _stopped are all private, and the existing tests in this file use them the same way (for example test_instance_returns_new_instance_after_stopping and test_lifespan). The new test follows that convention, and the behavior it checks — instance() gives a working service, and the cached service is the one that works — is the behavior callers depend on.

@codspeed-hq

codspeed-hq Bot commented Aug 31, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 8 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing devin/1788190860-fix-stopped-service-instance-cache (914c9e3) with main (3a128c2)

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

development Tech debt, refactors, CI, tests, and other related work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants