Skip to content

fix(vector_stores): finish in-flight expired file batch cleanup on provider shutdown - #6226

Merged
mattf merged 5 commits into
ogx-ai:mainfrom
kratos0718:fix/vector-store-cleanup-task-reference
Nov 6, 2026
Merged

mattf merged 5 commits into
ogx-ai:mainfrom
kratos0718:fix/vector-store-cleanup-task-reference

Conversation

@kratos0718

@kratos0718 kratos0718 commented Jul 1, 2026 •

Copy link
Copy Markdown
Contributor

What

openai_create_vector_store_file_batch schedules the throttled expired-batch cleanup with a bare asyncio.create_task(...) whose result is discarded. shutdown() walks _file_batch_tasks only, so an in-flight cleanup run is invisible to it:

  1. await mixin.shutdown() returns while the cleanup is still running — shutdown is not a barrier for the work its docstring says it covers ("Clean up mixin resources including background tasks").
  2. When the event loop then goes away (server process exit), the untracked task is cancelled mid-delete with no exception and no log line, leaving expired batches half-cleaned: the survivors sit in the store (and in the openai_file_batches registry) until the next run, which is throttled to once a day. On a non-exit re-initialization the same task survives as a zombie mutating reloaded state.

Why await, not cancel: cancelling the task in shutdown() still stops the cleanup early — the observable state (expired batches left behind) is identical to the bug. The new tests fail against a track-and-cancel fix and pass against track-and-await.

Fix

Track the cleanup task in a self-cleaning self._background_tasks set and await those tasks at the end of shutdown(), alongside the existing file-batch task handling. The await is bounded by the stack's existing 5-second provider shutdown deadline (asyncio.wait_for(impl.shutdown(), timeout=5) in stack.py); a cancelled cleanup task is treated as finished, while shutdown's own cancellation (the deadline firing) propagates.

Test plan

New: tests/unit/providers/utils/memory/test_openai_vector_store_mixin_cleanup_task.py — drives the real mixin (entry point → untracked spawn → real shutdown()) with a deterministic fake KV store whose deletes have a fixed 50 ms latency:

  • test_shutdown_is_a_barrier_for_cleanup — shutdown() must not return while the in-flight cleanup is unfinished.
  • test_process_exit_does_not_leave_cleanup_half_done — a nested asyncio.run (loop teardown = server exit) must not leave expired batches half-cleaned.
$ uv run pytest tests/unit/providers/utils/memory/ -q
# before fix: 2 failed (the two new tests), 54 passed
# after fix:  56 passed in 0.70s

Notes

  • Behavior-preserving for the happy path; shutdown now finishes work it previously abandoned.
  • Minimal diff, no new dependencies.

asyncio.create_task(self._cleanup_expired_file_batches()) discarded its result,
so the event loop's weak reference allowed the cleanup task to be garbage-
collected before completion, silently dropping the cleanup mid-run. Store it in
a dedicated _background_tasks set with a self-cleaning done-callback, and cancel
those tasks on shutdown() alongside the existing file-batch tasks.
@mattf

mattf commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

@kratos0718 how can we reproduce this?

@kratos0718

Copy link
Copy Markdown
Contributor Author

Fair question, and the honest answer is that the GC angle in my description is the RUF006 rationale rather than something I can trigger on demand. I tried and couldn't make CPython collect it reliably.

What is deterministic is the shutdown path, which I think is the real bug. shutdown() walks _file_batch_tasks only, and the cleanup task isn't in it, so it's invisible there and just gets torn down with the loop partway through its awaits. This script has the same shape as the mixin:

async def _cleanup_expired_file_batches(self):
    for b in ("expired-1", "expired-2", "expired-3"):
        await asyncio.sleep(0.05)      # stands in for kvstore.delete
        deleted.append(b)

async def create_file_batch(self, b):
    task = asyncio.create_task(self._process_file_batch_async(b))
    self._file_batch_tasks[b] = task                             # tracked
    asyncio.create_task(self._cleanup_expired_file_batches())    # not tracked
    await asyncio.sleep(0.06)

