mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
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>
277 lines
12 KiB
Python
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()
|