Skip to content

RedisKVStorage acquires its shared connection-pool reference at construction, so a failed LightRAG construction leaks it #4016

Description

@danielaskdd

Problem

RedisKVStorage.__post_init__ and RedisDocStatusStorage.__post_init__ call RedisConnectionManager.get_pool(url) at construction. That manager is class-level state: one shared ConnectionPool per URL plus a reference count (_pool_refs), incremented by get_pool and decremented only by release_pool_ref / release_pool, which run from the storage's async close() / finalize().

LightRAG.__post_init__ is synchronous, constructs twelve storage objects in sequence, and then runs a series of validations (llm_model_func present, role_llm_configs well-formed, ...). It has no teardown path: if any constructor or validation raises, every Redis storage already constructed keeps its pool reference forever. Repeated failed constructions grow the count without bound, and a later valid instance can never bring it to zero, so the shared pool is never closed.

Why only Redis

Every other backend defers connecting to initialize() and leaves the constructor side-effect free:

backend constructor connection
MongoDB (all four storages) validate workspace, build collection name initialize() → await ClientManager.get_client()
PostgreSQL (all four) validate workspace initialize() → await ClientManager.get_client(), pool via asyncpg.create_pool
OpenSearch (three) build index name initialize() → await ClientManager.get_client()
Milvus / Qdrant validate embedding, compute suffix, self._client = None initialize() creates the client
Neo4j / Memgraph handle workspace override, self._driver = None initialize() → AsyncGraphDatabase.driver()
JSON KV / DocStatus, NetworkX, NanoVectorDB, Faiss record paths initialize() binds shared dict / loads file
Redis KV / DocStatus RedisConnectionManager.get_pool() initialize() only pings

How it surfaced

Found by review on #4011 (configuration storage, slice 1). That PR adds one more KV storage object to __post_init__ and a new refusal path (validate_workspace_override, raised in an ordinary storage's constructor), which made the leak reachable through the configuration storage's reference. The PR sidesteps it by constructing the configuration storage as the last statement of __post_init__ that can raise, and records the leak between the business storages themselves as a pre-existing residue in docs/design/ConfigurationStorage.md (Cleanup before INITIALIZED exists, last paragraph). This issue tracks the root fix.

Proposed fix

Align Redis with the other ten backends: move RedisConnectionManager.get_pool() and the Redis(connection_pool=...) client creation from __post_init__ into initialize() (under get_data_init_lock(), guarded by _initialized as the ping already is). The constructor then records _redis_url only and sets _pool = _redis = None.

Things to check while doing it:

  • The constructor's existing error path (release_pool_ref + schedule_pool_close on a failed get_pool) moves with the acquisition.
  • close() must stay idempotent and tolerate a never-initialized instance (_pool is None).
  • Tests that construct a Redis storage and read _pool / _redis before initialize(), and tests that mock RedisConnectionManager.get_pool around construction (e.g. tests/workspace/test_reserved_workspace.py, tests/kg/redis_impl/), need to move their expectations to after initialize().
  • _get_redis_connection() should raise the existing "Redis connection not initialized" error when called before initialize(); the strict-read path in config_store already relies on that message.

Acceptance

  • Constructing any Redis storage, alone or via LightRAG(...), leaves RedisConnectionManager._pools / _pool_refs untouched.
  • A LightRAG construction that fails after Redis storages were built leaves the reference count unchanged (the tests/workspace/test_reserved_workspace.py pool-reference test can then drop its "constructed last" premise and assert directly).
  • After the fix lands, the construction-order paragraph in ConfigurationStorage.md can be reduced to a note that the ordering is no longer load-bearing.

Related: #4011, #4006.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtrackedIssue is tracked by projecttriage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions