Files
ragflow/test/unit_test/rag/graphrag/test_checkpoint_resume.py
Minal Mahala f930389311 Refact: improve task resume mechanism for graphrag (#14096)
### What problem does this PR solve?

Addresses review feedback on #14074 (Checkpoint mechanism for
long-running workflow jobs, issue #12494).

**Changes based on @yuzhichang's review:**

1. **Renamed `checkpoint_service.py` → `task_checkpoint.py`** as
suggested.
2. **Replaced Redis with direct docEngine queries** as suggested — the
subgraph already gets persisted to the doc store by
`generate_subgraph()`, so we just query for it instead of maintaining a
separate checkpoint in Redis. This is simpler, has no extra dependency,
and uses a single source of truth.

**Changes based on CodeRabbit review:**

3. **Fixed `source_id` query format mismatch** — subgraphs are stored
with `source_id: [doc_id]` (list), but the original query used
`source_id: doc_id` (string). Now follows the same pattern as
`does_graph_contains()` in `rag/graphrag/utils.py`: filter by
`knowledge_graph_kwd` only, then match `source_id` in Python. This
avoids ambiguity across Elasticsearch / Infinity / OceanBase backends.

### Changes

| File | Change |
|---|---|
| `api/db/services/task_checkpoint.py` (new) |
`load_subgraph_from_store()` and `has_raptor_chunks()` — docEngine-based
checkpoint queries |
| `rag/graphrag/general/index.py` | `build_one()` calls
`load_subgraph_from_store()` before running LLM extraction |
| `rag/svr/task_executor.py` | RAPTOR per-doc loop calls
`has_raptor_chunks()` before processing |
| `test/unit_test/rag/graphrag/test_checkpoint_resume.py` (new) | 10
unit tests covering subgraph loading, source_id filtering, edge cases |

### How it works

- **GraphRAG:** Before running expensive LLM entity/relation extraction
for a doc, checks the doc store for an existing subgraph (saved by a
previous interrupted run). If found, loads it directly and skips LLM
calls.
- **RAPTOR:** Before processing a doc, checks if RAPTOR chunks
(`raptor_kwd="raptor"`) already exist for it. If yes, skips.

### Testing

- 10 new unit tests — all passing
- Full existing suite: 617 passed

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-04-15 17:37:28 +08:00

317 lines
13 KiB
Python

#
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Tests for GraphRAG/RAPTOR checkpoint/resume logic.
Calls the real implementations:
- load_subgraph_from_store (rag/graphrag/general/index.py)
- has_raptor_chunks (rag/svr/task_executor.py)
Both modules are loaded via importlib with their infrastructure dependencies
mocked, so the actual query logic, pagination, and error handling are exercised
without needing running services.
"""
import importlib.util
import json
import pathlib
import sys
import warnings
from unittest.mock import MagicMock
# Suppress deprecation warnings from third-party libraries (e.g. huggingface_hub)
# that are triggered during module import but are not related to the code under test.
warnings.filterwarnings("ignore", category=UserWarning, module="huggingface_hub")
import networkx as nx
import pytest
# ---------------------------------------------------------------------------
# Additional sys.modules mocks needed beyond what conftest already provides.
#
# conftest.py (same directory) mocks the heavy packages listed in
# _modules_to_mock. We need a few more to satisfy index.py and
# task_executor.py's import-time dependencies.
# ---------------------------------------------------------------------------
_EXTRA_MOCKS = [
# for index.py
"api.db.services.document_service",
# for task_executor.py
"api.db",
"api.db.services.knowledgebase_service",
"api.db.services.pipeline_operation_log_service",
"api.db.joint_services",
"api.db.joint_services.memory_message_service",
"api.db.joint_services.tenant_model_service",
"api.db.services.doc_metadata_service",
"api.db.services.llm_service",
"api.db.services.file2document_service",
"api.db.db_models",
"common.metadata_utils",
"common.log_utils",
"common.config_utils",
"common.versions",
"common.token_utils",
"common.signal_utils",
"common.exceptions",
"common.constants",
"rag.utils.base64_image",
"rag.prompts.generator",
"rag.raptor",
"rag.app",
"rag.graphrag.utils",
]
for _m in _EXTRA_MOCKS:
if _m not in sys.modules:
sys.modules[_m] = MagicMock()
# ---------------------------------------------------------------------------
# Load the real implementations via importlib.
# ---------------------------------------------------------------------------
_ROOT = pathlib.Path(__file__).parents[4]
def _load_module(dotted_name: str, rel_path: str):
path = _ROOT / rel_path
spec = importlib.util.spec_from_file_location(dotted_name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[dotted_name] = mod
spec.loader.exec_module(mod)
return mod
_index_mod = _load_module("rag.graphrag.general.index", "rag/graphrag/general/index.py")
_executor_mod = _load_module("rag.svr.task_executor", "rag/svr/task_executor.py")
load_subgraph_from_store = _index_mod.load_subgraph_from_store
has_raptor_chunks = _executor_mod.has_raptor_chunks
# settings is a MagicMock installed by conftest; grab it to monkeypatch docStoreConn.
import common.settings as _settings # noqa: E402
# Ensure docStoreConn is a MagicMock so monkeypatch.setattr works in all environments.
if not isinstance(_settings.docStoreConn, MagicMock):
_settings.docStoreConn = MagicMock()
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _make_subgraph(doc_id: str) -> nx.Graph:
sg = nx.Graph()
sg.add_node("ENTITY_A", description="test entity A", source_id=[doc_id])
sg.add_node("ENTITY_B", description="test entity B", source_id=[doc_id])
sg.add_edge("ENTITY_A", "ENTITY_B", description="related", source_id=[doc_id], weight=1.0, keywords=[])
sg.graph["source_id"] = [doc_id]
return sg
def _to_store_content(sg: nx.Graph) -> str:
return json.dumps(nx.node_link_data(sg, edges="edges"), ensure_ascii=False)
def _single_page_mocks(field_map: dict):
"""search + get_fields mocks that simulate a single-page result."""
sentinel = object()
call_count = {"n": 0}
def _get_fields(_res, _fields):
call_count["n"] += 1
return field_map if call_count["n"] == 1 else {}
return MagicMock(return_value=sentinel), MagicMock(side_effect=_get_fields)
# ---------------------------------------------------------------------------
# Tests for load_subgraph_from_store (rag/graphrag/general/index.py)
# ---------------------------------------------------------------------------
class TestLoadSubgraphFromStore:
@pytest.mark.p1
@pytest.mark.asyncio
async def test_loads_existing_subgraph(self, monkeypatch):
"""Subgraph present in the store is returned as nx.Graph."""
doc_id = "doc_001"
sg = _make_subgraph(doc_id)
field_map = {"chunk_001": {"content_with_weight": _to_store_content(sg), "source_id": [doc_id]}}
s, gf = _single_page_mocks(field_map)
monkeypatch.setattr(_settings.docStoreConn, "search", s)
monkeypatch.setattr(_settings.docStoreConn, "get_fields", gf)
result = await load_subgraph_from_store("t1", "kb1", doc_id)
assert result is not None and isinstance(result, nx.Graph)
assert result.has_node("ENTITY_A") and result.has_node("ENTITY_B")
assert result.graph["source_id"] == [doc_id]
@pytest.mark.p1
@pytest.mark.asyncio
async def test_returns_none_when_no_subgraph(self, monkeypatch):
"""Empty store returns None without raising."""
s, gf = _single_page_mocks({})
monkeypatch.setattr(_settings.docStoreConn, "search", s)
monkeypatch.setattr(_settings.docStoreConn, "get_fields", gf)
assert await load_subgraph_from_store("t1", "kb1", "doc_missing") is None
@pytest.mark.p2
@pytest.mark.asyncio
async def test_passes_doc_id_in_search_condition(self, monkeypatch):
"""source_id (== doc_id) is included in the search condition so the doc
store filters results directly rather than fetching all subgraphs."""
captured = {}
def _capture(fields, filters, condition, *_a, **_kw):
captured["condition"] = condition
return object()
sg = _make_subgraph("doc_b")
monkeypatch.setattr(_settings.docStoreConn, "search", _capture)
monkeypatch.setattr(_settings.docStoreConn, "get_fields",
MagicMock(return_value={"chunk_b": {"content_with_weight": _to_store_content(sg), "source_id": ["doc_b"]}}))
result = await load_subgraph_from_store("t1", "kb1", "doc_b")
assert result is not None and result.graph["source_id"] == ["doc_b"]
assert captured["condition"]["source_id"] == ["doc_b"]
@pytest.mark.p2
@pytest.mark.asyncio
async def test_skips_malformed_json_returns_none(self, monkeypatch):
"""Malformed JSON is logged and skipped; None is returned (not raised)."""
field_map = {"chunk_bad": {"content_with_weight": "not valid json{{{", "source_id": ["doc_bad"]}}
s, gf = _single_page_mocks(field_map)
monkeypatch.setattr(_settings.docStoreConn, "search", s)
monkeypatch.setattr(_settings.docStoreConn, "get_fields", gf)
assert await load_subgraph_from_store("t1", "kb1", "doc_bad") is None
@pytest.mark.p2
@pytest.mark.asyncio
async def test_issues_single_query_with_limit_one(self, monkeypatch):
"""Exactly one search call is issued with limit=1 — the doc store index
does the filtering, so no pagination is required."""
doc_id = "doc_single"
sg = _make_subgraph(doc_id)
search_calls: list[tuple] = []
def _search(fields, filters, condition, order, orderby, offset, limit, *_a, **_kw):
search_calls.append((offset, limit))
return object()
monkeypatch.setattr(_settings.docStoreConn, "search", _search)
monkeypatch.setattr(_settings.docStoreConn, "get_fields",
MagicMock(return_value={"chunk_t": {"content_with_weight": _to_store_content(sg), "source_id": [doc_id]}}))
result = await load_subgraph_from_store("t1", "kb1", doc_id)
assert result is not None
assert len(search_calls) == 1, "must issue exactly one query"
assert search_calls[0] == (0, 1), "must use offset=0, limit=1"
@pytest.mark.p2
@pytest.mark.asyncio
async def test_doc_store_exception_returns_none(self, monkeypatch):
"""A doc-store exception is caught; None is returned safely."""
monkeypatch.setattr(_settings.docStoreConn, "search", MagicMock(side_effect=RuntimeError("db down")))
assert await load_subgraph_from_store("t1", "kb1", "doc_001") is None
# ---------------------------------------------------------------------------
# Tests for has_raptor_chunks (rag/svr/task_executor.py)
# ---------------------------------------------------------------------------
class TestHasRaptorChunks:
@pytest.mark.p1
@pytest.mark.asyncio
async def test_returns_true_when_raptor_chunk_exists(self, monkeypatch):
"""Doc store returns a RAPTOR row -> True."""
monkeypatch.setattr(_settings.docStoreConn, "search", MagicMock(return_value=object()))
monkeypatch.setattr(_settings.docStoreConn, "get_fields",
MagicMock(return_value={"chunk_r": {"raptor_kwd": "raptor"}}))
assert await has_raptor_chunks("doc_001", "t1", "kb1") is True
@pytest.mark.p1
@pytest.mark.asyncio
async def test_returns_false_when_no_raptor_chunks(self, monkeypatch):
"""Doc store returns empty -> False."""
monkeypatch.setattr(_settings.docStoreConn, "search", MagicMock(return_value=object()))
monkeypatch.setattr(_settings.docStoreConn, "get_fields", MagicMock(return_value={}))
assert await has_raptor_chunks("doc_001", "t1", "kb1") is False
@pytest.mark.p1
@pytest.mark.asyncio
async def test_queries_specifically_for_raptor_kwd(self, monkeypatch):
"""raptor_kwd is in the search condition so non-RAPTOR leading chunks
cannot produce a false-negative."""
captured = {}
def _capture(fields, filters, condition, *_a, **_kw):
captured["condition"] = condition
return object()
monkeypatch.setattr(_settings.docStoreConn, "search", _capture)
monkeypatch.setattr(_settings.docStoreConn, "get_fields", MagicMock(return_value={}))
await has_raptor_chunks("doc_001", "t1", "kb1")
assert captured["condition"] == {"doc_id": "doc_001", "raptor_kwd": ["raptor"]}
@pytest.mark.p2
@pytest.mark.asyncio
async def test_returns_false_on_doc_store_exception(self, monkeypatch):
"""Exception is caught; False is returned without crashing."""
monkeypatch.setattr(_settings.docStoreConn, "search", MagicMock(side_effect=RuntimeError("db down")))
assert await has_raptor_chunks("doc_001", "t1", "kb1") is False
# ---------------------------------------------------------------------------
# End-to-end workflow test
# ---------------------------------------------------------------------------
class TestCheckpointResumeWorkflow:
@pytest.mark.p1
@pytest.mark.asyncio
async def test_resume_finds_completed_docs_skips_new_ones(self, monkeypatch):
"""3 docs completed before crash; on resume each is found, new doc is not."""
completed = ["doc_1", "doc_2", "doc_3"]
field_map = {
f"chunk_{d}": {"content_with_weight": _to_store_content(_make_subgraph(d)), "source_id": [d]}
for d in completed
}
# The doc store filters by source_id (doc_id) directly, so get_fields
# should return only the matching chunk for each call.
def _get_fields_by_doc(res, fields):
# res is the sentinel from search; extract the doc_id it was called with
return {k: v for k, v in field_map.items() if v["source_id"] == [_get_fields_by_doc.last_doc_id]}
def _search(fields, filters, condition, *_a, **_kw):
_get_fields_by_doc.last_doc_id = (condition or {}).get("source_id", [None])[0]
return object()
monkeypatch.setattr(_settings.docStoreConn, "search", _search)
monkeypatch.setattr(_settings.docStoreConn, "get_fields", _get_fields_by_doc)
for doc_id in completed:
result = await load_subgraph_from_store("t1", "kb1", doc_id)
assert result is not None and result.graph["source_id"] == [doc_id]
assert await load_subgraph_from_store("t1", "kb1", "doc_4_new") is None