fix(dashboard): serialize the memory-provider config read-modify-write - #81598
fix(dashboard): serialize the memory-provider config read-modify-write#815980xGr1mm wants to merge 2 commits into
Conversation
|
This was generated by AI during triage. Summary: Problems:
Solution: Checked against |
The off-loop sweep introduced _CONFIG_MUTATION_LOCK because config.py's
_CONFIG_LOCK covers each load_config()/save_config() call but not the span
between them, and once handlers run in worker threads the event loop no
longer serializes that span. PUT /api/memory/providers/{name}/config runs
via asyncio.to_thread and does exactly that read-modify-write (it sets
memory.provider) without taking the lock, so a concurrent writer that
commits inside the span is erased by the stale save.
Take the lock around the worker body; the declared-surface branch mutates
config too, via _update_memory_provider_config. RLock, so helpers that also
take it cannot self-deadlock.
My original scan keyed on _run closures and reported exactly one gap. That was wrong twice over. Triage found _apply_model_assignment_sync, dispatched through asyncio.to_thread under a differently named closure, and rescanning by reachability surfaced four more: set_moa_models, upsert_custom_endpoint, activate_custom_endpoint and delete_custom_endpoint are plain def FastAPI route handlers, which the framework runs in its own threadpool. All five do load->mutate->save with no lock, so the event loop is not serializing them. Wrap them with a _serialized_config_write decorator rather than re-indenting five bodies; functools.wraps keeps the signature FastAPI builds requests from. A new test rescans the module by reachability so the next such handler fails a test instead of needing another manual sweep.
95a8a3a to
e7f1ea7
Compare
What does this PR do?
965a54878/4ecdee38amoved the dashboard's config handlers off the event loop and introduced_CONFIG_MUTATION_LOCKto replace the serialization the loop used to provide for free. Its docstring states the contract:PUT /api/memory/providers/{name}/configruns off-loop and does exactly that span, without the lock:Any concurrent writer that commits between the
load_config()and thesave_config()is erased by the stale save. Measured against a concurrentPUT /api/dashboard/theme:The declared-surface branch mutates config too, through
_update_memory_provider_config, so the lock wraps both branches rather than just the visible span.Correction: my first scan was wrong, and the gap is wider
The original description said a scan found "exactly one" site. That claim was
wrong, and it was wrong because the scan keyed on
_runclosures rather than onwhat actually matters, which is whether the span runs off the event loop.
Triage caught the first miss:
_apply_model_assignment_syncis dispatchedthrough
asyncio.to_threadunder a differently named closure. Rescanning byreachability surfaced four more, and they are a shape I had not considered at
all:
_apply_model_assignment_syncasyncio.to_thread(_apply_assignment)set_moa_models(PUT /api/model/moa)defFastAPI routeupsert_custom_endpoint(POST /api/providers/custom-endpoints)defFastAPI routeactivate_custom_endpoint(POST .../{id}/activate)defFastAPI routedelete_custom_endpoint(DELETE .../{id})defFastAPI routeFastAPI runs non-
asyncpath operations in its own threadpool, so a sync routehandler is exactly as off-loop as a
to_threadcall and gets none of the loop'sfree serialization. All five do
load_config()-> mutate ->save_config()withno lock.
Two candidates the rescan flagged are deliberately not wrapped, and the
reasons are worth stating:
_save_memory_provider_native_configis only reachedfrom inside the span the first commit already locks, and the lock is an
RLock,so it is covered;
_write_profile_modeland_write_profile_mcp_servershave nocall sites at all and are referenced only from docstrings.
The shape of the second commit
A
_serialized_config_writedecorator rather than five re-indented bodies. Itkeeps the diff readable, and
functools.wrapspreserves__wrapped__, which iswhat FastAPI follows when it builds the request signature; a test pins that the
parameters are still visible.
The last new test rescans the module by reachability rather than trusting a
hand-kept list, so the next sync
@app.*handler that reads and writes configwithout the lock fails a test instead of waiting for another manual sweep. On
the unpatched module it reports all four route handlers by name.
Related Issue
No issue; found while reading the off-loop sweep after it landed. Searched open and merged PRs first, per CONTRIBUTING's search-first section:
I also listed the most recently opened PRs by creation time rather than relying on search alone, because the index lags new PRs by minutes. Nothing covers this handler. This is the same defect class
4ecdee38a("close config-RMW gaps left by the off-loop sweep") set out to close; this is one it did not reach.Type of Change
Bug fix (non-breaking). No behaviour change on the success path beyond serialization; no config keys, no API shape change.
Changes Made
hermes_cli/web_server.py-update_memory_provider_config's worker body takes_CONFIG_MUTATION_LOCKaround both branches, matching the idiom used by the handlers the sweep already covered.tests/hermes_cli/test_memory_provider_config_rmw_lock.py- new, 1 test.How to Test
1 passed. It races a real
PUT /api/memory/providers/{name}/configagainst a realPUT /api/dashboard/themethrough Starlette'sTestClient, delaying only the memory writer's save so the theme write lands inside its span. Modelled on the existingTestConfigMutationLock::test_plugin_providers_put_serialized_against_other_writers.Reverting only
hermes_cli/web_server.pyand rerunning:Two details in that test are load-bearing and worth flagging, because a naive version of it passes on unpatched code and proves nothing.
web_serverbindssave_configat module import, so the slow wrapper has to replaceweb_server.save_config, nothermes_cli.config.save_config. And the delay is keyed on the payload so only the memory writer is slowed; slowing both writers hides the interleaving. My first attempt got both of these wrong and passed against the unpatched handler.Full
tests/hermes_cli/suite, this branch vsmain, same environment, run serially:Comparing the failing sets rather than the counts: zero regressions, the two sets are identical. The +1 is this PR's new test. Those 169 are pre-existing in a non-hermetic single-process run of this suite; CI's per-file isolation via
run_tests_parallel.pyis the supported path.Checklist
Code
fix(dashboard): …)Documentation & Housekeeping
cli-config.yaml.exampleis N/A, no config keysCONTRIBUTING.md/AGENTS.mdare N/ANotes for the reviewer
The lock is an
RLock, so wrapping the whole worker body is safe even though_write_memory_provider_config_valuesand_update_memory_provider_configmay themselves reach code that takes it. I wrapped the body rather than just the three-line span so the declared-surface branch is covered too; if you would rather keep the critical section minimal and add a secondwithinside that branch, that reads fine as well.The scan I described is reproducible and cheap. If it would help, the same shape could be turned into a lint-style test that fails when a new
to_threadclosure pairs a config read with a config save and no lock, so the next sweep does not have to be done by hand.