fix(investigation): persist large results and require graph links
Full-network pressure comparisons exceeded the worker limit before saved-result previews could run. Preserve complete calculation results and keep read-time summaries and pagination. Incremental graph publication accepted isolated nodes after flow-authoring rules were lost. Require new steps to connect explicitly while retaining historical snapshots and status-only patches. Regression tests reproduce both failures on the previous code.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Worker boundaries use real managed subprocesses, without external databases."""
|
||||
import asyncio
|
||||
import json
|
||||
from functools import wraps
|
||||
from types import SimpleNamespace
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -27,6 +28,66 @@ def run_async(function):
|
||||
return wrapper
|
||||
|
||||
|
||||
@run_async
|
||||
async def test_child_keeps_complete_result_above_eight_mib(monkeypatch, tmp_path):
|
||||
from app.mcp import calculation_jobs
|
||||
from app.services import calculation_worker
|
||||
|
||||
data = {"comparisons": "x" * (8 * 1024 * 1024), "map_payload": {"features": ["coverage"]}}
|
||||
|
||||
@asynccontextmanager
|
||||
async def scope(row):
|
||||
yield object()
|
||||
|
||||
async def calculate(scope, row):
|
||||
return SimpleNamespace(model_dump=lambda **kwargs: data)
|
||||
|
||||
monkeypatch.setattr(calculation_worker, "execution_scope", scope)
|
||||
monkeypatch.setattr(calculation_jobs, "execute_calculation_job", calculate)
|
||||
input_path, output_path = tmp_path / "input.json", tmp_path / "result.json"
|
||||
input_path.write_text("{}")
|
||||
await calculation_worker.execute_child(str(input_path), str(output_path))
|
||||
assert output_path.stat().st_size > 8 * 1024 * 1024
|
||||
assert json.loads(output_path.read_text()) == {"status": "succeeded", "result": data}
|
||||
|
||||
|
||||
@run_async
|
||||
async def test_supervisor_persists_complete_result_above_eight_mib(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("CALCULATION_WORKER_STATE_DIR", str(tmp_path))
|
||||
original = asyncio.create_subprocess_exec
|
||||
|
||||
async def spawn(*args, **kwargs):
|
||||
# A real child writes the same file protocol as the calculation process.
|
||||
code = ('import json,pathlib,sys; pathlib.Path(sys.argv[1]).write_text('
|
||||
'json.dumps({"status":"succeeded","result":{"comparisons":"x"*(8*1024*1024),'
|
||||
'"map_payload":{"features":["coverage"]}}}))')
|
||||
return await original(sys.executable, "-c", code, args[-1], start_new_session=True)
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", spawn)
|
||||
results = []
|
||||
|
||||
class Repository:
|
||||
async def heartbeat(self, *args):
|
||||
return "running"
|
||||
|
||||
async def finish(self, row, owner, **terminal):
|
||||
results.append(terminal)
|
||||
return True
|
||||
|
||||
@asynccontextmanager
|
||||
async def repository(project):
|
||||
yield Repository()
|
||||
|
||||
worker = CalculationWorker(WorkerSettings(heartbeat_seconds=.02), repository=repository)
|
||||
await asyncio.wait_for(worker.run_job({"project_id": "project", "job_id": "large-result",
|
||||
"timeout_seconds": 10}), 15)
|
||||
assert len(results) == 1
|
||||
assert results[0]["status"] == "succeeded"
|
||||
assert results[0]["result"] == {
|
||||
"comparisons": "x" * (8 * 1024 * 1024), "map_payload": {"features": ["coverage"]},
|
||||
}
|
||||
|
||||
|
||||
def test_worker_settings_require_valid_concurrency(monkeypatch):
|
||||
settings = WorkerSettings.from_environment()
|
||||
assert settings.max_concurrency == 2
|
||||
|
||||
@@ -239,6 +239,85 @@ def test_graph_renderer_escapes_labels_and_keeps_explicit_parallel_and_failed_pa
|
||||
assert graph.node_refs[2].status == "reference"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edges", [[], [{"source": "check", "target": "check"}]])
|
||||
def test_new_multistep_graph_requires_relationships_instead_of_isolated_nodes(edges):
|
||||
with pytest.raises(DomainError) as error:
|
||||
update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "read", "label": "观测核验"},
|
||||
{"node_id": "check", "label": "异常判断"}],
|
||||
"edges": edges,
|
||||
}))
|
||||
assert error.value.code == "GRAPH_RELATION_REQUIRED"
|
||||
assert "check" in str(error.value)
|
||||
|
||||
|
||||
def test_new_branch_must_connect_to_the_saved_narrative_without_inventing_edges():
|
||||
original = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "root", "label": "调查起点"}],
|
||||
}))
|
||||
with pytest.raises(DomainError) as error:
|
||||
update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "left", "label": "独立核查"},
|
||||
{"node_id": "right", "label": "核查发现"}],
|
||||
"edges": [{"source": "left", "target": "right"}],
|
||||
}))
|
||||
assert error.value.code == "GRAPH_RELATION_REQUIRED"
|
||||
assert original.edges == []
|
||||
assert len(original.node_refs) == 1
|
||||
|
||||
|
||||
def test_connected_parallel_branches_and_join_render_top_down():
|
||||
graph = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": key, "label": label} for key, label in (
|
||||
("start", "异常核验"), ("isolation", "隔离分析"),
|
||||
("impact", "爆管影响"), ("summary", "汇总判断"))],
|
||||
"edges": [{"source": source, "target": target, "label": label}
|
||||
for source, target, label in (
|
||||
("start", "isolation", "并行核查"), ("start", "impact", "并行核查"),
|
||||
("isolation", "summary", "汇总依据"), ("impact", "summary", "汇总依据"))],
|
||||
}))
|
||||
assert graph.mermaid.startswith("flowchart TD\n")
|
||||
assert len(graph.edges) == 4
|
||||
assert 'start -->|"并行核查"| impact' in graph.mermaid
|
||||
patched = update_investigation_graph(graph, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "impact", "status": "recorded"}],
|
||||
}))
|
||||
assert patched.edges == graph.edges
|
||||
|
||||
|
||||
def test_existing_disconnected_snapshot_remains_readable_and_patchable():
|
||||
original = InvestigationDiagram.model_validate({
|
||||
"title": "已保存的旧图", "mermaid": 'flowchart TD\n read["观测"]\n check["判断"]',
|
||||
"edges": [], "node_refs": [{"node_id": "read", "label": "观测"},
|
||||
{"node_id": "check", "label": "判断"}],
|
||||
})
|
||||
patched = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "check", "status": "recorded"}],
|
||||
}))
|
||||
assert patched.mermaid == original.mermaid
|
||||
assert patched.edges == original.edges == []
|
||||
assert original.node_refs[1].status is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_publication_rejects_missing_links_and_accepts_a_corrected_graph():
|
||||
task, repo, service = setup_service()
|
||||
first = await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "read", "label": "观测核验"},
|
||||
])
|
||||
original = first.task.latest_progress.progress.diagram.model_dump(mode="json")
|
||||
with pytest.raises(DomainError) as error:
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "check", "label": "异常判断"},
|
||||
])
|
||||
assert error.value.code == "GRAPH_RELATION_REQUIRED"
|
||||
assert (await service.detail(task["task_id"])).latest_progress.progress.diagram.model_dump(mode="json") == original
|
||||
corrected = await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "check", "label": "异常判断"},
|
||||
], edges=[{"source": "read", "target": "check", "label": "核验依据"}])
|
||||
assert 'read -->|"核验依据"| check' in corrected.task.latest_progress.progress.diagram.mermaid
|
||||
|
||||
|
||||
def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it():
|
||||
original = InvestigationDiagram.model_validate({
|
||||
"title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]',
|
||||
|
||||
Reference in New Issue
Block a user