Merge branch 'claude/guards-tasks-c80f5b' into deploy/konwledge

This commit is contained in:
Stephen Zhou
2026-10-15 20:02:49 +08:00
16 changed files with 1295 additions and 795 deletions
@@ -1,9 +1,9 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import click
import pytest
@@ -11,7 +11,15 @@ from click.testing import CliRunner
from commands import knowledge_fs as commands
from commands.knowledge_fs import _cutover_service, _read_jsonl, knowledge_fs_control_space
from models.knowledge_fs import (
KnowledgeFSControlSpace,
KnowledgeFSControlSpaceState,
KnowledgeFSLifecycleOperation,
KnowledgeFSLifecycleOutbox,
)
from services.knowledge_fs.cleanup import CleanupCompletionReport, CleanupStartReport
from services.knowledge_fs.control_space_commands import KnowledgeFSDeletionIntentResult
from services.knowledge_fs.control_space_management import KnowledgeFSControlSpaceDryRunReport
from services.knowledge_fs.cutover import (
CutoverBackfillReport,
QuarantineResolutionReport,
@@ -20,6 +28,126 @@ from services.knowledge_fs.cutover import (
)
@dataclass(frozen=True)
class _TenantPayload:
tenant_id: str
class _Report:
"""Operator report shape: the commands only serialize ``_asdict()``."""
def __init__(self, **values: object) -> None:
self._values = values
def _asdict(self) -> dict[str, object]:
return self._values
def _control_space(
control_space_id: str, state: KnowledgeFSControlSpaceState = KnowledgeFSControlSpaceState.ACTIVE
) -> KnowledgeFSControlSpace:
control_space = KnowledgeFSControlSpace(
tenant_id="tenant-1",
owner_account_id="account-1",
provisioning_key="provision-1",
state=state,
)
control_space.id = control_space_id
return control_space
def _deletion_outbox(control_space: KnowledgeFSControlSpace, operation_id: str) -> KnowledgeFSLifecycleOutbox:
return KnowledgeFSLifecycleOutbox(
tenant_id=control_space.tenant_id,
control_space_id=control_space.id,
operation_id=operation_id,
idempotency_key="delete:one",
operation=KnowledgeFSLifecycleOperation.DELETE,
command_payload={},
expected_control_space_version=0,
)
@dataclass
class _WorkspaceDeletionLifecycleService:
purged_control_spaces: int
preflight_tenants: list[str] = field(default_factory=list)
finalized_tenants: list[str] = field(default_factory=list)
def assert_workspace_deletion_allowed(self, *, tenant_id: str) -> None:
self.preflight_tenants.append(tenant_id)
def finalize_workspace_deletion(self, *, tenant_id: str) -> int:
self.finalized_tenants.append(tenant_id)
return self.purged_control_spaces
@dataclass
class _ObservationCutoverService:
status_report: dict[str, object]
observation_calls: list[tuple[str, dict[str, object]]] = field(default_factory=list)
status_tenants: list[str] = field(default_factory=list)
def status(self, *, tenant_id: str) -> dict[str, object]:
self.status_tenants.append(tenant_id)
return self.status_report
def begin_observation(self, **kwargs: object) -> None:
self.observation_calls.append(("begin", kwargs))
def complete_observation(self, **kwargs: object) -> None:
self.observation_calls.append(("complete", kwargs))
@dataclass
class _ManagementService:
registered: KnowledgeFSControlSpace
repaired: KnowledgeFSControlSpace
registrations: list[object] = field(default_factory=list)
repair_calls: list[dict[str, object]] = field(default_factory=list)
dry_run_tenants: list[str | None] = field(default_factory=list)
def dry_run(self, *, tenant_id: str | None) -> _Report:
self.dry_run_tenants.append(tenant_id)
return _Report(total=1, by_state={"active": 1})
def register(self, registration: object) -> tuple[KnowledgeFSControlSpace, bool]:
self.registrations.append(registration)
return self.registered, True
def repair_registration(self, **kwargs: object) -> KnowledgeFSControlSpace:
self.repair_calls.append(kwargs)
return self.repaired
@dataclass
class _CleanupService:
request_calls: list[tuple[object, bool]] = field(default_factory=list)
approve_calls: list[tuple[object, bool]] = field(default_factory=list)
status_calls: list[dict[str, str]] = field(default_factory=list)
def request_cleanup(self, payload: object, *, apply: bool) -> _Report:
self.request_calls.append((payload, apply))
return _Report(applied=False)
def approve_cleanup(self, payload: object, *, apply: bool) -> _Report:
self.approve_calls.append((payload, apply))
return _Report(applied=True)
def status(self, *, tenant_id: str, request_id: str) -> dict[str, object]:
self.status_calls.append({"tenant_id": tenant_id, "request_id": request_id})
return {"state": "approved"}
@dataclass
class _OrphanReconciler:
calls: list[dict[str, object]] = field(default_factory=list)
def reconcile(self, *, limit: int, apply_repairs: bool) -> _Report:
self.calls.append({"limit": limit, "apply_repairs": apply_repairs})
return _Report(scanned=3, repaired=0)
def test_control_space_command_exposes_all_p1a_management_operations() -> None:
assert {
"backfill",
@@ -37,18 +165,20 @@ def test_workspace_delete_request_is_dry_run_unless_apply_is_explicit() -> None:
patch("commands.knowledge_fs._management_service") as management_factory,
patch("commands.knowledge_fs._lifecycle_service") as lifecycle_factory,
):
management_factory.return_value.dry_run.return_value = SimpleNamespace(
management_factory.return_value.dry_run.return_value = KnowledgeFSControlSpaceDryRunReport(
total=2,
by_state={"active": 2},
irreversible_deletions=0,
)
dry_run = CliRunner().invoke(
knowledge_fs_control_space,
["workspace-delete-request", "--tenant-id", "tenant-1"],
)
control_space = _control_space("control-1")
lifecycle_factory.return_value.request_workspace_cleanup.return_value = (
SimpleNamespace(
control_space=SimpleNamespace(id="control-1"),
outbox=SimpleNamespace(operation_id="operation-1"),
KnowledgeFSDeletionIntentResult(
control_space=control_space,
outbox=_deletion_outbox(control_space, "operation-1"),
),
)
applied = CliRunner().invoke(
@@ -64,9 +194,8 @@ def test_workspace_delete_request_is_dry_run_unless_apply_is_explicit() -> None:
def test_workspace_delete_finalize_requires_terminal_preflight_and_explicit_purge_ack() -> None:
with patch("commands.knowledge_fs._lifecycle_service") as lifecycle_factory:
lifecycle_factory.return_value.assert_workspace_deletion_allowed = MagicMock()
lifecycle_factory.return_value.finalize_workspace_deletion.return_value = 2
lifecycle = _WorkspaceDeletionLifecycleService(purged_control_spaces=2)
with patch("commands.knowledge_fs._lifecycle_service", return_value=lifecycle):
preflight = CliRunner().invoke(
knowledge_fs_control_space,
["workspace-delete-finalize", "--tenant-id", "tenant-1"],
@@ -87,10 +216,10 @@ def test_workspace_delete_finalize_requires_terminal_preflight_and_explicit_purg
)
assert preflight.exit_code == 0
lifecycle_factory.return_value.assert_workspace_deletion_allowed.assert_called_once_with(tenant_id="tenant-1")
assert lifecycle.preflight_tenants == ["tenant-1"]
assert denied.exit_code == 2
assert "--acknowledge-control-plane-purge" in denied.output
lifecycle_factory.return_value.finalize_workspace_deletion.assert_called_once_with(tenant_id="tenant-1")
assert lifecycle.finalized_tenants == ["tenant-1"]
assert applied.exit_code == 0
assert '"purged_control_spaces": 2' in applied.output
@@ -454,35 +583,31 @@ def test_cleanup_complete_cli_requires_explicit_executed_ack_for_apply(tmp_path:
assert "--acknowledge-executed" in denied_apply.output
def _report(**values: object) -> SimpleNamespace:
return SimpleNamespace(_asdict=lambda: values)
def test_operator_commands_delegate_cutover_mutations_with_validated_timestamps(
tmp_path: Path,
) -> None:
input_path = tmp_path / "operator-input.jsonl"
input_path.write_text("{}\n", encoding="utf-8")
payload = SimpleNamespace(tenant_id="tenant-1")
service = MagicMock()
service.status.return_value = {
"legacy_dependency_ready": True,
"open_issues": 0,
"unresolved_cutover_quarantine": 0,
}
for method_name, method in (
("inventory", service.inventory),
("record_shadow_report", service.record_shadow_report),
("legacy_dependency_dashboard", service.legacy_dependency_dashboard),
):
method.return_value = _report(operation=method_name)
payload = _TenantPayload(tenant_id="tenant-1")
runner = CliRunner()
with (
patch("commands.knowledge_fs._cutover_service", return_value=service),
patch("commands.knowledge_fs._cutover_service") as service_factory,
patch("commands.knowledge_fs._read_jsonl", return_value=(payload,)),
patch("commands.knowledge_fs._read_one_jsonl", return_value=payload),
):
service = service_factory.return_value
service.status.return_value = {
"legacy_dependency_ready": True,
"open_issues": 0,
"unresolved_cutover_quarantine": 0,
}
for method_name, method in (
("inventory", service.inventory),
("record_shadow_report", service.record_shadow_report),
("legacy_dependency_dashboard", service.legacy_dependency_dashboard),
):
method.return_value = _Report(operation=method_name)
invocations = [
["inventory", "--input", str(input_path)],
[
@@ -612,8 +737,7 @@ def test_operator_commands_delegate_cutover_mutations_with_validated_timestamps(
def test_observation_command_enforces_mutually_exclusive_start_and_completion_modes() -> None:
service = MagicMock()
service.status.return_value = {"phase": "observing"}
service = _ObservationCutoverService(status_report={"phase": "observing"})
runner = CliRunner()
base = ["observe", "--tenant-id", "tenant-1", "--expected-cas-version", "7"]
@@ -655,12 +779,10 @@ def test_observation_command_enforces_mutually_exclusive_start_and_completion_mo
assert "cannot be combined" in conflicting.output
assert incomplete.exit_code == 2
assert "observation start requires" in incomplete.output
service.begin_observation.assert_called_once()
service.complete_observation.assert_called_once()
assert [name for name, _ in service.observation_calls] == ["begin", "complete"]
def test_legacy_check_returns_a_process_failure_when_any_cutover_gate_is_open() -> None:
service = MagicMock()
runner = CliRunner()
passing = {
"legacy_dependency_ready": True,
@@ -673,12 +795,12 @@ def test_legacy_check_returns_a_process_failure_when_any_cutover_gate_is_open()
{**passing, "unresolved_cutover_quarantine": 1},
)
service = _ObservationCutoverService(status_report=passing)
with patch("commands.knowledge_fs._cutover_service", return_value=service):
service.status.return_value = passing
passed = runner.invoke(knowledge_fs_control_space, ["legacy-check", "--tenant-id", "tenant-1"])
failed = []
for report in failing_reports:
service.status.return_value = report
service.status_report = report
failed.append(runner.invoke(knowledge_fs_control_space, ["legacy-check", "--tenant-id", "tenant-1"]))
assert passed.exit_code == 0
@@ -692,20 +814,10 @@ def test_management_cleanup_and_reconciliation_commands_preserve_apply_safety(
) -> None:
input_path = tmp_path / "operator-input.jsonl"
input_path.write_text("{}\n", encoding="utf-8")
payload = SimpleNamespace(tenant_id="tenant-1")
management = MagicMock()
management.dry_run.return_value = _report(total=1, by_state={"active": 1})
management.register.return_value = (SimpleNamespace(id="control-1"), True)
management.repair_registration.return_value = SimpleNamespace(
id="control-1",
state=SimpleNamespace(value="active"),
)
cleanup = MagicMock()
cleanup.request_cleanup.return_value = _report(applied=False)
cleanup.approve_cleanup.return_value = _report(applied=True)
cleanup.status.return_value = {"state": "approved"}
reconciler = MagicMock()
reconciler.reconcile.return_value = _report(scanned=3, repaired=0)
payload = _TenantPayload(tenant_id="tenant-1")
management = _ManagementService(registered=_control_space("control-1"), repaired=_control_space("control-1"))
cleanup = _CleanupService()
reconciler = _OrphanReconciler()
runner = CliRunner()
with (
@@ -752,20 +864,24 @@ def test_management_cleanup_and_reconciliation_commands_preserve_apply_safety(
results = [runner.invoke(knowledge_fs_control_space, arguments) for arguments in invocations]
assert all(result.exit_code == 0 for result in results), [(result.output, result.exception) for result in results]
registration = management.register.call_args.args[0]
assert management.dry_run_tenants == ["tenant-1"]
assert len(management.registrations) == 1
registration = management.registrations[0]
assert registration.tenant_id == "tenant-1"
assert registration.knowledge_space_revision == 2
management.repair_registration.assert_called_once_with(
tenant_id="tenant-1",
control_space_id="control-1",
expected_resource_version=3,
knowledge_space_id="knowledge-space-1",
knowledge_space_revision=4,
)
reconciler.reconcile.assert_called_once_with(limit=25, apply_repairs=False)
cleanup.request_cleanup.assert_called_once_with(payload, apply=False)
cleanup.approve_cleanup.assert_called_once_with(payload, apply=True)
cleanup.status.assert_called_once_with(tenant_id="tenant-1", request_id="request-1")
assert management.repair_calls == [
{
"tenant_id": "tenant-1",
"control_space_id": "control-1",
"expected_resource_version": 3,
"knowledge_space_id": "knowledge-space-1",
"knowledge_space_revision": 4,
}
]
assert reconciler.calls == [{"limit": 25, "apply_repairs": False}]
assert cleanup.request_calls == [(payload, False)]
assert cleanup.approve_calls == [(payload, True)]
assert cleanup.status_calls == [{"tenant_id": "tenant-1", "request_id": "request-1"}]
def test_jsonl_and_timestamp_helpers_reject_ambiguous_operator_input(tmp_path: Path) -> None:
@@ -1,17 +1,47 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import Mock
from collections.abc import Sequence
from dataclasses import dataclass, field
import pytest
from sqlalchemy.orm import Session
from core.workflow.nodes.knowledge_retrieval_v2.validation import missing_control_space_ids
from events.event_handlers import sync_knowledge_fs_bindings_when_app_published_workflow_updated as handler
from models.knowledge_fs import KnowledgeFSControlSpace, KnowledgeFSControlSpaceState
from models.model import App
from models.workflow import Workflow
@dataclass
class _RecordingAppBindings:
calls: list[dict[str, object]] = field(default_factory=list)
def sync_workflow_bindings(self, **kwargs: object) -> list[object]:
self.calls.append(kwargs)
return []
@dataclass
class _Runtime:
app_bindings: _RecordingAppBindings
class _FlushRecordingSession(Session):
def __init__(self) -> None:
super().__init__()
self.flush_count = 0
def flush(self, objects: Sequence[object] | None = None) -> None:
self.flush_count += 1
super().flush(objects)
def _app() -> App:
return App(id="app-1", tenant_id="tenant-1")
def _workflow(*space_groups: list[str]) -> Workflow:
return Workflow(
created_by="account-1",
@@ -43,52 +73,53 @@ def test_extracts_deduplicated_control_spaces_in_graph_order() -> None:
)
def test_missing_space_validation_preserves_requested_order() -> None:
session = Mock(spec=Session)
session.scalars.return_value = ["space-b"]
def test_missing_space_validation_preserves_requested_order(sqlite_session: Session) -> None:
active_space = KnowledgeFSControlSpace(
tenant_id="tenant-1",
owner_account_id="account-1",
provisioning_key="provision-b",
knowledge_space_id="knowledge-space-b",
state=KnowledgeFSControlSpaceState.ACTIVE,
)
active_space.id = "space-b"
sqlite_session.add(active_space)
sqlite_session.flush()
workflow = _workflow(["space-a", "space-b", "space-c"])
assert missing_control_space_ids(
session=session,
session=sqlite_session,
tenant_id="tenant-1",
graph=workflow.graph_dict,
) == ("space-a", "space-c")
def test_publish_handler_exactly_syncs_workflow_bindings(monkeypatch: pytest.MonkeyPatch) -> None:
sync = Mock()
monkeypatch.setattr(
handler,
"get_knowledge_fs_runtime",
lambda _session_maker: SimpleNamespace(app_bindings=SimpleNamespace(sync_workflow_bindings=sync)),
)
app = SimpleNamespace(id="app-1", tenant_id="tenant-1")
bindings = _RecordingAppBindings()
monkeypatch.setattr(handler, "get_knowledge_fs_runtime", lambda _session_maker: _Runtime(bindings))
workflow = _workflow(["space-a", "space-b"])
publish_session = SimpleNamespace()
publish_session = Session()
handler.handle(app, published_workflow=workflow, session=publish_session)
handler.handle(_app(), published_workflow=workflow, session=publish_session)
sync.assert_called_once_with(
tenant_id="tenant-1",
actor_account_id="account-1",
app_id="app-1",
control_space_ids=["space-a", "space-b"],
session=publish_session,
)
assert bindings.calls == [
{
"tenant_id": "tenant-1",
"actor_account_id": "account-1",
"app_id": "app-1",
"control_space_ids": ["space-a", "space-b"],
"session": publish_session,
}
]
def test_publish_union_includes_modern_agent_and_uses_the_publish_transaction(monkeypatch: pytest.MonkeyPatch) -> None:
sync = Mock()
monkeypatch.setattr(
handler,
"get_knowledge_fs_runtime",
lambda _: SimpleNamespace(app_bindings=SimpleNamespace(sync_workflow_bindings=sync)),
)
session = Mock()
bindings = _RecordingAppBindings()
monkeypatch.setattr(handler, "get_knowledge_fs_runtime", lambda _: _Runtime(bindings))
publish_session = _FlushRecordingSession()
def frozen_spaces(**kwargs: object) -> tuple[str, ...]:
session.flush.assert_called_once()
assert kwargs["session"] is session
assert publish_session.flush_count == 1
assert kwargs["session"] is publish_session
return ("space-b", "space-c")
monkeypatch.setattr(handler, "collect_workflow_agent_knowledge_space_ids", frozen_spaces)
@@ -96,6 +127,7 @@ def test_publish_union_includes_modern_agent_and_uses_the_publish_transaction(mo
graph = workflow.graph_dict
graph["nodes"].append({"data": {"type": "agent", "version": "2", "agent_node_kind": "dify_agent"}})
workflow.graph = json.dumps(graph)
handler.handle(SimpleNamespace(id="app-1", tenant_id="tenant-1"), published_workflow=workflow, session=session)
assert sync.call_args.kwargs["control_space_ids"] == ["space-a", "space-b", "space-c"]
assert sync.call_args.kwargs["session"] is session
handler.handle(_app(), published_workflow=workflow, session=publish_session)
assert len(bindings.calls) == 1
assert bindings.calls[0]["control_space_ids"] == ["space-a", "space-b", "space-c"]
assert bindings.calls[0]["session"] is publish_session
@@ -2,10 +2,10 @@
import ssl
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from enums import DeploymentEdition
from services.knowledge_fs.lifecycle_readiness import KnowledgeFSLifecycleWorkerReadiness
class TestCelerySSLConfiguration:
@@ -188,7 +188,7 @@ class TestCelerySSLConfiguration:
patch("extensions.ext_celery.dify_config", mock_config),
patch(
"services.knowledge_fs.lifecycle_readiness.get_configured_knowledge_fs_lifecycle_worker_readiness",
return_value=SimpleNamespace(ready=False),
return_value=KnowledgeFSLifecycleWorkerReadiness(ready=False, blockers=()),
),
):
from dify_app import DifyApp
@@ -249,7 +249,7 @@ class TestCelerySSLConfiguration:
patch("extensions.ext_celery.dify_config", mock_config),
patch(
"services.knowledge_fs.lifecycle_readiness.get_configured_knowledge_fs_lifecycle_worker_readiness",
return_value=SimpleNamespace(ready=False),
return_value=KnowledgeFSLifecycleWorkerReadiness(ready=False, blockers=()),
),
):
from dify_app import DifyApp
@@ -1,17 +1,22 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from flask import Flask
from sqlalchemy.orm import sessionmaker
from extensions import ext_knowledge_fs_observability
from repositories.sqlalchemy_knowledge_fs_control_space_state_metrics import (
SQLAlchemyKnowledgeFSControlSpaceStateCountReader,
)
from tests.unit_tests.tasks.knowledge_fs_task_fakes import RecordingKnowledgeFSMetrics
def test_extension_registers_the_database_backed_control_space_gauge() -> None:
app = Flask(__name__)
session_maker = MagicMock()
metrics = MagicMock()
reader = MagicMock()
session_maker = sessionmaker()
metrics = RecordingKnowledgeFSMetrics()
reader = SQLAlchemyKnowledgeFSControlSpaceStateCountReader(session_maker)
with (
patch(
@@ -30,13 +35,12 @@ def test_extension_registers_the_database_backed_control_space_gauge() -> None:
ext_knowledge_fs_observability.init_app(app)
reader_type.assert_called_once_with(session_maker)
metrics.register_control_space_state_gauge.assert_called_once_with(reader)
assert metrics.gauge_readers == [reader]
def test_extension_does_not_block_startup_when_metric_registration_fails() -> None:
app = Flask(__name__)
metrics = MagicMock()
metrics.register_control_space_state_gauge.side_effect = RuntimeError("metrics unavailable")
metrics = RecordingKnowledgeFSMetrics(gauge_registration_error=RuntimeError("metrics unavailable"))
with (
patch("extensions.ext_knowledge_fs_observability.session_factory.get_session_maker"),
@@ -1,12 +1,13 @@
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from dify_app import DifyApp
from extensions.ext_celery import get_celery_broker_transport_options, init_app
from services.knowledge_fs.background_contract import OPERATION_QUEUES, OPERATION_TASK
from services.knowledge_fs.lifecycle_readiness import KnowledgeFSLifecycleWorkerReadiness
from tests.unit_tests.config_override import apply_config_overrides
@pytest.mark.parametrize("background_enabled", [False, True])
@@ -16,53 +17,51 @@ def test_celery_registers_initial_source_task_when_knowledge_fs_lifecycle_is_rea
) -> None:
# A stale deployment variable must not suppress vector cleanup for the selected provider.
monkeypatch.setenv("KNOWLEDGE_VECTOR_STORAGE", "postgres")
config = MagicMock()
config.BROKER_USE_SSL = False
config.REDIS_KEY_PREFIX = "test"
config.HUMAN_INPUT_TIMEOUT_TASK_INTERVAL = 1
config.CELERY_BROKER_URL = "redis://localhost:6379/0"
config.CELERY_BACKEND = "redis"
config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
config.CELERY_USE_SENTINEL = False
config.CELERY_BROKER_VISIBILITY_TIMEOUT = 10800
config.KNOWLEDGE_FS_BACKGROUND_WORKER_ENABLED = background_enabled
config.VECTOR_STORE = vector_store
config.KNOWLEDGE_FS_BACKGROUND_POLL_INTERVAL_SECONDS = 5
config.KNOWLEDGE_FS_BACKGROUND_TASK_TIMEOUT_SECONDS = 7200
config.LOG_FORMAT = "%(message)s"
config.LOG_TZ = "UTC"
config.LOG_FILE = None
config.CELERY_TASK_ANNOTATIONS = dict[str, object]()
config.CELERY_BEAT_SCHEDULER_TIME = 1
config.KNOWLEDGE_FS_LIFECYCLE_POLL_INTERVAL_SECONDS = 2
config.ENABLE_CONVERSATION_CLEANUP_TASK = True
config.CONVERSATION_CLEANUP_TASK_INTERVAL = 5
config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK = False
config.ENABLE_CLEAN_UNUSED_DATASETS_TASK = False
config.ENABLE_CREATE_TIDB_SERVERLESS_TASK = False
config.ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK = False
config.ENABLE_CLEAN_MESSAGES = False
config.ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK = False
config.ENABLE_DATASETS_QUEUE_MONITOR = False
config.ENABLE_HUMAN_INPUT_TIMEOUT_TASK = False
config.ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK = False
config.MARKETPLACE_ENABLED = False
config.WORKFLOW_LOG_CLEANUP_ENABLED = False
config.ENABLE_WORKFLOW_RUN_CLEANUP_TASK = False
config.ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK = False
config.WORKFLOW_SCHEDULE_POLLER_INTERVAL = 1
config.ENABLE_TRIGGER_PROVIDER_REFRESH_TASK = False
config.TRIGGER_PROVIDER_REFRESH_INTERVAL = 15
config.ENABLE_API_TOKEN_LAST_USED_UPDATE_TASK = False
config.API_TOKEN_LAST_USED_UPDATE_INTERVAL = 30
config.ENTERPRISE_ENABLED = False
config.ENTERPRISE_TELEMETRY_ENABLED = False
apply_config_overrides(
monkeypatch,
REDIS_KEY_PREFIX="test",
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1,
CELERY_BROKER_URL="redis://localhost:6379/0",
CELERY_BACKEND="redis",
CELERY_USE_SENTINEL=False,
CELERY_BROKER_VISIBILITY_TIMEOUT=10800,
KNOWLEDGE_FS_BACKGROUND_WORKER_ENABLED=background_enabled,
VECTOR_STORE=vector_store,
KNOWLEDGE_FS_BACKGROUND_POLL_INTERVAL_SECONDS=5,
KNOWLEDGE_FS_BACKGROUND_TASK_TIMEOUT_SECONDS=7200,
LOG_FORMAT="%(message)s",
LOG_TZ="UTC",
LOG_FILE=None,
CELERY_TASK_ANNOTATIONS=dict[str, object](),
CELERY_BEAT_SCHEDULER_TIME=1,
KNOWLEDGE_FS_LIFECYCLE_POLL_INTERVAL_SECONDS=2,
ENABLE_CONVERSATION_CLEANUP_TASK=True,
CONVERSATION_CLEANUP_TASK_INTERVAL=5,
ENABLE_CLEAN_EMBEDDING_CACHE_TASK=False,
ENABLE_CLEAN_UNUSED_DATASETS_TASK=False,
ENABLE_CREATE_TIDB_SERVERLESS_TASK=False,
ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK=False,
ENABLE_CLEAN_MESSAGES=False,
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=False,
ENABLE_DATASETS_QUEUE_MONITOR=False,
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=False,
ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=False,
MARKETPLACE_ENABLED=False,
WORKFLOW_LOG_CLEANUP_ENABLED=False,
ENABLE_WORKFLOW_RUN_CLEANUP_TASK=False,
ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK=False,
WORKFLOW_SCHEDULE_POLLER_INTERVAL=1,
ENABLE_TRIGGER_PROVIDER_REFRESH_TASK=False,
TRIGGER_PROVIDER_REFRESH_INTERVAL=15,
ENABLE_API_TOKEN_LAST_USED_UPDATE_TASK=False,
API_TOKEN_LAST_USED_UPDATE_INTERVAL=30,
ENTERPRISE_TELEMETRY_ENABLED=False,
)
with (
patch("extensions.ext_celery.dify_config", config),
patch(
"services.knowledge_fs.lifecycle_readiness.get_configured_knowledge_fs_lifecycle_worker_readiness",
return_value=SimpleNamespace(ready=True),
return_value=KnowledgeFSLifecycleWorkerReadiness(ready=True, blockers=()),
),
):
celery_app = init_app(DifyApp(__name__))
@@ -99,10 +98,9 @@ def test_celery_registers_initial_source_task_when_knowledge_fs_lifecycle_is_rea
assert not any(name.startswith("knowledge_fs_background_") for name in schedules)
with (
patch("extensions.ext_celery.dify_config", config),
patch(
"services.knowledge_fs.lifecycle_readiness.get_configured_knowledge_fs_lifecycle_worker_readiness",
return_value=SimpleNamespace(ready=False),
return_value=KnowledgeFSLifecycleWorkerReadiness(ready=False, blockers=("test",)),
),
):
preview_only_app = init_app(DifyApp(f"{__name__}.preview_only"))
@@ -1,7 +1,6 @@
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import Session
@@ -18,6 +17,7 @@ from services.knowledge_fs.control_space_lifecycle import (
KnowledgeFSSpaceRegistrationConflictError,
KnowledgeFSWorkspaceDeletionBlockedError,
)
from tests.unit_tests.tasks.knowledge_fs_task_fakes import RecordingKnowledgeFSMetrics
@pytest.fixture
@@ -54,7 +54,7 @@ def test_lifecycle_service_uses_compare_and_set_resource_versions(
)
sqlite_session.add(control_space)
sqlite_session.commit()
metrics = MagicMock()
metrics = RecordingKnowledgeFSMetrics()
service = KnowledgeFSControlSpaceLifecycleService(repository, metrics=metrics)
transitioned = service.transition(
@@ -69,7 +69,7 @@ def test_lifecycle_service_uses_compare_and_set_resource_versions(
assert transitioned.state is KnowledgeFSControlSpaceState.ACTIVE
assert transitioned.resource_version == 1
assert transitioned.knowledge_space_id == "knowledge-space-1"
state_metric = metrics.record_control_space_state.call_args.args[0]
state_metric = metrics.control_space_states[-1]
assert state_metric.from_state == "provisioning"
assert state_metric.to_state == "active"
assert control_space.id not in str(state_metric)
@@ -1,7 +1,6 @@
from __future__ import annotations
from datetime import timedelta
from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import Session
@@ -17,6 +16,7 @@ from repositories.sqlalchemy_knowledge_fs_lifecycle_outbox_repository import (
SQLAlchemyKnowledgeFSLifecycleOutboxRepository,
)
from services.knowledge_fs.observability import KnowledgeFSLifecycleTaskMetric
from tests.unit_tests.tasks.knowledge_fs_task_fakes import RecordingKnowledgeFSMetrics
def _outbox(control_space: KnowledgeFSControlSpace) -> KnowledgeFSLifecycleOutbox:
@@ -53,15 +53,15 @@ def test_add_records_only_a_low_cardinality_queued_observation(sqlite_session: S
)
sqlite_session.add(control_space)
sqlite_session.flush()
metrics = MagicMock()
metrics = RecordingKnowledgeFSMetrics()
repository = SQLAlchemyKnowledgeFSLifecycleOutboxRepository(sqlite_session, metrics=metrics)
command = repository.add(_outbox(control_space))
metrics.record_lifecycle_task.assert_called_once_with(KnowledgeFSLifecycleTaskMetric(None, "provision", "queued"))
assert command.id not in str(metrics.record_lifecycle_task.call_args_list)
assert control_space.id not in str(metrics.record_lifecycle_task.call_args_list)
assert control_space.tenant_id not in str(metrics.record_lifecycle_task.call_args_list)
assert metrics.lifecycle_tasks == [KnowledgeFSLifecycleTaskMetric(None, "provision", "queued")]
assert command.id not in str(metrics.lifecycle_tasks)
assert control_space.id not in str(metrics.lifecycle_tasks)
assert control_space.tenant_id not in str(metrics.lifecycle_tasks)
@pytest.mark.parametrize(
@@ -0,0 +1,227 @@
"""Typed fakes shared by the KnowledgeFS task, command and extension unit tests."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from celery.exceptions import Retry
from core.knowledge_fs.retrieval_contracts import KnowledgeFSPublicFailureResponse
from services.knowledge_fs.observability import (
KnowledgeFSBatchStatusMetric,
KnowledgeFSCapabilityIssuanceMetric,
KnowledgeFSControlSpaceStateMetric,
KnowledgeFSLifecycleTaskMetric,
)
from services.knowledge_fs.product_dto import (
KnowledgeFSSourceConnectionListResponse,
KnowledgeFSSourceConnectionResponse,
KnowledgeFSSourceListResponse,
KnowledgeFSSourceProviderListResponse,
KnowledgeFSSourceProviderResponse,
KnowledgeFSSourceResponse,
KnowledgeFSSourceSyncPolicyResponse,
KnowledgeFSSourceWorkflowResponse,
)
@dataclass
class RecordingKnowledgeFSMetrics:
"""In-memory ``KnowledgeFSOperationalMetricsPort`` that records every event."""
batch_statuses: list[KnowledgeFSBatchStatusMetric] = field(default_factory=list)
capability_issuances: list[KnowledgeFSCapabilityIssuanceMetric] = field(default_factory=list)
control_space_states: list[KnowledgeFSControlSpaceStateMetric] = field(default_factory=list)
lifecycle_tasks: list[KnowledgeFSLifecycleTaskMetric] = field(default_factory=list)
gauge_readers: list[Callable[[], Mapping[str, int]]] = field(default_factory=list)
gauge_registration_error: Exception | None = None
def record_batch_status(self, event: KnowledgeFSBatchStatusMetric) -> None:
self.batch_statuses.append(event)
def record_capability_issuance(self, event: KnowledgeFSCapabilityIssuanceMetric) -> None:
self.capability_issuances.append(event)
def record_control_space_state(self, event: KnowledgeFSControlSpaceStateMetric) -> None:
self.control_space_states.append(event)
def record_lifecycle_task(self, event: KnowledgeFSLifecycleTaskMetric) -> None:
self.lifecycle_tasks.append(event)
def register_control_space_state_gauge(self, read_counts: Callable[[], Mapping[str, int]]) -> None:
if self.gauge_registration_error is not None:
raise self.gauge_registration_error
self.gauge_readers.append(read_counts)
@dataclass
class RecordingRetry:
"""Stand-in for a bound Celery ``Task.retry`` that records its keyword arguments."""
error: BaseException = field(default_factory=Retry)
calls: list[dict[str, object]] = field(default_factory=list)
def __call__(self, **kwargs: object) -> Retry:
self.calls.append(kwargs)
raise self.error
class RecordingKnowledgeFSFacade:
"""Source-facing subset of ``KnowledgeFSDataFacade`` with scripted responses.
``script`` sets what a method returns. With several results they are consumed
in order and the last one repeats; an exception result is raised. Calling a
method that was never scripted fails the test instead of returning a dummy.
"""
def __init__(self) -> None:
self.calls: dict[str, list[dict[str, object]]] = {}
self._results: dict[str, list[object]] = {}
def script(self, method: str, *results: object) -> None:
self._results[method] = list(results)
def call_count(self, method: str) -> int:
return len(self.calls.get(method, []))
def payload(self, method: str) -> object:
"""Return the ``payload`` keyword of the most recent call to ``method``."""
return self.calls[method][-1]["payload"]
def _respond(self, method: str, kwargs: dict[str, object]) -> object:
self.calls.setdefault(method, []).append(kwargs)
results = self._results.get(method)
if not results:
raise AssertionError(f"unexpected KnowledgeFS facade call: {method}")
result = results.pop(0) if len(results) > 1 else results[0]
if isinstance(result, BaseException):
raise result
return result
def list_sources(self, **kwargs: object) -> object:
return self._respond("list_sources", kwargs)
def get_source(self, **kwargs: object) -> object:
return self._respond("get_source", kwargs)
def create_source(self, **kwargs: object) -> object:
return self._respond("create_source", kwargs)
def update_source(self, **kwargs: object) -> object:
return self._respond("update_source", kwargs)
def list_source_providers(self, **kwargs: object) -> object:
return self._respond("list_source_providers", kwargs)
def list_source_connections(self, **kwargs: object) -> object:
return self._respond("list_source_connections", kwargs)
def create_source_connection(self, **kwargs: object) -> object:
return self._respond("create_source_connection", kwargs)
def import_selected_source_crawl(self, **kwargs: object) -> object:
return self._respond("import_selected_source_crawl", kwargs)
def import_source_workflow(self, **kwargs: object) -> object:
return self._respond("import_source_workflow", kwargs)
def consume_namespace_source_preview(self, **kwargs: object) -> object:
return self._respond("consume_namespace_source_preview", kwargs)
def get_source_workflow(self, **kwargs: object) -> object:
return self._respond("get_source_workflow", kwargs)
def get_source_sync_policy(self, **kwargs: object) -> object:
return self._respond("get_source_sync_policy", kwargs)
def update_source_sync_policy(self, **kwargs: object) -> object:
return self._respond("update_source_sync_policy", kwargs)
@dataclass(frozen=True)
class FacadeRuntime:
"""The ``KnowledgeFSRuntime`` member the source tasks read."""
facade: RecordingKnowledgeFSFacade
# Response builders construct real DTOs with only the fields a test exercises.
# ``model_construct`` keeps arbitrary remote error codes and messages verbatim.
def source_response(
*,
metadata: dict[str, object],
status: str,
version: int,
source_id: str = "source-1",
connection_id: str | None = None,
) -> KnowledgeFSSourceResponse:
return KnowledgeFSSourceResponse.model_construct(
id=source_id,
connection_id=connection_id,
metadata=metadata,
status=status,
version=version,
)
def source_list_response(
*sources: KnowledgeFSSourceResponse, next_cursor: str | None = None
) -> KnowledgeFSSourceListResponse:
return KnowledgeFSSourceListResponse.model_construct(data=list(sources), next_cursor=next_cursor)
def source_workflow_response(
*,
workflow_id: str,
state: str,
source_id: str | None = None,
failure_message: str | None = None,
last_error_code: str | None = None,
) -> KnowledgeFSSourceWorkflowResponse:
failure = (
None if failure_message is None else KnowledgeFSPublicFailureResponse.model_construct(message=failure_message)
)
return KnowledgeFSSourceWorkflowResponse.model_construct(
id=workflow_id,
state=state,
source_id=source_id,
failure=failure,
last_error_code=last_error_code,
)
def sync_policy_response(*, revision: int) -> KnowledgeFSSourceSyncPolicyResponse:
return KnowledgeFSSourceSyncPolicyResponse.model_construct(revision=revision)
def source_provider_list_response(*available_provider_ids: str) -> KnowledgeFSSourceProviderListResponse:
return KnowledgeFSSourceProviderListResponse.model_construct(
data=[
KnowledgeFSSourceProviderResponse.model_construct(id=provider_id, available=True)
for provider_id in available_provider_ids
]
)
def source_connection_response(
*,
connection_id: str,
provider_id: str,
status: str,
configuration: dict[str, bool | int | str] | None = None,
) -> KnowledgeFSSourceConnectionResponse:
return KnowledgeFSSourceConnectionResponse.model_construct(
id=connection_id,
provider_id=provider_id,
status=status,
configuration=configuration or {},
)
def source_connection_list_response(
*connections: KnowledgeFSSourceConnectionResponse, next_cursor: str | None = None
) -> KnowledgeFSSourceConnectionListResponse:
return KnowledgeFSSourceConnectionListResponse.model_construct(data=list(connections), next_cursor=next_cursor)
@@ -1,15 +1,30 @@
from unittest.mock import Mock
from dataclasses import dataclass, field
from uuid import uuid4
import pytest
from celery.exceptions import Retry
from dify_agent.protocol.knowledge_fs import KnowledgeFsError
from dify_agent.protocol.knowledge_investigation import KnowledgeInvestigationPayload
from services.knowledge_fs.product_remote import KnowledgeFSProductRequestRejectedError
from tasks import knowledge_fs_agent_investigation_tasks as module
from tests.unit_tests.tasks.knowledge_fs_task_fakes import RecordingRetry
from tests.unit_tests.tasks.task_options import task_options
@dataclass
class _Gateway:
"""Records investigation captures; raises ``error`` when one is configured."""
error: Exception | None = None
captures: list[tuple[KnowledgeInvestigationPayload, str]] = field(default_factory=list)
def capture_investigation(self, report: KnowledgeInvestigationPayload, control_space_id: str) -> None:
self.captures.append((report, control_space_id))
if self.error is not None:
raise self.error
def report():
space = str(uuid4())
return {
@@ -23,58 +38,56 @@ def report():
def test_dataset_worker_reconstructs_report_for_each_delivery(monkeypatch):
gateway = Mock()
gateway = _Gateway()
monkeypatch.setattr(module, "AgentKnowledgeGateway", lambda: gateway)
payload, space = report()
task = module.capture_agent_knowledge_investigation_task
task.run(report=payload, control_space_id=space)
task.run(report=payload, control_space_id=space)
assert task_options(task)["queue"] == "dataset"
first, second = gateway.capture_investigation.call_args_list
first, second = gateway.captures
assert first == second
assert str(first.args[0].investigation_id) == payload["investigation_id"]
assert first[1] == space
assert str(first[0].investigation_id) == payload["investigation_id"]
def test_revoked_authority_stops_publication_and_transient_failures_retry(monkeypatch):
gateway = Mock()
gateway = _Gateway(error=KnowledgeFsError("KNOWLEDGE_SCOPE_CHANGED", "revoked", 403))
monkeypatch.setattr(module, "AgentKnowledgeGateway", lambda: gateway)
payload, space = report()
task = module.capture_agent_knowledge_investigation_task
retry = Mock(side_effect=Retry())
retry = RecordingRetry()
monkeypatch.setattr(task, "retry", retry)
gateway.capture_investigation.side_effect = KnowledgeFsError("KNOWLEDGE_SCOPE_CHANGED", "revoked", 403)
task.run(report=payload, control_space_id=space)
retry.assert_not_called()
gateway.capture_investigation.side_effect = OSError("offline")
assert retry.calls == []
gateway.error = OSError("offline")
with pytest.raises(Retry):
task.run(report=payload, control_space_id=space)
retry.assert_called_once()
assert len(retry.calls) == 1
@pytest.mark.parametrize("status_code", [400, 403, 409, 413, 422])
def test_permanent_remote_rejections_do_not_retry(monkeypatch, status_code):
gateway = Mock()
gateway.capture_investigation.side_effect = KnowledgeFSProductRequestRejectedError(status_code=status_code)
gateway = _Gateway(error=KnowledgeFSProductRequestRejectedError(status_code=status_code))
monkeypatch.setattr(module, "AgentKnowledgeGateway", lambda: gateway)
task = module.capture_agent_knowledge_investigation_task
retry = Mock(side_effect=Retry())
retry = RecordingRetry()
monkeypatch.setattr(task, "retry", retry)
payload, space = report()
task.run(report=payload, control_space_id=space)
gateway.capture_investigation.assert_called_once()
retry.assert_not_called()
assert len(gateway.captures) == 1
assert retry.calls == []
@pytest.mark.parametrize(("retries", "countdown"), [(0, 30), (1, 60), (2, 120)])
def test_rate_limited_report_retries_with_backoff(monkeypatch, retries, countdown):
gateway = Mock()
error = KnowledgeFSProductRequestRejectedError(status_code=429)
gateway.capture_investigation.side_effect = error
gateway = _Gateway(error=error)
monkeypatch.setattr(module, "AgentKnowledgeGateway", lambda: gateway)
task = module.capture_agent_knowledge_investigation_task
retry = Mock(side_effect=Retry())
retry = RecordingRetry()
monkeypatch.setattr(task, "retry", retry)
monkeypatch.setattr(task.request, "retries", retries)
payload, space = report()
@@ -82,17 +95,17 @@ def test_rate_limited_report_retries_with_backoff(monkeypatch, retries, countdow
with pytest.raises(Retry):
task.run(report=payload, control_space_id=space)
retry.assert_called_once_with(exc=error, countdown=countdown)
assert retry.calls == [{"exc": error, "countdown": countdown}]
def test_malformed_queued_report_is_rejected_before_gateway_publication(monkeypatch):
gateway = Mock()
gateway = _Gateway()
monkeypatch.setattr(module, "AgentKnowledgeGateway", lambda: gateway)
task = module.capture_agent_knowledge_investigation_task
retry = Mock(side_effect=Retry())
retry = RecordingRetry()
monkeypatch.setattr(task, "retry", retry)
task.run(report={"investigation_id": "invalid"}, control_space_id=str(uuid4()))
gateway.capture_investigation.assert_not_called()
retry.assert_not_called()
assert gateway.captures == []
assert retry.calls == []
@@ -1,16 +1,17 @@
from __future__ import annotations
from types import SimpleNamespace
from dataclasses import dataclass, field
from typing import Literal, TypedDict
from unittest.mock import MagicMock
import pytest
from celery.app.task import Context
from celery.exceptions import Retry
from services.knowledge_fs.app_admission_service import KnowledgeFSAppAdmissionError
from services.knowledge_fs.product_dto import KnowledgeFSWorkflowFailedRetrievalCaptureResponse
from services.knowledge_fs.product_remote import KnowledgeFSProductRequestRejectedError
from tasks import knowledge_fs_failed_retrieval_tasks as task_module
from tests.unit_tests.tasks.knowledge_fs_task_fakes import RecordingRetry
from tests.unit_tests.tasks.task_options import task_options
EVENT_ID = "019fac9f-bfb0-75ee-9af5-252ebafbac1e"
@@ -38,14 +39,28 @@ def _task_kwargs() -> FailedRetrievalTaskArguments:
}
def _install_failing_capability(monkeypatch: pytest.MonkeyPatch, error: Exception) -> MagicMock:
capability = MagicMock()
capability.capture_workflow_failed_retrieval.side_effect = error
monkeypatch.setattr(
task_module,
"get_knowledge_fs_runtime",
lambda _session_maker: SimpleNamespace(app_capabilities=capability),
)
@dataclass
class _AppCapabilities:
outcome: KnowledgeFSWorkflowFailedRetrievalCaptureResponse | Exception
calls: list[dict[str, object]] = field(default_factory=list)
def capture_workflow_failed_retrieval(self, **kwargs: object) -> KnowledgeFSWorkflowFailedRetrievalCaptureResponse:
self.calls.append(kwargs)
if isinstance(self.outcome, Exception):
raise self.outcome
return self.outcome
@dataclass(frozen=True)
class _Runtime:
app_capabilities: _AppCapabilities
def _install_capability(
monkeypatch: pytest.MonkeyPatch, outcome: KnowledgeFSWorkflowFailedRetrievalCaptureResponse | Exception
) -> _AppCapabilities:
capability = _AppCapabilities(outcome)
monkeypatch.setattr(task_module, "get_knowledge_fs_runtime", lambda _session_maker: _Runtime(capability))
monkeypatch.setattr(task_module.session_factory, "get_session_maker", lambda: object())
return capability
@@ -62,23 +77,20 @@ def _run_task_with_retries(retries: int) -> None:
def test_failed_retrieval_task_runs_on_dataset_queue_and_reauthorizes_app(
monkeypatch: pytest.MonkeyPatch,
) -> None:
capability = MagicMock()
capability.capture_workflow_failed_retrieval.return_value = SimpleNamespace(
failed_query_id="failed-query-1",
verdict="retrieval-miss",
bad_case_id="bad-case-1",
capability = _install_capability(
monkeypatch,
KnowledgeFSWorkflowFailedRetrievalCaptureResponse.model_construct(
failed_query_id="failed-query-1",
verdict="retrieval-miss",
bad_case_id="bad-case-1",
),
)
monkeypatch.setattr(
task_module,
"get_knowledge_fs_runtime",
lambda _session_maker: SimpleNamespace(app_capabilities=capability),
)
monkeypatch.setattr(task_module.session_factory, "get_session_maker", lambda: object())
task_module.capture_workflow_failed_retrieval_task.run(**_task_kwargs())
assert task_options(task_module.capture_workflow_failed_retrieval_task)["queue"] == "dataset"
call = capability.capture_workflow_failed_retrieval.call_args.kwargs
assert len(capability.calls) == 1
call = capability.calls[0]
assert call["tenant_id"] == "tenant-1"
assert call["app_id"] == "app-1"
assert call["resource"].control_space_id == "control-1"
@@ -88,67 +100,67 @@ def test_failed_retrieval_task_runs_on_dataset_queue_and_reauthorizes_app(
def test_enqueue_is_idempotency_aware_and_never_propagates_broker_failure(monkeypatch: pytest.MonkeyPatch) -> None:
delay = MagicMock()
monkeypatch.setattr(task_module.capture_workflow_failed_retrieval_task, "delay", delay)
delayed: list[dict[str, object]] = []
monkeypatch.setattr(
task_module.capture_workflow_failed_retrieval_task, "delay", lambda **kwargs: delayed.append(kwargs)
)
task_module.enqueue_workflow_failed_retrieval_capture(**_task_kwargs())
delay.assert_called_once_with(**_task_kwargs())
assert delayed == [_task_kwargs()]
delay.side_effect = RuntimeError("broker unavailable")
def unavailable_broker(**kwargs: object) -> None:
delayed.append(kwargs)
raise RuntimeError("broker unavailable")
monkeypatch.setattr(task_module.capture_workflow_failed_retrieval_task, "delay", unavailable_broker)
task_module.enqueue_workflow_failed_retrieval_capture(**_task_kwargs())
assert len(delayed) == 2
def test_terminal_admission_rejection_is_swallowed(monkeypatch: pytest.MonkeyPatch) -> None:
capability = MagicMock()
capability.capture_workflow_failed_retrieval.side_effect = KnowledgeFSAppAdmissionError("not admitted")
monkeypatch.setattr(
task_module,
"get_knowledge_fs_runtime",
lambda _session_maker: SimpleNamespace(app_capabilities=capability),
)
monkeypatch.setattr(task_module.session_factory, "get_session_maker", lambda: object())
capability = _install_capability(monkeypatch, KnowledgeFSAppAdmissionError("not admitted"))
task_module.capture_workflow_failed_retrieval_task.run(**_task_kwargs())
capability.capture_workflow_failed_retrieval.assert_called_once()
assert len(capability.calls) == 1
def test_rate_limit_rejection_retries_with_initial_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
error = KnowledgeFSProductRequestRejectedError(status_code=429)
capability = _install_failing_capability(monkeypatch, error)
retry = MagicMock(side_effect=Retry())
capability = _install_capability(monkeypatch, error)
retry = RecordingRetry()
monkeypatch.setattr(task_module.capture_workflow_failed_retrieval_task, "retry", retry)
with pytest.raises(Retry):
_run_task_with_retries(0)
capability.capture_workflow_failed_retrieval.assert_called_once()
retry.assert_called_once_with(exc=error, countdown=30)
assert len(capability.calls) == 1
assert retry.calls == [{"exc": error, "countdown": 30}]
def test_transient_failure_retries_with_exponential_backoff(monkeypatch: pytest.MonkeyPatch) -> None:
error = RuntimeError("KnowledgeFS temporarily unavailable")
capability = _install_failing_capability(monkeypatch, error)
retry = MagicMock(side_effect=Retry())
capability = _install_capability(monkeypatch, error)
retry = RecordingRetry()
monkeypatch.setattr(task_module.capture_workflow_failed_retrieval_task, "retry", retry)
with pytest.raises(Retry):
_run_task_with_retries(1)
capability.capture_workflow_failed_retrieval.assert_called_once()
retry.assert_called_once_with(exc=error, countdown=60)
assert len(capability.calls) == 1
assert retry.calls == [{"exc": error, "countdown": 60}]
def test_retry_budget_exhaustion_raises_original_failure(monkeypatch: pytest.MonkeyPatch) -> None:
error = RuntimeError("KnowledgeFS remained unavailable")
capability = _install_failing_capability(monkeypatch, error)
retry = MagicMock()
capability = _install_capability(monkeypatch, error)
retry = RecordingRetry()
monkeypatch.setattr(task_module.capture_workflow_failed_retrieval_task, "retry", retry)
with pytest.raises(RuntimeError, match="KnowledgeFS remained unavailable") as exc_info:
_run_task_with_retries(3)
assert exc_info.value is error
capability.capture_workflow_failed_retrieval.assert_called_once()
retry.assert_not_called()
assert len(capability.calls) == 1
assert retry.calls == []
@@ -1,13 +1,58 @@
from types import SimpleNamespace
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Never
from unittest.mock import MagicMock, call, patch
from unittest.mock import patch
from models.account import AccountStatus
from sqlalchemy import inspect
from core.db.session_factory import session_factory
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
from services.knowledge_fs.initial_source_preview import KnowledgeFSInitialSourcePreviewCanceledError
from services.knowledge_fs.product_dto import KnowledgeFSInitialSourcePreviewResponse
from services.knowledge_fs.product_dto import (
KnowledgeFSInitialSourcePreviewJobResponse,
KnowledgeFSInitialSourcePreviewResponse,
)
from tasks.knowledge_fs_initial_source_preview_tasks import run_knowledge_fs_initial_source_preview
from tests.unit_tests.tasks.task_options import task_options
_JOB_KEY = {"tenant_id": "tenant-1", "account_id": "account-1", "job_id": "job-1"}
@dataclass
class _PreviewJobService:
"""Records preview job transitions; ``transition_results`` are consumed in order."""
transition_results: list[bool]
job_status: str = "running"
transitions: list[dict[str, object]] = field(default_factory=list)
stored: list[dict[str, object]] = field(default_factory=list)
released: list[dict[str, str]] = field(default_factory=list)
lookups: list[dict[str, str]] = field(default_factory=list)
def transition_status(self, **kwargs: object) -> bool:
self.transitions.append(kwargs)
return self.transition_results.pop(0)
def store_content(self, **kwargs: object) -> None:
self.stored.append(kwargs)
def release_active_job(self, *, tenant_id: str, account_id: str, job_id: str) -> None:
self.released.append({"tenant_id": tenant_id, "account_id": account_id, "job_id": job_id})
def get(self, *, tenant_id: str, account_id: str, job_id: str) -> KnowledgeFSInitialSourcePreviewJobResponse:
self.lookups.append({"tenant_id": tenant_id, "account_id": account_id, "job_id": job_id})
return KnowledgeFSInitialSourcePreviewJobResponse.model_construct(status=self.job_status)
@dataclass
class _PreviewService:
run: Callable[..., KnowledgeFSInitialSourcePreviewResponse]
calls: list[dict[str, object]] = field(default_factory=list)
def preview(self, **kwargs: object) -> KnowledgeFSInitialSourcePreviewResponse:
self.calls.append(kwargs)
return self.run(**kwargs)
def _payload() -> dict[str, object]:
return {
@@ -20,28 +65,28 @@ def _payload() -> dict[str, object]:
}
def test_preview_task_uses_the_dataset_queue() -> None:
assert task_options(run_knowledge_fs_initial_source_preview)["queue"] == "dataset"
def _seed_account(*, member: bool) -> None:
tenant = Tenant(name="Preview tenant")
tenant.id = "tenant-1"
account = Account(name="Preview user", email="preview-user@example.com", status=AccountStatus.ACTIVE)
account.id = "account-1"
with session_factory.create_session() as session:
session.add_all([tenant, account])
if member:
session.add(
TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
)
)
session.commit()
def test_preview_task_persists_running_and_completed_states() -> None:
account = SimpleNamespace(
current_tenant_id="tenant-1",
id="account-1",
set_tenant_id_with_session=MagicMock(),
status=AccountStatus.ACTIVE,
)
session = MagicMock()
session.get.return_value = account
session.scalar.return_value = "membership-1"
session_context = MagicMock()
session_context.__enter__.return_value = session
job_service = MagicMock()
job_service.transition_status.side_effect = [True, True]
preview_service = MagicMock()
result = KnowledgeFSInitialSourcePreviewResponse(kind="website_crawl")
preview_service.preview.return_value = result
def _run_preview(job_service: _PreviewJobService, preview_service: _PreviewService | None) -> list[dict[str, object]]:
"""Run the task and return the cleanup schedules it requested."""
scheduled_cleanups: list[dict[str, object]] = []
with (
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.KnowledgeFSInitialSourcePreviewJobService",
@@ -50,15 +95,11 @@ def test_preview_task_persists_running_and_completed_states() -> None:
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.KnowledgeFSInitialSourcePreviewService",
return_value=preview_service,
),
) as preview_service_type,
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.session_factory.create_session",
return_value=session_context,
"tasks.knowledge_fs_initial_source_preview_tasks.cleanup_knowledge_fs_initial_source_preview.apply_async",
side_effect=lambda **kwargs: scheduled_cleanups.append(kwargs),
),
patch("tasks.knowledge_fs_initial_source_preview_tasks.session_factory.get_session_maker"),
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.cleanup_knowledge_fs_initial_source_preview.apply_async"
) as schedule_cleanup,
):
run_knowledge_fs_initial_source_preview.run(
tenant_id="tenant-1",
@@ -66,92 +107,53 @@ def test_preview_task_persists_running_and_completed_states() -> None:
job_id="job-1",
payload=_payload(),
)
if preview_service is None:
preview_service_type.assert_not_called()
return scheduled_cleanups
assert job_service.transition_status.call_args_list == [
call(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
status="running",
allowed_from=("pending",),
),
call(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
status="completed",
allowed_from=("running",),
result=result,
),
def test_preview_task_uses_the_dataset_queue() -> None:
assert task_options(run_knowledge_fs_initial_source_preview)["queue"] == "dataset"
def test_preview_task_persists_running_and_completed_states() -> None:
_seed_account(member=True)
job_service = _PreviewJobService(transition_results=[True, True])
result = KnowledgeFSInitialSourcePreviewResponse(kind="website_crawl")
preview_service = _PreviewService(run=lambda **_: result)
scheduled_cleanups = _run_preview(job_service, preview_service)
assert job_service.transitions == [
{**_JOB_KEY, "status": "running", "allowed_from": ("pending",)},
{**_JOB_KEY, "status": "completed", "allowed_from": ("running",), "result": result},
]
job_service.store_content.assert_called_once_with(
tenant_id="tenant-1", account_id="account-1", job_id="job-1", result=result
)
schedule_cleanup.assert_called_once_with(
kwargs={"account_id": "account-1", "job_id": "job-1", "tenant_id": "tenant-1"}, countdown=3600
)
job_service.release_active_job.assert_called_once_with(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
)
account.set_tenant_id_with_session.assert_called_once_with("tenant-1", session=session)
session.expunge.assert_called_once_with(account)
preview_service.preview.assert_called_once()
assert job_service.stored == [{**_JOB_KEY, "result": result}]
assert scheduled_cleanups == [
{"kwargs": {"account_id": "account-1", "job_id": "job-1", "tenant_id": "tenant-1"}, "countdown": 3600}
]
assert job_service.released == [_JOB_KEY]
assert len(preview_service.calls) == 1
account = preview_service.calls[0]["account"]
assert isinstance(account, Account)
assert account.id == "account-1"
# The tenant was selected through the task's session before the account was detached from it.
assert account.current_tenant_id == "tenant-1"
assert inspect(account).detached
def test_preview_task_does_not_start_a_canceled_job() -> None:
job_service = MagicMock()
job_service.transition_status.return_value = False
job_service = _PreviewJobService(transition_results=[False])
with (
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.KnowledgeFSInitialSourcePreviewJobService",
return_value=job_service,
),
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.KnowledgeFSInitialSourcePreviewService"
) as preview_service,
patch("tasks.knowledge_fs_initial_source_preview_tasks.session_factory.get_session_maker"),
):
run_knowledge_fs_initial_source_preview.run(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
payload=_payload(),
)
_run_preview(job_service, preview_service=None)
job_service.transition_status.assert_called_once_with(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
status="running",
allowed_from=("pending",),
)
preview_service.assert_not_called()
job_service.release_active_job.assert_called_once_with(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
)
assert job_service.transitions == [{**_JOB_KEY, "status": "running", "allowed_from": ("pending",)}]
assert job_service.released == [_JOB_KEY]
def test_preview_task_keeps_a_cooperatively_canceled_job_terminal() -> None:
account = SimpleNamespace(
current_tenant_id="tenant-1",
id="account-1",
set_tenant_id_with_session=MagicMock(),
status=AccountStatus.ACTIVE,
)
session = MagicMock()
session.get.return_value = account
session.scalar.return_value = "membership-1"
session_context = MagicMock()
session_context.__enter__.return_value = session
job_service = MagicMock()
job_service.transition_status.return_value = True
job_service.get.return_value = SimpleNamespace(status="canceled")
preview_service = MagicMock()
_seed_account(member=True)
job_service = _PreviewJobService(transition_results=[True], job_status="canceled")
def preview(**kwargs: object) -> Never:
is_canceled = kwargs["is_canceled"]
@@ -159,79 +161,23 @@ def test_preview_task_keeps_a_cooperatively_canceled_job_terminal() -> None:
assert is_canceled() is True
raise KnowledgeFSInitialSourcePreviewCanceledError("canceled")
preview_service.preview.side_effect = preview
with (
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.KnowledgeFSInitialSourcePreviewJobService",
return_value=job_service,
),
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.KnowledgeFSInitialSourcePreviewService",
return_value=preview_service,
),
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.session_factory.create_session",
return_value=session_context,
),
patch("tasks.knowledge_fs_initial_source_preview_tasks.session_factory.get_session_maker"),
):
run_knowledge_fs_initial_source_preview.run(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
payload=_payload(),
)
preview_service = _PreviewService(run=preview)
job_service.transition_status.assert_called_once_with(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
status="running",
allowed_from=("pending",),
)
_run_preview(job_service, preview_service)
assert job_service.transitions == [{**_JOB_KEY, "status": "running", "allowed_from": ("pending",)}]
assert len(preview_service.calls) == 1
assert job_service.lookups == [_JOB_KEY]
def test_preview_task_rejects_an_account_removed_from_the_tenant() -> None:
account = SimpleNamespace(
current_tenant_id=None,
id="account-1",
set_tenant_id_with_session=MagicMock(),
status=AccountStatus.ACTIVE,
)
session = MagicMock()
session.get.return_value = account
session.scalar.return_value = None
session_context = MagicMock()
session_context.__enter__.return_value = session
job_service = MagicMock()
job_service.transition_status.side_effect = [True, True]
_seed_account(member=False)
job_service = _PreviewJobService(transition_results=[True, True])
with (
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.KnowledgeFSInitialSourcePreviewJobService",
return_value=job_service,
),
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.KnowledgeFSInitialSourcePreviewService"
) as preview_service,
patch(
"tasks.knowledge_fs_initial_source_preview_tasks.session_factory.create_session",
return_value=session_context,
),
patch("tasks.knowledge_fs_initial_source_preview_tasks.session_factory.get_session_maker"),
):
run_knowledge_fs_initial_source_preview.run(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
payload=_payload(),
)
_run_preview(job_service, preview_service=None)
preview_service.assert_not_called()
assert job_service.transition_status.call_args_list[-1] == call(
tenant_id="tenant-1",
account_id="account-1",
job_id="job-1",
status="failed",
allowed_from=("pending", "running"),
)
assert job_service.transitions[-1] == {
**_JOB_KEY,
"status": "failed",
"allowed_from": ("pending", "running"),
}
@@ -1,11 +1,14 @@
from collections.abc import Generator
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from dataclasses import dataclass, field
from unittest.mock import patch
import pytest
from models.knowledge_fs import KnowledgeFSControlSpaceState
from core.db.session_factory import session_factory
from models.account import Account
from models.knowledge_fs import KnowledgeFSControlSpace, KnowledgeFSControlSpaceState
from models.oauth import DatasourceProvider
from services.knowledge_fs.product_dto import (
KnowledgeFSInitialOnlineDocumentSourcePayload,
KnowledgeFSInitialOnlineDriveSourcePayload,
@@ -25,8 +28,17 @@ from tasks.knowledge_fs_initial_source_tasks import (
start_initial_website_source_import,
submit_initial_source_for_upgrade,
)
_DEFAULT_CREDENTIAL = object()
from tests.unit_tests.tasks.knowledge_fs_task_fakes import (
FacadeRuntime,
RecordingKnowledgeFSFacade,
source_connection_list_response,
source_connection_response,
source_list_response,
source_provider_list_response,
source_response,
source_workflow_response,
sync_policy_response,
)
def _payload(sync_policy: str = "daily") -> KnowledgeFSInitialWebsiteSourcePayload:
@@ -111,102 +123,149 @@ def _drive_payload() -> KnowledgeFSInitialOnlineDriveSourcePayload:
)
def _facade() -> MagicMock:
facade = MagicMock()
facade.list_sources.return_value = SimpleNamespace(data=[], next_cursor=None)
facade.list_source_providers.return_value = SimpleNamespace(
data=[SimpleNamespace(id="plugin-daemon-website", available=True)]
)
facade.list_source_connections.return_value = SimpleNamespace(
data=[
SimpleNamespace(
configuration={
"credentialId": "firecrawl-credential-1",
"datasource": "crawl",
"pluginId": "langgenius/firecrawl_datasource",
"provider": "firecrawl",
"providerKind": "website",
},
id="connection-1",
_FIRECRAWL_CONNECTION_CONFIGURATION: dict[str, bool | int | str] = {
"credentialId": "firecrawl-credential-1",
"datasource": "crawl",
"pluginId": "langgenius/firecrawl_datasource",
"provider": "firecrawl",
"providerKind": "website",
}
@dataclass(frozen=True)
class _Credential:
id: str
name: str
provider: str = "firecrawl"
plugin_id: str = "langgenius/firecrawl_datasource"
_FIRECRAWL_CREDENTIAL = _Credential(id="firecrawl-credential-1", name="Firecrawl")
@dataclass
class _ControlSpaceRepository:
control_space: KnowledgeFSControlSpace | None
lookups: list[dict[str, str]] = field(default_factory=list)
def get(self, *, tenant_id: str, control_space_id: str) -> KnowledgeFSControlSpace | None:
self.lookups.append({"tenant_id": tenant_id, "control_space_id": control_space_id})
return self.control_space
def _facade() -> RecordingKnowledgeFSFacade:
facade = RecordingKnowledgeFSFacade()
facade.script("list_sources", source_list_response())
facade.script("list_source_providers", source_provider_list_response("plugin-daemon-website"))
facade.script(
"list_source_connections",
source_connection_list_response(
source_connection_response(
configuration=_FIRECRAWL_CONNECTION_CONFIGURATION,
connection_id="connection-1",
provider_id="plugin-daemon-website",
status="active",
)
],
next_cursor=None,
),
)
facade.create_source_connection.return_value = SimpleNamespace(
configuration={"credentialId": "firecrawl-credential-1"},
id="connection-created",
provider_id="plugin-daemon-website",
status="active",
facade.script(
"create_source_connection",
source_connection_response(
configuration={"credentialId": "firecrawl-credential-1"},
connection_id="connection-created",
provider_id="plugin-daemon-website",
status="active",
),
)
facade.create_source.return_value = SimpleNamespace(id="source-1")
facade.import_selected_source_crawl.return_value = SimpleNamespace(
id="workflow-1",
source_id="source-1",
state="completed",
facade.script("create_source", source_response(metadata={}, status="disabled", version=1))
facade.script(
"import_selected_source_crawl",
source_workflow_response(workflow_id="workflow-1", source_id="source-1", state="completed"),
)
facade.get_source_workflow.return_value = SimpleNamespace(
id="workflow-1",
source_id="source-1",
state="completed",
facade.script(
"get_source_workflow",
source_workflow_response(workflow_id="workflow-1", source_id="source-1", state="completed"),
)
facade.get_source.return_value = SimpleNamespace(
metadata={
"clientRequestId": "initial-website-source:operation-1",
"parameters": {"limit": 99},
"preview": True,
},
status="disabled",
version=3,
facade.script(
"get_source",
source_response(
metadata={
"clientRequestId": "initial-website-source:operation-1",
"parameters": {"limit": 99},
"preview": True,
},
status="disabled",
version=3,
),
)
facade.update_source.return_value = SimpleNamespace(
metadata={
"clientRequestId": "initial-website-source:operation-1",
"preview": False,
},
status="active",
version=4,
facade.script(
"update_source",
source_response(
metadata={
"clientRequestId": "initial-website-source:operation-1",
"preview": False,
},
status="active",
version=4,
),
)
facade.get_source_sync_policy.side_effect = KnowledgeFSProductResourceNotFoundError("not found")
facade.script("get_source_sync_policy", KnowledgeFSProductResourceNotFoundError("not found"))
facade.script("update_source_sync_policy", None)
return facade
def _seed_account_and_credential(credential: _Credential | None) -> None:
account = Account(name="Initial source owner", email="initial-source-owner@example.com")
account.id = "account-1"
with session_factory.create_session() as session:
session.add(account)
if credential is not None:
provider = DatasourceProvider(
tenant_id="tenant-1",
name=credential.name,
provider=credential.provider,
plugin_id=credential.plugin_id,
auth_type="api_key",
encrypted_credentials={},
)
provider.id = credential.id
session.add(provider)
session.commit()
@contextmanager
def _runtime(
facade: MagicMock,
facade: RecordingKnowledgeFSFacade,
*,
credential: SimpleNamespace | None | object = _DEFAULT_CREDENTIAL,
credential: _Credential | None = _FIRECRAWL_CREDENTIAL,
state: KnowledgeFSControlSpaceState = KnowledgeFSControlSpaceState.ACTIVE,
knowledge_space_id: str | None = "space-1",
) -> Generator[MagicMock]:
session_context = MagicMock()
session = MagicMock()
session.scalar.return_value = (
SimpleNamespace(id="firecrawl-credential-1", name="Firecrawl")
if credential is _DEFAULT_CREDENTIAL
else credential
control_space_found: bool = True,
) -> Generator[_ControlSpaceRepository]:
_seed_account_and_credential(credential)
control_space = KnowledgeFSControlSpace(
tenant_id="tenant-1",
owner_account_id="account-1",
provisioning_key="provision-1",
state=state,
knowledge_space_id=knowledge_space_id,
)
session_context.__enter__.return_value = session
session_maker = MagicMock(return_value=session_context)
repository = _ControlSpaceRepository(control_space if control_space_found else None)
with (
patch(
"tasks.knowledge_fs_initial_source_tasks.session_factory.get_session_maker",
return_value=session_maker,
"tasks.knowledge_fs_initial_source_tasks.SQLAlchemyKnowledgeFSControlSpaceRepository",
return_value=repository,
),
patch(
"tasks.knowledge_fs_initial_source_tasks.get_knowledge_fs_runtime",
return_value=FacadeRuntime(facade),
),
patch("tasks.knowledge_fs_initial_source_tasks.SQLAlchemyKnowledgeFSControlSpaceRepository") as repository_type,
patch("tasks.knowledge_fs_initial_source_tasks.get_knowledge_fs_runtime") as get_runtime,
):
repository_type.return_value.get.return_value = SimpleNamespace(
state=state,
knowledge_space_id=knowledge_space_id,
)
get_runtime.return_value.facade = facade
yield repository_type
yield repository
def _start(
facade: MagicMock,
facade: RecordingKnowledgeFSFacade,
payload: KnowledgeFSInitialWebsiteSourcePayload,
*,
workflow_id: str | None = None,
@@ -227,7 +286,7 @@ def test_initial_website_source_import_recrawls_exact_selection_and_configures_d
assert _start(facade, _payload()) == "workflow-1"
source_payload = facade.create_source.call_args.kwargs["payload"]
source_payload = facade.payload("create_source")
assert source_payload.status == "disabled"
assert source_payload.connection_id == "connection-1"
assert source_payload.metadata["datasourceParameterMode"] == "exact"
@@ -240,16 +299,16 @@ def test_initial_website_source_import_recrawls_exact_selection_and_configures_d
"limit": 25,
"url": "https://docs.dify.ai",
}
import_payload = facade.import_selected_source_crawl.call_args.kwargs["payload"]
import_payload = facade.payload("import_selected_source_crawl")
assert import_payload.source_urls == [
"https://docs.dify.ai/a",
"https://docs.dify.ai/b",
]
source_update_payload = facade.update_source.call_args.kwargs["payload"]
source_update_payload = facade.payload("update_source")
assert source_update_payload.expected_version == 3
assert source_update_payload.metadata == {"preview": False}
assert source_update_payload.status == "active"
sync_payload = facade.update_source_sync_policy.call_args.kwargs["payload"]
sync_payload = facade.payload("update_source_sync_policy")
assert sync_payload.mode == "interval"
assert sync_payload.enabled is True
assert sync_payload.expected_revision == 0
@@ -275,7 +334,7 @@ def test_initial_website_source_import_preserves_distinct_urls_with_the_same_can
assert _start(facade, payload) == "workflow-1"
import_payload = facade.import_selected_source_crawl.call_args.kwargs["payload"]
import_payload = facade.payload("import_selected_source_crawl")
assert import_payload.source_urls == [
"https://docs.dify.ai/a/",
"https://DOCS.dify.ai:443/a#section",
@@ -287,7 +346,7 @@ def test_initial_website_source_import_preserves_legacy_crawl_option_projection(
assert _start(facade, _legacy_payload()) == "workflow-1"
source_payload = facade.create_source.call_args.kwargs["payload"]
source_payload = facade.payload("create_source")
assert "datasourceParameterMode" not in source_payload.metadata
assert "parameters" not in source_payload.metadata
assert source_payload.metadata["crawlOptions"] == {
@@ -334,25 +393,38 @@ def test_initial_connector_source_import_uses_exact_binding_and_selection(
expected_provider_name: str,
) -> None:
facade = _facade()
facade.list_source_providers.return_value = SimpleNamespace(data=[SimpleNamespace(id=provider_id, available=True)])
facade.list_source_connections.return_value = SimpleNamespace(data=[], next_cursor=None)
facade.create_source_connection.return_value = SimpleNamespace(id="connector-1", status="active")
facade.import_source_workflow.return_value = SimpleNamespace(
id="connector-workflow-1",
source_id="source-1",
state="completed",
facade.script("list_source_providers", source_provider_list_response(provider_id))
facade.script("list_source_connections", source_connection_list_response())
facade.script(
"create_source_connection",
source_connection_response(connection_id="connector-1", provider_id=provider_id, status="active"),
)
facade.get_source.return_value = SimpleNamespace(
metadata={"clientRequestId": "initial-source:operation-1", "preview": True},
status="disabled",
version=3,
facade.script(
"import_source_workflow",
source_workflow_response(workflow_id="connector-workflow-1", source_id="source-1", state="completed"),
)
facade.update_source.return_value = SimpleNamespace(
metadata={"clientRequestId": "initial-source:operation-1", "preview": False},
status="active",
version=4,
facade.script(
"get_source",
source_response(
metadata={"clientRequestId": "initial-source:operation-1", "preview": True},
status="disabled",
version=3,
),
)
facade.script(
"update_source",
source_response(
metadata={"clientRequestId": "initial-source:operation-1", "preview": False},
status="active",
version=4,
),
)
credential = _Credential(
id=credential_id,
name=expected_source_name,
provider=payload.provider,
plugin_id=payload.plugin_id,
)
credential = SimpleNamespace(id=credential_id, name=expected_source_name)
with _runtime(facade, credential=credential):
result = start_initial_source_import(
@@ -364,7 +436,7 @@ def test_initial_connector_source_import_uses_exact_binding_and_selection(
)
assert result == "connector-workflow-1"
connection_payload = facade.create_source_connection.call_args.kwargs["payload"]
connection_payload = facade.payload("create_source_connection")
assert connection_payload.provider_id == provider_id
assert connection_payload.configuration == {
"credentialId": credential_id,
@@ -373,45 +445,50 @@ def test_initial_connector_source_import_uses_exact_binding_and_selection(
"provider": payload.provider,
"providerKind": "online-document" if workflow_kind == "online-document-import" else "online-drive",
}
source_payload = facade.create_source.call_args.kwargs["payload"]
source_payload = facade.payload("create_source")
assert source_payload.connection_id == "connector-1"
assert source_payload.name == expected_source_name
assert source_payload.type == "connector"
assert source_payload.uri == "connector://connector-1"
assert source_payload.metadata["providerId"] == provider_id
assert source_payload.metadata["providerName"] == expected_provider_name
import_payload = facade.import_source_workflow.call_args.kwargs["payload"].root
import_payload = facade.payload("import_source_workflow").root
assert import_payload.kind == workflow_kind
assert import_payload.items == payload.selection
sync_payload = facade.update_source_sync_policy.call_args.kwargs["payload"]
sync_payload = facade.payload("update_source_sync_policy")
assert sync_payload.mode == ("manual" if workflow_kind == "online-drive-import" else "interval")
def test_initial_website_source_import_reuses_source_across_pages_and_preserves_failure() -> None:
facade = _facade()
existing_source = SimpleNamespace(
id="existing-source",
existing_source = source_response(
source_id="existing-source",
metadata={"clientRequestId": "initial-website-source:operation-1"},
status="disabled",
version=2,
)
facade.list_sources.side_effect = [
SimpleNamespace(
data=[SimpleNamespace(id="other", metadata={})],
facade.script(
"list_sources",
source_list_response(
source_response(source_id="other", metadata={}, status="active", version=1),
next_cursor="next-source",
),
SimpleNamespace(data=[existing_source], next_cursor=None),
]
facade.import_selected_source_crawl.return_value = SimpleNamespace(
failure=SimpleNamespace(message="Source document materialization failed"),
id="failed-workflow",
last_error_code="SOURCE_DOCUMENT_MATERIALIZATION_FAILED",
state="failed",
source_list_response(existing_source),
)
facade.script(
"import_selected_source_crawl",
source_workflow_response(
workflow_id="failed-workflow",
state="failed",
failure_message="Source document materialization failed",
last_error_code="SOURCE_DOCUMENT_MATERIALIZATION_FAILED",
),
)
assert _start(facade, _payload()) == "failed-workflow"
facade.create_source.assert_not_called()
facade.list_source_connections.assert_not_called()
source_update_payload = facade.update_source.call_args.kwargs["payload"]
assert facade.call_count("create_source") == 0
assert facade.call_count("list_source_connections") == 0
source_update_payload = facade.payload("update_source")
assert source_update_payload.status == "disabled"
assert "parameters" not in source_update_payload.metadata
assert source_update_payload.metadata["preview"] is False
@@ -421,12 +498,12 @@ def test_initial_website_source_import_reuses_source_across_pages_and_preserves_
"enabled": True,
"mode": "interval",
}
facade.update_source_sync_policy.assert_not_called()
assert facade.call_count("update_source_sync_policy") == 0
def test_initial_website_source_import_exposes_preview_consume_rejection() -> None:
facade = _facade()
facade.consume_namespace_source_preview.side_effect = KnowledgeFSProductRequestRejectedError(status_code=409)
facade.script("consume_namespace_source_preview", KnowledgeFSProductRequestRejectedError(status_code=409))
payload = _payload().model_copy(
update={
"credential_id": "firecrawl-credential-1",
@@ -445,7 +522,7 @@ def test_initial_website_source_import_exposes_preview_consume_rejection() -> No
with pytest.raises(KnowledgeFSProductRequestRejectedError):
_start(facade, payload)
source_update = facade.update_source.call_args.kwargs["payload"]
source_update = facade.payload("update_source")
assert source_update.status == "disabled"
assert source_update.metadata["preview"] is False
assert source_update.metadata["initialImport"]["state"] == "failed"
@@ -472,34 +549,25 @@ def test_initial_website_source_import_configures_remaining_sync_modes(
expected_custom_interval_seconds: int | None,
) -> None:
facade = _facade()
facade.list_source_connections.side_effect = [
SimpleNamespace(
data=[SimpleNamespace(id="inactive", provider_id="other", status="disabled")],
facade.script(
"list_source_connections",
source_connection_list_response(
source_connection_response(connection_id="inactive", provider_id="other", status="revoked"),
next_cursor="next-connection",
),
SimpleNamespace(
data=[
SimpleNamespace(
configuration={
"credentialId": "firecrawl-credential-1",
"datasource": "crawl",
"pluginId": "langgenius/firecrawl_datasource",
"provider": "firecrawl",
"providerKind": "website",
},
id="connection-2",
provider_id="plugin-daemon-website",
status="active",
)
],
next_cursor=None,
source_connection_list_response(
source_connection_response(
configuration=_FIRECRAWL_CONNECTION_CONFIGURATION,
connection_id="connection-2",
provider_id="plugin-daemon-website",
status="active",
)
),
]
facade.get_source_sync_policy.side_effect = None
facade.get_source_sync_policy.return_value = SimpleNamespace(revision=7)
)
facade.script("get_source_sync_policy", sync_policy_response(revision=7))
assert _start(facade, _payload(sync_policy)) == "workflow-1"
sync_payload = facade.update_source_sync_policy.call_args.kwargs["payload"]
sync_payload = facade.payload("update_source_sync_policy")
assert sync_payload.mode == expected_mode
assert sync_payload.enabled is expected_enabled
assert sync_payload.custom_interval_seconds == expected_custom_interval_seconds
@@ -509,19 +577,22 @@ def test_initial_website_source_import_configures_remaining_sync_modes(
def test_initial_website_source_import_does_not_recommit_active_source_after_retry() -> None:
facade = _facade()
facade.get_source.return_value = SimpleNamespace(
metadata={
"clientRequestId": "initial-website-source:operation-1",
"preview": False,
},
status="active",
version=4,
facade.script(
"get_source",
source_response(
metadata={
"clientRequestId": "initial-website-source:operation-1",
"preview": False,
},
status="active",
version=4,
),
)
assert _start(facade, _payload()) == "workflow-1"
facade.update_source.assert_not_called()
sync_payload = facade.update_source_sync_policy.call_args.kwargs["payload"]
assert facade.call_count("update_source") == 0
sync_payload = facade.payload("update_source_sync_policy")
assert sync_payload.expected_source_version == 4
@@ -570,8 +641,7 @@ def test_initial_website_source_import_rejects_unavailable_spaces(
def test_initial_website_source_import_rejects_missing_control_space() -> None:
facade = _facade()
with _runtime(facade) as repository_type:
repository_type.return_value.get.return_value = None
with _runtime(facade, control_space_found=False):
with pytest.raises(RuntimeError, match="control-space was not found"):
start_initial_website_source_import(
tenant_id="tenant-1",
@@ -584,7 +654,7 @@ def test_initial_website_source_import_rejects_missing_control_space() -> None:
def test_initial_website_source_import_rejects_unavailable_provider() -> None:
unavailable_provider = _facade()
unavailable_provider.list_source_providers.return_value = SimpleNamespace(data=[])
unavailable_provider.script("list_source_providers", source_provider_list_response())
with pytest.raises(RuntimeError, match="provider is unavailable"):
_start(unavailable_provider, _payload())
@@ -603,20 +673,22 @@ def test_initial_website_source_import_rejects_missing_firecrawl_credential() ->
payload=_payload(),
)
facade.create_source_connection.assert_not_called()
facade.create_source.assert_not_called()
assert facade.call_count("create_source_connection") == 0
assert facade.call_count("create_source") == 0
def test_initial_website_source_import_creates_missing_firecrawl_connection() -> None:
facade = _facade()
facade.list_source_connections.return_value = SimpleNamespace(
data=[SimpleNamespace(id="inactive", provider_id="other", status="disabled")],
next_cursor=None,
facade.script(
"list_source_connections",
source_connection_list_response(
source_connection_response(connection_id="inactive", provider_id="other", status="revoked")
),
)
assert _start(facade, _payload()) == "workflow-1"
connection_payload = facade.create_source_connection.call_args.kwargs["payload"]
connection_payload = facade.payload("create_source_connection")
assert connection_payload.auth_kind == "endpoint"
assert connection_payload.configuration == {
"credentialId": "firecrawl-credential-1",
@@ -627,43 +699,36 @@ def test_initial_website_source_import_creates_missing_firecrawl_connection() ->
}
assert connection_payload.credentials == {}
assert connection_payload.provider_id == "plugin-daemon-website"
source_payload = facade.create_source.call_args.kwargs["payload"]
source_payload = facade.payload("create_source")
assert source_payload.connection_id == "connection-created"
def test_initial_website_source_import_retries_provisioning_firecrawl_connection() -> None:
facade = _facade()
facade.list_source_connections.return_value = SimpleNamespace(
data=[
SimpleNamespace(
configuration={
"credentialId": "firecrawl-credential-1",
"datasource": "crawl",
"pluginId": "langgenius/firecrawl_datasource",
"provider": "firecrawl",
"providerKind": "website",
},
id="connection-1",
facade.script(
"list_source_connections",
source_connection_list_response(
source_connection_response(
configuration=_FIRECRAWL_CONNECTION_CONFIGURATION,
connection_id="connection-1",
provider_id="plugin-daemon-website",
status="provisioning",
)
],
next_cursor=None,
),
)
with pytest.raises(KnowledgeFSInitialSourceNotReadyError, match="still provisioning"):
_start(facade, _payload())
facade.create_source_connection.assert_not_called()
facade.create_source.assert_not_called()
assert facade.call_count("create_source_connection") == 0
assert facade.call_count("create_source") == 0
def test_initial_website_source_import_retries_running_workflow() -> None:
facade = _facade()
facade.import_selected_source_crawl.return_value = SimpleNamespace(
id="running-workflow",
source_id="source-1",
state="running",
facade.script(
"import_selected_source_crawl",
source_workflow_response(workflow_id="running-workflow", source_id="source-1", state="running"),
)
with pytest.raises(KnowledgeFSInitialSourceNotReadyError, match="still running") as raised:
@@ -677,49 +742,53 @@ def test_initial_website_source_import_polls_existing_workflow_without_recreatin
assert _start(facade, _payload(), workflow_id="workflow-1") == "workflow-1"
facade.get_source_workflow.assert_called_once_with(
tenant_id="tenant-1",
account_id="account-1",
control_space_id="control-1",
run_id="workflow-1",
)
facade.list_sources.assert_not_called()
facade.list_source_connections.assert_not_called()
facade.create_source.assert_not_called()
facade.import_selected_source_crawl.assert_not_called()
source_update_payload = facade.update_source.call_args.kwargs["payload"]
assert facade.calls["get_source_workflow"] == [
{
"tenant_id": "tenant-1",
"account_id": "account-1",
"control_space_id": "control-1",
"run_id": "workflow-1",
}
]
assert facade.call_count("list_sources") == 0
assert facade.call_count("list_source_connections") == 0
assert facade.call_count("create_source") == 0
assert facade.call_count("import_selected_source_crawl") == 0
source_update_payload = facade.payload("update_source")
assert source_update_payload.status == "active"
def test_initial_website_source_import_keeps_polling_existing_running_workflow() -> None:
facade = _facade()
facade.get_source_workflow.return_value = SimpleNamespace(
id="workflow-1",
source_id="source-1",
state="importing",
facade.script(
"get_source_workflow",
source_workflow_response(workflow_id="workflow-1", source_id="source-1", state="importing"),
)
with pytest.raises(KnowledgeFSInitialSourceNotReadyError, match="still running") as raised:
_start(facade, _payload(), workflow_id="workflow-1")
assert raised.value.workflow_id == "workflow-1"
facade.import_selected_source_crawl.assert_not_called()
facade.get_source.assert_not_called()
assert facade.call_count("import_selected_source_crawl") == 0
assert facade.call_count("get_source") == 0
def test_initial_website_source_import_exposes_failed_source_without_activating_it() -> None:
facade = _facade()
facade.get_source_workflow.return_value = SimpleNamespace(
failure=SimpleNamespace(message="Source document materialization failed"),
id="workflow-1",
last_error_code="SOURCE_DOCUMENT_MATERIALIZATION_FAILED",
source_id="source-1",
state="failed",
facade.script(
"get_source_workflow",
source_workflow_response(
workflow_id="workflow-1",
source_id="source-1",
state="failed",
failure_message="Source document materialization failed",
last_error_code="SOURCE_DOCUMENT_MATERIALIZATION_FAILED",
),
)
assert _start(facade, _payload(), workflow_id="workflow-1") == "workflow-1"
source_update_payload = facade.update_source.call_args.kwargs["payload"]
source_update_payload = facade.payload("update_source")
assert source_update_payload.status == "disabled"
assert source_update_payload.metadata["preview"] is False
initial_import = source_update_payload.metadata["initialImport"]
@@ -735,7 +804,7 @@ def test_initial_website_source_import_exposes_failed_source_without_activating_
"mode": "interval",
}
assert len(initial_import["configurationFingerprint"]) == 64
facade.update_source_sync_policy.assert_not_called()
assert facade.call_count("update_source_sync_policy") == 0
def test_initial_website_source_task_returns_result_and_retries_not_ready_error() -> None:
@@ -904,21 +973,25 @@ def test_initial_source_task_does_not_retry_authoritative_missing_resource() ->
def test_upgrade_source_submission_succeeds_when_first_import_submission_fails() -> None:
facade = _facade()
facade.create_source.return_value = SimpleNamespace(
connection_id="connection-1",
id="source-1",
metadata={"clientRequestId": "initial-website-source:operation-1", "preview": True},
status="disabled",
version=1,
facade.script(
"create_source",
source_response(
connection_id="connection-1",
metadata={"clientRequestId": "initial-website-source:operation-1", "preview": True},
status="disabled",
version=1,
),
)
facade.update_source.return_value = SimpleNamespace(
connection_id="connection-1",
id="source-1",
metadata={"clientRequestId": "initial-website-source:operation-1", "preview": False},
status="active",
version=2,
facade.script(
"update_source",
source_response(
connection_id="connection-1",
metadata={"clientRequestId": "initial-website-source:operation-1", "preview": False},
status="active",
version=2,
),
)
facade.import_selected_source_crawl.side_effect = RuntimeError("crawl queue unavailable")
facade.script("import_selected_source_crawl", RuntimeError("crawl queue unavailable"))
with _runtime(facade):
result = submit_initial_source_for_upgrade(
@@ -933,4 +1006,4 @@ def test_upgrade_source_submission_succeeds_when_first_import_submission_fails()
assert result.source_id == "source-1"
assert result.workflow_id is None
assert result.workflow_error == "RuntimeError"
facade.update_source_sync_policy.assert_called_once()
assert facade.call_count("update_source_sync_policy") == 1
@@ -1,12 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from services.knowledge_fs.lifecycle_readiness import KnowledgeFSLifecycleWorkerReadiness
from services.knowledge_fs.lifecycle_saga import KnowledgeFSLifecycleDispatchResult
from services.knowledge_fs.orphan_reconciler import KnowledgeFSOrphanReport
from tasks.knowledge_fs_lifecycle_tasks import (
cleanup_knowledge_fs_staged_uploads,
run_knowledge_fs_lifecycle_worker,
@@ -14,6 +16,41 @@ from tasks.knowledge_fs_lifecycle_tasks import (
from tests.unit_tests.config_override import apply_config_overrides
@dataclass
class _SagaRunner:
results: list[KnowledgeFSLifecycleDispatchResult]
dispatches: list[dict[str, object]] = field(default_factory=list)
def dispatch_one(self, **kwargs: object) -> KnowledgeFSLifecycleDispatchResult:
self.dispatches.append(kwargs)
return self.results.pop(0)
@dataclass
class _OrphanReconciler:
report: KnowledgeFSOrphanReport
calls: list[dict[str, object]] = field(default_factory=list)
def reconcile(self, *, limit: int, apply_repairs: bool) -> KnowledgeFSOrphanReport:
self.calls.append({"limit": limit, "apply_repairs": apply_repairs})
return self.report
@dataclass
class _StagedUploads:
expired: int
limits: list[int] = field(default_factory=list)
def cleanup_expired(self, *, limit: int) -> int:
self.limits.append(limit)
return self.expired
@dataclass(frozen=True)
class _Runtime:
facade: str
def test_worker_self_check_returns_before_assembling_remote_when_rollout_is_not_ready() -> None:
with (
patch(
@@ -31,21 +68,19 @@ def test_worker_self_check_returns_before_assembling_remote_when_rollout_is_not_
def test_ready_worker_dispatches_a_bounded_batch_then_repairs_orphans(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runner = SimpleNamespace(
dispatch_one=MagicMock(
side_effect=(
SimpleNamespace(claimed=True, completed=True),
SimpleNamespace(claimed=True, completed=False),
SimpleNamespace(claimed=False, completed=False),
)
)
runner = _SagaRunner(
results=[
KnowledgeFSLifecycleDispatchResult(claimed=True, completed=True, outbox_id="outbox-1"),
KnowledgeFSLifecycleDispatchResult(claimed=True, completed=False, outbox_id="outbox-2"),
KnowledgeFSLifecycleDispatchResult(claimed=False, completed=False, outbox_id=None),
]
)
reconciler = SimpleNamespace(
reconcile=MagicMock(
return_value=SimpleNamespace(
repaired_control_space_ids=("control-1",),
cleanup_control_space_ids=("control-2", "control-3"),
)
reconciler = _OrphanReconciler(
report=KnowledgeFSOrphanReport(
repaired_control_space_ids=("control-1",),
remote_orphan_space_ids=(),
local_missing_remote_ids=(),
cleanup_control_space_ids=("control-2", "control-3"),
)
)
apply_config_overrides(
@@ -69,14 +104,14 @@ def test_ready_worker_dispatches_a_bounded_batch_then_repairs_orphans(
result = run_knowledge_fs_lifecycle_worker.run()
assert result == {"status": "ok", "dispatched": 2, "completed": 1, "reconciled": 3}
assert runner.dispatch_one.call_count == 3
assert runner.dispatch_one.call_args.kwargs == {
assert len(runner.dispatches) == 3
assert runner.dispatches[-1] == {
"worker_id": "worker-host:manual",
"now": "now-1",
"lease_duration": timedelta(seconds=30),
"product_enabled": True,
}
reconciler.reconcile.assert_called_once_with(limit=5, apply_repairs=True)
assert reconciler.calls == [{"limit": 5, "apply_repairs": True}]
def test_staged_upload_cleanup_returns_before_runtime_assembly_when_not_ready() -> None:
@@ -94,8 +129,7 @@ def test_staged_upload_cleanup_returns_before_runtime_assembly_when_not_ready()
def test_staged_upload_cleanup_uses_the_configured_batch_limit(monkeypatch: pytest.MonkeyPatch) -> None:
staged_uploads = MagicMock()
staged_uploads.cleanup_expired.return_value = 4
staged_uploads = _StagedUploads(expired=4)
apply_config_overrides(monkeypatch, KNOWLEDGE_FS_LIFECYCLE_BATCH_SIZE=25)
with (
patch(
@@ -108,7 +142,7 @@ def test_staged_upload_cleanup_uses_the_configured_batch_limit(monkeypatch: pyte
),
patch(
"tasks.knowledge_fs_lifecycle_tasks.get_knowledge_fs_runtime",
return_value=SimpleNamespace(facade="facade-1"),
return_value=_Runtime(facade="facade-1"),
) as runtime,
patch(
"tasks.knowledge_fs_lifecycle_tasks.KnowledgeFSStagedUploadService",
@@ -120,4 +154,4 @@ def test_staged_upload_cleanup_uses_the_configured_batch_limit(monkeypatch: pyte
assert result == 4
runtime.assert_called_once_with("maker-1")
service.assert_called_once_with("maker-1", facade="facade-1")
staged_uploads.cleanup_expired.assert_called_once_with(limit=25)
assert staged_uploads.limits == [25]
@@ -1,10 +1,10 @@
from types import SimpleNamespace
from typing import Literal
from unittest.mock import MagicMock, patch
from typing import Literal, Never
from unittest.mock import patch
import pytest
from celery.exceptions import Retry
from services.knowledge_fs.product_dto import KnowledgeFSSourceResponse, KnowledgeFSSourceWorkflowResponse
from services.knowledge_fs.product_remote import (
KnowledgeFSProductRequestRejectedError,
KnowledgeFSProductResourceNotFoundError,
@@ -14,11 +14,17 @@ from tasks.knowledge_fs_source_import_tasks import (
KnowledgeFSSourceImportNotReadyError,
finalize_source_import_once,
)
from tests.unit_tests.tasks.knowledge_fs_task_fakes import (
FacadeRuntime,
RecordingKnowledgeFSFacade,
RecordingRetry,
source_response,
source_workflow_response,
)
def _source(*, status: str = "syncing") -> SimpleNamespace:
return SimpleNamespace(
id="source-1",
def _source(*, status: str = "syncing") -> KnowledgeFSSourceResponse:
return source_response(
metadata={
"parameters": {"limit": 99},
"preview": False,
@@ -34,9 +40,23 @@ def _source(*, status: str = "syncing") -> SimpleNamespace:
)
def _run(facade: MagicMock) -> str:
with patch("tasks.knowledge_fs_source_import_tasks.get_knowledge_fs_runtime") as runtime:
runtime.return_value.facade = facade
def _completed_workflow() -> KnowledgeFSSourceWorkflowResponse:
return source_workflow_response(workflow_id="import-1", state="completed")
def _facade(workflow: KnowledgeFSSourceWorkflowResponse) -> RecordingKnowledgeFSFacade:
facade = RecordingKnowledgeFSFacade()
facade.script("get_source_workflow", workflow)
facade.script("get_source_sync_policy", KnowledgeFSProductResourceNotFoundError("missing"))
facade.script("update_source_sync_policy", None)
return facade
def _run(facade: RecordingKnowledgeFSFacade) -> str:
with patch(
"tasks.knowledge_fs_source_import_tasks.get_knowledge_fs_runtime",
return_value=FacadeRuntime(facade),
):
return finalize_source_import_once(
tenant_id="tenant-1",
account_id="account-1",
@@ -46,45 +66,48 @@ def _run(facade: MagicMock) -> str:
)
def _raise(error: Exception) -> Never:
raise error
def test_finalize_source_import_waits_for_terminal_workflow() -> None:
facade = MagicMock()
facade.get_source_workflow.return_value = SimpleNamespace(state="importing")
facade = _facade(source_workflow_response(workflow_id="import-1", state="importing"))
with pytest.raises(KnowledgeFSSourceImportNotReadyError):
_run(facade)
facade.get_source.assert_not_called()
assert facade.call_count("get_source") == 0
def test_finalize_source_import_activates_source_then_applies_policy_for_new_version() -> None:
facade = MagicMock()
facade.get_source_workflow.return_value = SimpleNamespace(id="import-1", state="completed")
facade.get_source.return_value = _source()
facade.update_source.return_value = SimpleNamespace(
id="source-1",
metadata={
"lastImport": {
"kind": "crawl-preview-selection",
"previewWorkflowId": "preview-1",
"state": "completed",
"syncPolicy": {"enabled": False, "mode": "manual"},
"workflowId": "import-1",
facade = _facade(_completed_workflow())
facade.script("get_source", _source())
facade.script(
"update_source",
source_response(
metadata={
"lastImport": {
"kind": "crawl-preview-selection",
"previewWorkflowId": "preview-1",
"state": "completed",
"syncPolicy": {"enabled": False, "mode": "manual"},
"workflowId": "import-1",
},
"preview": False,
},
"preview": False,
},
status="active",
version=5,
status="active",
version=5,
),
)
facade.get_source_sync_policy.side_effect = KnowledgeFSProductResourceNotFoundError("missing")
assert _run(facade) == "import-1"
policy = facade.update_source_sync_policy.call_args.kwargs["payload"]
policy = facade.payload("update_source_sync_policy")
assert policy.enabled is False
assert policy.mode == "manual"
assert policy.expected_revision == 0
assert policy.expected_source_version == 5
update = facade.update_source.call_args.kwargs["payload"]
update = facade.payload("update_source")
assert update.status == "active"
assert "parameters" not in update.metadata
assert update.metadata["pendingImport"] is None
@@ -92,94 +115,98 @@ def test_finalize_source_import_activates_source_then_applies_policy_for_new_ver
def test_finalize_source_import_retries_policy_after_source_activation() -> None:
facade = MagicMock()
facade.get_source_workflow.return_value = SimpleNamespace(id="import-1", state="completed")
facade.get_source.return_value = SimpleNamespace(
id="source-1",
metadata={
"lastImport": {
"kind": "crawl-preview-selection",
"previewWorkflowId": "preview-1",
"state": "completed",
"syncPolicy": {"enabled": True, "mode": "interval"},
"workflowId": "import-1",
facade = _facade(_completed_workflow())
facade.script(
"get_source",
source_response(
metadata={
"lastImport": {
"kind": "crawl-preview-selection",
"previewWorkflowId": "preview-1",
"state": "completed",
"syncPolicy": {"enabled": True, "mode": "interval"},
"workflowId": "import-1",
},
"preview": False,
},
"preview": False,
},
status="active",
version=5,
status="active",
version=5,
),
)
facade.get_source_sync_policy.side_effect = KnowledgeFSProductResourceNotFoundError("missing")
assert _run(facade) == "import-1"
facade.update_source.assert_not_called()
policy = facade.update_source_sync_policy.call_args.kwargs["payload"]
assert facade.call_count("update_source") == 0
policy = facade.payload("update_source_sync_policy")
assert policy.expected_source_version == 5
def test_finalize_source_import_recovers_a_stale_failed_marker_after_workflow_completion() -> None:
facade = MagicMock()
facade.get_source_workflow.return_value = SimpleNamespace(id="import-1", state="completed")
facade.get_source.return_value = SimpleNamespace(
id="source-1",
metadata={
"lastImport": {
"errorCode": "SOURCE_OPERATION_FAILED",
"errorMessage": "provider failed",
"kind": "crawl-preview-selection",
"previewWorkflowId": "preview-1",
"state": "failed",
"syncPolicy": {"enabled": False, "mode": "manual"},
"workflowId": "import-1",
facade = _facade(_completed_workflow())
facade.script(
"get_source",
source_response(
metadata={
"lastImport": {
"errorCode": "SOURCE_OPERATION_FAILED",
"errorMessage": "provider failed",
"kind": "crawl-preview-selection",
"previewWorkflowId": "preview-1",
"state": "failed",
"syncPolicy": {"enabled": False, "mode": "manual"},
"workflowId": "import-1",
},
"pendingImport": None,
"preview": False,
},
"pendingImport": None,
"preview": False,
},
status="error",
version=5,
status="error",
version=5,
),
)
facade.update_source.return_value = SimpleNamespace(
id="source-1",
metadata={
"lastImport": {
"kind": "crawl-preview-selection",
"previewWorkflowId": "preview-1",
"state": "completed",
"syncPolicy": {"enabled": False, "mode": "manual"},
"workflowId": "import-1",
facade.script(
"update_source",
source_response(
metadata={
"lastImport": {
"kind": "crawl-preview-selection",
"previewWorkflowId": "preview-1",
"state": "completed",
"syncPolicy": {"enabled": False, "mode": "manual"},
"workflowId": "import-1",
},
"pendingImport": None,
"preview": False,
},
"pendingImport": None,
"preview": False,
},
status="active",
version=6,
status="active",
version=6,
),
)
facade.get_source_sync_policy.side_effect = KnowledgeFSProductResourceNotFoundError("missing")
assert _run(facade) == "import-1"
update = facade.update_source.call_args.kwargs["payload"]
update = facade.payload("update_source")
assert update.status == "active"
assert update.metadata["lastImport"]["state"] == "completed"
assert update.metadata["pendingImport"] is None
policy = facade.update_source_sync_policy.call_args.kwargs["payload"]
policy = facade.payload("update_source_sync_policy")
assert policy.expected_source_version == 6
def test_finalize_source_import_persists_failure_on_visible_source() -> None:
facade = MagicMock()
facade.get_source_workflow.return_value = SimpleNamespace(
failure=SimpleNamespace(message="provider failed"),
id="import-1",
last_error_code="PROVIDER_FAILED",
state="failed",
facade = _facade(
source_workflow_response(
workflow_id="import-1",
state="failed",
failure_message="provider failed",
last_error_code="PROVIDER_FAILED",
)
)
facade.get_source.return_value = _source()
facade.script("get_source", _source())
facade.script("update_source", _source(status="error"))
assert _run(facade) == "import-1"
update = facade.update_source.call_args.kwargs["payload"]
update = facade.payload("update_source")
assert update.status == "error"
assert "parameters" not in update.metadata
assert update.metadata["lastImport"] == {
@@ -192,28 +219,28 @@ def test_finalize_source_import_persists_failure_on_visible_source() -> None:
"workflowId": "import-1",
}
assert update.metadata["pendingImport"] is None
facade.update_source_sync_policy.assert_not_called()
assert facade.call_count("update_source_sync_policy") == 0
def test_finalize_website_source_import_waits_for_policy_write_fence() -> None:
facade = MagicMock()
facade.get_source_workflow.return_value = SimpleNamespace(id="import-1", state="completed")
facade.get_source.return_value = SimpleNamespace(
id="source-1",
metadata={
"lastImport": {
"kind": "website-crawl-import",
"state": "completed",
"syncPolicy": {"enabled": True, "mode": "interval"},
"workflowId": "import-1",
facade = _facade(_completed_workflow())
facade.script(
"get_source",
source_response(
metadata={
"lastImport": {
"kind": "website-crawl-import",
"state": "completed",
"syncPolicy": {"enabled": True, "mode": "interval"},
"workflowId": "import-1",
},
"preview": False,
},
"preview": False,
},
status="active",
version=5,
status="active",
version=5,
),
)
facade.get_source_sync_policy.side_effect = KnowledgeFSProductResourceNotFoundError("missing")
facade.update_source_sync_policy.side_effect = KnowledgeFSProductRequestRejectedError(status_code=400)
facade.script("update_source_sync_policy", KnowledgeFSProductRequestRejectedError(status_code=400))
with pytest.raises(KnowledgeFSSourceImportNotReadyError):
_run(facade)
@@ -223,25 +250,25 @@ def test_finalize_website_source_import_waits_for_policy_write_fence() -> None:
def test_finalize_non_website_source_import_does_not_reclassify_a_bad_request(
import_kind: str,
) -> None:
facade = MagicMock()
facade.get_source_workflow.return_value = SimpleNamespace(id="import-1", state="completed")
facade.get_source.return_value = SimpleNamespace(
id="source-1",
metadata={
"lastImport": {
"kind": import_kind,
"state": "completed",
"syncPolicy": {"enabled": True, "mode": "interval"},
"workflowId": "import-1",
facade = _facade(_completed_workflow())
facade.script(
"get_source",
source_response(
metadata={
"lastImport": {
"kind": import_kind,
"state": "completed",
"syncPolicy": {"enabled": True, "mode": "interval"},
"workflowId": "import-1",
},
"preview": False,
},
"preview": False,
},
status="active",
version=5,
status="active",
version=5,
),
)
facade.get_source_sync_policy.side_effect = KnowledgeFSProductResourceNotFoundError("missing")
rejection = KnowledgeFSProductRequestRejectedError(status_code=400)
facade.update_source_sync_policy.side_effect = rejection
facade.script("update_source_sync_policy", rejection)
with pytest.raises(KnowledgeFSProductRequestRejectedError) as raised:
_run(facade)
@@ -251,8 +278,8 @@ def test_finalize_non_website_source_import_does_not_reclassify_a_bad_request(
def test_finalize_source_import_retries_a_source_revision_conflict(monkeypatch: pytest.MonkeyPatch) -> None:
error = KnowledgeFSProductRequestRejectedError(status_code=409)
monkeypatch.setattr(task_module, "finalize_source_import_once", MagicMock(side_effect=error))
retry = MagicMock(side_effect=Retry())
monkeypatch.setattr(task_module, "finalize_source_import_once", lambda **_: _raise(error))
retry = RecordingRetry()
monkeypatch.setattr(task_module.finalize_source_import, "retry", retry)
with pytest.raises(Retry):
@@ -264,7 +291,7 @@ def test_finalize_source_import_retries_a_source_revision_conflict(monkeypatch:
workflow_id="import-1",
)
retry.assert_called_once_with(exc=error)
assert retry.calls == [{"exc": error}]
@pytest.mark.parametrize("status_code", [400, 422])
@@ -272,8 +299,8 @@ def test_finalize_source_import_does_not_retry_a_permanent_rejection(
monkeypatch: pytest.MonkeyPatch, status_code: Literal[400, 422]
) -> None:
error = KnowledgeFSProductRequestRejectedError(status_code=status_code)
monkeypatch.setattr(task_module, "finalize_source_import_once", MagicMock(side_effect=error))
retry = MagicMock(side_effect=Retry())
monkeypatch.setattr(task_module, "finalize_source_import_once", lambda **_: _raise(error))
retry = RecordingRetry()
monkeypatch.setattr(task_module.finalize_source_import, "retry", retry)
with pytest.raises(KnowledgeFSProductRequestRejectedError) as raised:
@@ -286,4 +313,4 @@ def test_finalize_source_import_does_not_retry_a_permanent_rejection(
)
assert raised.value is error
retry.assert_not_called()
assert retry.calls == []
@@ -1,6 +1,7 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from dataclasses import dataclass, field
from unittest.mock import patch
import pytest
from celery.app.task import Context
@@ -15,6 +16,24 @@ from tasks.knowledge_fs_upgrade_tasks import (
from tests.unit_tests.tasks.task_options import task_options
@dataclass
class _UpgradeRunner:
"""Returns ``outcome`` from ``run_next`` or raises it, and records failure reports."""
outcome: bool | Exception
runs: list[dict[str, object]] = field(default_factory=list)
failures: list[dict[str, object]] = field(default_factory=list)
def run_next(self, **kwargs: object) -> bool:
self.runs.append(kwargs)
if isinstance(self.outcome, Exception):
raise self.outcome
return self.outcome
def fail(self, *, job_id: str, error: Exception) -> None:
self.failures.append({"job_id": job_id, "error": error})
def test_upgrade_tasks_are_pinned_to_the_dedicated_queue() -> None:
assert task_options(run_knowledge_fs_upgrade)["queue"] == KNOWLEDGE_FS_UPGRADE_QUEUE
assert task_options(reconcile_knowledge_fs_upgrade_documents)["queue"] == KNOWLEDGE_FS_UPGRADE_QUEUE
@@ -33,8 +52,7 @@ def test_deferred_file_cleanup_uses_the_upgrade_session_factory() -> None:
@pytest.mark.parametrize("has_more", [True, False])
def test_worker_enqueues_only_the_expected_follow_up(has_more: bool) -> None:
runner = MagicMock()
runner.run_next.return_value = has_more
runner = _UpgradeRunner(outcome=has_more)
with (
patch("tasks.knowledge_fs_upgrade_tasks.session_factory.get_session_maker", return_value="maker"),
patch("tasks.knowledge_fs_upgrade_tasks.KnowledgeFSUpgradeRunner", return_value=runner),
@@ -43,7 +61,8 @@ def test_worker_enqueues_only_the_expected_follow_up(has_more: bool) -> None:
):
run_knowledge_fs_upgrade.run(job_id="job-1")
runner.run_next.assert_called_once()
assert len(runner.runs) == 1
assert runner.runs[0]["job_id"] == "job-1"
if has_more:
continue_upgrade.assert_called_once_with(kwargs={"job_id": "job-1"})
reconcile.assert_not_called()
@@ -53,9 +72,8 @@ def test_worker_enqueues_only_the_expected_follow_up(has_more: bool) -> None:
def test_worker_marks_parent_failed_when_a_checkpoint_raises() -> None:
runner = MagicMock()
error = RuntimeError("ordinary document upload failed")
runner.run_next.side_effect = error
runner = _UpgradeRunner(outcome=error)
with (
patch("tasks.knowledge_fs_upgrade_tasks.session_factory.get_session_maker", return_value="maker"),
patch("tasks.knowledge_fs_upgrade_tasks.KnowledgeFSUpgradeRunner", return_value=runner),
@@ -63,13 +81,12 @@ def test_worker_marks_parent_failed_when_a_checkpoint_raises() -> None:
):
run_knowledge_fs_upgrade.run(job_id="job-1")
runner.fail.assert_called_once_with(job_id="job-1", error=error)
assert runner.failures == [{"job_id": "job-1", "error": error}]
def test_worker_marks_parent_failed_when_provisioning_retries_are_exhausted() -> None:
runner = MagicMock()
error = KnowledgeFSUpgradeNotReadyError("Space is still provisioning")
runner.run_next.side_effect = error
runner = _UpgradeRunner(outcome=error)
run_knowledge_fs_upgrade.request_stack.push(Context(retries=run_knowledge_fs_upgrade.max_retries))
try:
with (
@@ -81,4 +98,4 @@ def test_worker_marks_parent_failed_when_provisioning_retries_are_exhausted() ->
finally:
run_knowledge_fs_upgrade.pop_request()
runner.fail.assert_called_once_with(job_id="job-1", error=error)
assert runner.failures == [{"job_id": "job-1", "error": error}]
@@ -1,7 +1,7 @@
import runpy
import sys
from pathlib import Path
from types import SimpleNamespace
from types import ModuleType
from unittest.mock import patch
import pytest
@@ -9,7 +9,8 @@ import pytest
@pytest.mark.parametrize("gevent_pool", [False, True])
def test_entrypoint_only_patches_libraries_for_a_gevent_pool(gevent_pool: bool) -> None:
fake_app = SimpleNamespace(app=object(), celery=object())
fake_app = ModuleType("app")
fake_app.__dict__.update(app=object(), celery=object())
with (
patch.dict(sys.modules, {"app": fake_app}),
patch("gevent.monkey.is_module_patched", return_value=gevent_pool),