mirror of
https://github.com/langgenius/dify.git
synced 2026-09-03 15:27:49 +08:00
1309 lines
59 KiB
Python
1309 lines
59 KiB
Python
"""Snapshot and resumable orchestration for legacy Dataset upgrades."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import uuid
|
|
from collections import defaultdict
|
|
from datetime import timedelta
|
|
from hashlib import sha256
|
|
from typing import Any, cast
|
|
|
|
import sqlalchemy as sa
|
|
from pydantic import TypeAdapter
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
|
|
from extensions.ext_storage import storage
|
|
from graphon.model_runtime.entities.model_entities import ModelType
|
|
from libs.datetime_utils import naive_utc_now
|
|
from models import Account, AccountStatus, TenantAccountJoin
|
|
from models.dataset import AppDatasetJoin, Dataset, DatasetMetadata, DatasetPermission, Document
|
|
from models.knowledge_fs import (
|
|
KnowledgeFSAppSpaceJoinType,
|
|
KnowledgeFSControlSpace,
|
|
KnowledgeFSControlSpacePermissionRole,
|
|
KnowledgeFSControlSpaceState,
|
|
KnowledgeFSControlSpaceVisibility,
|
|
KnowledgeFSUpgradeDocument,
|
|
KnowledgeFSUpgradeFileLease,
|
|
KnowledgeFSUpgradeFileLeaseStatus,
|
|
KnowledgeFSUpgradeItemStatus,
|
|
KnowledgeFSUpgradeJob,
|
|
KnowledgeFSUpgradeJobStatus,
|
|
KnowledgeFSUpgradeSource,
|
|
KnowledgeFSUpgradeStage,
|
|
)
|
|
from models.model import App, AppMode, Tag, TagBinding, UploadFile
|
|
from models.oauth import DatasourceProvider
|
|
from models.provider_ids import ModelProviderID
|
|
from services.dataset_knowledge_fs_upgrade_file_lease import release_upgrade_file_lease
|
|
from services.feature_service import FeatureService
|
|
from services.knowledge_fs.product_dto import (
|
|
KnowledgeFSAppBindingPayload,
|
|
KnowledgeFSDocumentAvailabilityPayload,
|
|
KnowledgeFSDocumentMetadataPayload,
|
|
KnowledgeFSDocumentStagedUploadPayload,
|
|
KnowledgeFSExternalAccessPayload,
|
|
KnowledgeFSInitialSourcePayload,
|
|
KnowledgeFSMemberBindingPayload,
|
|
KnowledgeFSMetadataFieldCreatePayload,
|
|
KnowledgeFSModelIntent,
|
|
KnowledgeFSRerankIntent,
|
|
KnowledgeFSRetrievalProfileIntent,
|
|
KnowledgeFSScoreThresholdIntent,
|
|
KnowledgeFSSpaceCreatePayload,
|
|
KnowledgeFSUpgradeDiscoveryResponse,
|
|
KnowledgeFSUpgradeJobResponse,
|
|
)
|
|
from services.knowledge_fs.runtime import get_knowledge_fs_runtime
|
|
from services.knowledge_fs.staged_upload_service import KnowledgeFSStagedUploadService
|
|
|
|
_ACTIVE_JOB_STATUSES = (KnowledgeFSUpgradeJobStatus.QUEUED, KnowledgeFSUpgradeJobStatus.RUNNING)
|
|
_SOURCE_SELECTION_LIMIT = 200
|
|
_FILE_LEASE_TTL = timedelta(days=7)
|
|
_INITIAL_SOURCE_ADAPTER: TypeAdapter[KnowledgeFSInitialSourcePayload] = TypeAdapter(KnowledgeFSInitialSourcePayload)
|
|
|
|
|
|
class KnowledgeFSUpgradeError(RuntimeError):
|
|
"""A persisted upgrade cannot safely continue."""
|
|
|
|
|
|
class KnowledgeFSUpgradeNotFoundError(KnowledgeFSUpgradeError):
|
|
pass
|
|
|
|
|
|
class KnowledgeFSUpgradeConflictError(KnowledgeFSUpgradeError):
|
|
pass
|
|
|
|
|
|
class KnowledgeFSUpgradeNotReadyError(KnowledgeFSUpgradeError):
|
|
"""Provisioning is progressing and the dedicated worker should retry."""
|
|
|
|
|
|
class KnowledgeFSUpgradeSnapshotService:
|
|
"""Persist the exact click-time Dataset manifest without external I/O."""
|
|
|
|
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
|
self._session_maker = session_maker
|
|
|
|
def create(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
dataset_id: str,
|
|
requested_by_account_id: str,
|
|
idempotency_key: str | None = None,
|
|
) -> KnowledgeFSUpgradeJob:
|
|
snapshot_at = naive_utc_now()
|
|
with self._session_maker(expire_on_commit=False) as session, session.begin():
|
|
dataset = session.scalar(
|
|
sa.select(Dataset).where(Dataset.id == dataset_id, Dataset.tenant_id == tenant_id).with_for_update()
|
|
)
|
|
if dataset is None:
|
|
raise KnowledgeFSUpgradeNotFoundError("Dataset was not found")
|
|
if dataset.provider == "external":
|
|
raise KnowledgeFSUpgradeConflictError("External-provider Datasets cannot be upgraded")
|
|
|
|
if idempotency_key:
|
|
idempotent = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeJob).where(
|
|
KnowledgeFSUpgradeJob.tenant_id == tenant_id,
|
|
KnowledgeFSUpgradeJob.idempotency_key == idempotency_key,
|
|
)
|
|
)
|
|
if idempotent is not None:
|
|
if idempotent.old_dataset_id != dataset_id:
|
|
raise KnowledgeFSUpgradeConflictError("Idempotency key belongs to another Dataset upgrade")
|
|
return idempotent
|
|
|
|
existing = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeJob)
|
|
.where(
|
|
KnowledgeFSUpgradeJob.tenant_id == tenant_id,
|
|
KnowledgeFSUpgradeJob.old_dataset_id == dataset_id,
|
|
KnowledgeFSUpgradeJob.status.in_(_ACTIVE_JOB_STATUSES),
|
|
)
|
|
.order_by(KnowledgeFSUpgradeJob.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
owner_account_id = str(dataset.maintainer or dataset.created_by)
|
|
if (
|
|
session.scalar(
|
|
sa.select(Account.id)
|
|
.join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
|
|
.where(
|
|
Account.id == owner_account_id,
|
|
Account.status != AccountStatus.BANNED,
|
|
TenantAccountJoin.tenant_id == tenant_id,
|
|
)
|
|
.limit(1)
|
|
)
|
|
is None
|
|
):
|
|
raise KnowledgeFSUpgradeConflictError("Dataset maintainer is not an active account")
|
|
|
|
permission_members = list(
|
|
session.scalars(
|
|
sa.select(DatasetPermission.account_id)
|
|
.join(Account, Account.id == DatasetPermission.account_id)
|
|
.join(
|
|
TenantAccountJoin,
|
|
sa.and_(
|
|
TenantAccountJoin.account_id == DatasetPermission.account_id,
|
|
TenantAccountJoin.tenant_id == tenant_id,
|
|
),
|
|
)
|
|
.where(
|
|
DatasetPermission.tenant_id == tenant_id,
|
|
DatasetPermission.dataset_id == dataset_id,
|
|
DatasetPermission.has_permission.is_(True),
|
|
Account.status != AccountStatus.BANNED,
|
|
)
|
|
)
|
|
)
|
|
app_bindings = _app_binding_snapshot(session, tenant_id=tenant_id, dataset_id=dataset_id)
|
|
tag_ids = list(
|
|
session.scalars(
|
|
sa.select(TagBinding.tag_id)
|
|
.join(Tag, Tag.id == TagBinding.tag_id)
|
|
.where(
|
|
TagBinding.tenant_id == tenant_id,
|
|
TagBinding.target_id == dataset_id,
|
|
Tag.type == "knowledge",
|
|
)
|
|
)
|
|
)
|
|
job = KnowledgeFSUpgradeJob(
|
|
tenant_id=tenant_id,
|
|
old_dataset_id=dataset_id,
|
|
requested_by_account_id=requested_by_account_id,
|
|
owner_account_id=owner_account_id,
|
|
idempotency_key=idempotency_key or str(uuid.uuid4()),
|
|
snapshot_at=snapshot_at,
|
|
config_snapshot=_config_snapshot(session, dataset),
|
|
permission_snapshot={
|
|
"visibility": _enum_value(dataset.permission),
|
|
"member_account_ids": permission_members,
|
|
},
|
|
app_binding_snapshot=app_bindings,
|
|
tag_ids_snapshot=[str(tag_id) for tag_id in tag_ids if tag_id is not None],
|
|
)
|
|
session.add(job)
|
|
session.flush()
|
|
|
|
source_groups: dict[str, list[tuple[Document, dict[str, Any]]]] = defaultdict(list)
|
|
document_snapshots: dict[str, KnowledgeFSUpgradeDocument] = {}
|
|
upload_file_ids: set[str] = set()
|
|
documents = list(
|
|
session.scalars(
|
|
sa.select(Document)
|
|
.where(Document.tenant_id == tenant_id, Document.dataset_id == dataset_id)
|
|
.order_by(Document.position, Document.id)
|
|
)
|
|
)
|
|
for document in documents:
|
|
data_source_type = _enum_value(document.data_source_type)
|
|
data_source_info = document.data_source_info_dict
|
|
old_upload_file_id = None
|
|
source_key = None
|
|
if data_source_type == "upload_file":
|
|
raw_upload_file_id = data_source_info.get("upload_file_id")
|
|
if raw_upload_file_id:
|
|
old_upload_file_id = str(raw_upload_file_id)
|
|
upload_file_ids.add(old_upload_file_id)
|
|
elif data_source_type == "notion_import":
|
|
source_key = _notion_source_group_key(data_source_info)
|
|
source_groups[source_key].append((document, data_source_info))
|
|
elif data_source_type == "website_crawl":
|
|
source_key = _website_source_group_key(data_source_info)
|
|
source_groups[source_key].append((document, data_source_info))
|
|
else:
|
|
raise KnowledgeFSUpgradeConflictError(
|
|
f"Unsupported legacy document source type: {data_source_type}"
|
|
)
|
|
document_snapshot = KnowledgeFSUpgradeDocument(
|
|
job_id=job.id,
|
|
tenant_id=tenant_id,
|
|
old_document_id=str(document.id),
|
|
name=document.name,
|
|
data_source_type=data_source_type,
|
|
data_source_info=cast(dict[str, object], data_source_info),
|
|
metadata_snapshot=cast(dict[str, object], document.doc_metadata or {}),
|
|
desired_enabled=bool(document.enabled and not document.archived),
|
|
legacy_archived=bool(document.archived),
|
|
legacy_indexing_status=_enum_value(document.indexing_status),
|
|
legacy_display_status=document.display_status,
|
|
old_upload_file_id=old_upload_file_id,
|
|
source_key=source_key,
|
|
)
|
|
session.add(document_snapshot)
|
|
document_snapshots[str(document.id)] = document_snapshot
|
|
|
|
source_count = 0
|
|
for base_source_key, group in source_groups.items():
|
|
for chunk_number, chunk in enumerate(_chunks(group, _SOURCE_SELECTION_LIMIT), start=1):
|
|
source_key = f"{base_source_key}:{chunk_number}"
|
|
payload = _source_payload_snapshot(dataset.name, chunk)
|
|
session.add(
|
|
KnowledgeFSUpgradeSource(
|
|
job_id=job.id,
|
|
tenant_id=tenant_id,
|
|
source_key=source_key,
|
|
payload_snapshot=payload,
|
|
)
|
|
)
|
|
for document, _ in chunk:
|
|
document_snapshots[str(document.id)].source_key = source_key
|
|
source_count += 1
|
|
|
|
for upload_file_id in sorted(upload_file_ids):
|
|
session.add(
|
|
KnowledgeFSUpgradeFileLease(
|
|
job_id=job.id,
|
|
old_upload_file_id=upload_file_id,
|
|
expires_at=snapshot_at + _FILE_LEASE_TTL,
|
|
)
|
|
)
|
|
job.total_documents = len(documents)
|
|
job.total_sources = source_count
|
|
session.flush()
|
|
return job
|
|
|
|
def get(self, *, tenant_id: str, job_id: str) -> KnowledgeFSUpgradeJob:
|
|
with self._session_maker() as session:
|
|
job = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeJob).where(
|
|
KnowledgeFSUpgradeJob.id == job_id,
|
|
KnowledgeFSUpgradeJob.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
if job is None:
|
|
raise KnowledgeFSUpgradeNotFoundError("Upgrade job was not found")
|
|
session.expunge(job)
|
|
return job
|
|
|
|
def get_latest(self, *, tenant_id: str, dataset_id: str) -> KnowledgeFSUpgradeJob | None:
|
|
jobs = self.get_latest_by_dataset_ids(tenant_id=tenant_id, dataset_ids=[dataset_id])
|
|
return jobs.get(dataset_id)
|
|
|
|
def list_by_statuses(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
statuses: tuple[KnowledgeFSUpgradeJobStatus, ...],
|
|
) -> list[KnowledgeFSUpgradeJob]:
|
|
with self._session_maker() as session:
|
|
jobs = list(
|
|
session.scalars(
|
|
sa.select(KnowledgeFSUpgradeJob)
|
|
.where(
|
|
KnowledgeFSUpgradeJob.tenant_id == tenant_id,
|
|
KnowledgeFSUpgradeJob.status.in_(statuses),
|
|
)
|
|
.order_by(KnowledgeFSUpgradeJob.created_at.desc(), KnowledgeFSUpgradeJob.id.desc())
|
|
)
|
|
)
|
|
for job in jobs:
|
|
session.expunge(job)
|
|
return jobs
|
|
|
|
def get_latest_by_dataset_ids(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
dataset_ids: list[str],
|
|
session: Session | None = None,
|
|
) -> dict[str, KnowledgeFSUpgradeJob]:
|
|
if not dataset_ids:
|
|
return {}
|
|
if session is not None:
|
|
return self._latest_jobs_from_session(session, tenant_id=tenant_id, dataset_ids=dataset_ids, detach=False)
|
|
with self._session_maker() as owned_session:
|
|
return self._latest_jobs_from_session(
|
|
owned_session,
|
|
tenant_id=tenant_id,
|
|
dataset_ids=dataset_ids,
|
|
detach=True,
|
|
)
|
|
|
|
@staticmethod
|
|
def _latest_jobs_from_session(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
dataset_ids: list[str],
|
|
detach: bool,
|
|
) -> dict[str, KnowledgeFSUpgradeJob]:
|
|
jobs = session.scalars(
|
|
sa.select(KnowledgeFSUpgradeJob)
|
|
.where(
|
|
KnowledgeFSUpgradeJob.tenant_id == tenant_id,
|
|
KnowledgeFSUpgradeJob.old_dataset_id.in_(dataset_ids),
|
|
)
|
|
.order_by(
|
|
KnowledgeFSUpgradeJob.old_dataset_id,
|
|
sa.case((KnowledgeFSUpgradeJob.status.in_(_ACTIVE_JOB_STATUSES), 0), else_=1),
|
|
KnowledgeFSUpgradeJob.created_at.desc(),
|
|
KnowledgeFSUpgradeJob.id.desc(),
|
|
)
|
|
)
|
|
latest: dict[str, KnowledgeFSUpgradeJob] = {}
|
|
for job in jobs:
|
|
if job.old_dataset_id not in latest:
|
|
if detach:
|
|
session.expunge(job)
|
|
latest[job.old_dataset_id] = job
|
|
return latest
|
|
|
|
def retry(self, *, tenant_id: str, job_id: str) -> KnowledgeFSUpgradeJob:
|
|
lease_expires_at = naive_utc_now() + _FILE_LEASE_TTL
|
|
with self._session_maker(expire_on_commit=False) as session, session.begin():
|
|
job = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeJob)
|
|
.where(KnowledgeFSUpgradeJob.id == job_id, KnowledgeFSUpgradeJob.tenant_id == tenant_id)
|
|
.with_for_update()
|
|
)
|
|
if job is None:
|
|
raise KnowledgeFSUpgradeNotFoundError("Upgrade job was not found")
|
|
if job.status is KnowledgeFSUpgradeJobStatus.SUCCEEDED:
|
|
raise KnowledgeFSUpgradeConflictError("Successful upgrades cannot be retried")
|
|
if job.status in _ACTIVE_JOB_STATUSES:
|
|
return job
|
|
active_job = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeJob)
|
|
.where(
|
|
KnowledgeFSUpgradeJob.tenant_id == tenant_id,
|
|
KnowledgeFSUpgradeJob.old_dataset_id == job.old_dataset_id,
|
|
KnowledgeFSUpgradeJob.id != job.id,
|
|
KnowledgeFSUpgradeJob.status.in_(_ACTIVE_JOB_STATUSES),
|
|
)
|
|
.limit(1)
|
|
)
|
|
if active_job is not None:
|
|
raise KnowledgeFSUpgradeConflictError("Another Dataset upgrade is already in progress")
|
|
job.status = KnowledgeFSUpgradeJobStatus.QUEUED
|
|
job.last_error_code = None
|
|
job.last_error_message = None
|
|
job.completed_at = None
|
|
job.celery_task_id = None
|
|
session.execute(
|
|
sa.update(KnowledgeFSUpgradeDocument)
|
|
.where(
|
|
KnowledgeFSUpgradeDocument.job_id == job.id,
|
|
KnowledgeFSUpgradeDocument.status == KnowledgeFSUpgradeItemStatus.FAILED,
|
|
)
|
|
.values(status=KnowledgeFSUpgradeItemStatus.PENDING, last_error_code=None, last_error_message=None)
|
|
)
|
|
session.execute(
|
|
sa.update(KnowledgeFSUpgradeSource)
|
|
.where(
|
|
KnowledgeFSUpgradeSource.job_id == job.id,
|
|
KnowledgeFSUpgradeSource.status == KnowledgeFSUpgradeItemStatus.FAILED,
|
|
)
|
|
.values(status=KnowledgeFSUpgradeItemStatus.PENDING, last_error_code=None, last_error_message=None)
|
|
)
|
|
session.execute(
|
|
sa.update(KnowledgeFSUpgradeFileLease)
|
|
.where(
|
|
KnowledgeFSUpgradeFileLease.job_id == job.id,
|
|
KnowledgeFSUpgradeFileLease.released_at.is_(None),
|
|
)
|
|
.values(
|
|
status=KnowledgeFSUpgradeFileLeaseStatus.ACTIVE,
|
|
expires_at=lease_expires_at,
|
|
)
|
|
)
|
|
return job
|
|
|
|
def claim_enqueue(self, *, tenant_id: str, job_id: str, task_id: str) -> bool:
|
|
with self._session_maker.begin() as session:
|
|
job = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeJob)
|
|
.where(KnowledgeFSUpgradeJob.id == job_id, KnowledgeFSUpgradeJob.tenant_id == tenant_id)
|
|
.with_for_update()
|
|
)
|
|
if job is None:
|
|
raise KnowledgeFSUpgradeNotFoundError("Upgrade job was not found")
|
|
if job.status is not KnowledgeFSUpgradeJobStatus.QUEUED or job.celery_task_id is not None:
|
|
return False
|
|
job.celery_task_id = task_id
|
|
return True
|
|
|
|
def release_enqueue_claim(self, *, tenant_id: str, job_id: str, task_id: str) -> None:
|
|
with self._session_maker.begin() as session:
|
|
job = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeJob)
|
|
.where(KnowledgeFSUpgradeJob.id == job_id, KnowledgeFSUpgradeJob.tenant_id == tenant_id)
|
|
.with_for_update()
|
|
)
|
|
if job is not None and job.status is KnowledgeFSUpgradeJobStatus.QUEUED and job.celery_task_id == task_id:
|
|
job.celery_task_id = None
|
|
|
|
|
|
class KnowledgeFSUpgradeRunner:
|
|
"""Execute one durable stage or item per dedicated-worker delivery."""
|
|
|
|
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
|
self._session_maker = session_maker
|
|
|
|
def run_next(self, *, job_id: str, celery_task_id: str | None = None) -> bool:
|
|
job = self._load_job(job_id)
|
|
if job.status in {KnowledgeFSUpgradeJobStatus.SUCCEEDED, KnowledgeFSUpgradeJobStatus.FAILED}:
|
|
return False
|
|
self._mark_running(job_id=job_id, celery_task_id=celery_task_id)
|
|
if job.stage is KnowledgeFSUpgradeStage.VALIDATING:
|
|
self._create_space(job)
|
|
raise KnowledgeFSUpgradeNotReadyError("KnowledgeFS Space provisioning is pending")
|
|
if job.stage is KnowledgeFSUpgradeStage.WAITING_FOR_SPACE:
|
|
self._advance_when_space_is_active(job)
|
|
return True
|
|
if job.stage is KnowledgeFSUpgradeStage.CREATING_SOURCES:
|
|
return self._create_next_source(job)
|
|
if job.stage is KnowledgeFSUpgradeStage.SUBMITTING_DOCUMENTS:
|
|
return self._submit_next_document(job)
|
|
if job.stage is KnowledgeFSUpgradeStage.MIGRATING_ACCESS:
|
|
self._migrate_access(job)
|
|
return True
|
|
if job.stage is KnowledgeFSUpgradeStage.FINALIZING:
|
|
self._finalize(job)
|
|
return False
|
|
return False
|
|
|
|
def fail(self, *, job_id: str, error: Exception) -> None:
|
|
with self._session_maker.begin() as session:
|
|
job = session.get(KnowledgeFSUpgradeJob, job_id)
|
|
if job is None or job.status is KnowledgeFSUpgradeJobStatus.SUCCEEDED:
|
|
return
|
|
job.status = KnowledgeFSUpgradeJobStatus.FAILED
|
|
job.last_error_code = type(error).__name__[:128]
|
|
job.last_error_message = str(error)[:4000]
|
|
job.completed_at = naive_utc_now()
|
|
|
|
def _load_job(self, job_id: str) -> KnowledgeFSUpgradeJob:
|
|
with self._session_maker() as session:
|
|
job = session.get(KnowledgeFSUpgradeJob, job_id)
|
|
if job is None:
|
|
raise KnowledgeFSUpgradeNotFoundError("Upgrade job was not found")
|
|
session.expunge(job)
|
|
return job
|
|
|
|
def _mark_running(self, *, job_id: str, celery_task_id: str | None) -> None:
|
|
lease_expires_at = naive_utc_now() + _FILE_LEASE_TTL
|
|
with self._session_maker.begin() as session:
|
|
job = session.get(KnowledgeFSUpgradeJob, job_id)
|
|
if job is None:
|
|
raise KnowledgeFSUpgradeNotFoundError("Upgrade job was not found")
|
|
job.status = KnowledgeFSUpgradeJobStatus.RUNNING
|
|
job.attempt_count += 1
|
|
job.celery_task_id = celery_task_id
|
|
session.execute(
|
|
sa.update(KnowledgeFSUpgradeFileLease)
|
|
.where(
|
|
KnowledgeFSUpgradeFileLease.job_id == job_id,
|
|
KnowledgeFSUpgradeFileLease.status == KnowledgeFSUpgradeFileLeaseStatus.ACTIVE,
|
|
)
|
|
.values(expires_at=lease_expires_at)
|
|
)
|
|
|
|
def _create_space(self, job: KnowledgeFSUpgradeJob) -> None:
|
|
resolved = _resolve_configuration(job)
|
|
runtime = get_knowledge_fs_runtime(self._session_maker)
|
|
response = runtime.application.create_space(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
payload=KnowledgeFSSpaceCreatePayload(
|
|
name=str(job.config_snapshot["name"])[:40],
|
|
slug=f"legacy-{job.old_dataset_id[:12]}-{job.id[:8]}",
|
|
description=str(job.config_snapshot.get("description") or "")[:2000] or None,
|
|
icon=cast(str | None, job.config_snapshot.get("icon")),
|
|
visibility=KnowledgeFSControlSpaceVisibility.ONLY_ME,
|
|
embedding=KnowledgeFSModelIntent.model_validate(resolved["embedding"]),
|
|
retrieval=KnowledgeFSRetrievalProfileIntent.model_validate(resolved["retrieval"]),
|
|
idempotency_key=f"upgrade:{job.id}:space",
|
|
),
|
|
)
|
|
with self._session_maker.begin() as session:
|
|
persisted = session.get(KnowledgeFSUpgradeJob, job.id)
|
|
if persisted is None:
|
|
raise KnowledgeFSUpgradeNotFoundError("Upgrade job disappeared")
|
|
persisted.new_control_space_id = response.control_space_id
|
|
persisted.resolved_configuration = resolved
|
|
persisted.stage = KnowledgeFSUpgradeStage.WAITING_FOR_SPACE
|
|
|
|
def _advance_when_space_is_active(self, job: KnowledgeFSUpgradeJob) -> None:
|
|
if job.new_control_space_id is None:
|
|
raise KnowledgeFSUpgradeError("Upgrade Space reference is missing")
|
|
with self._session_maker.begin() as session:
|
|
control_space = session.scalar(
|
|
sa.select(KnowledgeFSControlSpace).where(
|
|
KnowledgeFSControlSpace.id == job.new_control_space_id,
|
|
KnowledgeFSControlSpace.tenant_id == job.tenant_id,
|
|
)
|
|
)
|
|
if control_space is None:
|
|
raise KnowledgeFSUpgradeError("Upgrade Space was not found")
|
|
if control_space.state is KnowledgeFSControlSpaceState.PROVISIONING:
|
|
raise KnowledgeFSUpgradeNotReadyError("KnowledgeFS Space provisioning is pending")
|
|
if control_space.state is not KnowledgeFSControlSpaceState.ACTIVE:
|
|
raise KnowledgeFSUpgradeError(f"KnowledgeFS Space provisioning failed in {control_space.state.value}")
|
|
persisted = session.get(KnowledgeFSUpgradeJob, job.id)
|
|
assert persisted is not None
|
|
persisted.stage = (
|
|
KnowledgeFSUpgradeStage.CREATING_SOURCES
|
|
if persisted.total_sources
|
|
else KnowledgeFSUpgradeStage.SUBMITTING_DOCUMENTS
|
|
)
|
|
|
|
def _create_next_source(self, job: KnowledgeFSUpgradeJob) -> bool:
|
|
with self._session_maker.begin() as session:
|
|
source = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeSource)
|
|
.where(
|
|
KnowledgeFSUpgradeSource.job_id == job.id,
|
|
KnowledgeFSUpgradeSource.status.in_(
|
|
(KnowledgeFSUpgradeItemStatus.PENDING, KnowledgeFSUpgradeItemStatus.PROCESSING)
|
|
),
|
|
)
|
|
.order_by(KnowledgeFSUpgradeSource.id)
|
|
.with_for_update(skip_locked=True)
|
|
.limit(1)
|
|
)
|
|
if source is None:
|
|
persisted = session.get(KnowledgeFSUpgradeJob, job.id)
|
|
assert persisted is not None
|
|
persisted.stage = KnowledgeFSUpgradeStage.SUBMITTING_DOCUMENTS
|
|
return True
|
|
source.status = KnowledgeFSUpgradeItemStatus.PROCESSING
|
|
source_id = source.id
|
|
source_payload = dict(source.payload_snapshot)
|
|
payload = self._resolve_initial_source_payload(job, source_payload)
|
|
from tasks.knowledge_fs_initial_source_tasks import submit_initial_source_for_upgrade
|
|
|
|
try:
|
|
result = submit_initial_source_for_upgrade(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
control_space_id=_required_space_id(job),
|
|
operation_id=f"upgrade:{job.id}:{source_id}",
|
|
payload=payload,
|
|
)
|
|
except KnowledgeFSUpgradeNotReadyError:
|
|
raise
|
|
except Exception as error:
|
|
from tasks.knowledge_fs_initial_source_tasks import KnowledgeFSInitialSourceNotReadyError
|
|
|
|
if isinstance(error, KnowledgeFSInitialSourceNotReadyError):
|
|
with self._session_maker.begin() as session:
|
|
persisted_source = session.get(KnowledgeFSUpgradeSource, source_id)
|
|
if persisted_source is not None:
|
|
persisted_source.status = KnowledgeFSUpgradeItemStatus.PENDING
|
|
raise KnowledgeFSUpgradeNotReadyError(str(error)) from error
|
|
self._fail_source(source_id=source_id, error=error)
|
|
raise
|
|
with self._session_maker.begin() as session:
|
|
persisted_source = session.get(KnowledgeFSUpgradeSource, source_id)
|
|
persisted_job = session.get(KnowledgeFSUpgradeJob, job.id)
|
|
assert persisted_source is not None
|
|
assert persisted_job is not None
|
|
persisted_source.status = KnowledgeFSUpgradeItemStatus.SUCCEEDED
|
|
persisted_source.new_connection_id = result.connection_id
|
|
persisted_source.new_source_id = result.source_id
|
|
persisted_source.initial_sync_task_id = result.workflow_id
|
|
if result.workflow_error:
|
|
persisted_source.last_error_code = result.workflow_error
|
|
persisted_source.last_error_message = "The Source exists; retry its first import in KnowledgeFS"
|
|
source_documents = list(
|
|
session.scalars(
|
|
sa.select(KnowledgeFSUpgradeDocument).where(
|
|
KnowledgeFSUpgradeDocument.job_id == job.id,
|
|
KnowledgeFSUpgradeDocument.source_key == persisted_source.source_key,
|
|
)
|
|
)
|
|
)
|
|
for document in source_documents:
|
|
if document.status is not KnowledgeFSUpgradeItemStatus.SUCCEEDED:
|
|
document.status = KnowledgeFSUpgradeItemStatus.SUCCEEDED
|
|
persisted_job.completed_documents += 1
|
|
persisted_job.completed_sources += 1
|
|
return True
|
|
|
|
def _resolve_initial_source_payload(
|
|
self, job: KnowledgeFSUpgradeJob, payload_snapshot: dict[str, object]
|
|
) -> KnowledgeFSInitialSourcePayload:
|
|
payload = dict(payload_snapshot)
|
|
legacy_credential_id = payload.pop("legacy_credential_id", None)
|
|
workspace_id = payload.pop("legacy_workspace_id", None)
|
|
if payload.get("kind") == "online_document":
|
|
payload["credential_id"] = self._resolve_notion_credential(
|
|
tenant_id=job.tenant_id,
|
|
legacy_credential_id=str(legacy_credential_id or ""),
|
|
workspace_id=str(workspace_id or ""),
|
|
)
|
|
return _INITIAL_SOURCE_ADAPTER.validate_python(payload)
|
|
|
|
def _resolve_notion_credential(self, *, tenant_id: str, legacy_credential_id: str, workspace_id: str) -> str:
|
|
with self._session_maker() as session:
|
|
direct = session.scalar(
|
|
sa.select(DatasourceProvider).where(
|
|
DatasourceProvider.id == legacy_credential_id,
|
|
DatasourceProvider.tenant_id == tenant_id,
|
|
DatasourceProvider.plugin_id == "langgenius/notion_datasource",
|
|
DatasourceProvider.provider == "notion_datasource",
|
|
)
|
|
)
|
|
if direct is not None:
|
|
return direct.id
|
|
candidates = list(
|
|
session.scalars(
|
|
sa.select(DatasourceProvider)
|
|
.where(
|
|
DatasourceProvider.tenant_id == tenant_id,
|
|
DatasourceProvider.plugin_id == "langgenius/notion_datasource",
|
|
DatasourceProvider.provider == "notion_datasource",
|
|
)
|
|
.order_by(DatasourceProvider.is_default.desc(), DatasourceProvider.created_at)
|
|
)
|
|
)
|
|
for candidate in candidates:
|
|
if str(candidate.encrypted_credentials.get("workspace_id") or "") == workspace_id:
|
|
return candidate.id
|
|
if len(candidates) == 1:
|
|
return candidates[0].id
|
|
raise KnowledgeFSUpgradeError("A matching Notion datasource credential is unavailable")
|
|
|
|
def _fail_source(self, *, source_id: str, error: Exception) -> None:
|
|
with self._session_maker.begin() as session:
|
|
source = session.get(KnowledgeFSUpgradeSource, source_id)
|
|
if source is not None:
|
|
source.status = KnowledgeFSUpgradeItemStatus.FAILED
|
|
source.last_error_code = type(error).__name__[:128]
|
|
source.last_error_message = str(error)[:4000]
|
|
|
|
def _submit_next_document(self, job: KnowledgeFSUpgradeJob) -> bool:
|
|
with self._session_maker.begin() as session:
|
|
document = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeDocument)
|
|
.where(
|
|
KnowledgeFSUpgradeDocument.job_id == job.id,
|
|
KnowledgeFSUpgradeDocument.data_source_type == "upload_file",
|
|
KnowledgeFSUpgradeDocument.status.in_(
|
|
(KnowledgeFSUpgradeItemStatus.PENDING, KnowledgeFSUpgradeItemStatus.PROCESSING)
|
|
),
|
|
)
|
|
.order_by(KnowledgeFSUpgradeDocument.id)
|
|
.with_for_update(skip_locked=True)
|
|
.limit(1)
|
|
)
|
|
if document is None:
|
|
persisted = session.get(KnowledgeFSUpgradeJob, job.id)
|
|
assert persisted is not None
|
|
persisted.stage = KnowledgeFSUpgradeStage.MIGRATING_ACCESS
|
|
return True
|
|
document.status = KnowledgeFSUpgradeItemStatus.PROCESSING
|
|
document_id = document.id
|
|
upload_file_id = document.old_upload_file_id
|
|
staged_upload_id = document.staged_upload_id
|
|
if not upload_file_id:
|
|
error = KnowledgeFSUpgradeError("Legacy upload document has no source file reference")
|
|
self._fail_document(document_id=document_id, error=error)
|
|
raise error
|
|
try:
|
|
staged = KnowledgeFSStagedUploadService(
|
|
self._session_maker,
|
|
facade=get_knowledge_fs_runtime(self._session_maker).facade,
|
|
)
|
|
if staged_upload_id is None:
|
|
with self._session_maker() as session:
|
|
upload_file = session.scalar(
|
|
sa.select(UploadFile).where(
|
|
UploadFile.id == upload_file_id,
|
|
UploadFile.tenant_id == job.tenant_id,
|
|
)
|
|
)
|
|
account = session.get(Account, job.owner_account_id)
|
|
if upload_file is None or account is None:
|
|
raise KnowledgeFSUpgradeError("Legacy source file is unavailable")
|
|
storage_key = upload_file.key
|
|
file_name = upload_file.name
|
|
content_type = upload_file.mime_type or "application/octet-stream"
|
|
session.expunge(account)
|
|
body = storage.load(storage_key)
|
|
if not isinstance(body, bytes):
|
|
raise KnowledgeFSUpgradeError("Legacy source file returned an invalid body")
|
|
staged_response = staged.stage(
|
|
tenant_id=job.tenant_id,
|
|
account=account,
|
|
file_name=file_name,
|
|
content_type=content_type,
|
|
body=body,
|
|
file_size_limit_mb=FeatureService.get_knowledge_file_size_limit(job.tenant_id),
|
|
)
|
|
staged_upload_id = staged_response.id
|
|
with self._session_maker.begin() as session:
|
|
persisted_document = session.get(KnowledgeFSUpgradeDocument, document_id)
|
|
assert persisted_document is not None
|
|
persisted_document.staged_upload_id = staged_upload_id
|
|
claimed = staged.claim(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
control_space_id=_required_space_id(job),
|
|
payload=KnowledgeFSDocumentStagedUploadPayload(upload_id=staged_upload_id),
|
|
)
|
|
except Exception as error:
|
|
self._fail_document(document_id=document_id, error=error)
|
|
raise
|
|
with self._session_maker.begin() as session:
|
|
persisted_document = session.get(KnowledgeFSUpgradeDocument, document_id)
|
|
persisted_job = session.get(KnowledgeFSUpgradeJob, job.id)
|
|
assert persisted_document is not None
|
|
assert persisted_job is not None
|
|
persisted_document.status = KnowledgeFSUpgradeItemStatus.SUCCEEDED
|
|
persisted_document.new_document_asset_id = claimed.document_asset_id
|
|
persisted_document.compilation_job_id = claimed.compilation_job_id
|
|
persisted_job.completed_documents += 1
|
|
remaining_references = session.scalar(
|
|
sa.select(sa.func.count(KnowledgeFSUpgradeDocument.id)).where(
|
|
KnowledgeFSUpgradeDocument.job_id == job.id,
|
|
KnowledgeFSUpgradeDocument.old_upload_file_id == upload_file_id,
|
|
KnowledgeFSUpgradeDocument.status != KnowledgeFSUpgradeItemStatus.SUCCEEDED,
|
|
)
|
|
)
|
|
if remaining_references == 0:
|
|
release_upgrade_file_lease(
|
|
session,
|
|
job_id=job.id,
|
|
upload_file_id=upload_file_id,
|
|
)
|
|
return True
|
|
|
|
def _fail_document(self, *, document_id: str, error: Exception) -> None:
|
|
with self._session_maker.begin() as session:
|
|
document = session.get(KnowledgeFSUpgradeDocument, document_id)
|
|
if document is not None:
|
|
document.status = KnowledgeFSUpgradeItemStatus.FAILED
|
|
document.last_error_code = type(error).__name__[:128]
|
|
document.last_error_message = str(error)[:4000]
|
|
|
|
def _migrate_access(self, job: KnowledgeFSUpgradeJob) -> None:
|
|
control_space_id = _required_space_id(job)
|
|
runtime = get_knowledge_fs_runtime(self._session_maker)
|
|
_migrate_metadata_fields(job, runtime.facade)
|
|
members = [
|
|
KnowledgeFSMemberBindingPayload(
|
|
account_id=str(account_id),
|
|
role=KnowledgeFSControlSpacePermissionRole.VIEWER,
|
|
)
|
|
for account_id in cast(list[str], job.permission_snapshot.get("member_account_ids", []))
|
|
if str(account_id) != job.owner_account_id
|
|
]
|
|
runtime.control_plane.replace_members(
|
|
tenant_id=job.tenant_id,
|
|
actor_account_id=job.owner_account_id,
|
|
control_space_id=control_space_id,
|
|
members=members,
|
|
)
|
|
runtime.control_plane.update_visibility(
|
|
tenant_id=job.tenant_id,
|
|
actor_account_id=job.owner_account_id,
|
|
control_space_id=control_space_id,
|
|
visibility=KnowledgeFSControlSpaceVisibility(str(job.permission_snapshot["visibility"])),
|
|
)
|
|
has_agent = any(binding["caller_kind"] == "agent" for binding in job.app_binding_snapshot)
|
|
has_workflow = any(binding["caller_kind"] == "workflow" for binding in job.app_binding_snapshot)
|
|
runtime.control_plane.update_external_access(
|
|
tenant_id=job.tenant_id,
|
|
actor_account_id=job.owner_account_id,
|
|
control_space_id=control_space_id,
|
|
payload=KnowledgeFSExternalAccessPayload(
|
|
service_api_enabled=bool(job.config_snapshot.get("enable_api")),
|
|
agent_enabled=has_agent,
|
|
workflow_enabled=has_workflow,
|
|
mcp_enabled=False,
|
|
),
|
|
)
|
|
for binding in job.app_binding_snapshot:
|
|
runtime.app_bindings.upsert(
|
|
tenant_id=job.tenant_id,
|
|
actor_account_id=job.owner_account_id,
|
|
control_space_id=control_space_id,
|
|
payload=KnowledgeFSAppBindingPayload(
|
|
app_id=str(binding["app_id"]),
|
|
caller_kind=KnowledgeFSAppSpaceJoinType(str(binding["caller_kind"])),
|
|
),
|
|
)
|
|
runtime.space_tags.replace_tags(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
control_space_id=control_space_id,
|
|
tag_ids=list(job.tag_ids_snapshot),
|
|
)
|
|
with self._session_maker.begin() as session:
|
|
persisted = session.get(KnowledgeFSUpgradeJob, job.id)
|
|
assert persisted is not None
|
|
persisted.stage = KnowledgeFSUpgradeStage.FINALIZING
|
|
|
|
def _finalize(self, job: KnowledgeFSUpgradeJob) -> None:
|
|
with self._session_maker.begin() as session:
|
|
persisted = session.scalar(
|
|
sa.select(KnowledgeFSUpgradeJob).where(KnowledgeFSUpgradeJob.id == job.id).with_for_update()
|
|
)
|
|
if persisted is None:
|
|
raise KnowledgeFSUpgradeNotFoundError("Upgrade job disappeared")
|
|
if persisted.completed_documents != persisted.total_documents:
|
|
raise KnowledgeFSUpgradeError("Not all Dataset documents were handed off")
|
|
if persisted.completed_sources != persisted.total_sources:
|
|
raise KnowledgeFSUpgradeError("Not all Dataset Sources were created")
|
|
persisted.status = KnowledgeFSUpgradeJobStatus.SUCCEEDED
|
|
persisted.stage = KnowledgeFSUpgradeStage.COMPLETED
|
|
persisted.completed_at = naive_utc_now()
|
|
persisted.last_error_code = None
|
|
persisted.last_error_message = None
|
|
|
|
|
|
class KnowledgeFSUpgradeDocumentReconciler:
|
|
"""Apply snapshot metadata and availability after new logical documents appear.
|
|
|
|
This is deliberately separate from parent-job success: KnowledgeFS owns parsing and
|
|
indexing task outcomes, while this best-effort loop preserves the legacy document's
|
|
click-time availability once the new logical document can be addressed.
|
|
"""
|
|
|
|
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
|
self._session_maker = session_maker
|
|
|
|
def reconcile(self, *, job_id: str) -> int:
|
|
with self._session_maker() as session:
|
|
job = session.get(KnowledgeFSUpgradeJob, job_id)
|
|
if job is None or job.status is not KnowledgeFSUpgradeJobStatus.SUCCEEDED:
|
|
return 0
|
|
documents = list(
|
|
session.scalars(
|
|
sa.select(KnowledgeFSUpgradeDocument)
|
|
.where(
|
|
KnowledgeFSUpgradeDocument.job_id == job_id,
|
|
KnowledgeFSUpgradeDocument.status == KnowledgeFSUpgradeItemStatus.SUCCEEDED,
|
|
KnowledgeFSUpgradeDocument.state_reconciled_at.is_(None),
|
|
)
|
|
.order_by(KnowledgeFSUpgradeDocument.id)
|
|
)
|
|
)
|
|
sources = {
|
|
source.source_key: source.new_source_id
|
|
for source in session.scalars(
|
|
sa.select(KnowledgeFSUpgradeSource).where(KnowledgeFSUpgradeSource.job_id == job_id)
|
|
)
|
|
}
|
|
session.expunge(job)
|
|
for document in documents:
|
|
session.expunge(document)
|
|
|
|
if not documents:
|
|
return 0
|
|
runtime = get_knowledge_fs_runtime(self._session_maker)
|
|
logical_documents = _list_all_logical_documents(job, runtime.facade)
|
|
by_asset_id = {
|
|
logical_document.active.document_asset_id: logical_document
|
|
for logical_document in logical_documents
|
|
if logical_document.active is not None
|
|
}
|
|
by_source_item = {
|
|
(logical_document.source_id, logical_document.provider_item_id): logical_document
|
|
for logical_document in logical_documents
|
|
if logical_document.source_id and logical_document.provider_item_id
|
|
}
|
|
remaining = 0
|
|
for document in documents:
|
|
logical_document = None
|
|
if document.new_document_asset_id:
|
|
logical_document = by_asset_id.get(document.new_document_asset_id)
|
|
elif document.source_key:
|
|
logical_document = by_source_item.get(
|
|
(sources.get(document.source_key), _expected_provider_item_id(document))
|
|
)
|
|
if logical_document is None:
|
|
self._record_reconcile_wait(document.id, "The new logical document is not visible yet")
|
|
remaining += 1
|
|
continue
|
|
try:
|
|
current = logical_document
|
|
if document.metadata_snapshot:
|
|
current = runtime.facade.update_document_metadata(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
control_space_id=_required_space_id(job),
|
|
document_id=current.id,
|
|
payload=KnowledgeFSDocumentMetadataPayload(
|
|
expectedRowVersion=current.row_version,
|
|
patch=dict(document.metadata_snapshot),
|
|
),
|
|
)
|
|
if current.enabled != document.desired_enabled:
|
|
current = runtime.facade.update_logical_document_availability(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
control_space_id=_required_space_id(job),
|
|
document_id=current.id,
|
|
payload=KnowledgeFSDocumentAvailabilityPayload(
|
|
enabled=document.desired_enabled,
|
|
expectedRowVersion=current.row_version,
|
|
),
|
|
)
|
|
except Exception as error:
|
|
self._record_reconcile_wait(document.id, str(error))
|
|
remaining += 1
|
|
continue
|
|
with self._session_maker.begin() as session:
|
|
persisted = session.get(KnowledgeFSUpgradeDocument, document.id)
|
|
if persisted is not None and persisted.state_reconciled_at is None:
|
|
persisted.new_logical_document_id = current.id
|
|
persisted.state_reconciled_at = naive_utc_now()
|
|
persisted.state_reconcile_error = None
|
|
persisted.state_reconcile_attempt_count += 1
|
|
return remaining
|
|
|
|
def _record_reconcile_wait(self, document_id: str, message: str) -> None:
|
|
with self._session_maker.begin() as session:
|
|
document = session.get(KnowledgeFSUpgradeDocument, document_id)
|
|
if document is not None and document.state_reconciled_at is None:
|
|
document.state_reconcile_attempt_count += 1
|
|
document.state_reconcile_error = message[:4000]
|
|
|
|
|
|
def upgrade_job_response(job: KnowledgeFSUpgradeJob) -> KnowledgeFSUpgradeJobResponse:
|
|
return KnowledgeFSUpgradeJobResponse(
|
|
id=job.id,
|
|
old_dataset_id=job.old_dataset_id,
|
|
new_control_space_id=job.new_control_space_id,
|
|
status=job.status,
|
|
stage=job.stage,
|
|
snapshot_at=job.snapshot_at,
|
|
total_documents=job.total_documents,
|
|
completed_documents=job.completed_documents,
|
|
total_sources=job.total_sources,
|
|
completed_sources=job.completed_sources,
|
|
last_error_code=job.last_error_code,
|
|
last_error_message=job.last_error_message,
|
|
completed_at=job.completed_at,
|
|
)
|
|
|
|
|
|
def upgrade_discovery_response(
|
|
job: KnowledgeFSUpgradeJob | None,
|
|
*,
|
|
feature_enabled: bool,
|
|
dataset_provider: str,
|
|
) -> KnowledgeFSUpgradeDiscoveryResponse:
|
|
if not feature_enabled:
|
|
return KnowledgeFSUpgradeDiscoveryResponse(
|
|
job=upgrade_job_response(job) if job else None,
|
|
can_upgrade=False,
|
|
can_retry=False,
|
|
block_reason="feature_disabled",
|
|
)
|
|
if dataset_provider == "external":
|
|
return KnowledgeFSUpgradeDiscoveryResponse(
|
|
job=upgrade_job_response(job) if job else None,
|
|
can_upgrade=False,
|
|
can_retry=False,
|
|
block_reason="unsupported_dataset_provider",
|
|
)
|
|
if job is None:
|
|
return KnowledgeFSUpgradeDiscoveryResponse(can_upgrade=True, can_retry=False)
|
|
if job.status in _ACTIVE_JOB_STATUSES:
|
|
return KnowledgeFSUpgradeDiscoveryResponse(
|
|
job=upgrade_job_response(job),
|
|
can_upgrade=False,
|
|
can_retry=False,
|
|
block_reason="upgrade_in_progress",
|
|
)
|
|
if job.status is KnowledgeFSUpgradeJobStatus.FAILED:
|
|
return KnowledgeFSUpgradeDiscoveryResponse(
|
|
job=upgrade_job_response(job),
|
|
can_upgrade=True,
|
|
can_retry=True,
|
|
block_reason="retry_required",
|
|
)
|
|
return KnowledgeFSUpgradeDiscoveryResponse(
|
|
job=upgrade_job_response(job),
|
|
can_upgrade=True,
|
|
can_retry=False,
|
|
)
|
|
|
|
|
|
def _migrate_metadata_fields(job: KnowledgeFSUpgradeJob, facade: Any) -> None:
|
|
expected_fields = cast(list[dict[str, str]], job.config_snapshot.get("metadata_fields") or [])
|
|
if not expected_fields:
|
|
return
|
|
existing_by_name: dict[str, str] = {}
|
|
cursor = None
|
|
while True:
|
|
page = facade.list_metadata_fields(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
control_space_id=_required_space_id(job),
|
|
cursor=cursor,
|
|
limit=100,
|
|
)
|
|
existing_by_name.update({field.name: field.type for field in page.data})
|
|
cursor = page.next_cursor
|
|
if cursor is None:
|
|
break
|
|
for field in expected_fields:
|
|
name = str(field["name"])
|
|
field_type = str(field["type"])
|
|
existing_type = existing_by_name.get(name)
|
|
if existing_type is not None:
|
|
if existing_type != field_type:
|
|
raise KnowledgeFSUpgradeError(f"Metadata field {name!r} has an incompatible type")
|
|
continue
|
|
facade.create_metadata_field(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
control_space_id=_required_space_id(job),
|
|
payload=KnowledgeFSMetadataFieldCreatePayload.model_validate({"name": name, "type": field_type}),
|
|
)
|
|
existing_by_name[name] = field_type
|
|
|
|
|
|
def _list_all_logical_documents(job: KnowledgeFSUpgradeJob, facade: Any) -> list[Any]:
|
|
documents: list[Any] = []
|
|
cursor = None
|
|
while True:
|
|
page = facade.list_logical_documents(
|
|
tenant_id=job.tenant_id,
|
|
account_id=job.owner_account_id,
|
|
control_space_id=_required_space_id(job),
|
|
cursor=cursor,
|
|
)
|
|
documents.extend(page.data)
|
|
cursor = page.next_cursor
|
|
if cursor is None:
|
|
return documents
|
|
|
|
|
|
def _expected_provider_item_id(document: KnowledgeFSUpgradeDocument) -> str:
|
|
info = document.data_source_info
|
|
if document.data_source_type == "notion_import":
|
|
workspace_id = str(info.get("notion_workspace_id") or info.get("workspace_id") or "")
|
|
page_id = str(info.get("notion_page_id") or "")
|
|
return json.dumps([workspace_id, page_id], separators=(",", ":"))
|
|
if document.data_source_type == "website_crawl":
|
|
return sha256(str(info.get("url") or "").encode()).hexdigest()
|
|
raise KnowledgeFSUpgradeError(f"Document {document.old_document_id} has no provider item identity")
|
|
|
|
|
|
def _resolve_configuration(job: KnowledgeFSUpgradeJob) -> dict[str, object]:
|
|
provider_manager = create_plugin_provider_manager(tenant_id=job.tenant_id)
|
|
embedding = _resolve_model(
|
|
provider_manager,
|
|
tenant_id=job.tenant_id,
|
|
model_type=ModelType.TEXT_EMBEDDING,
|
|
preferred_provider=cast(str | None, job.config_snapshot.get("embedding_model_provider")),
|
|
preferred_model=cast(str | None, job.config_snapshot.get("embedding_model")),
|
|
)
|
|
retrieval = cast(dict[str, Any], job.config_snapshot.get("retrieval_model") or {})
|
|
reranking_model = cast(dict[str, Any], retrieval.get("reranking_model") or {})
|
|
rerank = _resolve_model(
|
|
provider_manager,
|
|
tenant_id=job.tenant_id,
|
|
model_type=ModelType.RERANK,
|
|
preferred_provider=cast(str | None, reranking_model.get("reranking_provider_name")),
|
|
preferred_model=cast(str | None, reranking_model.get("reranking_model_name")),
|
|
)
|
|
reasoning = _resolve_model(
|
|
provider_manager,
|
|
tenant_id=job.tenant_id,
|
|
model_type=ModelType.LLM,
|
|
preferred_provider=None,
|
|
preferred_model=None,
|
|
)
|
|
threshold_enabled = bool(retrieval.get("score_threshold_enabled"))
|
|
threshold_value = retrieval.get("score_threshold")
|
|
profile = KnowledgeFSRetrievalProfileIntent(
|
|
default_mode="fast",
|
|
reasoning_model=reasoning,
|
|
rerank=KnowledgeFSRerankIntent(enabled=True, model=rerank),
|
|
score_threshold=KnowledgeFSScoreThresholdIntent(
|
|
enabled=threshold_enabled,
|
|
stage="mode-final",
|
|
value=float(threshold_value) if threshold_enabled and threshold_value is not None else None,
|
|
),
|
|
top_k=max(1, min(100, int(retrieval.get("top_k") or 4))),
|
|
)
|
|
return {
|
|
"embedding": embedding.model_dump(mode="json", by_alias=True),
|
|
"retrieval": profile.model_dump(mode="json", by_alias=True),
|
|
}
|
|
|
|
|
|
def _resolve_model(
|
|
provider_manager,
|
|
*,
|
|
tenant_id: str,
|
|
model_type: ModelType,
|
|
preferred_provider: str | None,
|
|
preferred_model: str | None,
|
|
) -> KnowledgeFSModelIntent:
|
|
active_models = provider_manager.get_configurations(tenant_id).get_models(model_type=model_type, only_active=True)
|
|
if preferred_provider and preferred_model:
|
|
preferred_provider_id = str(ModelProviderID(preferred_provider))
|
|
for active_model in active_models:
|
|
active_provider_id = str(ModelProviderID(active_model.provider.provider))
|
|
if active_model.model == preferred_model and active_provider_id == preferred_provider_id:
|
|
return _model_intent(active_model.provider.provider, active_model.model)
|
|
default = provider_manager.get_default_model(tenant_id=tenant_id, model_type=model_type)
|
|
if default is None:
|
|
raise KnowledgeFSUpgradeError(f"Workspace default {model_type.value} model is unavailable")
|
|
default_provider_id = str(ModelProviderID(default.provider.provider))
|
|
for active_model in active_models:
|
|
if (
|
|
active_model.model == default.model
|
|
and str(ModelProviderID(active_model.provider.provider)) == default_provider_id
|
|
):
|
|
return _model_intent(active_model.provider.provider, active_model.model)
|
|
raise KnowledgeFSUpgradeError(f"Workspace default {model_type.value} model is not active")
|
|
|
|
|
|
def _model_intent(provider: str, model: str) -> KnowledgeFSModelIntent:
|
|
provider_id = ModelProviderID(provider)
|
|
return KnowledgeFSModelIntent(plugin_id=provider_id.plugin_id, provider=provider_id.provider_name, model=model)
|
|
|
|
|
|
def _config_snapshot(session: Session, dataset: Dataset) -> dict[str, object]:
|
|
icon = None
|
|
if isinstance(dataset.icon_info, dict):
|
|
candidate = dataset.icon_info.get("icon")
|
|
if isinstance(candidate, str) and re.fullmatch(r"(?:builtin:)?[+a-z0-9_-]{1,64}", candidate):
|
|
icon = candidate
|
|
return {
|
|
"name": dataset.name,
|
|
"description": dataset.description,
|
|
"icon": icon,
|
|
"indexing_technique": _enum_value(dataset.indexing_technique) if dataset.indexing_technique else None,
|
|
"embedding_model": dataset.embedding_model,
|
|
"embedding_model_provider": dataset.embedding_model_provider,
|
|
"retrieval_model": dataset.retrieval_model or {},
|
|
"summary_index_setting": dataset.summary_index_setting or {},
|
|
"built_in_field_enabled": dataset.built_in_field_enabled,
|
|
"enable_api": dataset.enable_api,
|
|
"metadata_fields": [
|
|
{"name": metadata.name, "type": _enum_value(metadata.type)}
|
|
for metadata in session.scalars(
|
|
sa.select(DatasetMetadata)
|
|
.where(
|
|
DatasetMetadata.tenant_id == dataset.tenant_id,
|
|
DatasetMetadata.dataset_id == dataset.id,
|
|
)
|
|
.order_by(DatasetMetadata.created_at, DatasetMetadata.id)
|
|
)
|
|
],
|
|
}
|
|
|
|
|
|
def _app_binding_snapshot(session: Session, *, tenant_id: str, dataset_id: str) -> list[dict[str, object]]:
|
|
result: list[dict[str, object]] = []
|
|
rows = session.execute(
|
|
sa.select(AppDatasetJoin.app_id, App.mode)
|
|
.join(App, App.id == AppDatasetJoin.app_id)
|
|
.where(AppDatasetJoin.dataset_id == dataset_id, App.tenant_id == tenant_id)
|
|
.order_by(AppDatasetJoin.app_id)
|
|
)
|
|
for app_id, mode in rows:
|
|
if mode in {AppMode.AGENT, AppMode.AGENT_CHAT}:
|
|
caller_kind = KnowledgeFSAppSpaceJoinType.AGENT
|
|
elif mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
|
caller_kind = KnowledgeFSAppSpaceJoinType.WORKFLOW
|
|
else:
|
|
continue
|
|
result.append({"app_id": str(app_id), "caller_kind": caller_kind.value})
|
|
return result
|
|
|
|
|
|
def _notion_source_group_key(info: dict[str, Any]) -> str:
|
|
workspace_id = str(info.get("notion_workspace_id") or info.get("workspace_id") or "")
|
|
credential_id = str(info.get("credential_id") or "")
|
|
if not workspace_id:
|
|
raise KnowledgeFSUpgradeConflictError("Notion document has no workspace identity")
|
|
return f"notion:{workspace_id}:{credential_id}"
|
|
|
|
|
|
def _website_source_group_key(info: dict[str, Any]) -> str:
|
|
provider = str(info.get("provider") or "").strip().lower()
|
|
job_id = str(info.get("job_id") or "")
|
|
if not provider or not info.get("url"):
|
|
raise KnowledgeFSUpgradeConflictError("Website document has incomplete source identity")
|
|
return f"website:{provider}:{job_id}"
|
|
|
|
|
|
def _source_payload_snapshot(dataset_name: str, group: list[tuple[Document, dict[str, Any]]]) -> dict[str, object]:
|
|
first_document, first_info = group[0]
|
|
if _enum_value(first_document.data_source_type) == "notion_import":
|
|
workspace_id = str(first_info.get("notion_workspace_id") or first_info.get("workspace_id") or "")
|
|
return {
|
|
"kind": "online_document",
|
|
"name": f"{dataset_name} Notion"[:200],
|
|
"plugin_id": "langgenius/notion_datasource",
|
|
"provider": "notion_datasource",
|
|
"datasource": "notion_datasource",
|
|
"parameters": {},
|
|
"sync_policy": "manual",
|
|
"legacy_credential_id": str(first_info.get("credential_id") or ""),
|
|
"legacy_workspace_id": workspace_id,
|
|
"selection": [
|
|
{
|
|
"name": document.name,
|
|
"page_id": str(info.get("notion_page_id") or ""),
|
|
"provider_item_id": json.dumps(
|
|
[workspace_id, str(info.get("notion_page_id") or "")], separators=(",", ":")
|
|
),
|
|
"type": str(info.get("type") or "page"),
|
|
"workspace_id": workspace_id,
|
|
}
|
|
for document, info in group
|
|
],
|
|
}
|
|
provider = str(first_info.get("provider") or "")
|
|
return {
|
|
"kind": "website_crawl",
|
|
"name": f"{dataset_name} Web"[:200],
|
|
"provider": provider,
|
|
"datasource": "crawl",
|
|
"parameters": {"only_main_content": bool(first_info.get("only_main_content", True))},
|
|
"root_url": str(first_info.get("url")),
|
|
"crawl_options": {"include_subpages": False, "limit": min(200, len(group))},
|
|
"selection": [{"source_url": str(info.get("url")), "title": document.name} for document, info in group],
|
|
"sync_policy": "manual",
|
|
}
|
|
|
|
|
|
def _chunks[T](items: list[T], size: int) -> list[list[T]]:
|
|
return [items[index : index + size] for index in range(0, len(items), size)]
|
|
|
|
|
|
def _enum_value(value: object) -> str:
|
|
return str(getattr(value, "value", value))
|
|
|
|
|
|
def _required_space_id(job: KnowledgeFSUpgradeJob) -> str:
|
|
if job.new_control_space_id is None:
|
|
raise KnowledgeFSUpgradeError("Upgrade Space reference is missing")
|
|
return job.new_control_space_id
|
|
|
|
|
|
__all__ = [
|
|
"KnowledgeFSUpgradeConflictError",
|
|
"KnowledgeFSUpgradeDocumentReconciler",
|
|
"KnowledgeFSUpgradeError",
|
|
"KnowledgeFSUpgradeNotFoundError",
|
|
"KnowledgeFSUpgradeNotReadyError",
|
|
"KnowledgeFSUpgradeRunner",
|
|
"KnowledgeFSUpgradeSnapshotService",
|
|
"upgrade_discovery_response",
|
|
"upgrade_job_response",
|
|
]
|