async def shutdown(self):
    for _, t in list(self._file_batch_tasks.items()):
        if not t.done():
            t.cancel()
            try: await t
            except asyncio.CancelledError: pass
untracked (current main): ['expired-1']
tracked + awaited       : ['expired-1', 'expired-2', 'expired-3']

So one expired batch gets deleted and the other two stay in the store, with no exception and no log line. The openai_file_batches pops go the same way, so the in-memory dict and the store end up disagreeing. Since cleanup only runs once a day, the leftovers sit there until the next run.

One thing worth deciding: my PR tracks the task and cancels it in shutdown() alongside the batch tasks, which makes teardown deliberate but still stops it early. Awaiting it instead is what actually finishes the cleanup, as in the second line above. Happy to change it to await, or to add a timeout, whichever you prefer.

@kratos0718

Copy link
Copy Markdown
Contributor Author

@mattf gentle ping on this one. The reproduction for the shutdown path is in my comment above. If you tell me whether you'd rather shutdown() cancel the cleanup task or await it with a timeout, I'll push that as a follow-up commit.

@VANDRANKI VANDRANKI left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Community review. This does not clear the merge gate.

I read the diff in openai_vector_store_mixin.py and ran it against the PR branch. I think it is correct.

What I ran (PR branch, C:/ox, Python venv, mocked adapter built on OpenAIVectorStoreMixin):

  • Called openai_create_vector_store_file_batch with cleanup_interval_seconds = 0 and a cleanup stub that waits on an event. While it waits, _background_tasks has 1 entry. After the stub finishes, the set is empty, so the done callback discards it.
  • Started a second cleanup that never finishes, then called shutdown(). The task ends up cancelled and the set is empty.
  • On origin/main the same script fails with AttributeError: 'M' object has no attribute '_background_tasks', as expected, since nothing tracks the cleanup task there.
  • tests/unit/providers/utils/memory/test_openai_vector_store_mixin.py passes on the PR branch (16 passed).

I also checked that every adapter that subclasses the mixin calls super().__init__(...) (faiss, sqlite_vec, chroma, elasticsearch, infinispan, milvus, neo4j, oci26ai, pgvector, qdrant, weaviate), so _background_tasks always exists. The test mocks in test_openai_vector_store_mixin.py and test_vector_store_config_registration.py do the same.

What I did not do: I did not reproduce the task being garbage collected on main. That is the documented asyncio behavior (the loop holds only a weak reference), but I did not trigger it, so I cannot say how likely it is with the real cleanup coroutine.

Notes, none blocking:

  • The PR adds no test. A small test like the probe above (assert the set is populated, then emptied, and that shutdown() cancels a pending cleanup) would lock in the shutdown change.
  • The PR is marked BEHIND main. I applied its diff to current origin/main with git apply --check and it applies cleanly.

@mattf mattf changed the title fix: keep a reference to the throttled cleanup background task (avoid GC before completion) fix(vector_stores): finish in-flight expired file batch cleanup on provider shutdown Nov 6, 2026
@mattf
mattf force-pushed the fix/vector-store-cleanup-task-reference branch from bdac1a3 to d5d12f1 Compare November 6, 2026 13:04
mattf added 2 commits November 6, 2026 08:08
…ovider shutdown

The cleanup task was cancelled in shutdown() alongside the file batch
tasks, which still stopped the cleanup mid-delete: the observable
state (expired batches left behind) was identical to leaving the task
untracked. Awaiting the in-flight cleanup runs instead makes shutdown
a barrier for the work its docstring says it covers. A cancelled
cleanup task is treated as finished, while shutdown's own
cancellation (e.g. the stack's 5-second provider shutdown deadline)
propagates.

Adds a deterministic reproducer driving the real mixin with a fake
KV store whose deletes have a fixed latency.

Signed-off-by: Matthew Farrellee <[email protected]>
@mattf
mattf added this pull request to the merge queue Nov 6, 2026
Merged via the queue into ogx-ai:main with commit 4919eae Nov 6, 2026
78 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants