Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions learning_resources_search/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,12 +284,13 @@ def content_files_loaded(self, run):
index_tasks.append(tasks.index_run_content_files.si(run.id))

if django_settings.QDRANT_ENABLE_INDEXING_PLUGIN_HOOKS:
index_tasks.append(vector_tasks.embed_run_content_files.si(run.id))
# Always purge unpublished files' points so a failed removal
# task self-heals on the next load.
# Purge before embedding so unpublished files' points are
# removed even if the embed task fails; the two tasks touch
# disjoint sets (published=False vs published=True files).
index_tasks.append(
vector_tasks.remove_unpublished_run_content_files.si(run.id)
)
index_tasks.append(vector_tasks.embed_run_content_files.si(run.id))

if index_tasks:
try_with_retry_as_task(chain(*index_tasks))
12 changes: 10 additions & 2 deletions learning_resources_search/plugins_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,10 +572,14 @@ def test_search_index_plugin_resource_upserted_generate_embeddings(

@pytest.mark.django_db
def test_content_files_loaded_always_purges_unpublished(
mock_search_index_helpers, settings
mocker, mock_search_index_helpers, settings
):
"""The remove-unpublished task always runs so failed removals self-heal."""
"""
The remove-unpublished task always runs, and ahead of the embed task in the
chain, so a failed embed can't strand unpublished files' points in Qdrant.
"""
settings.QDRANT_ENABLE_INDEXING_PLUGIN_HOOKS = True
chain_mock = mocker.patch("learning_resources_search.plugins.chain")
run = LearningResourceRunFactory.create(
published=True, learning_resource__create_runs=False
)
Expand All @@ -586,3 +590,7 @@ def test_content_files_loaded_always_purges_unpublished(
mock_search_index_helpers.mock_remove_unpublished_run_contentfiles_immutable_signature.assert_called_once_with(
run.id
)
chained = list(chain_mock.call_args.args)
purge = mock_search_index_helpers.mock_remove_unpublished_run_contentfiles_immutable_signature.return_value
embed = mock_search_index_helpers.mock_embed_run_contentfiles_immutable_signature.return_value
assert chained.index(purge) < chained.index(embed)
45 changes: 38 additions & 7 deletions vector_search/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ def embed_new_content_files(self):
)


@app.task(bind=True)
@app.task(bind=True, max_retries=3)
def embed_run_content_files(self, run_id):
"""
Embed the run's published content files whose Qdrant points are missing or
Expand All @@ -478,6 +478,12 @@ def embed_run_content_files(self, run_id):
newly generated summary, ...) is dispatched but exits via the payload-only
update path downstream — no re-embedding. Failed or purged embeds show up
as missing/stale points, so they self-heal on the next load.

Content-less files are excluded: they never produce Qdrant points, so they
would otherwise be re-flagged on every load. Any point left over from when
such a file still had content is removed. Transient Qdrant errors during
the pre-pass retry with backoff so a blip doesn't defer the run's embedding
to the next load.
"""
run = (
LearningResourceRun.objects.select_related("learning_resource__platform")
Expand All @@ -504,16 +510,33 @@ def chunk0_point_id(key):
)
)

contentless = Q(content__isnull=True) | Q(content="")
pid_rows = [
(cf_id, chunk0_point_id(key), checksum, meta)
for cf_id, key, checksum, *meta in ContentFile.objects.filter(
run=run, published=True
).values_list("id", "key", "checksum", *CONTENT_FILE_PREPASS_PAYLOAD_FIELDS)
)
.exclude(contentless)
.values_list("id", "key", "checksum", *CONTENT_FILE_PREPASS_PAYLOAD_FIELDS)
]
stored = _stored_content_payloads(
[pid for _, pid, _, _ in pid_rows],
fields=("checksum", *CONTENT_FILE_PREPASS_PAYLOAD_FIELDS),
)
contentless_rows = [
(cf_id, chunk0_point_id(key))

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.

super tiny nit-pick with the name chunk0_point_id (it feels a bit odd). could we rename to chunk_0_point_id or first_chunk_point_id?

for cf_id, key in ContentFile.objects.filter(
contentless, run=run, published=True
).values_list("id", "key")
]
try:
stored = _stored_content_payloads(
[pid for _, pid, _, _ in pid_rows] + [pid for _, pid in contentless_rows],
fields=("checksum", *CONTENT_FILE_PREPASS_PAYLOAD_FIELDS),
)
except grpc.RpcError as err:
if err.code() in (
grpc.StatusCode.DEADLINE_EXCEEDED,
grpc.StatusCode.UNAVAILABLE,
):
raise self.retry(exc=err, countdown=_retry_countdown(self.request.retries)) # noqa: B904
raise

def is_stale(pid, checksum, meta):
payload = stored.get(pid)
Expand All @@ -529,12 +552,20 @@ def is_stale(pid, checksum, meta):
for cf_id, pid, checksum, meta in pid_rows
if is_stale(pid, checksum, meta)
]
# A stored point for a now-contentless file is a leftover from when the
# file had content — remove it. Inline rather than a chained task: leftovers
# are rare and few, and a failed delete self-heals on the next load.
leftover_ids = [cf_id for cf_id, pid in contentless_rows if pid in stored]
log.info(
"embed_run_content_files run %s: %d of %d files need embedding",
"embed_run_content_files run %s: %d of %d files need embedding, "
"%d leftover contentless points to remove",
run_id,
len(ids),
len(pid_rows),
len(leftover_ids),
)
if leftover_ids:
remove_qdrant_records(leftover_ids, CONTENT_FILE_TYPE)
if not ids:
return None
return _replace_with_finalized_chain(self, ids, overwrite=True)
Expand Down
84 changes: 81 additions & 3 deletions vector_search/tasks_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,8 @@ def test_embed_run_content_files(mocker, mocked_celery, settings):
settings.QDRANT_CHUNK_SIZE = 2
run = LearningResourceRunFactory.create()
content_file_ids = [
content_file.id for content_file in ContentFileFactory.create_batch(3, run=run)
content_file.id
for content_file in ContentFileFactory.create_batch(3, run=run, content="text")
]
ContentFileFactory.create()
generate_embeddings_mock = mocker.patch(
Expand Down Expand Up @@ -773,8 +774,8 @@ def test_embed_run_content_files_skips_unpublished(mocker, mocked_celery, settin
"""Unpublished files are never embedded."""
settings.QDRANT_CHUNK_SIZE = 50
run = LearningResourceRunFactory.create()
published = ContentFileFactory.create(run=run, published=True)
ContentFileFactory.create(run=run, published=False)
published = ContentFileFactory.create(run=run, published=True, content="aaa")
ContentFileFactory.create(run=run, published=False, content="bbb")
generate_embeddings_mock = mocker.patch(
"vector_search.tasks.generate_embeddings", autospec=True
)
Expand All @@ -785,6 +786,83 @@ def test_embed_run_content_files_skips_unpublished(mocker, mocked_celery, settin
assert _embedded_content_file_ids(generate_embeddings_mock) == {published.id}


def test_embed_run_content_files_skips_contentless(mocker, mocked_celery, settings):
"""
Files without content never produce Qdrant points, so the pre-pass must not
flag them as stale (they would otherwise be re-dispatched on every load).
"""
settings.QDRANT_CHUNK_SIZE = 50
run = LearningResourceRunFactory.create()
with_content = ContentFileFactory.create(run=run, published=True, content="aaa")
ContentFileFactory.create(run=run, published=True, content="")
ContentFileFactory.create(run=run, published=True, content=None)
generate_embeddings_mock = mocker.patch(
"vector_search.tasks.generate_embeddings", autospec=True
)

with pytest.raises(mocked_celery.replace_exception_class):
embed_run_content_files.delay(run.id)

assert _embedded_content_file_ids(generate_embeddings_mock) == {with_content.id}


def test_embed_run_content_files_removes_leftover_contentless_points(mocker):
"""
A published file whose content became empty keeps the point embedded from
its old content; the pre-pass detects and removes it. Contentless files
with no stored point trigger no removal.
"""
run = LearningResourceRunFactory.create()
emptied = ContentFileFactory.create(run=run, published=True, content="")
ContentFileFactory.create(run=run, published=True, content=None)
pids = _serializer_chunk0_pids([emptied])
mocker.patch(
"vector_search.tasks._stored_content_payloads",
return_value={pids[emptied.id]: {"checksum": "from-old-content"}},
)
generate_embeddings_mock = mocker.patch(
"vector_search.tasks.generate_embeddings", autospec=True
)
remove_mock = mocker.patch(
"vector_search.tasks.remove_qdrant_records", autospec=True
)

assert embed_run_content_files(run.id) is None

generate_embeddings_mock.si.assert_not_called()
remove_mock.assert_called_once_with([emptied.id], CONTENT_FILE_TYPE)


def test_embed_run_content_files_retries_transient_qdrant_errors(mocker):
"""A transient Qdrant error in the pre-pass retries instead of failing the run."""
run = LearningResourceRunFactory.create()
ContentFileFactory.create(run=run, published=True, content="aaa")
mocker.patch(
"vector_search.tasks._stored_content_payloads",
side_effect=_rpc_error(grpc.StatusCode.UNAVAILABLE),
)
retry = mocker.patch.object(embed_run_content_files, "retry", side_effect=Retry())

with pytest.raises(Retry):
embed_run_content_files(run.id)

retry.assert_called_once()
assert retry.call_args.kwargs["countdown"] >= 0


def test_embed_run_content_files_does_not_retry_terminal_errors(mocker):
"""A non-transient Qdrant error in the pre-pass propagates without retry."""
run = LearningResourceRunFactory.create()
ContentFileFactory.create(run=run, published=True, content="aaa")
mocker.patch(
"vector_search.tasks._stored_content_payloads",
side_effect=_rpc_error(grpc.StatusCode.INVALID_ARGUMENT),
)

with pytest.raises(grpc.RpcError):
embed_run_content_files(run.id)


def _serializer_chunk0_pids(content_files):
"""Chunk-0 point ids as the embed pipeline (serializer path) computes them"""
return {
Expand Down
Loading