You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two tightly coupled defects show up together under REPOWISE_DB_URL (shared PostgreSQL) with repowise watch --workspace:
1. Connection leak / pool exhaustion (symptom that surfaces first)
Long-running watch (alongside serve / mcp on the same DB) eventually fails with:
asyncpg.exceptions.TooManyConnectionsError:
remaining connection slots are reserved for roles with the SUPERUSER attribute
That is a server-side exhaustion of non-superuser slots (max_connections − superuser_reserved_connections). It is not a client QueuePool timeout.
Why connections accumulate
Postgres engines are created via create_engine() with nopoolclass / pool_size override:
# packages/core/src/repowise/core/persistence/database.pyelse:
# PostgreSQL — asyncpg handles its own connection pool # misleadingkwargs["pool_pre_ping"] =Trueengine=create_async_engine(db_url, **kwargs) # → AsyncAdaptedQueuePool defaults
SQLite short-lived engines correctly use NullPool. Postgres short-lived engines get SQLAlchemy’s default pool (pool_size=5, max_overflow=10 → up to 15 server connections per engine). asyncpg does not own a separate pool here; SQLAlchemy does.
Every workspace update opens one or more of these engines. If an engine is not dispose()d in the same event loop that created it — or is abandoned across asyncio.run() boundaries — pooled asyncpg connections stay open on Postgres until the backend times them out. That is a connection leak (client-side ownership lost; server-side slots remain occupied). The same lifecycle class is documented in #2062 for the cost-tracker path; watch/update creates many more engines under the gate below, so the leak is amplified and eventually hits TooManyConnectionsError.
2. Incremental updates never run (root amplifier)
Under Postgres, per-repo indexes live in the shared DB and no<repo>/.repowise/wiki.db is created. update_single_repo_index still requires that file before taking the incremental path:
So every watch trigger / update_workspace pass does a fullindex_repo_full, which calls create_engine (and init_db) again. Concurrent members (up to _MAX_CONCURRENT_UPDATES) multiply that. Full re-index on every filesystem event is what makes the leak fatal in practice.
Same wiki.db-as-“already indexed” assumption as #1034 / #2138 / #2139 / #2140, but this call site is the write/update path watch uses — fixing serve/list/search alone does not stop full re-indexes or the connection growth.
Observed on v0.48.0 (fbb78c4); gate still present in v0.49.0.
Steps to Reproduce
A — Wrong update path (deterministic)
Workspace with ≥1 repo; REPOWISE_DB_URL=postgresql+asyncpg://….
Index so Postgres has repositories (+ graph) rows; confirm no<repo>/.repowise/wiki.db.
Ensure state.json has last_sync_commit equal to HEAD.
Run with logging that shows pipeline choice, or set a breakpoint / temporary log on the wiki.db.is_file() branch.
repowise watch --workspace, then touch one tracked source file.
B — Connection leak under watch (needs a few cycles)
Same Postgres (SHOW max_connections; — e.g. 100).
Start serve, mcp, and repowise watch --workspace against that DB.
In another session, sample connections every few seconds while triggering updates (edit files or FF merges):
-- run repeatedly during watch activitySELECTcount(*) AS total,
state,
application_name,
left(query, 60) AS query_preview
FROM pg_stat_activity
WHERE datname = current_database()
AND usename =current_user-- app role, not superuserGROUP BY2, 3, 4ORDER BY total DESC;
SELECTcount(*) AS app_backends
FROM pg_stat_activity
WHERE datname = current_database()
AND usename =current_user;
SHOW max_connections;
SHOW superuser_reserved_connections;
Continue until watch logs TooManyConnectionsError, or until app_backends climbs toward max_connections - superuser_reserved_connections and does not return to the idle baseline after each update finishes.
Leak signature: after a watch cycle completes, non-superuser backend count stays elevated (idle/idle in transaction rows accumulate) instead of returning to ~serve+mcp steady state. Peak-only spikes that always drain are exhaustion under load; a rising floor between cycles is a leak.
Expected Behavior
Already-indexed workspace members on a shared Postgres URL take the incremental path when last_sync_commit is valid — wiki.db must not be required.
Every create_engine() for a short-lived update must dispose() in the same event loop before that loop closes; Postgres should use NullPool or an explicit tiny pool so abandoned engines cannot hold a dozen server slots each.
After an update finishes, Postgres backends for the app role should return to the long-lived serve/mcp baseline.
Actual Behavior
Incremental gate fails → full re-index every watch event.
Short-lived Postgres engines use default QueuePool; undisposed / cross-loop engines leak server connections.
Postgres QueuePool + engine not disposed in the creating loop → leaked/unusable asyncpg connections
Call sites
Incremental gate — packages/core/src/repowise/core/workspace/update.py (update_single_repo_index). Docstring still says “plus an existing wiki.db”. Config-drift branch and reconcile_repo_head_commit in the same file also key off wiki.db.
Pool — packages/core/src/repowise/core/persistence/database.pycreate_engine() Postgres branch (quoted above).
Full index engine — packages/core/src/repowise/core/pipeline/full_index.py does dispose() in finally when that path completes cleanly; leak still occurs when other engines on the update path are left open, when dispose races a closed loop (#2062 class), or when many pooled engines overlap across concurrent full re-indexes.
Diagnosis helpers
1. Confirm the gate without waiting for OOM / connection death — temporary log (or PYTHONBREAKPOINT) at the wiki.db check:
# in update_single_repo_index, immediately before the incremental `if`_wiki= (repo_path/".repowise"/"wiki.db").is_file()
_log.info(
"workspace_update_path_choice",
alias=alias,
wiki_db_exists=_wiki,
base_ref=base_ref,
config_changed=config_changed,
path="incremental"if (_wikiandbase_refandnotconfig_changed) else"full",
)
On Postgres you should see wiki_db_exists=False / path=full even when the shared DB already has a full index.
2. Confirm leak vs transient spike — sample app_backends (SQL above) on a timer:
# example: every 5s for 10 minutes while watch is runningwhiletrue;do
psql "$REPOWISE_DB_URL_PSYCOPG" -Atc \
"SELECT now(), count(*) FROM pg_stat_activity WHERE datname = current_database() AND usename = current_user;"
sleep 5
done
Plot or inspect: a rising baseline between update bursts = leak. A flat baseline with tall spikes only during index_repo_full = pool sizing / concurrency (still harmful; still caused by the full-reindex gate).
3. Attribute backends — if the app sets application_name (or you can add it on connect), group pg_stat_activity by application_name / client_addr to see whether growth is from the watch process vs serve/mcp.
Suggested fix
Gate: when get_configured_db_url() is Postgres, decide “already indexed” from shared DB / last_sync_commit (not wiki.db.is_file()). Keep wiki.db only for SQLite.
Pool / lifecycle: use NullPool (or pool_size=1, max_overflow=0) for short-lived Postgres engines; ensure every create_engine has a matching await engine.dispose() in the same loop (same discipline as [Bug] Async PostgreSQL cleanup races with event-loop shutdown #2062).
Until (1) lands, watch-on-Postgres will keep forcing full re-indexes and keep pressure (and leak surface) high.
Describe the Bug
Two tightly coupled defects show up together under
REPOWISE_DB_URL(shared PostgreSQL) withrepowise watch --workspace:1. Connection leak / pool exhaustion (symptom that surfaces first)
Long-running watch (alongside
serve/mcpon the same DB) eventually fails with:That is a server-side exhaustion of non-superuser slots (
max_connections − superuser_reserved_connections). It is not a client QueuePool timeout.Why connections accumulate
Postgres engines are created via
create_engine()with nopoolclass/pool_sizeoverride:SQLite short-lived engines correctly use
NullPool. Postgres short-lived engines get SQLAlchemy’s default pool (pool_size=5,max_overflow=10→ up to 15 server connections per engine). asyncpg does not own a separate pool here; SQLAlchemy does.Every workspace update opens one or more of these engines. If an engine is not
dispose()d in the same event loop that created it — or is abandoned acrossasyncio.run()boundaries — pooled asyncpg connections stay open on Postgres until the backend times them out. That is a connection leak (client-side ownership lost; server-side slots remain occupied). The same lifecycle class is documented in #2062 for the cost-tracker path; watch/update creates many more engines under the gate below, so the leak is amplified and eventually hitsTooManyConnectionsError.2. Incremental updates never run (root amplifier)
Under Postgres, per-repo indexes live in the shared DB and no
<repo>/.repowise/wiki.dbis created.update_single_repo_indexstill requires that file before taking the incremental path:So every watch trigger /
update_workspacepass does a fullindex_repo_full, which callscreate_engine(andinit_db) again. Concurrent members (up to_MAX_CONCURRENT_UPDATES) multiply that. Full re-index on every filesystem event is what makes the leak fatal in practice.Same
wiki.db-as-“already indexed” assumption as #1034 / #2138 / #2139 / #2140, but this call site is the write/update path watch uses — fixing serve/list/search alone does not stop full re-indexes or the connection growth.Observed on v0.48.0 (
fbb78c4); gate still present in v0.49.0.Steps to Reproduce
A — Wrong update path (deterministic)
REPOWISE_DB_URL=postgresql+asyncpg://….repositories(+ graph) rows; confirm no<repo>/.repowise/wiki.db.state.jsonhaslast_sync_commitequal toHEAD.wiki.db.is_file()branch.repowise watch --workspace, then touch one tracked source file.Expect: incremental update. Actual:
index_repo_full.B — Connection leak under watch (needs a few cycles)
SHOW max_connections;— e.g. 100).serve,mcp, andrepowise watch --workspaceagainst that DB.TooManyConnectionsError, or untilapp_backendsclimbs towardmax_connections - superuser_reserved_connectionsand does not return to the idle baseline after each update finishes.Leak signature: after a watch cycle completes, non-superuser backend count stays elevated (idle/
idle in transactionrows accumulate) instead of returning to ~serve+mcp steady state. Peak-only spikes that always drain are exhaustion under load; a rising floor between cycles is a leak.Expected Behavior
last_sync_commitis valid —wiki.dbmust not be required.create_engine()for a short-lived update mustdispose()in the same event loop before that loop closes; Postgres should useNullPoolor an explicit tiny pool so abandoned engines cannot hold a dozen server slots each.Actual Behavior
TooManyConnectionsError.Environment
REPOWISE_DB_URL=postgresql+asyncpg://…, workspace moderepowise serve,repowise mcp,repowise watch --workspacemax_connections=100Additional Context
Related issues
wiki.db“indexed?” signal on other call sitesCall sites
Incremental gate —
packages/core/src/repowise/core/workspace/update.py(update_single_repo_index). Docstring still says “plus an existingwiki.db”. Config-drift branch andreconcile_repo_head_commitin the same file also key offwiki.db.Watch —
packages/cli/src/repowise/cli/commands/watch_cmd.py→update_workspace(..., include_working_tree=True)→update_single_repo_index.Pool —
packages/core/src/repowise/core/persistence/database.pycreate_engine()Postgres branch (quoted above).Full index engine —
packages/core/src/repowise/core/pipeline/full_index.pydoesdispose()infinallywhen that path completes cleanly; leak still occurs when other engines on the update path are left open, when dispose races a closed loop (#2062 class), or when many pooled engines overlap across concurrent full re-indexes.Diagnosis helpers
1. Confirm the gate without waiting for OOM / connection death — temporary log (or
PYTHONBREAKPOINT) at thewiki.dbcheck:On Postgres you should see
wiki_db_exists=False/path=fulleven when the shared DB already has a full index.2. Confirm leak vs transient spike — sample
app_backends(SQL above) on a timer:Plot or inspect: a rising baseline between update bursts = leak. A flat baseline with tall spikes only during
index_repo_full= pool sizing / concurrency (still harmful; still caused by the full-reindex gate).3. Attribute backends — if the app sets
application_name(or you can add it on connect), grouppg_stat_activitybyapplication_name/client_addrto see whether growth is from the watch process vs serve/mcp.Suggested fix
get_configured_db_url()is Postgres, decide “already indexed” from shared DB /last_sync_commit(notwiki.db.is_file()). Keepwiki.dbonly for SQLite.NullPool(orpool_size=1,max_overflow=0) for short-lived Postgres engines; ensure everycreate_enginehas a matchingawait engine.dispose()in the same loop (same discipline as [Bug] Async PostgreSQL cleanup races with event-loop shutdown #2062).