-
Notifications
You must be signed in to change notification settings - Fork 48
fix(durable-learning): retire library-local schedulers once their handles die #498
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
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 |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| """A library-local scheduler outlives its handles for no longer than one call.""" | ||
|
|
||
| import gc | ||
| import threading | ||
|
|
||
| import pytest | ||
|
|
||
| from reflexio.models.config_schema import ( | ||
| Config, | ||
| ProfileExtractorConfig, | ||
| StorageConfigSQLite, | ||
| ) | ||
| from reflexio.server.api_endpoints.request_context import RequestContext | ||
| from reflexio.server.services.configurator.configurator import DefaultConfigurator | ||
| from reflexio.server.services.durable_learning import local | ||
|
|
||
| _THREAD_NAME = "reflexio-durable-learning-scheduler" | ||
|
|
||
|
|
||
| def _scheduler_threads() -> int: | ||
| return sum(1 for t in threading.enumerate() if t.name == _THREAD_NAME) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def isolated_registry(): | ||
| """Swap the module registry for an empty one and stop whatever it collects.""" | ||
| with local._lock: | ||
| saved_schedulers = local._schedulers | ||
| saved_contexts = local._contexts | ||
| saved_server = local._server_scheduler | ||
| local._schedulers = {} | ||
| local._contexts = type(saved_contexts)() | ||
| local._server_scheduler = None | ||
| yield | ||
| with local._lock: | ||
| created = list(local._schedulers.values()) | ||
| local._schedulers = saved_schedulers | ||
| local._contexts = saved_contexts | ||
| local._server_scheduler = saved_server | ||
| for scheduler in created: | ||
| scheduler.stop() | ||
|
|
||
|
|
||
| def _context(base_dir, org_id: str) -> RequestContext: | ||
| base_dir.mkdir(parents=True, exist_ok=True) | ||
| configurator = DefaultConfigurator(org_id=org_id, base_dir=str(base_dir)) | ||
| configurator.set_config( | ||
| Config( | ||
| storage_config=StorageConfigSQLite(db_path=str(base_dir / "lifecycle.db")), | ||
| window_size=1, | ||
| stride_size=1, | ||
| profile_extractor_config=ProfileExtractorConfig( | ||
| extraction_definition_prompt="Preferences" | ||
| ), | ||
| user_playbook_extractor_config=None, | ||
| ) | ||
| ) | ||
| return RequestContext( | ||
| org_id=org_id, storage_base_dir=str(base_dir), configurator=configurator | ||
| ) | ||
|
|
||
|
|
||
| def test_dropped_handles_do_not_accumulate_scheduler_threads( | ||
| tmp_path, isolated_registry | ||
| ): | ||
| baseline = _scheduler_threads() | ||
| for index in range(6): | ||
| context = _context(tmp_path / f"dir{index}", f"lifecycle{index}") | ||
| local.ensure_local_extraction(context) | ||
| del context | ||
| gc.collect() | ||
| # Only the most recently registered directory may still hold a scheduler: | ||
| # it is retired by the next caller, never by the one that created it. | ||
| assert len(local._schedulers) == 1 | ||
| assert _scheduler_threads() <= baseline + 1 | ||
|
|
||
|
|
||
| def test_scheduler_survives_while_its_context_is_reachable(tmp_path, isolated_registry): | ||
| kept = _context(tmp_path / "kept", "kept-org") | ||
| local.ensure_local_extraction(kept) | ||
| kept_scheduler = local._schedulers[kept.storage_base_dir] | ||
|
|
||
| transient = _context(tmp_path / "other", "other-org") | ||
| local.ensure_local_extraction(transient) | ||
| del transient | ||
| gc.collect() | ||
|
|
||
| local.ensure_local_extraction(_context(tmp_path / "third", "third-org")) | ||
| gc.collect() | ||
|
|
||
| assert local._schedulers.get(kept.storage_base_dir) is kept_scheduler | ||
| assert kept_scheduler.is_running() | ||
| assert str(tmp_path / "other") not in local._schedulers | ||
|
|
||
|
|
||
| def test_two_handles_on_one_key_both_keep_the_scheduler_alive( | ||
| tmp_path, isolated_registry | ||
| ): | ||
| """A second handle on the same org+directory must not unregister the first. | ||
|
|
||
| `ReflexioBase` builds an independent `RequestContext` per handle, so two | ||
| handles sharing an org and a directory are two distinct objects. A registry | ||
| keyed by `(org_id, storage_base_dir)` collapses them: the second | ||
| registration evicts the first, and collecting the second empties the key | ||
| while the first handle is still alive and using its scheduler. The sweep | ||
| then retires that scheduler and extraction stops silently under a live | ||
| caller. | ||
|
|
||
| Measured on the keyed version: with both handles alive the registry held | ||
| only the second. This is the regression test for that. | ||
| """ | ||
| shared = tmp_path / "shared" | ||
| first = _context(shared, "same-org") | ||
| local.ensure_local_extraction(first) | ||
| scheduler = local._schedulers[first.storage_base_dir] | ||
|
|
||
| second = _context(shared, "same-org") | ||
| assert second is not first, "precondition: the handles are distinct objects" | ||
| local.ensure_local_extraction(second) | ||
| # The first must still be represented -- the whole point of the set. | ||
| assert first in local._live[first.storage_base_dir] | ||
|
|
||
| del second | ||
| gc.collect() | ||
|
|
||
| # Any later caller triggers the sweep; the first handle is still alive, so | ||
| # its scheduler must survive it. | ||
| local.ensure_local_extraction(_context(tmp_path / "elsewhere", "other-org")) | ||
| gc.collect() | ||
|
|
||
| assert first in local._live[first.storage_base_dir] | ||
| assert local._schedulers.get(first.storage_base_dir) is scheduler | ||
| assert scheduler.is_running() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -99,6 +99,10 @@ def test_library_recovers_persisted_backlog_without_new_publish(tmp_path, monkey | |
| finally: | ||
| with local._lock: | ||
| scheduler = local._schedulers.pop(str(tmp_path), None) | ||
| local._contexts.pop((org, str(tmp_path)), None) | ||
| # `_contexts` is a WeakSet of live contexts, not a dict keyed by | ||
| # (org, dir) -- two handles here share that key, which is exactly | ||
| # why the key was removed. Clear it; this is teardown. | ||
| local._contexts.clear() | ||
| local._live.clear() | ||
|
Comment on lines
+102
to
+106
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. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Scope the The lifecycle tests keep a context reachable and require its scheduler to remain running. After 🤖 Prompt for AI Agents |
||
| if scheduler: | ||
| scheduler.stop() | ||
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.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Retain a live fallback context for each
(org_id, directory).When the newer context is collected,
_contextsloses the key, while_livekeeps the older context and the scheduler running.discover()then reads no organizations, so_run_once()does not start workers for pending durable work. The factory also cannot select the older context. Keep an ordered weak fallback per key, or restore the newest remaining context before both discovery and factory lookup. Add a regression test that processes pending work after the newer context is collected.🤖 Prompt for AI Agents