fix: prevent SearchIndex/AsyncSearchIndex finalizer from leaking every instance - #657
Conversation
…y instance SearchIndex.__init__ registered weakref.finalize(self, self.disconnect). A bound method holds a strong reference to its instance, and weakref.finalize keeps its callback alive in a module-level registry until the finalizer fires, so every index that owned its Redis client was retained for the lifetime of the process: the finalizer could never fire, disconnect() never ran, and the schema, field objects, locks, and client were all retained. AsyncSearchIndex had the same defect through sync_wrapper closing over self.disconnect. Introduced in 0.4.0 by #280 and reported with production OOM kills in index-per-request workloads. Binding the client at __init__ alone is not enough: in the default from_dict/from_yaml + redis_url path the client does not exist yet and is created lazily, so the finalizer would capture None and never close the real connection. Instead, register the finalizer at every site where an owned client is created or replaced, binding only the client (never self), and detach it when the client is closed or replaced: - module-level close callbacks for sync and async clients - _register_client_finalizer/_detach_client_finalizer on BaseSearchIndex - registration in __init__ (from_existing path), lazy creation, and the deprecated connect()/set_client() paths - disconnect() detaches the finalizer before closing, so an explicitly disconnected index is not double-closed at collection time Unowned (caller-provided) clients are unaffected: no finalizer is registered and the client is never closed by the index. Adds unit and integration regression tests asserting instances are collectable after del + gc.collect(), owned clients close exactly once on collection, and injected clients are left open.
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Fixes a process-lifetime memory leak where SearchIndex / AsyncSearchIndex instances that own their Redis client could never be garbage collected due to a weakref.finalize(...) callback that (indirectly) held a strong reference back to the index instance. The new design registers finalizers that close the owned client (not the index), and detaches/re-registers them when the owned client is created, replaced, or explicitly disconnected.
Changes:
- Replace instance-bound finalizers with module-level close callbacks that only capture the owned Redis client (sync + async).
- Add
BaseSearchIndexhelpers to register/detach client finalizers, and wire them into owned-client creation/replacement sites and bothdisconnect()paths. - Add unit + integration regression tests verifying collectability and correct owned/unowned client closing behavior (including lazy client creation).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
redisvl/index/index.py |
Reworks finalizer registration to avoid retaining index instances; ensures owned clients are closed on GC and finalizers are detached on disconnect/replacement. |
tests/unit/test_index_gc_finalizer.py |
Unit regression tests (mocked clients) for GC collectability and owned/unowned client close semantics. |
tests/integration/test_index_gc_finalizer_integration.py |
Integration tests (real Redis) verifying owned client sockets are torn down and index-per-request usage does not accumulate live instances. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
abrookins
left a comment
There was a problem hiding this comment.
Makes sense to me! 👍 Good find.
|
🚀 PR was released in |
Background
An external user reported that every
SearchIndexandAsyncSearchIndexinstance is retained for the lifetime of the process, and attributed unbounded memory growth in a document-ingestion service (which constructs an index per upload request) to it.Their production telemetry, quoted here as reported and not independently verified: about 33.5 KB leaked per request, container RSS climbing 3.82 GB to 5.72 GB over 42 hours at roughly 21k requests/hour, and pods OOM-killed about every 4 days.
What was verified locally against origin/main (v0.24.0):
delplus a fullgc.collect().gc.collect(): about 5.1 KB per construction with a 3-field schema, and about 29 KB with a 25-field schema (the instance drags itsIndexSchema, every field object, its lock, and its Redis client along with it). With this fix both fall to noise (roughly 0.1 KB) and zero instances are retained.disconnect()does not mitigate the leak, and indexes given a caller-provided client never leaked. Both corollaries follow from the mechanism below.The bug was introduced by #280 and first shipped in 0.4.0, so every release from 0.4.0 through 0.24.0 is affected (wider than the 0.18.2+ range in the original report).
Description
SearchIndex.__init__registered a finalizer whose callback is a bound method of the instance being finalized:A bound method holds a strong reference to its instance, and
weakref.finalizestores its callback in a module-level registry that is a genuine GC root until the finalizer fires. The finalizer can only fire once the instance is unreachable, but the instance can never become unreachable while the registry holds the callback that references it. The finalizer therefore never fires,disconnect()never runs, and the index (schema, field objects, locks, and Redis client) is immortal.AsyncSearchIndexhad the same defect one level removed:sync_wrapper(self.disconnect)returns a closure over the bound method.This is the documented constraint of
weakref.finalize: the callback must not hold a reference to the object being finalized.Reproduction (no Redis needed): construct and drop 300 indexes, then
gc.collect(). Before this change, all 300 remain alive for both classes; after, zero. This also explains the two corollaries above: the registry entry retains the instance whether or notdisconnect()is ever called, and no finalizer is registered at all on the caller-provided-client path.Fix
Bind the finalizer to the client, never to
self. Binding at__init__alone is not enough: in the defaultfrom_dict/from_yaml+redis_urlpath the client does not exist yet at construction and is created lazily, so an__init__-bound finalizer would captureNoneand silently stop closing real connections.Instead, the finalizer is registered at every site where an owned client is created or replaced, and detached whenever that client is closed or swapped out:
_close_owned_sync_client,_close_owned_async_client) that take the client as an argument and cannot reference the index. The async callback reuses the existing shutdown-hardenedsync_wrapper._register_client_finalizer/_detach_client_finalizerhelpers onBaseSearchIndex. Registration detaches any previous finalizer first, so a replaced client is never closed later by a stale finalizer.__init__(covers thefrom_existingconstructors, which pass an owned client), lazy creation (sync_redis_clientproperty, async_get_client), and the deprecatedconnect()/set_client()paths.disconnect()implementations detach the finalizer before closing, so an explicitly disconnected index is not double-closed at collection time.Behavior is unchanged for indexes that do not own their client: no finalizer is registered and the caller's client is never touched.
Testing
Test-driven: all tests were written first and confirmed failing against unmodified origin/main, then the fix was applied.
tests/unit/test_index_gc_finalizer.py(11 tests, no Redis required): instances of both classes are collectable afterdel+gc.collect(); repeated construct/drop cycles do not accumulate; a lazily created owned client is closed exactly once on collection; explicitdisconnect()closes once and detaches; injected clients are never closed. Before the fix 8 of 11 failed (the 3 passing were the unowned-client cases, consistent with the diagnosis).tests/integration/test_index_gc_finalizer_integration.py(6 tests, real Redis via testcontainers): an owned client that performed a real round trip is closed exactly once on index collection and its pool sockets are torn down; same for the async client throughsync_wrapper; injected sync and async clients survive collection and still answer PING; the index-per-request pattern leaves no live instances. Before the fix 4 of 6 failed.pytest -n auto,redis:8.4): 1976 passed. Sixtest_sql_redis_hybrid.pytests failed locally at the time, and were verified to fail identically on unmodified origin/main, so they were unrelated to this change. Correction added after merge: their cause was a stale local virtualenv holdingsql-redis0.6.0 whilepyproject.tomlrequires>=0.7.1. Afteruv sync --all-extrasall six pass locally. They were never broken in the repository or in CI.make format,make check-types(mypy), and pre-commit all pass.On the
redis:latestCI jobOne matrix job (
Python 3.13 - redis-py 7.x [redis:latest]) fails with twotest_hybrid.pyassertions returning zero rows. This looks unrelated to this change:tests/docker-compose.ymlalready documents this failure mode: Redis 8.8 changed the RediSearch worker-pool default to a multithreaded background executor, "which surfaces a race where FT.SEARCH can return nil fields for docs that expire mid-query -- the source of flaky redis:latest CI runs".mainfail the same way onredis:latestwith a rotating cast of tests (test_unf_noindex_integrationon Jul 31,test_query_cosine_distance_un_normalizedon Jul 29), all with the same signature of fewer documents than expected.redis:latest(8.10.0), repeatedly, under-n auto.RedisConnectionFactory.get_redis_connectionbuilds a fresh pool per call viaRedis.from_urlwith no caching, so a finalizer firing on one index cannot affect any other client's connections or document visibility.Two separate observations for whoever picks up the
redis:latestflakiness, neither of which blocks this PR:redisimage does not readREDIS_ARGS(that is a redis-stack convention), so--search-workers 0is never applied. Confirmed locally: a container started withREDIS_ARGS=--maxmemory 123mbreportsmaxmemory 0, and one started with the compose settings reportssearch-workers 14. The flags belong in a composecommand:entry.redis:latestfailures is still unattributed. Loading documents and querying immediately, 150 iterations againstredis:latest(8.10.0) withsearch-workers 14and again withsearch-workers 0, produced no shortfall in either configuration (vector and filter queries both saw all documents every time). So the worker-pool race described in the compose comment was not reproducible here, and fixing item 1 should not be assumed to fix the flake.Notes for users on affected versions (0.4.0 through 0.24.0)
disconnect()does not mitigate the leak on affected versions; only instance reuse does.