修改问题
This commit is contained in:
@@ -78,6 +78,14 @@ class DataCollection:
|
|||||||
# node_id 不变但 data_id 变更,清理旧索引
|
# node_id 不变但 data_id 变更,清理旧索引
|
||||||
self._data_id_to_node_id.pop(old_node.data_id, None)
|
self._data_id_to_node_id.pop(old_node.data_id, None)
|
||||||
|
|
||||||
|
stale_data_ids = [
|
||||||
|
data_id
|
||||||
|
for data_id, existing_node_id in self._data_id_to_node_id.items()
|
||||||
|
if existing_node_id == node.node_id and data_id != node.data_id
|
||||||
|
]
|
||||||
|
for stale_data_id in stale_data_ids:
|
||||||
|
self._data_id_to_node_id.pop(stale_data_id, None)
|
||||||
|
|
||||||
if node.data_id and node.data_id != "":
|
if node.data_id and node.data_id != "":
|
||||||
existing_node_id = self._data_id_to_node_id.get(node.data_id)
|
existing_node_id = self._data_id_to_node_id.get(node.data_id)
|
||||||
if existing_node_id is not None and existing_node_id != node.node_id:
|
if existing_node_id is not None and existing_node_id != node.node_id:
|
||||||
|
|||||||
@@ -315,14 +315,19 @@ class ProjectDetailApiHandler(BaseApiHandler):
|
|||||||
|
|
||||||
existing_by_pair = self._find_existing_by_project_key(project_id=project_id, key=key)
|
existing_by_pair = self._find_existing_by_project_key(project_id=project_id, key=key)
|
||||||
if existing_by_pair is not None:
|
if existing_by_pair is not None:
|
||||||
if loaded_data_id and existing_by_pair.data_id != loaded_data_id:
|
if loaded_data_id and existing_by_pair.data_id == loaded_data_id:
|
||||||
with existing_by_pair.allow_core_state_write("project_detail_pair_rebind"):
|
existing_by_pair.set_data(data)
|
||||||
existing_by_pair.data_id = loaded_data_id
|
existing_by_pair.set_origin_data(data)
|
||||||
existing_by_pair.set_data(data)
|
if project_id:
|
||||||
existing_by_pair.set_origin_data(data)
|
existing_by_pair.context["project_id"] = project_id
|
||||||
if project_id:
|
return existing_by_pair
|
||||||
existing_by_pair.context["project_id"] = project_id
|
|
||||||
return existing_by_pair
|
existing_data_id = existing_by_pair.data_id or "<empty>"
|
||||||
|
loaded_data_id_text = loaded_data_id or "<empty>"
|
||||||
|
raise RuntimeError(
|
||||||
|
"project_detail_data load conflict: "
|
||||||
|
f"project_id={project_id}, key={key}, existing_data_id={existing_data_id}, loaded_data_id={loaded_data_id_text}"
|
||||||
|
)
|
||||||
|
|
||||||
node = self._create_basic_node(data, depend_ids=[])
|
node = self._create_basic_node(data, depend_ids=[])
|
||||||
if project_id:
|
if project_id:
|
||||||
|
|||||||
@@ -157,10 +157,24 @@ class FullSyncPipeline:
|
|||||||
|
|
||||||
created = await strategy.create()
|
created = await strategy.create()
|
||||||
self._logger.info(f"✅ Strategy create: {node_type} (created={len(created)})")
|
self._logger.info(f"✅ Strategy create: {node_type} (created={len(created)})")
|
||||||
|
created_local_node_ids = {
|
||||||
|
node.node_id for node in created if self.local_collection.get_by_node_id(node.node_id) is not None
|
||||||
|
}
|
||||||
|
created_remote_node_ids = {
|
||||||
|
node.node_id for node in created if self.remote_collection.get_by_node_id(node.node_id) is not None
|
||||||
|
}
|
||||||
create_written = await self.commit_creates(node_type)
|
create_written = await self.commit_creates(node_type)
|
||||||
await self.normalize_create_success_to_update_entry(node_type)
|
await self.normalize_create_success_to_update_entry(node_type)
|
||||||
if create_written:
|
if create_written:
|
||||||
await self._reload_node_type(node_type, reason="create wrote data")
|
await self._reload_node_type(node_type, reason="create wrote data")
|
||||||
|
await refresh_bind_data_id(
|
||||||
|
node_type=node_type,
|
||||||
|
local_collection=self.local_collection,
|
||||||
|
remote_collection=self.remote_collection,
|
||||||
|
binding_manager=self.binding_manager,
|
||||||
|
local_node_ids=created_local_node_ids,
|
||||||
|
remote_node_ids=created_remote_node_ids,
|
||||||
|
)
|
||||||
|
|
||||||
updated = await strategy.update()
|
updated = await strategy.update()
|
||||||
self._logger.info(f"✅ Strategy update: {node_type} (updated={len(updated)})")
|
self._logger.info(f"✅ Strategy update: {node_type} (updated={len(updated)})")
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ async def refresh_bind_data_id(
|
|||||||
continue
|
continue
|
||||||
remote_node_id = local_to_remote.get(local_node.node_id)
|
remote_node_id = local_to_remote.get(local_node.node_id)
|
||||||
remote_node = remote_collection.get_by_node_id(remote_node_id) if remote_node_id else None
|
remote_node = remote_collection.get_by_node_id(remote_node_id) if remote_node_id else None
|
||||||
if remote_node and remote_node.data_id:
|
local_status = getattr(local_node.status, "value", None)
|
||||||
|
remote_status = getattr(remote_node.status, "value", None) if remote_node is not None else None
|
||||||
|
if local_status != "FAILED" and remote_node and remote_node.data_id and remote_status != "FAILED":
|
||||||
local_node.context["bind_data_id"] = remote_node.data_id
|
local_node.context["bind_data_id"] = remote_node.data_id
|
||||||
else:
|
else:
|
||||||
local_node.context.pop("bind_data_id", None)
|
local_node.context.pop("bind_data_id", None)
|
||||||
@@ -126,7 +128,9 @@ async def refresh_bind_data_id(
|
|||||||
continue
|
continue
|
||||||
local_node_id = remote_to_local.get(remote_node.node_id)
|
local_node_id = remote_to_local.get(remote_node.node_id)
|
||||||
local_node = local_collection.get_by_node_id(local_node_id) if local_node_id else None
|
local_node = local_collection.get_by_node_id(local_node_id) if local_node_id else None
|
||||||
if local_node and local_node.data_id:
|
remote_status = getattr(remote_node.status, "value", None)
|
||||||
|
local_status = getattr(local_node.status, "value", None) if local_node is not None else None
|
||||||
|
if remote_status != "FAILED" and local_node and local_node.data_id and local_status != "FAILED":
|
||||||
remote_node.context["bind_data_id"] = local_node.data_id
|
remote_node.context["bind_data_id"] = local_node.data_id
|
||||||
else:
|
else:
|
||||||
remote_node.context.pop("bind_data_id", None)
|
remote_node.context.pop("bind_data_id", None)
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ async def test_create_all_marks_duplicate_project_key_in_same_batch_as_error() -
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_node_reuses_existing_node_by_project_key_and_rebinds_data_id() -> None:
|
async def test_create_node_raises_on_existing_project_key_with_different_data_id() -> None:
|
||||||
collection = DataCollection("remote")
|
collection = DataCollection("remote")
|
||||||
|
|
||||||
handler = ProjectDetailApiHandler()
|
handler = ProjectDetailApiHandler()
|
||||||
@@ -278,17 +278,15 @@ async def test_create_node_reuses_existing_node_by_project_key_and_rebinds_data_
|
|||||||
)
|
)
|
||||||
await collection.add(existing)
|
await collection.add(existing)
|
||||||
|
|
||||||
created = handler._create_node(
|
with pytest.raises(RuntimeError, match="project_detail_data load conflict"):
|
||||||
{
|
handler._create_node(
|
||||||
"id": "remote-detail-99",
|
{
|
||||||
"project_id": "project-1",
|
"id": "remote-detail-99",
|
||||||
"key": "plan_construction",
|
"project_id": "project-1",
|
||||||
"value": [{"date": "2026-01-01", "capacity": 21.0}],
|
"key": "plan_construction",
|
||||||
}
|
"value": [{"date": "2026-01-01", "capacity": 21.0}],
|
||||||
)
|
}
|
||||||
|
)
|
||||||
assert created.node_id == "detail-node-old"
|
|
||||||
assert created.data_id == "remote-detail-99"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -34,3 +34,20 @@ async def test_collection_filter_by_project_ids() -> None:
|
|||||||
assert collection.get("c1") is not None
|
assert collection.get("c1") is not None
|
||||||
assert collection.get("p2_node") is not None
|
assert collection.get("p2_node") is not None
|
||||||
assert collection.get("c2") is None
|
assert collection.get("c2") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_collection_update_clears_stale_data_id_index_for_same_node() -> None:
|
||||||
|
register_test_domain()
|
||||||
|
|
||||||
|
collection = DataCollection("local")
|
||||||
|
node = build_project_node("p1_node", "p1", name="P1", code="P1")
|
||||||
|
await collection.add(node)
|
||||||
|
|
||||||
|
with node.allow_core_state_write("test_rebind"):
|
||||||
|
node.data_id = "p2"
|
||||||
|
|
||||||
|
await collection.update(node)
|
||||||
|
|
||||||
|
assert collection.get_by_data_id("test_project", "p1") is None
|
||||||
|
assert collection.get_by_data_id("test_project", "p2") is node
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from sync_state_machine.common.collection import DataCollection
|
from sync_state_machine.common.collection import DataCollection
|
||||||
@@ -95,14 +97,14 @@ class _DS(BaseDataSource):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_all_continue_when_one_handler_fails(capsys) -> None:
|
async def test_load_all_continue_when_one_handler_fails(caplog) -> None:
|
||||||
ds = _DS()
|
ds = _DS()
|
||||||
ds.add_handler(_FailHandler())
|
ds.add_handler(_FailHandler())
|
||||||
ds.add_handler(_OkHandler())
|
ds.add_handler(_OkHandler())
|
||||||
ds.set_collection(DataCollection("local"))
|
ds.set_collection(DataCollection("local"))
|
||||||
|
|
||||||
await ds.load_all(order=["fail", "ok"])
|
with caplog.at_level(logging.INFO):
|
||||||
|
await ds.load_all(order=["fail", "ok"])
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
assert "[fail] 加载失败" in caplog.text
|
||||||
assert "[fail] 加载失败" in out
|
assert "Loaded nodes for ok: 0" in caplog.text
|
||||||
assert "Loaded nodes for ok: 0" in out
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ class _StubStrategy:
|
|||||||
async def update(self):
|
async def update(self):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def normalize_compare_payload(self, data, data_id_map=None):
|
||||||
|
del data_id_map
|
||||||
|
return dict(data or {})
|
||||||
|
|
||||||
|
|
||||||
class _FakePersistence:
|
class _FakePersistence:
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
|||||||
@@ -150,11 +150,11 @@ async def test_post_check_uses_same_id_resolver_as_create_update_for_depend_ids(
|
|||||||
|
|
||||||
result = await evaluate_consistency_for_node_type(
|
result = await evaluate_consistency_for_node_type(
|
||||||
node_type="construction_task",
|
node_type="construction_task",
|
||||||
strategy=strategy,
|
|
||||||
local_collection=local_collection,
|
local_collection=local_collection,
|
||||||
remote_collection=remote_collection,
|
remote_collection=remote_collection,
|
||||||
binding_manager=binding_manager,
|
binding_manager=binding_manager,
|
||||||
logger=logging.getLogger("sync_state_machine"),
|
logger=logging.getLogger("sync_state_machine"),
|
||||||
|
normalize_compare_payload=strategy.normalize_compare_payload,
|
||||||
depend_fields={"contract_id": "contract"},
|
depend_fields={"contract_id": "contract"},
|
||||||
compare_to_remote=True,
|
compare_to_remote=True,
|
||||||
)
|
)
|
||||||
@@ -237,11 +237,11 @@ async def test_post_check_ignores_only_explicit_configured_fields(tmp_path: Path
|
|||||||
|
|
||||||
result = await evaluate_consistency_for_node_type(
|
result = await evaluate_consistency_for_node_type(
|
||||||
node_type="contract",
|
node_type="contract",
|
||||||
strategy=strategy,
|
|
||||||
local_collection=local_collection,
|
local_collection=local_collection,
|
||||||
remote_collection=remote_collection,
|
remote_collection=remote_collection,
|
||||||
binding_manager=binding_manager,
|
binding_manager=binding_manager,
|
||||||
logger=logging.getLogger("sync_state_machine"),
|
logger=logging.getLogger("sync_state_machine"),
|
||||||
|
normalize_compare_payload=strategy.normalize_compare_payload,
|
||||||
compare_to_remote=True,
|
compare_to_remote=True,
|
||||||
ignore_fields=["code"],
|
ignore_fields=["code"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -94,3 +94,41 @@ async def test_project_detail_create_failure_preserves_push_id_and_biz_id() -> N
|
|||||||
assert result.metadata["biz_id"] == "project-1"
|
assert result.metadata["biz_id"] == "project-1"
|
||||||
assert result.metadata["project_id"] == "project-1"
|
assert result.metadata["project_id"] == "project-1"
|
||||||
assert result.metadata["key"] == "production"
|
assert result.metadata["key"] == "production"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_detail_load_conflict_raises_on_same_project_key_different_data_id() -> None:
|
||||||
|
collection = DataCollection("remote")
|
||||||
|
|
||||||
|
project = _ProjectNode(
|
||||||
|
node_id="project-node-1",
|
||||||
|
data_id="project-1",
|
||||||
|
data={"id": "project-1"},
|
||||||
|
)
|
||||||
|
collection._nodes[project.node_id] = project
|
||||||
|
|
||||||
|
existing_detail = ProjectDetailSyncNode(
|
||||||
|
node_id="detail-node-1",
|
||||||
|
data_id="detail-old",
|
||||||
|
data={
|
||||||
|
"id": "detail-old",
|
||||||
|
"project_id": "project-1",
|
||||||
|
"key": "actual_construction",
|
||||||
|
"value": [{"date": "2026-01-01", "capacity": 1.0}],
|
||||||
|
},
|
||||||
|
context={"project_id": "project-1"},
|
||||||
|
)
|
||||||
|
collection._nodes[existing_detail.node_id] = existing_detail
|
||||||
|
collection._data_id_to_node_id[existing_detail.data_id] = existing_detail.node_id
|
||||||
|
|
||||||
|
handler = ProjectDetailApiHandler()
|
||||||
|
handler.set_collection(collection)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="project_detail_data load conflict"):
|
||||||
|
handler._create_node(
|
||||||
|
{
|
||||||
|
"id": "detail-new",
|
||||||
|
"project_id": "project-1",
|
||||||
|
"key": "actual_construction",
|
||||||
|
"value": [{"date": "2026-01-02", "capacity": 2.0}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user