Files
mnemosyne/mnemosyne/library/tasks.py
Robert Helewka e1f128659e 🐾 fix(tasks): unsupported file types fail ingest terminally, no retries
Unsupported file types raised a bare ValueError from the parser, which
ingest_from_daedalus treated like any transient fault — ERROR logs and
pointless Celery retries for input that can never parse. A new
UnsupportedFileTypeError (ValueError subclass, so existing callers still
catch it) is classified in the task as a terminal client-data failure:
logged at WARNING, job marked failed with reason
"unsupported_file_type", never retried.

Recovered from uncommitted work predating PR #6 (which deliberately
excluded it); test fixes on top: IngestJob's pk is id not job_id, and
.run() needs push_request() since the task persists self.request.id
into the NOT NULL celery_task_id column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:27:24 -04:00

547 lines
18 KiB
Python

"""
Celery tasks for the embedding pipeline.
All tasks pass UIDs (not model instances) per Red Panda Standards.
Tasks are idempotent, include retry logic, and track progress
via Memcached: library:task:{task_id}:progress.
"""
import logging
from celery import shared_task
from django.core.cache import cache
from neomodel import db
logger = logging.getLogger(__name__)
# Cache key pattern for task progress
PROGRESS_KEY = "library:task:{task_id}:progress"
# MIME type → file extension, for when Daedalus sends content_type as file_type
_MIME_TO_EXT = {
"text/markdown": "md",
"text/plain": "txt",
"text/html": "html",
"text/csv": "csv",
"text/xml": "xml",
"application/pdf": "pdf",
"application/epub+zip": "epub",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/json": "json",
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/tiff": "tiff",
}
def _normalize_file_type(raw: str) -> str:
"""Convert a MIME type or extension string to a bare extension."""
raw = (raw or "").strip().lower()
if "/" in raw:
# It's a MIME type — look up or derive from the subtype
ext = _MIME_TO_EXT.get(raw)
if ext:
return ext
# Fallback: use the part after the slash, strip vendor prefixes
subtype = raw.split("/", 1)[1]
subtype = subtype.split("+")[-1] # e.g. "epub+zip" → "zip"; "vnd.ms-excel" → keep
return subtype.lstrip(".") or "bin"
return raw.lstrip(".") or "bin"
def _update_progress(task, percent: int, message: str):
"""
Update task progress in Memcached and Celery state.
:param task: Celery task instance (self).
:param percent: Progress percentage (0-100).
:param message: Human-readable status message.
"""
try:
task.update_state(state="PROGRESS", meta={"percent": percent, "message": message})
cache.set(
PROGRESS_KEY.format(task_id=task.request.id),
{"percent": percent, "message": message},
timeout=3600,
)
except Exception:
pass
@shared_task(
name="library.tasks.embed_item",
bind=True,
queue="embedding",
max_retries=3,
default_retry_delay=60,
acks_late=True,
)
def embed_item(self, item_uid: str, user_id: int = None):
"""
Run the full embedding pipeline for a single Item.
:param item_uid: UID of the Item node to process.
:param user_id: Optional user ID for usage tracking.
:returns: Dict with processing results.
"""
logger.info("Task embed_item starting item_uid=%s task_id=%s", item_uid, self.request.id)
try:
from library.services.pipeline import EmbeddingPipeline
user = _resolve_user(user_id)
pipeline = EmbeddingPipeline(user=user)
def progress_cb(percent, message):
_update_progress(self, percent, message)
result = pipeline.process_item(item_uid, progress_callback=progress_cb)
logger.info(
"Task embed_item completed item_uid=%s chunks=%d images=%d",
item_uid,
result.get("chunks_created", 0),
result.get("images_stored", 0),
)
return {"success": True, "item_uid": item_uid, **result}
except Exception as exc:
logger.error(
"Task embed_item failed item_uid=%s: %s",
item_uid,
exc,
exc_info=True,
)
# Retry on transient errors
if self.request.retries < self.max_retries:
raise self.retry(exc=exc)
return {"success": False, "item_uid": item_uid, "error": str(exc)}
@shared_task(
name="library.tasks.reembed_item",
bind=True,
queue="embedding",
max_retries=3,
default_retry_delay=60,
acks_late=True,
)
def reembed_item(self, item_uid: str, user_id: int = None):
"""
Delete existing embeddings and re-process an Item.
:param item_uid: UID of the Item node to re-embed.
:param user_id: Optional user ID for usage tracking.
:returns: Dict with processing results.
"""
logger.info("Task reembed_item starting item_uid=%s", item_uid)
try:
from library.services.pipeline import EmbeddingPipeline
user = _resolve_user(user_id)
pipeline = EmbeddingPipeline(user=user)
def progress_cb(percent, message):
_update_progress(self, percent, message)
result = pipeline.reprocess_item(item_uid, progress_callback=progress_cb)
logger.info("Task reembed_item completed item_uid=%s", item_uid)
return {"success": True, "item_uid": item_uid, **result}
except Exception as exc:
logger.error("Task reembed_item failed item_uid=%s: %s", item_uid, exc, exc_info=True)
if self.request.retries < self.max_retries:
raise self.retry(exc=exc)
return {"success": False, "item_uid": item_uid, "error": str(exc)}
@shared_task(
name="library.tasks.embed_collection",
bind=True,
queue="batch",
acks_late=True,
)
def embed_collection(self, collection_uid: str, user_id: int = None):
"""
Embed all items in a collection.
:param collection_uid: UID of the Collection node.
:param user_id: Optional user ID for usage tracking.
:returns: Dict with summary results.
"""
logger.info("Task embed_collection starting collection_uid=%s", collection_uid)
try:
from library.models import Collection
col = Collection.nodes.get(uid=collection_uid)
items = col.items.all()
results = {"total": len(items), "successful": 0, "failed": 0, "skipped": 0}
for i, item in enumerate(items):
# Skip already-completed items with unchanged content
if item.embedding_status == "completed" and item.content_hash:
results["skipped"] += 1
logger.debug("Skipping already-embedded item_uid=%s", item.uid)
continue
try:
embed_item.delay(item.uid, user_id)
results["successful"] += 1
except Exception as exc:
results["failed"] += 1
logger.error(
"Failed to queue embed for item_uid=%s: %s", item.uid, exc
)
_update_progress(
self,
int((i + 1) / len(items) * 100),
f"Queued {i + 1}/{len(items)} items",
)
logger.info(
"Task embed_collection completed collection_uid=%s queued=%d skipped=%d failed=%d",
collection_uid,
results["successful"],
results["skipped"],
results["failed"],
)
return {"success": True, "collection_uid": collection_uid, **results}
except Exception as exc:
logger.error(
"Task embed_collection failed collection_uid=%s: %s",
collection_uid,
exc,
exc_info=True,
)
return {"success": False, "collection_uid": collection_uid, "error": str(exc)}
@shared_task(
name="library.tasks.embed_library",
bind=True,
queue="batch",
acks_late=True,
)
def embed_library(self, library_uid: str, user_id: int = None):
"""
Embed all items across all collections in a library.
:param library_uid: UID of the Library node.
:param user_id: Optional user ID for usage tracking.
:returns: Dict with summary results.
"""
logger.info("Task embed_library starting library_uid=%s", library_uid)
try:
from library.models import Library
lib = Library.nodes.get(uid=library_uid)
collections = lib.collections.all()
results = {"total_collections": len(collections), "items_queued": 0}
for col in collections:
embed_collection.delay(col.uid, user_id)
results["items_queued"] += len(col.items.all())
logger.info(
"Task embed_library completed library_uid=%s collections=%d items=%d",
library_uid,
results["total_collections"],
results["items_queued"],
)
return {"success": True, "library_uid": library_uid, **results}
except Exception as exc:
logger.error(
"Task embed_library failed library_uid=%s: %s",
library_uid,
exc,
exc_info=True,
)
return {"success": False, "library_uid": library_uid, "error": str(exc)}
@shared_task(
name="library.tasks.batch_embed_items",
bind=True,
queue="batch",
acks_late=True,
)
def batch_embed_items(self, item_uids: list[str], user_id: int = None):
"""
Queue embedding tasks for a specific list of items.
:param item_uids: List of Item UIDs.
:param user_id: Optional user ID for usage tracking.
:returns: Dict with queuing results.
"""
logger.info("Task batch_embed_items starting count=%d", len(item_uids))
queued = 0
for uid in item_uids:
try:
embed_item.delay(uid, user_id)
queued += 1
except Exception as exc:
logger.error("Failed to queue item_uid=%s: %s", uid, exc)
logger.info("Task batch_embed_items completed queued=%d/%d", queued, len(item_uids))
return {"success": True, "queued": queued, "total": len(item_uids)}
def _resolve_user(user_id: int = None):
"""
Resolve a user ID to a User instance.
:param user_id: Optional user ID.
:returns: User instance, or None.
"""
if not user_id:
return None
try:
from django.contrib.auth import get_user_model
User = get_user_model()
return User.objects.get(pk=user_id)
except Exception:
return None
# ---------------------------------------------------------------------------
# Ingest task (Daedalus integration)
# ---------------------------------------------------------------------------
@shared_task(
name="library.tasks.ingest_from_daedalus",
bind=True,
queue="embedding",
max_retries=3,
default_retry_delay=60,
acks_late=True,
)
def ingest_from_daedalus(self, job_id: str):
"""
Process a single IngestJob: fetch from Daedalus S3 → create Item →
run embedding pipeline → mark complete.
Idempotent on (library_uid, source_ref, content_hash) — handled in the
REST view that creates the IngestJob, so by the time this task runs the
job either represents new content or a content_hash-changed re-ingest.
For a content_hash-changed re-ingest, the prior Item with the same
source_ref is deleted before the new one is processed (ensures no
stale chunks linger).
"""
from datetime import datetime, timezone
from library.models import IngestJob, Item, Library
from library.services.parsers import UnsupportedFileTypeError
from library.services.source_s3 import (
copy_into_mnemosyne,
fetch_from_source,
)
from library.services.pipeline import EmbeddingPipeline
logger.info(
"Task ingest_from_daedalus starting job_id=%s task_id=%s",
job_id, self.request.id,
)
try:
job = IngestJob.objects.get(pk=job_id)
except IngestJob.DoesNotExist:
logger.error("IngestJob not found job_id=%s", job_id)
return {"success": False, "error": "job_not_found"}
job.status = "processing"
job.progress = "fetching"
job.started_at = datetime.now(timezone.utc)
job.celery_task_id = self.request.id
job.save(update_fields=["status", "progress", "started_at", "celery_task_id"])
try:
# --- 1. Resolve target Library ---
try:
lib = Library.nodes.get(uid=job.library_uid)
except Library.DoesNotExist:
raise RuntimeError(f"Library not found: {job.library_uid}")
# --- 2. Supersede prior Item with same source_ref but different hash ---
prior_item_uid = None
if job.source_ref:
rows, _ = db.cypher_query(
"""
MATCH (l:Library {uid: $library_uid})-[:CONTAINS]->(:Collection)
-[:CONTAINS]->(i:Item)
WHERE i.metadata IS NOT NULL
AND i.metadata CONTAINS $source_ref_marker
RETURN i.uid LIMIT 1
""",
{
"library_uid": lib.uid,
"source_ref_marker": f'"source_ref": "{job.source_ref}"',
},
)
if rows:
prior_item_uid = rows[0][0]
logger.info(
"Superseding prior Item job_id=%s prior_item_uid=%s",
job_id, prior_item_uid,
)
_delete_item_and_chunks(prior_item_uid)
# --- 3. Fetch from the source bucket, copy into Mnemosyne bucket ---
job.progress = "copying"
job.save(update_fields=["progress"])
data = fetch_from_source(job.source, job.s3_key)
# --- 4. Create Item node ---
ext = _normalize_file_type(job.file_type)
item = Item(
title=job.title,
file_type=ext,
file_size=len(data),
content_hash=job.content_hash,
embedding_status="pending",
metadata={
"source": job.source,
"source_ref": job.source_ref,
},
)
item.save()
mnemosyne_s3_key = f"items/{item.uid}/original.{ext}"
copy_into_mnemosyne(data, mnemosyne_s3_key)
item.s3_key = mnemosyne_s3_key
item.save()
# --- 5. Connect to library/collection ---
col = _resolve_or_create_default_collection(lib, job.collection_uid)
col.items.connect(item)
job.item_uid = item.uid
job.save(update_fields=["item_uid"])
# --- 6. Run the embedding pipeline ---
job.progress = "embedding"
job.save(update_fields=["progress"])
def progress_cb(percent, message):
_update_progress(self, percent, message)
pipeline = EmbeddingPipeline(user=None)
result = pipeline.process_item(item.uid, progress_callback=progress_cb)
# --- 7. Mark complete ---
job.status = "completed"
job.progress = "done"
job.chunks_created = result.get("chunks_created", 0)
job.concepts_extracted = result.get("concepts_extracted", 0)
job.embedding_model = result.get("embedding_model", "")
job.completed_at = datetime.now(timezone.utc)
job.save()
logger.info(
"Task ingest_from_daedalus completed job_id=%s item_uid=%s "
"chunks=%d concepts=%d",
job_id, item.uid, job.chunks_created, job.concepts_extracted,
)
return {
"success": True,
"job_id": job_id,
"item_uid": item.uid,
**result,
}
except UnsupportedFileTypeError as exc:
# Deterministic, input-driven — re-parsing identical bytes can never
# succeed. Terminal client-data failure, never retried, and logged at
# WARNING (not ERROR) because an unparseable input is not a server fault.
logger.warning(
"Task ingest_from_daedalus rejected job_id=%s: %s", job_id, exc,
)
job.status = "failed"
job.error = str(exc)
job.completed_at = datetime.now(timezone.utc)
job.save(update_fields=["status", "error", "completed_at"])
return {
"success": False,
"job_id": job_id,
"error": str(exc),
"reason": "unsupported_file_type",
}
except Exception as exc:
logger.error(
"Task ingest_from_daedalus failed job_id=%s: %s",
job_id, exc, exc_info=True,
)
if self.request.retries < self.max_retries:
job.retry_count = self.request.retries + 1
job.save(update_fields=["retry_count"])
raise self.retry(exc=exc)
job.status = "failed"
job.error = str(exc)
job.completed_at = datetime.now(timezone.utc)
job.save(update_fields=["status", "error", "completed_at"])
return {"success": False, "job_id": job_id, "error": str(exc)}
def _delete_item_and_chunks(item_uid: str):
"""Delete an Item, its chunks, and its images. Concept GC is workspace-delete only."""
db.cypher_query(
"""
MATCH (i:Item {uid: $uid})
OPTIONAL MATCH (i)-[:HAS_CHUNK]->(c:Chunk)
OPTIONAL MATCH (i)-[:HAS_IMAGE]->(img:Image)
OPTIONAL MATCH (img)-[:HAS_EMBEDDING]->(emb:ImageEmbedding)
DETACH DELETE c, img, emb, i
""",
{"uid": item_uid},
)
def _resolve_or_create_default_collection(lib, collection_uid: str = ""):
"""
Find or create the default Collection for a Library.
Daedalus integration creates one Collection per Library, named "default".
Explicit collection_uid is honored if provided.
"""
from library.models import Collection
if collection_uid:
try:
return Collection.nodes.get(uid=collection_uid)
except Collection.DoesNotExist:
pass
# Look for an existing "default" collection in this library
rows, _ = db.cypher_query(
"MATCH (l:Library {uid: $library_uid})-[:CONTAINS]->(c:Collection {name: 'default'}) "
"RETURN c.uid LIMIT 1",
{"library_uid": lib.uid},
)
if rows:
return Collection.nodes.get(uid=rows[0][0])
col = Collection(name="default", description="Default collection")
col.save()
lib.collections.connect(col)
col.library.connect(lib)
return col