Expected Behavior
When a learning resource (or run) is deleted, its embeddings — and its content files'
embeddings — should be removed from Qdrant, just as they are on unpublish.
Current Behavior
Qdrant removal is queued as an async Celery task, but the task rebuilds its delete
filter by re-reading the database:
remove_qdrant_records (vector_search/utils.py:1437) re-serializes resources via
serialize_bulk_learning_resources / serialize_bulk_content_files
(learning_resources_search/serializers.py:711,724), which filter on live rows
(id__in=ids).
resource_delete_actions (learning_resources/utils.py:411) fires
resource_before_delete (which only queues vector_tasks.remove_embeddings /
remove_run_content_files, learning_resources_search/plugins.py:159-176) and then
immediately calls resource.delete().
By the time a worker runs the task, the rows (and cascade-deleted ContentFiles —
remove_run_content_files re-queries ContentFile.objects.filter(run__id=run_id),
vector_search/tasks.py:478) are gone. The serializer yields nothing, so
remove_points_matching_params builds no filter and (correctly, to avoid wiping the
collection) deletes nothing. Silent no-op; the points are orphaned permanently.
Unpublish paths (resource_unpublished, bulk_resources_unpublished) work because the
row still exists when the task runs. Delete paths leak — including ETL churn, since
loaders.py:503 calls resource_delete_actions when courses drop out of a feed. This
likely explains ongoing Qdrant disk growth.
Not caught by tests: they mock remove_embeddings and only assert it was queued
(vector_search/tasks_test.py:319), so the delete-vs-task race is never exercised.
Steps to Reproduce
- Run with a real Celery worker (
CELERY_TASK_ALWAYS_EAGER=False, the production default).
- Embed a resource so it has points in the Qdrant resources/content-files collections.
- Delete it via
resource_delete_actions (or trigger an ETL load that drops it).
- Observe the
remove_embeddings task runs after the row is deleted and removes
nothing; the points remain in Qdrant.
Possible Solution
- Snapshot the identifying fields (
readable_id, platform, content-file keys) in
resource_before_delete while the rows still exist, and pass them directly to
remove_points_matching_params — or delete by pre-computed point IDs
(vector_point_id). This mirrors serialize_bulk_learning_resources_for_deletion
(learning_resources_search/serializers.py:780), which OpenSearch already uses to
deindex without a live row; the Qdrant path has no equivalent (and can't reuse that
one, since Qdrant points are keyed by a hash of readable_id/platform, not DB id).
- Additionally, a reconciliation command to purge already-orphaned points (points whose
readable_id no longer exists in the DB) is needed to clean up existing leakage.
Expected Behavior
When a learning resource (or run) is deleted, its embeddings — and its content files'
embeddings — should be removed from Qdrant, just as they are on unpublish.
Current Behavior
Qdrant removal is queued as an async Celery task, but the task rebuilds its delete
filter by re-reading the database:
remove_qdrant_records(vector_search/utils.py:1437) re-serializes resources viaserialize_bulk_learning_resources/serialize_bulk_content_files(
learning_resources_search/serializers.py:711,724), which filter on live rows(
id__in=ids).resource_delete_actions(learning_resources/utils.py:411) firesresource_before_delete(which only queuesvector_tasks.remove_embeddings/remove_run_content_files,learning_resources_search/plugins.py:159-176) and thenimmediately calls
resource.delete().By the time a worker runs the task, the rows (and cascade-deleted ContentFiles —
remove_run_content_filesre-queriesContentFile.objects.filter(run__id=run_id),vector_search/tasks.py:478) are gone. The serializer yields nothing, soremove_points_matching_paramsbuilds no filter and (correctly, to avoid wiping thecollection) deletes nothing. Silent no-op; the points are orphaned permanently.
Unpublish paths (
resource_unpublished,bulk_resources_unpublished) work because therow still exists when the task runs. Delete paths leak — including ETL churn, since
loaders.py:503callsresource_delete_actionswhen courses drop out of a feed. Thislikely explains ongoing Qdrant disk growth.
Not caught by tests: they mock
remove_embeddingsand only assert it was queued(
vector_search/tasks_test.py:319), so the delete-vs-task race is never exercised.Steps to Reproduce
CELERY_TASK_ALWAYS_EAGER=False, the production default).resource_delete_actions(or trigger an ETL load that drops it).remove_embeddingstask runs after the row is deleted and removesnothing; the points remain in Qdrant.
Possible Solution
readable_id,platform, content-file keys) inresource_before_deletewhile the rows still exist, and pass them directly toremove_points_matching_params— or delete by pre-computed point IDs(
vector_point_id). This mirrorsserialize_bulk_learning_resources_for_deletion(
learning_resources_search/serializers.py:780), which OpenSearch already uses todeindex without a live row; the Qdrant path has no equivalent (and can't reuse that
one, since Qdrant points are keyed by a hash of
readable_id/platform, not DB id).readable_idno longer exists in the DB) is needed to clean up existing leakage.