Skip to content

Commit d4badce

Browse files
author
STARGA Inc
committed
fix: reuse the Postgres ConnectionPool across MCP calls (thread leak → v4.2.1)
Every MCP tool call routes through storage.get_block_store() (uncached per-call factory); for a Postgres-backed workspace each fresh PostgresBlockStore._get_pool() opened its own psycopg_pool.ConnectionPool (1 scheduler + 3 workers) and never closed it, leaking ~4 threads/call. A production MCP server hit 76k threads / ~32 GB RAM over 2.6 days until the host could no longer fork(). Fix: process-wide _pool_registry keyed on (dsn, schema) so _get_pool() reuses the open pool; close() evicts its entry. Surgical — the ~10 other call sites all go through _get_pool() and benefit automatically. New tests/test_mcp_thread_leak.py (CI-safe fake-pool + opt-in live-Postgres) asserts exactly 1 pool across 100 calls; pre-fix 40 calls→40 pools→161 threads, post-fix 1 pool→5 threads flat. Full suite 5574 passed.
1 parent 58d0065 commit d4badce

6 files changed

Lines changed: 376 additions & 26 deletions

File tree

ANATOMY.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
> Re-generate with: `anatomy .`
66
77
**Project:** `mind-mem`
8-
**Files:** 893 | **Est. tokens:** ~1,905,582
9-
**Generated:** 2026-07-04 09:01 UTC
8+
**Files:** 894 | **Est. tokens:** ~1,909,221
9+
**Generated:** 2026-07-04 15:49 UTC
1010

1111
## Token Budget Guide
1212

@@ -22,7 +22,7 @@
2222

2323
| Directory | Files | Est. tokens |
2424
|-----------|-------|-------------|
25-
| `./` | 35 | ~68,860 |
25+
| `./` | 35 | ~68,854 |
2626
| `.agents/skills/mind-mem-development/` | 1 | ~456 |
2727
| `.arch-mind/` | 4 | ~1,313 |
2828
| `audits/` | 5 | ~24,039 |
@@ -59,7 +59,7 @@
5959
| `skills/integrity-scan/` | 1 | ~376 |
6060
| `skills/memory-recall/` | 1 | ~549 |
6161
| `src/` | 1 | ~280 |
62-
| `src/mind_mem/` | 162 | ~572,260 |
62+
| `src/mind_mem/` | 162 | ~573,005 |
6363
| `src/mind_mem/api/` | 5 | ~16,595 |
6464
| `src/mind_mem/mcp/` | 3 | ~4,128 |
6565
| `src/mind_mem/mcp/infra/` | 8 | ~9,991 |
@@ -69,7 +69,7 @@
6969
| `src/mind_mem/tool_output/` | 3 | ~4,611 |
7070
| `src/mind_mem/v4/` | 22 | ~58,472 |
7171
| `templates/` | 19 | ~1,041 |
72-
| `tests/` | 309 | ~662,326 |
72+
| `tests/` | 310 | ~665,226 |
7373
| `tests/integration/` | 2 | ~1,575 |
7474
| `tests/red_team/` | 3 | ~806 |
7575
| `tests/red_team/transcripts/` | 1 | ~0 |
@@ -109,7 +109,7 @@
109109
- `.pre-commit-config.yaml` (~366 tok, medium) — repos:
110110
- `pyproject.toml` (~2257 tok, huge) — [project]
111111
- `.python-version` (~2 tok, tiny) — 3.12
112-
- `README.md` (~25316 tok, huge) — 30-Second Demo
112+
- `README.md` (~25310 tok, huge) — 30-Second Demo
113113
- `requirements-optional.txt` (~768 tok, large) — # mind-mem optional ML stack — pinned with SHA256 integrity hashes for
114114
- `.run-ledger.jsonl` (~154 tok, small) — {"ended_at": "2026-05-11T03:10:20+00:00", "eval_summary": "127/131 (109 main + 1
115115
- `SECURITY_AUDIT_2026-04.md` (~2403 tok, huge) — Security Audit — MIND-Mem v3.1.9 (April 2026)
@@ -471,7 +471,7 @@
471471
- `block_metadata.py` (~2223 tok, huge) — mind-mem A-MEM — auto-evolving block metadata.
472472
- `block_parser.py` (~7555 tok, huge) — Mind Mem Block Parser v1.0 — Self-hosted, zero external dependencies.
473473
- `block_store_encrypted.py` (~2313 tok, huge) — # Copyright 2026 STARGA, Inc.
474-
- `block_store_postgres.py` (~14850 tok, huge) — PostgresBlockStore — PostgreSQL-backed BlockStore for mind-mem v3.2.0.
474+
- `block_store_postgres.py` (~15595 tok, huge) — PostgresBlockStore — PostgreSQL-backed BlockStore for mind-mem v3.2.0.
475475
- `block_store_postgres_replica.py` (~2530 tok, huge) — v3.2.0 — read-replica routing for PostgresBlockStore.
476476
- `block_store.py` (~10535 tok, huge) — BlockStore abstraction — decouples block access from storage format.
477477
- `bootstrap_corpus.py` (~2158 tok, huge) — mind-mem Bootstrap Corpus — one-time backfill from existing knowledge sources.
@@ -901,6 +901,7 @@
901901
- `test_mcp_integration.py` (~5478 tok, huge) — MCP transport and auth integration tests (#474).
902902
- `test_mcp_pipeline.py` (~1465 tok, large) — Tests for the v3.9.0 pipeline-hash MCP tools."""
903903
- `test_mcp_server.py` (~5277 tok, huge) — Tests for mcp_server.py — tests the MCP server resources and tool logic.
904+
- `test_mcp_thread_leak.py` (~2900 tok, huge) — Regression tests for the MCP-server thread leak (2026-07-04).
904905
- `test_mcp_tools_model.py` (~2249 tok, huge) — Tests for ``mind_mem.mcp.tools.model`` — MCP wrappers for audit / sign / verify."""
905906
- `test_mcp_tools.py` (~277 tok, medium) — Tests for MCP server tool definitions."""
906907
- `test_mcp_tool_surface_v3_2.py` (~2002 tok, huge) — v3.2.0 — consolidated MCP public dispatcher tests."""

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
All notable changes to MIND-Mem are documented in this file.
44

5+
## v4.2.1 — fix a Postgres connection-pool thread leak in the MCP server
6+
7+
**Bugfix (resource leak).** Every MCP tool call routes through `storage.get_block_store()`,
8+
an intentionally uncached per-call factory. For a **Postgres-backed** workspace, each fresh
9+
`PostgresBlockStore._get_pool()` opened its *own* `psycopg_pool.ConnectionPool` (1 scheduler +
10+
3 worker threads) and never closed it — so a long-running server leaked ~4 threads per call.
11+
Observed in production: a single MCP server accumulated **76k threads / ~32 GB RAM over 2.6
12+
days** until the host could no longer `fork()`. (Markdown/default backends were unaffected —
13+
they open no pool.)
14+
15+
- **Fix:** a process-wide `_pool_registry` keyed on `(dsn, schema)` in
16+
`block_store_postgres.py`, so `_get_pool()` reuses the open pool instead of creating a new
17+
one; `close()` evicts its own registry entry. Surgical — no change to the factory contract
18+
the other ~10 call sites rely on.
19+
- **Proof:** new `tests/test_mcp_thread_leak.py` (CI-safe fake-pool + opt-in live-Postgres)
20+
asserts exactly **1** pool is created across 100 tool-calls. Pre-fix: 40 calls → 40 pools →
21+
161 threads (and real Postgres slot exhaustion at 50). Post-fix: 40 calls → 1 pool → 5
22+
threads, flat. Full suite green (5574 passed).
23+
524
## v4.2.0 — tool-output offload store (keep giant command logs out of agent context)
625

726
New capability `mind_mem.tool_output` (ROADMAP Group J, §5). A single `cargo test`

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
</p>
3030

3131
<p align="center"><sub>
32-
<strong>Current release:</strong> <code>v4.2.0</code> &mdash; tool-output offload store (keep giant command logs out of agent context) &mdash;
32+
<strong>Current release:</strong> <code>v4.2.1</code> &mdash; fix a Postgres connection-pool thread leak in the MCP server &mdash;
3333
<a href="CHANGELOG.md">see CHANGELOG</a>
3434
(single source of truth; per-version detail tables below may lag the changelog)
3535
</sub></p>
@@ -63,7 +63,7 @@ Output:
6363
decisions/DECISIONS.md:20
6464
```
6565

66-
<sub>Current release: **v4.2.0**tool-output offload store (keep giant command logs out of agent context) — Full per-release notes (issues closed, CI run ids, job counts) live in <a href="./CHANGELOG.md">CHANGELOG.md</a>.</sub>
66+
<sub>Current release: **v4.2.1**fix a Postgres connection-pool thread leak in the MCP server — Full per-release notes (issues closed, CI run ids, job counts) live in <a href="./CHANGELOG.md">CHANGELOG.md</a>.</sub>
6767

6868
### Substrate Properties
6969

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mind-mem"
3-
version = "4.2.0"
3+
version = "4.2.1"
44
description = "Drop-in memory for Claude Code, OpenClaw, and any MCP-compatible agent."
55
readme = "README.md"
66
license = { text = "Apache-2.0" }

src/mind_mem/block_store_postgres.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@
2929

3030
__all__ = ["PostgresBlockStore", "BlockStoreError"]
3131

32+
# ─── Process-wide connection-pool registry (thread-leak fix) ─────────────────
33+
#
34+
# ``storage.get_block_store()`` is a factory: every recall() / hybrid_search()
35+
# / _check_workspace() call on a Postgres-backed workspace constructs a
36+
# *fresh* PostgresBlockStore (by design — construction itself is free, see
37+
# __init__ below). ``_get_pool()`` used to open a brand-new
38+
# ``psycopg_pool.ConnectionPool(..., open=True)`` on that fresh instance's
39+
# first real query. Opening a pool spawns a scheduler thread plus
40+
# ``num_workers`` (default 3) worker threads that run until ``.close()`` is
41+
# called — and nothing on the per-MCP-tool-call path ever called it, since
42+
# the ephemeral PostgresBlockStore went out of scope as soon as the tool
43+
# call returned. The 4 background threads per call were only reclaimed
44+
# incidentally whenever Python's garbage collector happened to collect the
45+
# discarded pool, which is not guaranteed to keep pace under sustained
46+
# traffic. Production symptom: 76,479 accumulated threads / ~32GB RSS over
47+
# 2.6 days on a long-running `mcp_server.py` process (~1 leaked thread set
48+
# per tool call), exhausting the box's fork/thread capacity.
49+
#
50+
# Fix: key pools by (dsn, schema) in a process-wide registry so every
51+
# PostgresBlockStore pointed at the same database shares one long-lived
52+
# pool, regardless of how many wrapper instances the factory constructs.
53+
_pool_registry: dict[tuple[str, str], Any] = {}
54+
_pool_registry_lock = threading.Lock()
55+
3256
# Schema names must be safe Postgres identifiers (no injection surface).
3357
_SAFE_SCHEMA_RE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
3458

@@ -531,25 +555,42 @@ def __init__(
531555
# ─── Lifecycle ────────────────────────────────────────────────────────────
532556

533557
def _get_pool(self) -> Any:
534-
"""Return the connection pool, creating it on first call."""
558+
"""Return the connection pool, creating (or reusing) it on first call.
559+
560+
Pools are cached process-wide in ``_pool_registry``, keyed by
561+
``(dsn, schema)`` — see the module-level note above. This avoids
562+
opening a brand-new ``ConnectionPool`` (and its background
563+
scheduler + worker threads) for every ephemeral PostgresBlockStore
564+
the ``storage.get_block_store()`` factory constructs. ``self._pool``
565+
still caches the resolved pool locally so repeated calls on the
566+
same instance skip the registry lookup.
567+
"""
535568
if self._pool is not None:
536569
return self._pool
537570
_, ConnectionPool = _require_psycopg()
538571
with self._init_lock:
539-
if self._pool is None:
540-
self._pool = ConnectionPool(
541-
self._dsn,
542-
min_size=1,
543-
max_size=10,
544-
open=True,
545-
# Append the pgvector extension's schema to each
546-
# connection's search_path so the bare ``vector`` type,
547-
# ``<=>`` operator and ``vector_cosine_ops`` opclass
548-
# resolve even when the DSN isolates search_path to a
549-
# single workspace schema (bug-6: embeddings silently
550-
# disabled / hybrid_search crashed under isolation).
551-
configure=_configure_vector_search_path,
552-
)
572+
if self._pool is not None:
573+
return self._pool
574+
key = (self._dsn, self._schema)
575+
with _pool_registry_lock:
576+
pool = _pool_registry.get(key)
577+
if pool is None or pool.closed:
578+
pool = ConnectionPool(
579+
self._dsn,
580+
min_size=1,
581+
max_size=10,
582+
open=True,
583+
# Append the pgvector extension's schema to each
584+
# connection's search_path so the bare ``vector``
585+
# type, ``<=>`` operator and ``vector_cosine_ops``
586+
# opclass resolve even when the DSN isolates
587+
# search_path to a single workspace schema (bug-6:
588+
# embeddings silently disabled / hybrid_search
589+
# crashed under isolation).
590+
configure=_configure_vector_search_path,
591+
)
592+
_pool_registry[key] = pool
593+
self._pool = pool
553594
return self._pool
554595

555596
def ping(self, *, timeout: float = 5.0) -> dict[str, Any]:
@@ -1261,12 +1302,24 @@ def list_files(self) -> list[str]:
12611302
# ─── Context manager / cleanup ────────────────────────────────────────────
12621303

12631304
def close(self) -> None:
1264-
"""Close the underlying connection pool."""
1305+
"""Close the underlying connection pool.
1306+
1307+
Pools are shared process-wide via ``_pool_registry`` (keyed by
1308+
``(dsn, schema)``), so this also evicts the registry entry —
1309+
otherwise a *different* PostgresBlockStore instance still holding
1310+
the same (now-closed) pool object cached on ``self._pool`` would
1311+
try to reuse it. The next ``_get_pool()`` call for this
1312+
``(dsn, schema)`` opens a fresh pool.
1313+
"""
12651314
if self._pool is not None:
1315+
key = (self._dsn, self._schema)
12661316
try:
12671317
self._pool.close()
12681318
except Exception as exc:
12691319
_log.debug("pg_pool_close_failed: %s", exc)
1320+
with _pool_registry_lock:
1321+
if _pool_registry.get(key) is self._pool:
1322+
del _pool_registry[key]
12701323
self._pool = None
12711324

12721325
def __enter__(self) -> "PostgresBlockStore":

0 commit comments

Comments
 (0)