Skip to content

Async Qdrant removal task potentially no-ops on delete, orphaning embeddings #3632

Description

@shanbady

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

  1. Run with a real Celery worker (CELERY_TASK_ALWAYS_EAGER=False, the production default).
  2. Embed a resource so it has points in the Qdrant resources/content-files collections.
  3. Delete it via resource_delete_actions (or trigger an ETL load that drops it).
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions