Skip to content

fix(metrics): make Prometheus metrics correct with multiple workers, and wire the trackers up (port of #1523 to main) - #1678

Merged
malatewang merged 3 commits into
MemMachine:mainfrom
wanghy73:port-main/metrics-correctness-1523
Sep 18, 2026
Merged

malatewang merged 3 commits into
MemMachine:mainfrom
wanghy73:port-main/metrics-correctness-1523

Conversation

@wanghy73

@wanghy73 wanghy73 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Port of #1523 from speedkick to main.

Cherry-pick of ace7752184a85d45ad5b34d6f3cf7c1c20f0c948, the squash commit #1523 merged as on
speedkick. It applied to main with no conflicts, and the diff is
byte-identical to the one reviewed on #1523 (checked with git patch-id), so the
review there still stands.

Re-checked on this branch, based on main:

  • ruff check and ruff format --check clean repo-wide on the pinned ruff 0.16.8.
  • Server unit suite: 1887 passed, 3 skipped, against 1869 passed on main —
    the 18 added are this PR's two new test modules, and nothing regressed.

One extra commit on top, test-only: main runs ty check over the server
package (speedkick did not when #1523 merged), and it rejects the
get_sql_engine stub in the new test. It now patches the class via
monkeypatch with the real method's signature instead of assigning to the
instance. ty passes on 3.12 and 3.14. I confirmed the test still fails when
the segment-store wiring is reverted, so it keeps its teeth.

The body below is #1523's, unchanged.


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

/metrics called bare generate_latest(). With MEMMACHINE_WORKERS > 1 uvicorn
forks 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 MultiProcessCollector when
PROMETHEUS_MULTIPROC_DIR is set. The single-worker path keeps the default
registry, so nothing changes for the default deployment.

Measured, on an 8-tenant capacity run over one rate ladder:

workers counter resets before after
4 70 0
8 87 0

Five consecutive scrapes of a loaded server returned identical values where they
previously diverged.

This needs PROMETHEUS_MULTIPROC_DIR set and a writable directory mounted at it.
The chart change that does so is separate.

2. OperationTracker silently discards timings when given no factory

OperationTracker accepts metrics_factory=None and then throws away every
timing 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.py asserts at the call sites, not on the
components. 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

  • All 9 changed Python files parse; the new test module was written against the
    running server.
  • Deployed to a test cluster as memmachine/memmachine:instrumented-mp and
    exercised with 1, 4 and 8 workers under load.

The two changes are kept as separate commits so either can be reverted alone.

@edwinyyyu edwinyyyu 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.

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:

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.

Should move this check out of the if code block? If there is only one worker, there is no sense to setup the directory.

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.

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.

wanghy73 added a commit to wanghy73/MemMachine that referenced this pull request Sep 17, 2026
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]>
wanghy73 and others added 3 commits September 18, 2026 00:35
…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]>
@wanghy73
wanghy73 force-pushed the port-main/metrics-correctness-1523 branch from be659b8 to 1507144 Compare September 18, 2026 00:36
@malatewang
malatewang merged commit 46004c2 into MemMachine:main Sep 18, 2026
44 checks passed
This was referenced Sep 18, 2026
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Sep 22, 2026
…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]>
edwinyyyu added a commit that referenced this pull request Sep 24, 2026
…(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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants