Files
dify/api/tasks/clean_dataset_task.py
fd2db63184 feat: KnowledgeFS and New Knowledge (#44165)
Co-authored-by: Jyong <76649700+JohnJyong@users.noreply.github.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: zhaohao1004 <zhaohao1004@gmail.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: FFXN <lizy@dify.ai>
2026-10-23 03:34:59 +00:00

277 lines
12 KiB
Python

import logging
import time
from collections.abc import Callable, Sequence
import click
from celery import shared_task
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from core.rag.index_processor.index_processor import IndexProcessorFactory
from core.tools.utils.web_reader_tool import get_image_upload_file_ids
from extensions.ext_storage import storage
from models import WorkflowType
from models.dataset import (
AppDatasetJoin,
Dataset,
DatasetMetadata,
DatasetMetadataBinding,
DatasetProcessRule,
DatasetQuery,
Document,
DocumentSegment,
Pipeline,
SegmentAttachmentBinding,
)
from models.model import UploadFile
from models.workflow import Workflow
from services.dataset_knowledge_fs_upgrade_file_lease import reserve_upgrade_file_cleanup
from services.dataset_storage_cleanup_service import cleanup_dataset_crawl_cache
from tasks.refresh_billing_vector_space_task import schedule_billing_vector_space_refresh
logger = logging.getLogger(__name__)
# Keep committed batches on failure, but preserve dependent rows until a retry
# finishes their prerequisite cleanup.
_DELETE_BATCH_SIZE = 1000
_MAX_RETRIES = 3
_RETRY_DELAY_SECONDS = 30
@shared_task(queue="dataset", bind=True, max_retries=_MAX_RETRIES, default_retry_delay=_RETRY_DELAY_SECONDS)
def clean_dataset_task(
self,
dataset_id: str,
tenant_id: str,
indexing_technique: str | None,
index_struct: str | None,
collection_binding_id: str | None,
doc_form: str,
pipeline_id: str | None = None,
*,
index_cleanup_done: bool = False,
) -> None:
"""
Clean dataset when dataset deleted.
:param dataset_id: dataset id
:param tenant_id: tenant id
:param indexing_technique: indexing technique
:param index_struct: index struct dict
:param collection_binding_id: collection binding id
:param doc_form: dataset form
:param index_cleanup_done: preserve successful index cleanup across explicit retries
Usage: clean_dataset_task.delay(dataset_id, tenant_id, indexing_technique, index_struct)
"""
logger.info(click.style(f"Start clean dataset when dataset deleted: {dataset_id}", fg="green"))
start_at = time.perf_counter()
# Enhanced validation: Check if doc_form is None, empty string, or contains only whitespace.
# Treating all invalid values as the default paragraph index type keeps the vector
# database cleanup reachable for empty/broken datasets.
if doc_form is None or (isinstance(doc_form, str) and not doc_form.strip()):
from core.rag.index_processor.constant.index_type import IndexStructureType
doc_form = IndexStructureType.PARAGRAPH_INDEX
logger.info(
click.style(f"Invalid doc_form detected, using default index type for cleanup: {doc_form}", fg="yellow")
)
dataset = Dataset(
id=dataset_id,
tenant_id=tenant_id,
indexing_technique=indexing_technique,
index_struct=index_struct,
collection_binding_id=collection_binding_id,
)
try:
cleanup_dataset_crawl_cache(tenant_id, dataset_id)
with session_factory.create_session() as session:
if not index_cleanup_done:
# Index processors also delete relational child rows, so the whole step must succeed first.
_run_step(session, dataset_id, "dataset index", lambda: _clean_index(session, dataset, doc_form))
index_cleanup_done = True
schedule_billing_vector_space_refresh(dataset.tenant_id)
# Stop on failure so documents and their resources outlive any remaining segments.
_run_step(session, dataset_id, "segments", lambda: _delete_segments(session, dataset_id))
_run_step(session, dataset_id, "documents", lambda: _delete_documents(session, dataset_id))
_run_step(session, dataset_id, "attachments", lambda: _delete_attachments(session, tenant_id, dataset_id))
_run_step(
session,
dataset_id,
"dataset metadata",
lambda: _delete_dataset_scoped_rows(session, tenant_id, dataset_id, pipeline_id),
)
except Exception as exc:
if self.request.retries >= _MAX_RETRIES:
logger.exception("Dataset cleanup retry budget exhausted, dataset_id=%s", dataset_id)
raise
logger.warning(
"Dataset cleanup failed, scheduling retry %d/%d, dataset_id=%s",
self.request.retries + 1,
_MAX_RETRIES,
dataset_id,
exc_info=True,
)
# The session is closed before retrying. Normalize arguments for positional callers too.
raise self.retry(
exc=exc,
args=(),
kwargs={
"dataset_id": dataset_id,
"tenant_id": tenant_id,
"indexing_technique": indexing_technique,
"index_struct": index_struct,
"collection_binding_id": collection_binding_id,
"doc_form": doc_form,
"pipeline_id": pipeline_id,
"index_cleanup_done": index_cleanup_done,
},
countdown=_RETRY_DELAY_SECONDS * (2**self.request.retries),
) from exc
end_at = time.perf_counter()
logger.info(
click.style(
f"Cleaned dataset when dataset deleted: {dataset_id} latency: {end_at - start_at}",
fg="green",
)
)
def _run_step(session: Session, dataset_id: str, name: str, step: Callable[[], None]) -> None:
"""Roll back uncommitted work on failure and stop dependent steps until retry."""
try:
step()
except Exception:
logger.exception(click.style(f"Failed to clean {name} for dataset {dataset_id}", fg="red"))
try:
session.rollback()
except Exception:
logger.exception("Failed to rollback database session")
raise
def _clean_index(session: Session, dataset: Dataset, doc_form: str) -> None:
"""Clean the dataset index and commit any relational rows removed by its processor."""
index_processor = IndexProcessorFactory(doc_form).init_index_processor()
index_processor.clean(dataset, None, with_keywords=True, delete_child_chunks=True, session=session)
session.commit()
logger.info(click.style(f"Successfully cleaned vector database for dataset: {dataset.id}", fg="green"))
def _delete_segments(session: Session, dataset_id: str) -> None:
"""Delete document segments in batches, removing their inline image files first."""
while True:
segments = session.scalars(
select(DocumentSegment).where(DocumentSegment.dataset_id == dataset_id).limit(_DELETE_BATCH_SIZE)
).all()
if not segments:
break
image_upload_file_ids: list[str] = []
for segment in segments:
image_upload_file_ids.extend(get_image_upload_file_ids(segment.content))
_delete_upload_files(session, image_upload_file_ids)
session.execute(delete(DocumentSegment).where(DocumentSegment.id.in_([segment.id for segment in segments])))
session.commit()
def _delete_documents(session: Session, dataset_id: str) -> None:
"""Delete documents in batches, removing their uploaded source files first."""
while True:
documents = session.scalars(
select(Document).where(Document.dataset_id == dataset_id).limit(_DELETE_BATCH_SIZE)
).all()
if not documents:
break
file_ids: list[str] = []
for document in documents:
if document.data_source_type == "upload_file" and document.data_source_info:
data_source_info = document.data_source_info_dict
if data_source_info and "upload_file_id" in data_source_info:
file_ids.append(data_source_info["upload_file_id"])
# Active KnowledgeFS upgrades still read these sources; their leases record the cleanup instead.
leased_file_ids = reserve_upgrade_file_cleanup(session, file_ids)
if leased_file_ids:
logger.info(
"Keep %d source files while KnowledgeFS upgrade leases are active, dataset_id=%s",
len(leased_file_ids),
dataset_id,
)
_delete_upload_files(session, [file_id for file_id in file_ids if file_id not in leased_file_ids])
session.execute(delete(Document).where(Document.id.in_([document.id for document in documents])))
session.commit()
def _delete_upload_files(session: Session, file_ids: Sequence[str]) -> None:
"""Delete blobs before their lookup rows so failures retain storage keys for retry."""
if not file_ids:
return
files = session.scalars(select(UploadFile).where(UploadFile.id.in_(file_ids))).all()
for file in files:
try:
storage.delete(file.key)
except FileNotFoundError:
logger.debug("Upload file already absent from storage, upload_file_id: %s", file.id)
session.execute(delete(UploadFile).where(UploadFile.id.in_(file_ids)))
def _delete_attachments(session: Session, tenant_id: str, dataset_id: str) -> None:
"""Delete segment attachments (upload files + bindings) for the dataset."""
# Inline-image cleanup may already have deleted the file; its binding still needs cleanup.
attachments_with_bindings = session.execute(
select(SegmentAttachmentBinding, UploadFile)
.outerjoin(UploadFile, UploadFile.id == SegmentAttachmentBinding.attachment_id)
.where(
SegmentAttachmentBinding.tenant_id == tenant_id,
SegmentAttachmentBinding.dataset_id == dataset_id,
)
).all()
if not attachments_with_bindings:
return
attachment_ids = [
attachment_file.id for _, attachment_file in attachments_with_bindings if attachment_file is not None
]
binding_ids = [binding.id for binding, _ in attachments_with_bindings]
for binding, attachment_file in attachments_with_bindings:
if attachment_file is None:
continue
try:
storage.delete(attachment_file.key)
except Exception:
logger.exception(
"Delete attachment_file failed when storage deleted, attachment_file_id: %s",
binding.attachment_id,
)
if attachment_ids:
session.execute(delete(UploadFile).where(UploadFile.id.in_(attachment_ids)))
session.execute(delete(SegmentAttachmentBinding).where(SegmentAttachmentBinding.id.in_(binding_ids)))
session.commit()
def _delete_dataset_scoped_rows(session: Session, tenant_id: str, dataset_id: str, pipeline_id: str | None) -> None:
"""Delete the remaining small dataset-scoped tables (and the pipeline/workflow)."""
session.execute(delete(DatasetProcessRule).where(DatasetProcessRule.dataset_id == dataset_id))
session.execute(delete(DatasetQuery).where(DatasetQuery.dataset_id == dataset_id))
session.execute(delete(AppDatasetJoin).where(AppDatasetJoin.dataset_id == dataset_id))
# delete dataset metadata
session.execute(delete(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset_id))
session.execute(delete(DatasetMetadataBinding).where(DatasetMetadataBinding.dataset_id == dataset_id))
# delete pipeline and workflow
if pipeline_id:
session.execute(delete(Pipeline).where(Pipeline.id == pipeline_id))
session.execute(
delete(Workflow).where(
Workflow.tenant_id == tenant_id,
Workflow.app_id == pipeline_id,
Workflow.type == WorkflowType.RAG_PIPELINE,
)
)
session.commit()