mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-08 00:18:12 +08:00
fix(api): decrement knowledgebase counters on SDK re-parse / stop-parse (#17236)
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import importlib.util
|
||||
import sys
|
||||
@@ -85,6 +87,7 @@ class _DummyDoc:
|
||||
doc_type=FileType.OTHER,
|
||||
status=True,
|
||||
run=0,
|
||||
progress_msg="",
|
||||
):
|
||||
self.id = doc_id
|
||||
self.kb_id = kb_id
|
||||
@@ -97,6 +100,7 @@ class _DummyDoc:
|
||||
self.type = doc_type
|
||||
self.status = status
|
||||
self.run = run
|
||||
self.progress_msg = progress_msg
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -137,7 +141,19 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
monkeypatch.setitem(sys.modules, "common", common_pkg)
|
||||
|
||||
apps_mod = ModuleType("api.apps")
|
||||
apps_mod.login_required = lambda func: func
|
||||
|
||||
def _login_required(func=None, **_kwargs):
|
||||
# Real login_required is used both bare (chunk_api) and as a factory with
|
||||
# auth_types=[...] (document_api); the mock must pass through both forms.
|
||||
if func is None:
|
||||
return lambda inner: inner
|
||||
return func
|
||||
|
||||
apps_mod.login_required = _login_required
|
||||
apps_mod.current_user = SimpleNamespace(id="tenant-1")
|
||||
apps_mod.AUTH_JWT = None
|
||||
apps_mod.AUTH_API = None
|
||||
apps_mod.AUTH_BETA = None
|
||||
monkeypatch.setitem(sys.modules, "api.apps", apps_mod)
|
||||
|
||||
common_settings_mod = ModuleType("common.settings")
|
||||
@@ -152,6 +168,7 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
common_misc_utils_mod.thread_pool_exec = _thread_pool_exec
|
||||
common_misc_utils_mod.get_uuid = lambda: "uuid-1"
|
||||
monkeypatch.setitem(sys.modules, "common.misc_utils", common_misc_utils_mod)
|
||||
|
||||
common_string_utils_mod = ModuleType("common.string_utils")
|
||||
@@ -180,21 +197,58 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
def is_null(self, value=True):
|
||||
return _FakeExpr()
|
||||
|
||||
class _StubFreshDoc:
|
||||
id = "doc-1"
|
||||
kb_id = "kb-1"
|
||||
token_num = 2
|
||||
chunk_num = 1
|
||||
process_duration = 0.0
|
||||
|
||||
class _StubDocQuery:
|
||||
# Stands in for Document.select().where(...).for_update().first().
|
||||
def where(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def for_update(self):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return _StubDocumentModel.fresh_doc
|
||||
|
||||
class _StubDocumentModel:
|
||||
id = _FakeField()
|
||||
run = _FakeField()
|
||||
# The row re-read under lock by _release_doc_counters; tests that assert on
|
||||
# the decrement override this to mirror the document's current counters.
|
||||
fresh_doc = _StubFreshDoc()
|
||||
|
||||
@classmethod
|
||||
def select(cls, *_args, **_kwargs):
|
||||
return _StubDocQuery()
|
||||
|
||||
class _StubTaskModel:
|
||||
doc_id = _FakeField()
|
||||
|
||||
class _AnyFieldMeta(type):
|
||||
def __getattr__(cls, _name):
|
||||
return _FakeField()
|
||||
|
||||
class _StubModel(metaclass=_AnyFieldMeta):
|
||||
pass
|
||||
|
||||
db_models_mod = ModuleType("api.db.db_models")
|
||||
db_models_mod.APIToken = SimpleNamespace(query=lambda **_kwargs: [])
|
||||
db_models_mod.Document = _StubDocumentModel
|
||||
db_models_mod.Task = _StubTaskModel
|
||||
db_models_mod.DB = SimpleNamespace(atomic=lambda: contextlib.nullcontext(), connection_context=lambda: lambda fn: fn)
|
||||
# Transitively-loaded real services import assorted model classes (File,
|
||||
# Knowledgebase, UserTenant, ...); hand them a permissive stub on demand.
|
||||
db_models_mod.__getattr__ = lambda _name: _StubModel
|
||||
monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod)
|
||||
|
||||
services_pkg = ModuleType("api.db.services")
|
||||
services_pkg.__path__ = [str(repo_root / "api" / "db" / "services")]
|
||||
services_pkg.duplicate_name = lambda _query, name="", **_kwargs: name
|
||||
monkeypatch.setitem(sys.modules, "api.db.services", services_pkg)
|
||||
|
||||
doc_metadata_service_mod = ModuleType("api.db.services.doc_metadata_service")
|
||||
@@ -207,10 +261,12 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
document_service_mod = ModuleType("api.db.services.document_service")
|
||||
document_service_mod.DocumentService = SimpleNamespace(
|
||||
query=lambda **_kwargs: [],
|
||||
accessible=lambda *_args, **_kwargs: True,
|
||||
filter_update=lambda *_args, **_kwargs: 0,
|
||||
get_by_id=lambda *_args, **_kwargs: (False, None),
|
||||
update_by_id=lambda *_args, **_kwargs: True,
|
||||
decrement_chunk_num=lambda *_args, **_kwargs: None,
|
||||
increment_chunk_num=lambda *_args, **_kwargs: True,
|
||||
get_embd_id=lambda *_args, **_kwargs: "",
|
||||
get_tenant_embd_id=lambda *_args, **_kwargs: None,
|
||||
)
|
||||
@@ -233,13 +289,38 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
monkeypatch.setitem(sys.modules, "api.db.services.knowledgebase_service", knowledgebase_service_mod)
|
||||
|
||||
task_service_mod = ModuleType("api.db.services.task_service")
|
||||
task_service_mod.TaskService = SimpleNamespace(filter_delete=lambda *_args, **_kwargs: None)
|
||||
task_service_mod.TaskService = SimpleNamespace(filter_delete=lambda *_args, **_kwargs: None, query=lambda **_kwargs: [])
|
||||
task_service_mod.cancel_all_task_of = lambda *_args, **_kwargs: None
|
||||
task_service_mod.queue_tasks = lambda *_args, **_kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "api.db.services.task_service", task_service_mod)
|
||||
|
||||
file_service_mod = ModuleType("api.db.services.file_service")
|
||||
file_service_mod.FileService = SimpleNamespace()
|
||||
monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod)
|
||||
|
||||
canvas_service_mod = ModuleType("api.db.services.canvas_service")
|
||||
canvas_service_mod.UserCanvasService = SimpleNamespace()
|
||||
monkeypatch.setitem(sys.modules, "api.db.services.canvas_service", canvas_service_mod)
|
||||
|
||||
# document_api imports check_kb_team_permission; stub it so the real module
|
||||
# and its user_service/common_service (peewee) import chain stay out.
|
||||
check_team_permission_mod = ModuleType("api.common.check_team_permission")
|
||||
check_team_permission_mod.check_kb_team_permission = lambda *_args, **_kwargs: True
|
||||
check_team_permission_mod.check_file_team_permission = lambda *_args, **_kwargs: True
|
||||
monkeypatch.setitem(sys.modules, "api.common.check_team_permission", check_team_permission_mod)
|
||||
|
||||
api_utils_mod = ModuleType("api.utils.api_utils")
|
||||
api_utils_mod.add_tenant_id_to_kwargs = lambda func: func
|
||||
|
||||
def _add_tenant_id_to_kwargs(func):
|
||||
# Mirror the real decorator's functools.wraps so tests can reach the raw
|
||||
# handler through ``route.__wrapped__`` and pass ``tenant_id`` explicitly.
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
api_utils_mod.add_tenant_id_to_kwargs = _add_tenant_id_to_kwargs
|
||||
api_utils_mod.check_duplicate_ids = lambda ids, _kind="item": (ids, [])
|
||||
api_utils_mod.construct_json_result = lambda code=0, message="success", data=None: {"code": code, "message": message, "data": data}
|
||||
api_utils_mod.get_error_data_result = lambda message="Sorry! Data missing!", code=102: {"code": code, "message": message}
|
||||
@@ -248,6 +329,9 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
key: value for key, value in {"code": code, "message": message, "data": data, "total": total}.items() if value is not None
|
||||
}
|
||||
api_utils_mod.server_error_response = lambda e: {"code": 500, "message": str(e)}
|
||||
api_utils_mod.get_data_error_result = lambda message="Sorry! Data missing!", code=102: {"code": code, "message": message}
|
||||
api_utils_mod.get_error_argument_result = lambda message="": {"code": 101, "message": message}
|
||||
api_utils_mod.get_json_result = lambda code=0, message="success", data=None, **_kwargs: {"code": code, "message": message, "data": data}
|
||||
monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod)
|
||||
|
||||
image_utils_mod = ModuleType("api.utils.image_utils")
|
||||
@@ -278,6 +362,7 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
common_metadata_utils_mod = ModuleType("common.metadata_utils")
|
||||
common_metadata_utils_mod.convert_conditions = lambda conditions: conditions
|
||||
common_metadata_utils_mod.meta_filter = lambda *_args, **_kwargs: []
|
||||
common_metadata_utils_mod.turn2jsonschema = lambda *_args, **_kwargs: {}
|
||||
monkeypatch.setitem(sys.modules, "common.metadata_utils", common_metadata_utils_mod)
|
||||
|
||||
rag_app_tag_mod = ModuleType("rag.app.tag")
|
||||
@@ -470,10 +555,19 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
# Return mock tenant with default model configurations
|
||||
return _MockModelConfig2(tenant_id, "chat-model").to_dict()
|
||||
|
||||
def _split_model_name(model_name):
|
||||
parts = model_name.rsplit("@", 2)
|
||||
if len(parts) == 3:
|
||||
return parts[0], parts[1], parts[2]
|
||||
if len(parts) == 2:
|
||||
return parts[0], "default", parts[1]
|
||||
return parts[0], "", ""
|
||||
|
||||
tenant_model_service_mod.get_model_config_by_id = _get_model_config_by_id
|
||||
tenant_model_service_mod.get_model_config_from_provider_instance = _get_model_config_from_provider_instance
|
||||
tenant_model_service_mod.resolve_model_config = _get_model_config_from_provider_instance
|
||||
tenant_model_service_mod.get_tenant_default_model_by_type = _get_tenant_default_model_by_type
|
||||
tenant_model_service_mod.split_model_name = _split_model_name
|
||||
monkeypatch.setitem(sys.modules, "api.db.joint_services.tenant_model_service", tenant_model_service_mod)
|
||||
|
||||
if module_basename == "document_api":
|
||||
@@ -493,6 +587,29 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"):
|
||||
document_api_service_mod.update_document_status_only = lambda *_args, **_kwargs: None
|
||||
document_api_service_mod.reset_document_for_reparse = lambda *_args, **_kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "api.apps.services.document_api_service", document_api_service_mod)
|
||||
else:
|
||||
# chunk_api imports structure_graph_common from api.apps.services; stub
|
||||
# it in sys.modules so the real module and its transitive imports are
|
||||
# not loaded.
|
||||
stub_apps_services = ModuleType("api.apps.services")
|
||||
monkeypatch.setitem(sys.modules, "api.apps.services", stub_apps_services)
|
||||
|
||||
sgc_mod = ModuleType("api.apps.services.structure_graph_common")
|
||||
|
||||
async def _sgc_keyword_subgraph(*_args, **_kwargs):
|
||||
return {}, [], []
|
||||
|
||||
async def _sgc_build_bucket(*_args, **_kwargs):
|
||||
return [], []
|
||||
|
||||
sgc_mod.keyword_subgraph = _sgc_keyword_subgraph
|
||||
sgc_mod.build_bucket = _sgc_build_bucket
|
||||
monkeypatch.setitem(sys.modules, "api.apps.services.structure_graph_common", sgc_mod)
|
||||
|
||||
# document_counter_service is a real module (release_reparse_counters); evict
|
||||
# any cached copy so it re-imports against this test's freshly-stubbed
|
||||
# db_models / document_service rather than a prior test's stubs.
|
||||
monkeypatch.delitem(sys.modules, "api.db.services.document_counter_service", raising=False)
|
||||
|
||||
module_path = repo_root / "api" / "apps" / "restful_apis" / f"{module_basename}.py"
|
||||
spec = importlib.util.spec_from_file_location("test_doc_sdk_routes_unit", module_path)
|
||||
@@ -552,80 +669,63 @@ class TestDocRoutesUnit:
|
||||
assert "length of 5" in str(exc_info.value)
|
||||
|
||||
def test_download_and_download_doc_errors(self, monkeypatch):
|
||||
module = _load_doc_module(monkeypatch)
|
||||
module = _load_doc_module(monkeypatch, module_basename="document_api")
|
||||
_patch_send_file(monkeypatch, module)
|
||||
_patch_storage(monkeypatch, module, file_stream=b"")
|
||||
res = _run(module.download.__wrapped__("tenant-1", "ds-1", ""))
|
||||
assert res["message"] == "Specify document_id please."
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "query", lambda **_kwargs: [])
|
||||
res = _run(module.download.__wrapped__("tenant-1", "ds-1", "doc-1"))
|
||||
assert "do not own the dataset" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "query", lambda **_kwargs: [1])
|
||||
# download(dataset_id, document_id)
|
||||
res = _run(module.download("ds-1", ""))
|
||||
assert res["message"] == "Specify document_id please."
|
||||
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda **_kwargs: False)
|
||||
res = _run(module.download("ds-1", "doc-1"))
|
||||
assert res["message"] == "Document not found!"
|
||||
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda **_kwargs: True)
|
||||
monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: False)
|
||||
res = _run(module.download("ds-1", "doc-1"))
|
||||
assert res["message"] == "Document not found!"
|
||||
|
||||
monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: [])
|
||||
res = _run(module.download.__wrapped__("tenant-1", "ds-1", "doc-1"))
|
||||
res = _run(module.download("ds-1", "doc-1"))
|
||||
assert "not own the document" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: [_DummyDoc()])
|
||||
monkeypatch.setattr(module.File2DocumentService, "get_storage_address", lambda **_kwargs: ("b", "n"))
|
||||
res = _run(module.download.__wrapped__("tenant-1", "ds-1", "doc-1"))
|
||||
res = _run(module.download("ds-1", "doc-1"))
|
||||
assert res["message"] == "This file is empty."
|
||||
|
||||
monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer"}))
|
||||
res = _run(module.download_doc("doc-1"))
|
||||
assert "Authorization is not valid" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer token"}))
|
||||
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [])
|
||||
res = _run(module.download_doc("doc-1"))
|
||||
assert "API key is invalid" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1"), SimpleNamespace(tenant_id="tenant-2")])
|
||||
res = _run(module.download_doc("doc-1"))
|
||||
assert "API key configuration is ambiguous" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")])
|
||||
res = _run(module.download_doc(""))
|
||||
# download_document(document_id)
|
||||
res = _run(module.download_document(""))
|
||||
assert res["message"] == "Specify document_id please."
|
||||
|
||||
monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: False)
|
||||
res = _run(module.download_document("doc-1"))
|
||||
assert res["message"] == "Document not found!"
|
||||
|
||||
monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: [])
|
||||
res = _run(module.download_doc("doc-1"))
|
||||
res = _run(module.download_document("doc-1"))
|
||||
assert "not own the document" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: [_DummyDoc()])
|
||||
kb_query_calls = []
|
||||
|
||||
def _deny_kb_query(**kwargs):
|
||||
kb_query_calls.append(kwargs)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "query", _deny_kb_query)
|
||||
monkeypatch.setattr(
|
||||
module.File2DocumentService,
|
||||
"get_storage_address",
|
||||
lambda **_kwargs: (_ for _ in ()).throw(AssertionError("storage lookup must not run before tenant authorization")),
|
||||
)
|
||||
res = _run(module.download_doc("doc-1"))
|
||||
assert res["message"] == "You do not have access to this document."
|
||||
assert kb_query_calls == [{"id": "kb-1", "tenant_id": "tenant-1"}]
|
||||
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "query", lambda **_kwargs: [1])
|
||||
monkeypatch.setattr(module.File2DocumentService, "get_storage_address", lambda **_kwargs: ("b", "n"))
|
||||
_patch_storage(monkeypatch, module, file_stream=b"")
|
||||
res = _run(module.download_doc("doc-1"))
|
||||
res = _run(module.download_document("doc-1"))
|
||||
assert res["message"] == "This file is empty."
|
||||
|
||||
_patch_storage(monkeypatch, module, file_stream=b"abc")
|
||||
res = _run(module.download_doc("doc-1"))
|
||||
res = _run(module.download_document("doc-1"))
|
||||
assert res["filename"] == "doc.txt"
|
||||
|
||||
def test_download_mimetype_from_filename(self, monkeypatch):
|
||||
module = _load_doc_module(monkeypatch, module_basename="document_api")
|
||||
_patch_send_file(monkeypatch, module)
|
||||
_patch_storage(monkeypatch, module, file_stream=b"pdf-bytes")
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda **_kwargs: True)
|
||||
monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: [_DummyDoc(name="report.pdf", doc_type=FileType.PDF)])
|
||||
monkeypatch.setattr(module.File2DocumentService, "get_storage_address", lambda **_kwargs: ("b", "n"))
|
||||
res = _run(module.download.__wrapped__("ds-1", "doc-1"))
|
||||
res = _run(module.download("ds-1", "doc-1"))
|
||||
assert res["filename"] == "report.pdf"
|
||||
assert res["mimetype"] == "application/pdf"
|
||||
|
||||
@@ -636,6 +736,7 @@ class TestDocRoutesUnit:
|
||||
assert "don't own the dataset" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda **_kwargs: True)
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _id: (True, SimpleNamespace(tenant_id="tenant-1", pipeline_id=None)))
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"document_ids": ["doc-1"]}))
|
||||
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (ids, []))
|
||||
toggle_doc = _ToggleBoolDocList(_DummyDoc(progress=0))
|
||||
@@ -670,6 +771,58 @@ class TestDocRoutesUnit:
|
||||
assert res["code"] == module.RetCode.DATA_ERROR
|
||||
assert "Duplicate document ids" in res["message"]
|
||||
|
||||
def test_parse_and_stop_decrement_kb_counters(self, monkeypatch):
|
||||
# Both routes delete the document's chunks, so the knowledgebase aggregate
|
||||
# must drop by the document's current counters. Zeroing only the document
|
||||
# row leaves that amount stranded in the KB total on every re-parse.
|
||||
module = _load_doc_module(monkeypatch)
|
||||
# _release_doc_counters re-reads the row under lock; mirror the document's
|
||||
# current counters so the decrement is driven by that fresh read.
|
||||
monkeypatch.setattr(module.Document, "fresh_doc", SimpleNamespace(id="doc-1", kb_id="kb-1", token_num=70, chunk_num=7, process_duration=1.5))
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda **_kwargs: True)
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _id: (True, SimpleNamespace(tenant_id="tenant-1", pipeline_id=None)))
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"document_ids": ["doc-1"]}))
|
||||
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (ids, []))
|
||||
monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _id: (True, _DummyDoc()))
|
||||
monkeypatch.setattr(module.File2DocumentService, "get_storage_address", lambda **_kwargs: ("b", "n"))
|
||||
monkeypatch.setattr(module.TaskService, "filter_delete", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(module, "queue_tasks", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(module, "cancel_all_task_of", lambda *_args, **_kwargs: None)
|
||||
_patch_docstore(monkeypatch, module, delete=lambda *_args, **_kwargs: None)
|
||||
|
||||
decrements = []
|
||||
updates = []
|
||||
update_by_id_payloads = []
|
||||
|
||||
def _capture_filter_update(_conditions, info):
|
||||
updates.append(info)
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(module.DocumentService, "update_by_id", lambda _id, info: update_by_id_payloads.append(info) or True)
|
||||
|
||||
monkeypatch.setattr(module.DocumentService, "increment_chunk_num", lambda *args: decrements.append(args))
|
||||
monkeypatch.setattr(module.DocumentService, "filter_update", _capture_filter_update)
|
||||
|
||||
monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: [_DummyDoc(token_num=70, chunk_num=7, process_duration=1.5)])
|
||||
assert _run(module.parse.__wrapped__("tenant-1", "ds-1"))["code"] == 0
|
||||
assert decrements == [("doc-1", "kb-1", -70, -7, -1.5)]
|
||||
# The document update must not zero the counters itself; that is exactly
|
||||
# what strands the difference in the KB aggregate.
|
||||
assert "chunk_num" not in updates[0]
|
||||
assert "token_num" not in updates[0]
|
||||
|
||||
decrements.clear()
|
||||
monkeypatch.setattr(
|
||||
module.DocumentService,
|
||||
"query",
|
||||
lambda **_kwargs: [_DummyDoc(run=module.TaskStatus.RUNNING.value, token_num=70, chunk_num=7, process_duration=1.5)],
|
||||
)
|
||||
assert _run(module.stop_parsing.__wrapped__("tenant-1", "ds-1"))["code"] == 0
|
||||
assert decrements == [("doc-1", "kb-1", -70, -7, -1.5)]
|
||||
# stop_parsing must not zero the counters in its own document update either.
|
||||
assert "chunk_num" not in update_by_id_payloads[0]
|
||||
assert "token_num" not in update_by_id_payloads[0]
|
||||
|
||||
def test_stop_parsing_branches(self, monkeypatch):
|
||||
module = _load_doc_module(monkeypatch)
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda **_kwargs: False)
|
||||
@@ -677,6 +830,7 @@ class TestDocRoutesUnit:
|
||||
assert "don't own the dataset" in res["message"]
|
||||
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda **_kwargs: True)
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _id: (True, SimpleNamespace(tenant_id="tenant-1", pipeline_id=None)))
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({}))
|
||||
res = _run(module.stop_parsing.__wrapped__("tenant-1", "ds-1"))
|
||||
assert "`document_ids` is required" in res["message"]
|
||||
@@ -733,7 +887,7 @@ class TestDocRoutesUnit:
|
||||
monkeypatch.setattr(
|
||||
module.KnowledgebaseService,
|
||||
"get_by_id",
|
||||
lambda _id: (True, SimpleNamespace(tenant_id=owner_tenant)),
|
||||
lambda _id: (True, SimpleNamespace(tenant_id=owner_tenant, pipeline_id=None)),
|
||||
)
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"document_ids": ["doc-1"]}))
|
||||
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (ids, []))
|
||||
@@ -769,7 +923,7 @@ class TestDocRoutesUnit:
|
||||
monkeypatch.setattr(
|
||||
module.KnowledgebaseService,
|
||||
"get_by_id",
|
||||
lambda _id: (True, SimpleNamespace(tenant_id=owner_tenant)),
|
||||
lambda _id: (True, SimpleNamespace(tenant_id=owner_tenant, pipeline_id=None)),
|
||||
)
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"document_ids": ["doc-1"]}))
|
||||
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (ids, []))
|
||||
@@ -796,6 +950,7 @@ class TestDocRoutesUnit:
|
||||
module = _load_doc_module(monkeypatch, module_basename="document_api")
|
||||
updated = []
|
||||
deleted = []
|
||||
decrements = []
|
||||
|
||||
monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda **_kwargs: True)
|
||||
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"document_ids": ["doc-1"]}))
|
||||
@@ -808,6 +963,14 @@ class TestDocRoutesUnit:
|
||||
)
|
||||
monkeypatch.setattr(module.TaskService, "query", lambda **_kwargs: [SimpleNamespace(progress=0.5)])
|
||||
monkeypatch.setattr(module, "cancel_all_task_of", lambda *_args, **_kwargs: None)
|
||||
# release_reparse_counters re-reads the row under lock and decrements the
|
||||
# KB by the document's partial counts before chunk_num is zeroed below.
|
||||
monkeypatch.setattr(
|
||||
sys.modules["api.db.db_models"].Document,
|
||||
"fresh_doc",
|
||||
SimpleNamespace(id="doc-1", kb_id="kb-1", token_num=70, chunk_num=7, process_duration=1.5),
|
||||
)
|
||||
monkeypatch.setattr(module.DocumentService, "increment_chunk_num", lambda *args: decrements.append(args))
|
||||
monkeypatch.setattr(
|
||||
module.DocumentService,
|
||||
"update_by_id",
|
||||
@@ -824,16 +987,19 @@ class TestDocRoutesUnit:
|
||||
|
||||
assert res["code"] == 0
|
||||
assert res["data"]["success_count"] == 1
|
||||
assert updated == [
|
||||
(
|
||||
"doc-1",
|
||||
{
|
||||
"run": module.TaskStatus.CANCEL.value,
|
||||
"progress": 0,
|
||||
"chunk_num": 0,
|
||||
},
|
||||
)
|
||||
]
|
||||
assert len(updated) == 1
|
||||
updated_doc_id, updated_info = updated[0]
|
||||
assert updated_doc_id == "doc-1"
|
||||
assert updated_info["run"] == str(module.TaskStatus.CANCEL.value)
|
||||
assert updated_info["progress"] == 0
|
||||
# release_reparse_counters is the sole counter adjustment; the status
|
||||
# update must not set chunk_num itself (that would strand KB counts).
|
||||
assert "chunk_num" not in updated_info
|
||||
# progress_msg carries a timestamped cancellation marker, so match loosely.
|
||||
assert "Task stopped by user." in updated_info["progress_msg"]
|
||||
# The partial chunk/token counts are released from the KB aggregate via the
|
||||
# row re-read under lock.
|
||||
assert decrements == [("doc-1", "kb-1", -70, -7, -1.5)]
|
||||
assert deleted == [({"doc_id": "doc-1"}, module.search.index_name("tenant-1"), "kb-1")]
|
||||
|
||||
deleted.clear()
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import importlib.util
|
||||
import sys
|
||||
@@ -223,6 +224,14 @@ def _load_chunk_module(monkeypatch):
|
||||
constants_mod.LLMType = _DummyLLMType
|
||||
constants_mod.ParserType = _DummyParserType
|
||||
constants_mod.PAGERANK_FLD = "pagerank_flt"
|
||||
constants_mod.TaskStatus = SimpleNamespace(
|
||||
UNSTART=SimpleNamespace(value="0"),
|
||||
RUNNING=SimpleNamespace(value="1"),
|
||||
CANCEL=SimpleNamespace(value="2"),
|
||||
DONE=SimpleNamespace(value="3"),
|
||||
FAIL=SimpleNamespace(value="4"),
|
||||
SCHEDULE=SimpleNamespace(value="5"),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "common.constants", constants_mod)
|
||||
|
||||
string_utils_mod = ModuleType("common.string_utils")
|
||||
@@ -232,8 +241,28 @@ def _load_chunk_module(monkeypatch):
|
||||
|
||||
metadata_utils_mod = ModuleType("common.metadata_utils")
|
||||
metadata_utils_mod.apply_meta_data_filter = lambda *_args, **_kwargs: {}
|
||||
metadata_utils_mod.convert_conditions = lambda *_args, **_kwargs: {}
|
||||
metadata_utils_mod.meta_filter = lambda *_args, **_kwargs: {}
|
||||
monkeypatch.setitem(sys.modules, "common.metadata_utils", metadata_utils_mod)
|
||||
|
||||
doc_store_base_mod = ModuleType("common.doc_store.doc_store_base")
|
||||
doc_store_base_mod.OrderByExpr = type("OrderByExpr", (), {})
|
||||
monkeypatch.setitem(sys.modules, "common.doc_store", ModuleType("common.doc_store"))
|
||||
monkeypatch.setitem(sys.modules, "common.doc_store.doc_store_base", doc_store_base_mod)
|
||||
|
||||
tag_feature_utils_mod = ModuleType("common.tag_feature_utils")
|
||||
tag_feature_utils_mod.validate_tag_features = lambda *_args, **_kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "common.tag_feature_utils", tag_feature_utils_mod)
|
||||
|
||||
pagination_utils_mod = ModuleType("api.utils.pagination_utils")
|
||||
pagination_utils_mod.validate_rest_api_page_size = lambda *_args, **_kwargs: (1, 30)
|
||||
monkeypatch.setitem(sys.modules, "api.utils.pagination_utils", pagination_utils_mod)
|
||||
|
||||
reference_metadata_utils_mod = ModuleType("api.utils.reference_metadata_utils")
|
||||
reference_metadata_utils_mod.enrich_chunks_with_document_metadata = lambda chunks, *_args, **_kwargs: chunks
|
||||
reference_metadata_utils_mod.resolve_reference_metadata_preferences = lambda *_args, **_kwargs: {}
|
||||
monkeypatch.setitem(sys.modules, "api.utils.reference_metadata_utils", reference_metadata_utils_mod)
|
||||
|
||||
misc_utils_mod = ModuleType("common.misc_utils")
|
||||
|
||||
async def _thread_pool_exec(func):
|
||||
@@ -293,6 +322,7 @@ def _load_chunk_module(monkeypatch):
|
||||
api_utils_mod.add_tenant_id_to_kwargs = lambda func: func
|
||||
api_utils_mod.check_duplicate_ids = lambda ids, _kind: (list(dict.fromkeys(ids)), [] if len(ids) == len(set(ids)) else [f"Duplicate {_kind} ids"])
|
||||
api_utils_mod.get_request_json = lambda: _AwaitableValue({})
|
||||
api_utils_mod.construct_json_result = lambda code=0, message="success", data=None: {"code": code, "message": message, "data": data}
|
||||
monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod)
|
||||
|
||||
image_utils_mod = ModuleType("api.utils.image_utils")
|
||||
@@ -312,8 +342,94 @@ def _load_chunk_module(monkeypatch):
|
||||
tenant_model_service_mod.get_model_config_from_provider_instance = lambda *_args, **_kwargs: {"llm_name": "embed", "model_type": "embedding"}
|
||||
tenant_model_service_mod.resolve_model_config = lambda *_args, **_kwargs: {"llm_name": "embed", "model_type": "embedding"}
|
||||
tenant_model_service_mod.get_tenant_default_model_by_type = lambda *_args, **_kwargs: {"llm_name": "chat", "model_type": "chat"}
|
||||
tenant_model_service_mod.split_model_name = lambda model_name: (model_name.rsplit("@", 2) + ["", ""])[:3]
|
||||
monkeypatch.setitem(sys.modules, "api.db.joint_services.tenant_model_service", tenant_model_service_mod)
|
||||
|
||||
# chunk_api imports structure_graph_common from api.apps.services; stub it in
|
||||
# sys.modules so the real module and its transitive imports are not loaded.
|
||||
stub_apps_services = ModuleType("api.apps.services")
|
||||
monkeypatch.setitem(sys.modules, "api.apps.services", stub_apps_services)
|
||||
|
||||
sgc_mod = ModuleType("api.apps.services.structure_graph_common")
|
||||
|
||||
async def _sgc_keyword_subgraph(*_args, **_kwargs):
|
||||
return {}, [], []
|
||||
|
||||
async def _sgc_build_bucket(*_args, **_kwargs):
|
||||
return [], []
|
||||
|
||||
sgc_mod.keyword_subgraph = _sgc_keyword_subgraph
|
||||
sgc_mod.build_bucket = _sgc_build_bucket
|
||||
monkeypatch.setitem(sys.modules, "api.apps.services.structure_graph_common", sgc_mod)
|
||||
|
||||
# chunk_api imports DB, Document, Task from db_models; stub them so the real
|
||||
# module (which pulls in quart_auth) is never loaded.
|
||||
class _FakeExpr:
|
||||
def __or__(self, other):
|
||||
return self
|
||||
|
||||
def __and__(self, other):
|
||||
return self
|
||||
|
||||
class _FakeField:
|
||||
def __eq__(self, other):
|
||||
return _FakeExpr()
|
||||
|
||||
def __ne__(self, other):
|
||||
return _FakeExpr()
|
||||
|
||||
def is_null(self, value=True):
|
||||
return _FakeExpr()
|
||||
|
||||
class _StubFreshDoc:
|
||||
id = "doc-1"
|
||||
kb_id = "kb-1"
|
||||
token_num = 2
|
||||
chunk_num = 1
|
||||
process_duration = 0.0
|
||||
|
||||
class _StubDocQuery:
|
||||
def where(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def for_update(self):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return _StubDocumentModel.fresh_doc
|
||||
|
||||
class _StubDocumentModel:
|
||||
id = _FakeField()
|
||||
run = _FakeField()
|
||||
fresh_doc = _StubFreshDoc()
|
||||
|
||||
@classmethod
|
||||
def select(cls, *_args, **_kwargs):
|
||||
return _StubDocQuery()
|
||||
|
||||
class _StubTaskModel:
|
||||
doc_id = _FakeField()
|
||||
|
||||
db_models_mod = ModuleType("api.db.db_models")
|
||||
db_models_mod.Document = _StubDocumentModel
|
||||
db_models_mod.Task = _StubTaskModel
|
||||
db_models_mod.DB = SimpleNamespace(atomic=lambda: contextlib.nullcontext())
|
||||
monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod)
|
||||
|
||||
file2document_service_mod = ModuleType("api.db.services.file2document_service")
|
||||
file2document_service_mod.File2DocumentService = SimpleNamespace(get_storage_address=lambda **_kwargs: ("", ""))
|
||||
monkeypatch.setitem(sys.modules, "api.db.services.file2document_service", file2document_service_mod)
|
||||
|
||||
task_service_mod = ModuleType("api.db.services.task_service")
|
||||
task_service_mod.TaskService = SimpleNamespace(filter_delete=lambda *_args, **_kwargs: None)
|
||||
task_service_mod.cancel_all_task_of = lambda *_args, **_kwargs: None
|
||||
task_service_mod.queue_tasks = lambda *_args, **_kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "api.db.services.task_service", task_service_mod)
|
||||
|
||||
document_counter_service_mod = ModuleType("api.db.services.document_counter_service")
|
||||
document_counter_service_mod.release_reparse_counters = lambda *_args, **_kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "api.db.services.document_counter_service", document_counter_service_mod)
|
||||
|
||||
document_service_mod = ModuleType("api.db.services.document_service")
|
||||
|
||||
class _DocumentService:
|
||||
@@ -499,6 +615,14 @@ def _load_chunk_api_module(monkeypatch):
|
||||
module.manager = _DummyManager()
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
spec.loader.exec_module(module)
|
||||
# chunk_api imports these inside the chunk-write helpers; re-expose the shared
|
||||
# stubs so tests can reach them via module.<name> (setattr / method patching).
|
||||
module.rag_tokenizer = sys.modules["rag.nlp"].rag_tokenizer
|
||||
module.beAdoc = sys.modules["rag.app.qa"].beAdoc
|
||||
module.rmPrefix = sys.modules["rag.app.qa"].rmPrefix
|
||||
module.label_question = sys.modules["rag.app.tag"].label_question
|
||||
module.cross_languages = sys.modules["rag.prompts.generator"].cross_languages
|
||||
module.keyword_extraction = sys.modules["rag.prompts.generator"].keyword_extraction
|
||||
return module
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user