Skip to content

perf(status): aggregate wing/room counts in the backend instead of client-side fetch - #1824

Closed
gavinmcfall wants to merge 1 commit into
MemPalace:developfrom
gavinmcfall:feat/pgvector-status-groupby
Closed

perf(status): aggregate wing/room counts in the backend instead of client-side fetch#1824
gavinmcfall wants to merge 1 commit into
MemPalace:developfrom
gavinmcfall:feat/pgvector-status-groupby

Conversation

@gavinmcfall

Copy link
Copy Markdown

Problem

mempalace_status builds its wing/room breakdown by pulling every drawer's metadata to the client (_get_cached_metadata) and counting in Python. That's O(n) memory and time.

On a 418,997-drawer pgvector palace this took ~344s and OOM-killed a 2Gi pod. Since the MCP wake-up protocol calls status first, large palaces effectively can't wake up.

Change

  • Add an optional collection hook count_by_metadata_field(field) -> {value: count}.
  • Implement it on the pgvector backend as a single SELECT metadata->>field, count(*) ... GROUP BY 1.
  • tool_status uses it when the collection provides it; backends without it keep the existing client-side path unchanged (duck-typed, fully backward compatible).

Result

Same 418k palace: status drops from ~344s → ~0.4s, flat memory. Counts verified identical to the client-side tally (7 wings / 50 rooms; e.g. my_other_repos=169,587).

Notes

  • Backward compatible — chroma/qdrant/sqlite paths are untouched.
  • list_wings / list_rooms have the same client-side pattern and could adopt this hook in a follow-up; kept this PR focused on the wake-up path.

… fetch

