Repository navigation
fix(metrics): make Prometheus metrics correct with multiple workers, and wire the trackers up (port of #1523 to main) - #1678
Conversation
edwinyyyu
left a comment
There was a problem hiding this comment.
Does not touch core/storage so any changes are easily undoable. Not reviewed for better approaches.
| path = os.environ.get("PROMETHEUS_MULTIPROC_DIR") | ||
| chosen = False | ||
| if not path: | ||
| if _worker_count() <= 1: |
There was a problem hiding this comment.
Should move this check out of the if code block? If there is only one worker, there is no sense to setup the directory.
There was a problem hiding this comment.
Checked this one rather than moved it, because hoisting the check turns out to break a case.
prometheus_client picks its multiprocess value class from the presence of PROMETHEUS_MULTIPROC_DIR alone — the worker count never enters into it — and then mmaps a file into that directory on the first metric. So a one-worker deployment where the operator has set the variable still needs the directory to exist and to be clear of the previous run’s files.
Verified directly against the library, pointing the variable at a missing directory in a single process:
ValueClass chosen: MmapedValue
raised FileNotFoundError: [Errno 2] No such file or directory:
/tmp/definitely-not-created-probe/counter_4084679.db
That is the failure the docstring warns about, and moving if _worker_count() <= 1: return to the top of the function would reintroduce it for exactly that configuration.
Your underlying point is right though, and it is already what the code does: the worker count gates choosing a default directory, which is why the check sits inside if not path. test_an_explicit_directory_is_honoured_for_one_worker pins the other half.
I have added a comment at that line in be659b8 recording the reasoning, so the next reader does not have to rediscover it. Happy to restructure if you would rather see it expressed differently.
Review follow-up on MemMachine#1678. Shu asked whether the worker-count check should move out of the `if not path` block, since a single worker has no need of the directory. It cannot: when PROMETHEUS_MULTIPROC_DIR is set by the operator, prometheus_client switches to its multiprocess value class on the strength of the environment variable alone and mmaps a file into the directory on the first metric, whatever the worker count. Hoisting the check would skip the mkdir for a one-worker deployment that sets the variable, and the first metric would raise FileNotFoundError deep in the worker - the failure the docstring already warns about. Verified against prometheus_client directly: with the variable pointing at a missing directory, a single-process Counter().inc() raises FileNotFoundError on <dir>/counter_<pid>.db. test_an_explicit_directory_is_honoured_for_one_worker already pins this, so the comment records the reasoning rather than changing behaviour. Signed-off-by: Haiyan Wang <[email protected]>
…and wire the trackers up (MemMachine#1523) * fix(metrics): aggregate Prometheus metrics across uvicorn workers With MEMMACHINE_WORKERS > 1 uvicorn forks a worker per process and each keeps its own registry, so /metrics returned whichever worker happened to answer the scrape - about 1/N of the traffic, a different 1/N each time. Consecutive scrapes then look like counter resets, and every rate, histogram quantile and calls-per-request figure derived from them is wrong in an unbounded direction. Build a fresh registry per scrape and attach MultiProcessCollector when PROMETHEUS_MULTIPROC_DIR is set, leaving the single-worker path on the default registry so nothing changes for the default deployment. Measured on an 8-tenant capacity run: counter resets over one ladder went from 70 (4 workers) and 87 (8 workers) to 0, and five consecutive scrapes of a loaded server returned identical values where they previously diverged. Requires PROMETHEUS_MULTIPROC_DIR to be set and a writable directory mounted at it; the chart change that does so is separate. * fix(metrics): hand every OperationTracker a metrics factory OperationTracker accepts metrics_factory=None and then silently discards every timing it takes - no error, no warning, no series. A component that is fully instrumented but never given a factory is therefore indistinguishable from one that was never instrumented at all, and the only way to notice is to go looking for a metric that should exist. The Neo4j store, the episode store and the session store each shipped instrumented and unwired, which is why database latency appeared to be unmeasurable: every call was timed and thrown away. Wire the factory through the resource manager, the database manager and the event backend's params so those trackers emit. The tests assert at the call sites rather than on the components. An earlier version tested that each store honours a factory it is given, which passes whether or not anything passes one - it still passed with the fix reverted. These fail when the wiring is removed. * fix(metrics): choose a multiprocess directory when workers > 1 PROMETHEUS_MULTIPROC_DIR had to be set by whoever deployed the server. Set MEMMACHINE_WORKERS=4 without it and nothing complains: each worker keeps its own registry, a scrape is answered by whichever worker the load balancer picked, and the numbers that come back look plausible. There is no error to notice, so the only way to find out is to compare a counter against a request count and see it come up short by a factor of the worker count. The worker count is the thing that decides whether aggregation is needed, so read it here and default the directory when it is above 1. An explicit setting still wins, which is how two servers on one host keep their metrics apart. If the chosen directory cannot be created the variable is removed again - prometheus_client raises in every worker if it cannot open the directory it is pointed at, and taking the server down over metrics would be the wrong trade. Worker-count parsing moves into _worker_count() so start_http() and the directory setup cannot disagree about it. Verified: uvicorn spawns workers via multiprocessing.get_context("spawn"), so children re-import in a fresh interpreter and inherit the variable set here. 13 new tests cover the default, the explicit override, nested creation, stale file clearing, and both failure paths; 1228 server/common tests pass; ruff check, ruff format --check and ty are clean (17 ty diagnostics, all pre-existing and none in these files). Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP --------- Co-authored-by: Claude Opus 5 <[email protected]> (cherry picked from commit ace7752) Signed-off-by: Haiyan Wang <[email protected]>
`main` runs `ty check` over the server package, which `speedkick` did not when MemMachine#1523 merged, and it rejects the stub this test installed: manager.get_sql_engine = fake_engine error[invalid-assignment]: Object of type `def fake_engine(_name) -> ...` is not assignable to attribute `get_sql_engine` of type `def get_sql_engine(self, name: str, validate: bool = False) -> ...` Patch the class with monkeypatch instead of assigning to the instance, and give the stub the real method's signature, matching how this test already replaces `get_metrics_factory`. The `cast` is needed because the return is a MagicMock. Test-only; the wiring under test is untouched. Confirmed the test still fails when the segment-store wiring is reverted, so it keeps its teeth. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Signed-off-by: Haiyan Wang <[email protected]>
Review follow-up on MemMachine#1678. Shu asked whether the worker-count check should move out of the `if not path` block, since a single worker has no need of the directory. It cannot: when PROMETHEUS_MULTIPROC_DIR is set by the operator, prometheus_client switches to its multiprocess value class on the strength of the environment variable alone and mmaps a file into the directory on the first metric, whatever the worker count. Hoisting the check would skip the mkdir for a one-worker deployment that sets the variable, and the first metric would raise FileNotFoundError deep in the worker - the failure the docstring already warns about. Verified against prometheus_client directly: with the variable pointing at a missing directory, a single-process Counter().inc() raises FileNotFoundError on <dir>/counter_<pid>.db. test_an_explicit_directory_is_honoured_for_one_worker already pins this, so the comment records the reasoning rather than changing behaviour. Signed-off-by: Haiyan Wang <[email protected]>
be659b8 to
1507144
Compare
…he key too The row read "Qdrant: ...", but on main Neo4jConf carries the same mixin and the Neo4j store receives the resolved factory the same way (MemMachine#1678), with no docs row of its own. Named inline, as the table names Milvus on its rows. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…(port of #1532) (#1682) * fix(metrics): wire the Qdrant vector store (speedkick) (#1532) QdrantVectorStore was the fourth component built without a metrics factory. OperationTracker accepts metrics_factory=None and then discards every timing without an error, so the store looked instrumented and emitted nothing - the same defect as the Neo4j store, the episode store and the session store, which is why no Qdrant latency was observable. QdrantConf gains MetricsFactoryIdMixin so it can resolve one, and database_manager passes it through. test_qdrant_creates_vector_store pinned the exact params and had to change. It now asserts metrics_factory is not None rather than pinning it: passing the keyword is not the property worth guarding, since None is accepted and silently discards everything. Removing the wiring fails it. Ported to main without #1532's Dockerfile change (the EXTRAS build arg), which is unrelated to the wiring; the `metrics_factory_id` key is added to the database configuration table in the docs. (cherry picked from commit b6c90ab) Claude-Session: https://claude.ai/code/session_01Nr9kacmpFVTTfkZRw6esxP Co-authored-by: Claude Opus 5 <[email protected]> * docs(config): name Neo4j on the metrics_factory_id row, as it takes the key too The row read "Qdrant: ...", but on main Neo4jConf carries the same mixin and the Neo4j store receives the resolved factory the same way (#1678), with no docs row of its own. Named inline, as the table names Milvus on its rows. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --------- Co-authored-by: wanghy73 <[email protected]> Co-authored-by: Claude Opus 5 <[email protected]>
Two fixes to metrics that are wrong in ways nothing reports. Both were found while
trying to attribute request latency on a capacity run and discovering the numbers
could not be trusted.
1. Metrics are not aggregated across uvicorn workers
/metricscalled baregenerate_latest(). WithMEMMACHINE_WORKERS > 1uvicornforks a worker per process and each keeps its own registry, so a scrape returned
whichever worker happened to answer — about 1/N of the traffic, a different 1/N
each time. Consecutive scrapes look like counter resets, and every rate, histogram
quantile and calls-per-request figure derived from them is wrong in an unbounded
direction.
Build a fresh registry per scrape and attach
MultiProcessCollectorwhenPROMETHEUS_MULTIPROC_DIRis set. The single-worker path keeps the defaultregistry, so nothing changes for the default deployment.
Measured, on an 8-tenant capacity run over one rate ladder:
Five consecutive scrapes of a loaded server returned identical values where they
previously diverged.
This needs
PROMETHEUS_MULTIPROC_DIRset and a writable directory mounted at it.The chart change that does so is separate.
2. OperationTracker silently discards timings when given no factory
OperationTrackeracceptsmetrics_factory=Noneand then throws away everytiming it takes — no error, no warning, no series. A component that is fully
instrumented but never handed a factory is indistinguishable from one that was
never instrumented at all, and the only way to notice is to go looking for a
metric that should exist and find nothing.
That is not hypothetical: the Neo4j store, the episode store and the session
store each shipped instrumented and unwired, which is why database latency
appeared to be unmeasurable. Every call was being timed and discarded. This wires
the factory through the resource manager, the database manager and the event
backend's params.
On the tests
test_metrics_factory_wiring.pyasserts at the call sites, not on thecomponents. An earlier version tested that each store honours a factory it is
given — which passes whether or not anything passes one, and still passed with
the fix reverted. The tests here fail when the wiring is removed.
Verification
running server.
memmachine/memmachine:instrumented-mpandexercised with 1, 4 and 8 workers under load.
The two changes are kept as separate commits so either can be reverted alone.