fix(data-process): 延迟重新生成破坏性变更
This commit is contained in:
@@ -30,6 +30,7 @@ class FakeDataProcessStore:
|
||||
self.previews: dict[str, list[dict[str, Any]]] = {}
|
||||
self.results: dict[str, list[dict[str, Any]]] = {}
|
||||
self.datasets: dict[str, dict[str, Any]] = {}
|
||||
self.regeneration_prepared: set[str] = set()
|
||||
self.sequence = 0
|
||||
|
||||
def _id(self, prefix: str) -> str:
|
||||
@@ -109,27 +110,19 @@ class FakeDataProcessStore:
|
||||
raise InvalidStateError("data process task was modified by another request")
|
||||
if payload["process_type"] != task["process_type"]:
|
||||
raise InvalidStateError("process_type cannot be changed during regeneration")
|
||||
published_outputs_preserved = bool(task.get("output_dataset_id"))
|
||||
self.results[task_id] = []
|
||||
published_outputs_preserved = bool(task.get("output_dataset_id")) or any(
|
||||
dataset.get("source_task_id") == task_id
|
||||
for dataset in self.datasets.values()
|
||||
)
|
||||
task.update(
|
||||
{
|
||||
"name": payload["name"],
|
||||
"description": payload["description"],
|
||||
"config": deepcopy(payload["config"]),
|
||||
"status": "pending",
|
||||
"progress": 20,
|
||||
"output_dataset_id": None,
|
||||
"output_count": 0,
|
||||
"filtered_count": 0,
|
||||
"duplicate_count": 0,
|
||||
"error_count": 0,
|
||||
"failure_reason": None,
|
||||
"generation_run_id": None,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"updated_at": "2026-07-25T20:00:00Z",
|
||||
}
|
||||
)
|
||||
self.regeneration_prepared.add(task_id)
|
||||
return {
|
||||
"task": deepcopy(task),
|
||||
"preview_invalidated": False,
|
||||
@@ -340,14 +333,19 @@ class FakeDataProcessStore:
|
||||
def start_generation(self, task_id: str, *, replace_existing: bool) -> dict[str, Any]:
|
||||
if not self.previews[task_id]:
|
||||
raise InvalidStateError("preview must be built before generation")
|
||||
task = self.tasks[task_id]
|
||||
if task.get("output_dataset_id") and task_id not in self.regeneration_prepared:
|
||||
raise InvalidStateError("published task cannot be regenerated")
|
||||
if replace_existing:
|
||||
self.results[task_id] = []
|
||||
self.tasks[task_id].update(
|
||||
task.update(
|
||||
status="running",
|
||||
progress=30,
|
||||
output_dataset_id=None,
|
||||
output_count=0,
|
||||
generation_run_id=self._id("dprun"),
|
||||
)
|
||||
self.regeneration_prepared.discard(task_id)
|
||||
return self.get_task(task_id)
|
||||
|
||||
def generation_is_running(self, task_id: str, generation_run_id: str) -> bool:
|
||||
@@ -487,6 +485,8 @@ class FakeDataProcessStore:
|
||||
|
||||
def publish(self, task_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
task = self.tasks[task_id]
|
||||
if task_id in self.regeneration_prepared:
|
||||
raise InvalidStateError("regeneration must start and complete before publishing")
|
||||
published = [
|
||||
dataset
|
||||
for dataset in self.datasets.values()
|
||||
@@ -969,6 +969,13 @@ def test_regenerate_endpoint_prepares_an_existing_published_task(tmp_path: Path)
|
||||
"output_count": 2,
|
||||
}
|
||||
)
|
||||
store.datasets["dataset_train"] = {
|
||||
"id": "dataset_train",
|
||||
"name": "原训练集",
|
||||
"type": "train",
|
||||
"source_task_id": task_id,
|
||||
"deleted_at": None,
|
||||
}
|
||||
store.previews[task_id] = [{"id": "preview_1", "edited_content": "原切片"}]
|
||||
store.results[task_id] = [{"id": "result_1"}]
|
||||
|
||||
@@ -985,16 +992,24 @@ def test_regenerate_endpoint_prepares_an_existing_published_task(tmp_path: Path)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()["data"]
|
||||
assert data["task"]["status"] == "pending"
|
||||
assert data["task"]["output_dataset_id"] is None
|
||||
assert data["task"]["status"] == "completed"
|
||||
assert data["task"]["output_dataset_id"] == "dataset_train"
|
||||
assert data["task"]["output_count"] == 2
|
||||
assert data["preview_invalidated"] is False
|
||||
assert data["published_outputs_preserved"] is True
|
||||
assert store.results[task_id] == []
|
||||
assert store.results[task_id] == [{"id": "result_1"}]
|
||||
assert store.previews[task_id][0]["id"] == "preview_1"
|
||||
|
||||
detail = client.get(f"/modelTF/data-process/{task_id}").json()["data"]
|
||||
assert detail["status"] == "completed"
|
||||
assert detail["output_dataset_id"] == "dataset_train"
|
||||
assert detail["output_count"] == 2
|
||||
assert [item["id"] for item in detail["output_datasets"]] == ["dataset_train"]
|
||||
|
||||
|
||||
def test_published_split_datasets_remain_in_detail_after_regeneration(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, store, _ = make_client(tmp_path)
|
||||
task_id = client.post(
|
||||
@@ -1017,6 +1032,7 @@ def test_published_split_datasets_remain_in_detail_after_regeneration(
|
||||
"output": "答案",
|
||||
}
|
||||
]
|
||||
store.previews[task_id] = [{"id": "preview_1", "edited_content": "原切片"}]
|
||||
|
||||
published = client.post(
|
||||
f"/modelTF/data-process/{task_id}/publish",
|
||||
@@ -1039,16 +1055,40 @@ def test_published_split_datasets_remain_in_detail_after_regeneration(
|
||||
},
|
||||
)
|
||||
assert regenerated.status_code == 200
|
||||
assert regenerated.json()["data"]["task"]["output_dataset_id"] is None
|
||||
original_output_dataset_id = store.tasks[task_id]["output_dataset_id"]
|
||||
prepared_task = regenerated.json()["data"]["task"]
|
||||
assert prepared_task["status"] == "completed"
|
||||
assert prepared_task["output_dataset_id"] == original_output_dataset_id
|
||||
assert prepared_task["output_count"] == 1
|
||||
assert store.results[task_id][0]["id"] == "result_1"
|
||||
|
||||
detail = client.get(f"/modelTF/data-process/{task_id}")
|
||||
assert detail.status_code == 200
|
||||
detail_data = detail.json()["data"]
|
||||
assert detail_data["status"] == "pending"
|
||||
assert detail_data["output_dataset_id"] is None
|
||||
assert detail_data["status"] == "completed"
|
||||
assert detail_data["output_dataset_id"] == original_output_dataset_id
|
||||
assert detail_data["output_count"] == 1
|
||||
assert len(detail_data["output_datasets"]) == 3
|
||||
assert {item["id"] for item in detail_data["output_datasets"]} == published_ids
|
||||
assert set(store.datasets) == published_ids
|
||||
retained_results = client.get(f"/modelTF/data-process/{task_id}/results").json()["data"]
|
||||
assert retained_results["total"] == 1
|
||||
assert retained_results["items"][0]["id"] == "result_1"
|
||||
|
||||
monkeypatch.setattr(data_process_endpoint, "_run_generation", lambda *args: None)
|
||||
started = client.post(f"/modelTF/data-process/{task_id}/generate")
|
||||
assert started.status_code == 200
|
||||
running = started.json()["data"]
|
||||
assert running["status"] == "running"
|
||||
assert running["output_count"] == 0
|
||||
assert store.results[task_id] == []
|
||||
assert set(store.datasets) == published_ids
|
||||
|
||||
running_detail = client.get(f"/modelTF/data-process/{task_id}").json()["data"]
|
||||
assert running_detail["status"] == "running"
|
||||
assert running_detail["output_dataset_id"] is None
|
||||
assert running_detail["output_count"] == 0
|
||||
assert {item["id"] for item in running_detail["output_datasets"]} == published_ids
|
||||
|
||||
|
||||
def test_regenerate_endpoint_validates_snapshot_and_locked_process_type(
|
||||
|
||||
@@ -225,18 +225,7 @@ class _RegenerationConnection:
|
||||
"name": params[0],
|
||||
"description": params[1],
|
||||
"config": params[2],
|
||||
"status": "pending",
|
||||
"progress": params[3],
|
||||
"output_dataset_id": None,
|
||||
"output_count": 0,
|
||||
"filtered_count": 0,
|
||||
"duplicate_count": 0,
|
||||
"error_count": 0,
|
||||
"failure_reason": None,
|
||||
"generation_run_id": None,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"updated_at": params[4],
|
||||
"updated_at": params[3],
|
||||
}
|
||||
)
|
||||
return _Result(row=dict(self.task))
|
||||
@@ -369,39 +358,50 @@ class _TaskListStore(DataProcessStore):
|
||||
|
||||
|
||||
class _StartGenerationConnection:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, published_prepared: bool = False, preview_count: int = 1) -> None:
|
||||
config = {"chunk_method": "fixed", "temperature": 0.7}
|
||||
if published_prepared:
|
||||
config["_regeneration_prepared"] = {
|
||||
"prepared": True,
|
||||
"preview_invalidated": False,
|
||||
}
|
||||
self.task = {
|
||||
**_regeneration_task(
|
||||
status="pending",
|
||||
output_dataset_id=None,
|
||||
status="completed" if published_prepared else "pending",
|
||||
config=config,
|
||||
output_dataset_id="dataset_train" if published_prepared else None,
|
||||
output_count=28,
|
||||
),
|
||||
"generation_run_id": None,
|
||||
}
|
||||
self.preview_count = preview_count
|
||||
self.results = [{"id": "old-result"}]
|
||||
|
||||
def execute(self, sql: str, params: Any = None) -> _Result:
|
||||
normalized = " ".join(sql.split())
|
||||
if normalized.startswith("SELECT COUNT(*) AS count FROM data_process_preview_items"):
|
||||
return _Result(row={"count": 1})
|
||||
return _Result(row={"count": self.preview_count})
|
||||
if normalized.startswith("DELETE FROM data_process_results"):
|
||||
self.results.clear()
|
||||
return _Result()
|
||||
assert normalized.startswith("UPDATE data_process_tasks SET status='running'")
|
||||
assert normalized.startswith("UPDATE data_process_tasks SET config=%s, status='running'")
|
||||
assert "output_dataset_id=NULL" in normalized
|
||||
assert "output_count=0" in normalized
|
||||
self.task.update(
|
||||
{
|
||||
"config": params[0],
|
||||
"status": "running",
|
||||
"progress": 30,
|
||||
"output_count": 0,
|
||||
"output_dataset_id": None,
|
||||
"failure_reason": None,
|
||||
"started_at": params[0],
|
||||
"started_at": params[1],
|
||||
"completed_at": None,
|
||||
"filtered_count": 0,
|
||||
"duplicate_count": 0,
|
||||
"error_count": 0,
|
||||
"generation_run_id": params[1],
|
||||
"updated_at": params[2],
|
||||
"generation_run_id": params[2],
|
||||
"updated_at": params[3],
|
||||
}
|
||||
)
|
||||
return _Result(row=dict(self.task))
|
||||
@@ -591,6 +591,31 @@ def test_start_generation_clears_previous_output_count() -> None:
|
||||
assert conn.results == []
|
||||
|
||||
|
||||
def test_prepared_published_task_is_only_cleared_when_generation_starts() -> None:
|
||||
conn = _StartGenerationConnection(published_prepared=True)
|
||||
|
||||
task = _StartGenerationStore(conn).start_generation("task-1")
|
||||
|
||||
assert task["status"] == "running"
|
||||
assert task["output_dataset_id"] is None
|
||||
assert task["output_count"] == 0
|
||||
assert "_regeneration_prepared" not in task["config"]
|
||||
assert conn.results == []
|
||||
|
||||
|
||||
def test_prepared_published_task_survives_generation_preflight_failure() -> None:
|
||||
conn = _StartGenerationConnection(published_prepared=True, preview_count=0)
|
||||
|
||||
with pytest.raises(InvalidStateError, match="preview must be built"):
|
||||
_StartGenerationStore(conn).start_generation("task-1")
|
||||
|
||||
assert conn.task["status"] == "completed"
|
||||
assert conn.task["output_dataset_id"] == "dataset_train"
|
||||
assert conn.task["output_count"] == 28
|
||||
assert "_regeneration_prepared" in conn.task["config"]
|
||||
assert conn.results == [{"id": "old-result"}]
|
||||
|
||||
|
||||
def _legacy_published_datasets(task_id: str = "task-1") -> list[dict[str, Any]]:
|
||||
specs = (
|
||||
("dataset_train", "制度问答-训练集", "train", "train", 22),
|
||||
@@ -640,13 +665,16 @@ def test_prepare_regeneration_preserves_outputs_sources_and_generation_only_prev
|
||||
|
||||
assert result["preview_invalidated"] is False
|
||||
assert result["published_outputs_preserved"] is True
|
||||
assert result["task"]["output_dataset_id"] is None
|
||||
assert result["task"]["status"] == "pending"
|
||||
assert result["task"]["progress"] == 20
|
||||
assert result["task"]["output_count"] == 0
|
||||
assert result["task"]["started_at"] is None
|
||||
assert result["task"]["completed_at"] is None
|
||||
assert conn.results == []
|
||||
assert result["task"]["output_dataset_id"] == "dataset_train"
|
||||
assert result["task"]["status"] == "completed"
|
||||
assert result["task"]["progress"] == 100
|
||||
assert result["task"]["output_count"] == 28
|
||||
assert result["task"]["started_at"] == "2026-07-25T18:00:00Z"
|
||||
assert result["task"]["completed_at"] == "2026-07-25T18:05:00Z"
|
||||
assert "_regeneration_prepared" not in result["task"]["config"]
|
||||
stored_config = json.loads(conn.task["config"])
|
||||
assert stored_config["_regeneration_prepared"]["prepared"] is True
|
||||
assert conn.results == [{"id": "result_1"}]
|
||||
assert conn.previews == [{"id": "preview_1"}]
|
||||
assert conn.datasets == original_datasets
|
||||
assert conn.sources == original_sources
|
||||
@@ -694,7 +722,7 @@ def test_prepare_regeneration_backfills_and_keeps_legacy_task_datasets_visible()
|
||||
)
|
||||
|
||||
assert result["published_outputs_preserved"] is True
|
||||
assert result["task"]["output_dataset_id"] is None
|
||||
assert result["task"]["output_dataset_id"] == "dataset_train"
|
||||
assert len(conn.datasets) == 5
|
||||
assert all(
|
||||
item["source_task_id"] == "task-1" for item in conn.datasets[:3]
|
||||
@@ -712,7 +740,7 @@ def test_prepare_regeneration_backfills_and_keeps_legacy_task_datasets_visible()
|
||||
]
|
||||
|
||||
|
||||
def test_prepare_regeneration_deletes_preview_when_chunk_configuration_changes() -> None:
|
||||
def test_prepare_regeneration_defers_preview_deletion_when_chunk_configuration_changes() -> None:
|
||||
conn = _RegenerationConnection(_regeneration_task(output_dataset_id=None))
|
||||
|
||||
result = _RegenerationStore(conn).prepare_regeneration(
|
||||
@@ -728,9 +756,11 @@ def test_prepare_regeneration_deletes_preview_when_chunk_configuration_changes()
|
||||
|
||||
assert result["preview_invalidated"] is True
|
||||
assert result["published_outputs_preserved"] is True
|
||||
assert result["task"]["progress"] == 0
|
||||
assert conn.previews == []
|
||||
assert conn.results == []
|
||||
assert result["task"]["status"] == "completed"
|
||||
assert result["task"]["progress"] == 100
|
||||
assert result["task"]["output_count"] == 28
|
||||
assert conn.previews == [{"id": "preview_1"}]
|
||||
assert conn.results == [{"id": "result_1"}]
|
||||
assert len(conn.datasets) == 3
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user