mempalace_status tallied wings/rooms by pulling EVERY drawer's metadata to the
client and counting in Python — O(n) memory and time. On a 418k-drawer pgvector
palace this took ~344s (and OOM'd at 2Gi). Add an optional
BaseCollection-style hook count_by_metadata_field(field) implemented by the
pgvector backend as a single SQL GROUP BY, and use it in tool_status when the
collection provides it; other backends keep the existing client-side path.

Result on the same 418k palace: status drops from ~344s to ~0.4s.

Assisted-by: Claude Code (claude-opus-4-8)
Agentically-Engineered: https://nerdz.cloud/agentic-engineering

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces a server-side aggregation method count_by_metadata_field for the pgvector backend, optimizing the tool_status endpoint to fetch wing and room counts in a single query instead of streaming all metadata to the client. A review comment identifies a correctness bug in pgvector.py where explicit "unknown" values and null values would overwrite each other in the dictionary comprehension, and suggests resolving this by using COALESCE directly in the SQL query.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1069 to +1074
sql = (
f"SELECT metadata->>%s AS k, count(*) AS c "
f"FROM {_quote_identifier(self._table)} GROUP BY 1"
)
rows = self._client._execute(sql, [field], fetch=True)
return {(r[0] if r[0] is not None else "unknown"): int(r[1]) for r in rows}

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.

high

There is a correctness bug here when a collection contains both explicit "unknown" values and missing/null values for the metadata field. Because both map to the key "unknown" in the dict comprehension, one will overwrite the other, resulting in an incorrect count. Pushing the COALESCE aggregation to SQL solves this cleanly and correctly.

Suggested change
sql = (
f"SELECT metadata->>%s AS k, count(*) AS c "
f"FROM {_quote_identifier(self._table)} GROUP BY 1"
)
rows = self._client._execute(sql, [field], fetch=True)
return {(r[0] if r[0] is not None else "unknown"): int(r[1]) for r in rows}
sql = (
f"SELECT COALESCE(metadata->>%s, 'unknown') AS k, count(*) AS c "
f"FROM {_quote_identifier(self._table)} GROUP BY 1"
)
rows = self._client._execute(sql, [field], fetch=True)
return {r[0]: int(r[1]) for r in rows}

@messelink

Copy link
Copy Markdown
Contributor

Hey @gavinmcfall — thanks for filing this, the SQL is essentially the right shape. A couple of things have landed in adjacent threads since you opened this PR that you might not have seen:

I'd suggest aligning this PR's surface area with #1835's spec (rename count_by_metadata_fieldfacet_counts, add where=None, switch to the capability-token + UnsupportedCapabilityError default). Your SQL is good; it's mainly the wrapper.

Two concrete additions from building the same thing for our pgvector deployment that might be worth folding in:

  1. where parameter to match the spec. Pgvector form: thread the existing _where_to_sql translator into the GROUP BY query — keeps array/object semantics consistent with the query/get paths.
  2. EmbeddingCollection explicit forwarder. When I added count_by_metadata_* to BaseCollection, the wrapped pgvector implementation was shadowed — EmbeddingCollection.__getattr__ doesn't fire for methods inherited from BaseCollection (Python MRO resolves them first). Without an explicit forwarder on EmbeddingCollection, the call silently returns the base default. The distance_metric block in embedding_wrapper.py has the same shape.

Happy to send a follow-up PR with these on top of yours once the design is settled, or you can pick them up directly — whatever's easier.

@messelink

Copy link
Copy Markdown
Contributor

Cross-link follow-up to my earlier comment: the wire-byte half I mentioned (the with_document skip for metadata-only fetches, closing #1840's documented follow-up) is now filed as #1892. Disjoint from your PR's aggregation work — different surface area (scroll_rows parameter + get_all_metadata override vs. your count_by_metadata_field), and the two can land in either order. The alignment question for your PR re: #1835's spec / #1868's facet_counts naming is still the main open thread here, separate from the wire-byte work.

@igorls igorls left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, and especially for the concrete incident data — a 419k-drawer pgvector palace taking ~344s and OOM-killing a 2Gi pod on status is exactly the kind of real-world pressure that justifies server-side aggregation. The SQL here is sound and injection-safe (bound %s field, _quote_identifier on the table, NULL → "unknown" matching the client-side ground truth).

The blocker isn't correctness — it's interface convergence. This PR predates the agreed design in #1835, which a parallel PR (#1868) now implements as the canonical contract:

  • a BaseCollection.facet_counts(field, where=None) -> dict[str, int] method (default raises UnsupportedCapabilityError, mirroring lexical_search),
  • a supports_metadata_facets capability token on the backend,
  • all four call sites (tool_status, tool_list_wings, tool_list_rooms, tool_get_taxonomy) checking the token and falling back to client-side counting.

This PR instead adds count_by_metadata_field(field) (no where, duck-typed via getattr/callable, only tool_status), which would entrench a second, non-conforming aggregation interface — and it textually conflicts with #1868 (both rewrite the same wing/room loop in tool_status).

Proposed path so your work lands cleanly:

  1. We merge #1868 first (it establishes the base method, the capability token, and all four call sites).
  2. Rebase this PR on top as the pgvector implementation of facet_counts: rename count_by_metadata_fieldfacet_counts(field, where=None), add the where → SQL WHERE mapping, register supports_metadata_facets on PgVectorBackend, drop the tool_status edit entirely (those call sites already live in #1868), and add unit tests for the new method.

After that rebase this shrinks to roughly one method on pgvector.py + one token line, with no mcp_server.py changes and no conflict. The SQL, the NULL bucketing, and the incident motivation all carry straight over. Would you be up for reshaping it that way? Happy to help line it up once #1868 is in.

One thing worth confirming for your scenario: the wing/room tools already short-circuit through a _sqlite_taxonomy() cache when present, so it's worth verifying the OOM path actually reached the client-side counting branch (i.e. the SQLite cache wasn't populated) so the fix lands where the incident occurred.

@messelink

Copy link
Copy Markdown
Contributor

@gavinmcfall — thank you for the original SQL and the concrete incident data; both carry forward. Filed #2038 as a take-over of this PR's territory, implementing the rebase path @igorls proposed above: renamed to facet_counts per the merged #1868 contract, registered supports_metadata_facets on PgVectorBackend, no tool_status edits, and added unit tests. Attribution kept in the PR body.

Filing separately rather than pushing to your branch since the changes are substantial and you may still want to iterate on your own version. If maintainers prefer to close this in favor of the new PR that seems reasonable to us; if you'd rather reshape this one instead, let us know and we can withdraw the new PR.

@gavinmcfall

Copy link
Copy Markdown
Author

Agreed — #2038 is the right shape and supersedes this. The facet_counts + supports_metadata_facets contract from #1835/#1868 is a better fit than my count_by_metadata_field, and threading it through the already-merged tool_status wiring is cleaner than my edits there. Apologies for leaving the review feedback unanswered. Thanks for carrying the incident data forward — happy to see it close this way. Closing in favour of #2038.

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