Skip to content

[Bug] Workspace watch under PostgreSQL full-reindexes every change and leaks Postgres connections #2148

Description

@T012m3n7oR

Describe the Bug

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 no poolclass / pool_size override:

# packages/core/src/repowise/core/persistence/database.py
else:
    # PostgreSQL — asyncpg handles its own connection pool   # misleading
    kwargs["pool_pre_ping"] = True

engine = 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:

# packages/core/src/repowise/core/workspace/update.py — update_single_repo_index
if (
    not config_changed
    and base_ref
    and (repo_path / ".repowise" / "wiki.db").is_file()  # always false on Postgres
    and commit_exists(repo_path, str(base_ref))
):
    incremental_result = await _incremental_repo_update(...)
# else → index_repo_full(...)

So every watch trigger / update_workspace pass does a full index_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)

  1. Workspace with ≥1 repo; REPOWISE_DB_URL=postgresql+asyncpg://….
  2. Index so Postgres has repositories (+ graph) rows; confirm no <repo>/.repowise/wiki.db.
  3. Ensure state.json has last_sync_commit equal to HEAD.
  4. Run with logging that shows pipeline choice, or set a breakpoint / temporary log on the wiki.db.is_file() branch.
  5. 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)

  1. Same Postgres (SHOW max_connections; — e.g. 100).
  2. Start serve, mcp, and repowise watch --workspace against that DB.
  3. In another session, sample connections every few seconds while triggering updates (edit files or FF merges):
-- run repeatedly during watch activity
SELECT
  count(*) 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 superuser
GROUP BY 2, 3, 4
ORDER BY total DESC;

SELECT count(*) AS app_backends
FROM pg_stat_activity
WHERE datname = current_database()
  AND usename = current_user;

SHOW max_connections;
SHOW superuser_reserved_connections;
  1. 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.
  • Watch eventually raises TooManyConnectionsError.

Environment

  • OS: Linux (Kubernetes / container)
  • Python: 3.12 (image runtime)
  • Repowise: 0.48.0 (gate verified in 0.49.0 tree)
  • Install: Docker, REPOWISE_DB_URL=postgresql+asyncpg://…, workspace mode
  • Processes on one DB: repowise serve, repowise mcp, repowise watch --workspace
  • Postgres: single-instance CNPG, max_connections=100

Additional Context

Related issues

Issue Why related
#1034, #2138, #2139, #2140 Same incorrect wiki.db “indexed?” signal on other call sites
#2062 Postgres QueuePool + engine not disposed in the creating loop → leaked/unusable asyncpg connections

Call sites

Incremental gatepackages/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.

Watchpackages/cli/src/repowise/cli/commands/watch_cmd.pyupdate_workspace(..., include_working_tree=True)update_single_repo_index.

Poolpackages/core/src/repowise/core/persistence/database.py create_engine() Postgres branch (quoted above).

Full index enginepackages/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 (_wiki and base_ref and not config_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 running
while true; 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

  1. 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.
  2. 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).
  3. Until (1) lands, watch-on-Postgres will keep forcing full re-indexes and keep pressure (and leak surface) high.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions