项目文件夹

文件
wehub-resource-sync c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:02:24 +08:00

479 行
21 KiB
Python

"""
Backwards Compatibility Test
Phase 1 - run with a legacy cognee version
Seeds the database with Lorem Ipsum data: add → cognify → search.
Phase 2 - run with current Cognee branch
Runs startup migrations (relational + graph + vector), then verifies that the
legacy-seeded graph, vector AND relational-ledger data is actually *accessible
and correct* after migration, then re-cognifies and verifies again.
Why these specific checks?
--------------------------
A migration can leave the database query-able yet semantically broken — e.g.
the node-ID change in PR #2515 (issue #2510) did not raise, it silently left
old graphs with the pre-namespacing IDs, breaking projection/search and causing
re-cognify to raise ``EntityAlreadyExistsError``.
Gating on completion-style search (GRAPH_COMPLETION/RAG_COMPLETION) is NOT
reliable: the LLM can return a non-empty "I don't know" answer even when graph
retrieval is empty. So this test gates on signals that genuinely fail when data
is inaccessible:
* graph — read the dataset's graph directly and assert it has nodes AND that no
Entity/EntityType node is still on the pre-#2515 ID scheme (which would mean
the migration didn't run/complete);
* ledger — read the relational ``nodes``/``edges`` tables and assert every id
the migration could have moved still resolves to a live graph node (a stale
ledger id silently orphans nodes on delete);
* vector — a CHUNKS search (raw vector retrieval) must return results.
* delete — hard-delete documents one by one; after each, only the nodes/edges
owned solely by that document disappear, shared ones survive until their last
owner goes, and the graph ends empty. Delete resolves nodes via the ledger
(``nodes.slug``), so this is the end-to-end proof of ledger/graph lockstep: a
stale ledger id makes delete silently orphan a migrated node, which the static
checks above can't catch.
Completion searches are still exercised afterwards as a smoke check (must not
raise), just not used as the accessibility gate.
"""
import asyncio
import sys
from collections import Counter
import cognee
from sqlalchemy import select
from cognee.api.v1.search import SearchType
from cognee.context_global_variables import set_database_global_context_variables
from cognee.infrastructure.databases.graph import get_graph_engine
from cognee.infrastructure.databases.relational import get_relational_engine
from cognee.modules.data.methods.get_dataset_databases import get_dataset_databases
from cognee.modules.data.models import DatasetData
from cognee.modules.engine.models import Entity, EntityType
from cognee.modules.graph.models import Edge, Node
from cognee.modules.migrations.versions.namespace_entity_type_node_ids import build_id_remap
_LEDGER_ENTITY_TYPES = (Entity.__name__, EntityType.__name__)
LOREM_IPSUM = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
in culpa qui officia deserunt mollit anim id est laborum.
Lorem ipsum is placeholder text commonly used in the graphic, print, and publishing industries for
previewing layouts and visual mockups. It has been the industry standard dummy text since the 1500s
when an unknown printer scrambled a passage of text to make a type specimen book.
Loren Ipsum Dolor sit amet, Lorem ipsum.
"""
DATASET = "lorem_ipsum"
SEARCH_QUERY = "What is Lorem Ipsum and where does it come from?"
def _fail(message: str) -> None:
print(f"ERROR: {message}")
sys.exit(1)
def _count_stale_ids(nodes: list) -> int:
"""Count Entity/EntityType nodes the migration would still remap.
Uses the migration's own recognition (``build_id_remap``) so "stale" means
exactly "still on a recognized old ID scheme" — i.e. the migration did not
run/complete. This avoids false positives on freshly-created nodes whose id
was (legitimately) derived from a value other than the stored name.
"""
return len(build_id_remap(nodes))
async def _collect_graph() -> tuple[list, list]:
"""Gather all (nodes, edges) across every dataset's graph, addressed BY ID.
Datasets are addressed by their stored ID + owner (from the dataset_database
rows), never by name: the name→ID mapping is per-user/tenant, so resolving by
name across the seed/verify boundary points at a *different* dataset. See
``get_unique_dataset_id`` for why.
"""
rows = await get_dataset_databases()
all_nodes: list = []
all_edges: list = []
if rows:
for row in rows:
async with set_database_global_context_variables(row.dataset_id, row.owner_id):
graph_engine = await get_graph_engine()
nodes, edges = await graph_engine.get_graph_data()
all_nodes.extend(nodes)
all_edges.extend(edges)
else:
# Access control off: a single global graph, no per-dataset rows.
graph_engine = await get_graph_engine()
all_nodes, all_edges = await graph_engine.get_graph_data()
return all_nodes, all_edges
async def _verify_graph_access(stage: str, nodes: list, edges: list) -> None:
"""Fail unless the migrated graph is non-empty, fully migrated, and intact.
Checks (a) graph is non-empty, (b) no Entity/EntityType node is still on an
old ID scheme, (c) no dangling edges — every edge endpoint resolves to a
node, which proves the migration rewired entity edges and that ALL node
types (DocumentChunk, TextSummary, EdgeType, …), not just entities, remain
reachable and connected after the ID remap.
"""
if not nodes:
_fail(f"[{stage}] graph has no nodes — graph data is not accessible after migration.")
stale = _count_stale_ids(nodes)
if stale:
_fail(
f"[{stage}] {stale} Entity/EntityType node(s) still use the pre-#2515 ID scheme — "
"the graph migration did not run or did not complete."
)
node_ids = {node_id for node_id, _ in nodes}
# Ladybug/Kuzu injects (id, id, "SELF") self-loops when a graph has no edges,
# which would mask total edge loss — drop them before the connectivity check.
real_edges = [(s, t, r) for s, t, r, _ in edges if r != "SELF"]
if not real_edges:
_fail(f"[{stage}] graph has no real edges — possible edge loss after migration.")
dangling = [(s, t, r) for s, t, r in real_edges if s not in node_ids or t not in node_ids]
if dangling:
_fail(
f"[{stage}] {len(dangling)} dangling edge(s) after migration "
f"(e.g. {dangling[0]}) — the ID remap broke cross-type connectivity."
)
# Independent positive correctness check (does NOT use the migration's own
# recognizer): at least one Entity must be addressable at its model-owned
# id_for(name). Catches a migration that ran but wrote wrong (not-old-scheme)
# ids — which the build_id_remap-based stale count cannot see.
if not any(
props.get("type") == "Entity"
and props.get("name")
and node_id == str(Entity.id_for(props["name"]))
for node_id, props in nodes
):
_fail(
f"[{stage}] no Entity is on the model-owned ID scheme "
"(id != Entity.id_for(name)) — the migration produced wrong IDs."
)
type_counts = Counter(props.get("type") for _, props in nodes)
print(
f" [graph] {len(nodes)} nodes across {len(type_counts)} types {dict(type_counts)}, "
f"{len(real_edges)} edges, 0 stale, 0 dangling — OK"
)
async def _verify_vector_access(stage: str) -> None:
"""CHUNKS is raw vector retrieval (no LLM fallback); empty == inaccessible."""
results = await cognee.search(query_type=SearchType.CHUNKS, query_text=SEARCH_QUERY)
if not results:
_fail(f"[{stage}] CHUNKS search returned no results — vector data is not accessible.")
print(f" [vector] CHUNKS: {len(results)} result(s) — OK")
async def _verify_ledger_access(stage: str, graph_node_ids: set) -> None:
"""Read the relational delete-ledger (``nodes`` / ``edges``) and verify it
still points at the migrated graph.
The delete system deletes graph nodes by ``nodes.slug`` and references edges
by ``edges.source_node_id`` / ``destination_node_id`` — all graph node ids.
If the id migration moved graph nodes but left these stale, a later delete
silently misses the migrated nodes (orphans). So this asserts every ledger
id that the migration could have moved (Entity/EntityType ``slug`` and every
edge endpoint) still resolves to a live graph node.
A legacy seed predating the ledger leaves the tables empty; that is not a
failure here (re-cognify on the current branch repopulates them and Step 3
re-checks), but reading them still proves the relational store is reachable.
"""
db_engine = get_relational_engine()
async with db_engine.get_async_session() as session:
node_rows = (await session.scalars(select(Node))).all()
edge_rows = (await session.scalars(select(Edge))).all()
if not node_rows and not edge_rows:
print(" [ledger] empty (legacy seed predates the node/edge ledger) — read OK, skipped")
return
stale_node_slugs = [
str(row.slug)
for row in node_rows
if row.type in _LEDGER_ENTITY_TYPES and str(row.slug) not in graph_node_ids
]
if stale_node_slugs:
_fail(
f"[{stage}] {len(stale_node_slugs)} ledger Entity/EntityType slug(s) do not resolve "
f"to a graph node (e.g. {stale_node_slugs[0]}) — the migration left the relational "
"ledger stale; deletes would orphan these nodes in the graph."
)
dangling_edges = [
(str(row.source_node_id), str(row.destination_node_id))
for row in edge_rows
if str(row.source_node_id) not in graph_node_ids
or str(row.destination_node_id) not in graph_node_ids
]
if dangling_edges:
_fail(
f"[{stage}] {len(dangling_edges)} ledger edge endpoint(s) do not resolve to a graph "
f"node (e.g. {dangling_edges[0]}) — ledger edges desynced from the migrated graph."
)
print(
f" [ledger] {len(node_rows)} node row(s), {len(edge_rows)} edge row(s), "
"all ids resolve to the migrated graph — OK"
)
async def _verify_access(stage: str) -> None:
print(f"\n[{stage}] Verifying graph + vector + ledger access")
nodes, edges = await _collect_graph()
graph_node_ids = {node_id for node_id, _ in nodes}
await _verify_graph_access(stage, nodes, edges)
await _verify_ledger_access(stage, graph_node_ids)
await _verify_vector_access(stage)
# Smoke-check the completion + summaries paths (must not raise). Not used
# as the accessibility gate: the LLM can answer non-empty even with no
# context.
for query_type in (
SearchType.GRAPH_COMPLETION,
SearchType.RAG_COMPLETION,
SearchType.SUMMARIES,
):
await cognee.search(query_type=query_type, query_text=SEARCH_QUERY)
print(" [smoke] GRAPH_COMPLETION + RAG_COMPLETION + SUMMARIES ran without error — OK")
async def _snapshot_dataset_graph(dataset_id, owner_by_dataset):
"""(node_ids, props_by_id, edge_keys) for the graph holding ``dataset_id``.
``owner_by_dataset`` is the dataset_id→owner_id map from dataset_database
rows when access control is on (per-dataset graphs), or ``None`` when off
(one global graph). Edge keys are (source, target, relationship_name) with
Ladybug's synthetic SELF self-loops dropped.
"""
if owner_by_dataset is None:
graph_engine = await get_graph_engine()
nodes, edges = await graph_engine.get_graph_data()
else:
async with set_database_global_context_variables(dataset_id, owner_by_dataset[dataset_id]):
graph_engine = await get_graph_engine()
nodes, edges = await graph_engine.get_graph_data()
node_ids = {str(node_id) for node_id, _ in nodes}
props_by_id = {str(node_id): props for node_id, props in nodes}
edge_keys = {
(str(s), str(t), str(r)) for s, t, r, _ in edges if not (r == "SELF" and str(s) == str(t))
}
return node_ids, props_by_id, edge_keys
async def _ledger_expectations(data_id, dataset_id, scope_to_dataset: bool):
"""Compute what THIS document's hard delete may remove, from the live ledger.
A slug/edge is expected to be deleted only when no other live document
references it; anything referenced by another document must survive (the
two phase documents share most entities, so this is genuinely exercised).
``scope_to_dataset`` limits "other documents" to the same dataset when
access control is on (each dataset has its own physical graph; the same
deterministic slug in another dataset lives in a different graph and must
not be counted as a survivor here).
"""
db_engine = get_relational_engine()
async with db_engine.get_async_session() as session:
node_rows = (await session.scalars(select(Node))).all()
edge_rows = (await session.scalars(select(Edge))).all()
if scope_to_dataset:
node_rows = [row for row in node_rows if row.dataset_id == dataset_id]
edge_rows = [row for row in edge_rows if row.dataset_id == dataset_id]
def _is_doc_row(row):
return row.data_id == data_id and row.dataset_id == dataset_id
doc_nodes = {str(row.slug) for row in node_rows if _is_doc_row(row)}
other_nodes = {str(row.slug) for row in node_rows if not _is_doc_row(row)}
def _edge_key(row):
return (
str(row.source_node_id),
str(row.destination_node_id),
str(row.label or row.relationship_name),
)
doc_edges = {_edge_key(row) for row in edge_rows if _is_doc_row(row)}
other_edges = {_edge_key(row) for row in edge_rows if not _is_doc_row(row)}
return doc_nodes - other_nodes, doc_edges - other_edges
async def _verify_delete(stage: str) -> None:
"""Hard-delete documents one by one, checking the graph after each.
Delete resolves graph nodes by the ledger's ``nodes.slug``, so deleting
migrated data exercises ledger/graph lockstep — a stale slug makes delete
silently miss nodes rather than raise, so we gate on the resulting graph, not
the call. After each delete, diff before/after: nodes/edges owned only by
that document are gone (no orphans), shared ones survive until their last
owner goes (no collateral damage; EdgeType nodes are exempt — derived
bookkeeping GC'd with their last edge), and nothing new appears. After the
last document the graph must be empty.
"""
print(f"\n[{stage}] Hard-deleting documents one by one, verifying the graph after each")
db_engine = get_relational_engine()
async with db_engine.get_async_session() as session:
pairs = (await session.execute(select(DatasetData.data_id, DatasetData.dataset_id))).all()
if not pairs:
_fail(f"[{stage}] no dataset_data rows found — nothing to delete, seed is broken.")
dataset_rows = await get_dataset_databases()
owner_by_dataset = {row.dataset_id: row.owner_id for row in dataset_rows} or None
for index, (data_id, dataset_id) in enumerate(pairs, start=1):
expected_gone_nodes, expected_gone_edges = await _ledger_expectations(
data_id, dataset_id, scope_to_dataset=owner_by_dataset is not None
)
before_nodes, before_props, before_edges = await _snapshot_dataset_graph(
dataset_id, owner_by_dataset
)
await cognee.delete(data_id=data_id, dataset_id=dataset_id, mode="hard")
after_nodes, _, after_edges = await _snapshot_dataset_graph(dataset_id, owner_by_dataset)
doc_tag = f"document {index}/{len(pairs)} ({data_id})"
missed_nodes = expected_gone_nodes & after_nodes
if missed_nodes:
_fail(
f"[{stage}] {doc_tag}: {len(missed_nodes)} solely-owned node(s) survived the "
f"hard delete (e.g. {next(iter(missed_nodes))}) — delete missed migrated nodes."
)
disappeared_nodes = before_nodes - after_nodes
allowed_gone = expected_gone_nodes | {
node_id for node_id in before_nodes if before_props[node_id].get("type") == "EdgeType"
}
collateral_nodes = disappeared_nodes - allowed_gone
if collateral_nodes:
sample = next(iter(collateral_nodes))
_fail(
f"[{stage}] {doc_tag}: {len(collateral_nodes)} node(s) NOT owned solely by this "
f"document were deleted (e.g. {sample}, type="
f"{before_props[sample].get('type')}) — delete removed shared/foreign nodes."
)
appeared_nodes = after_nodes - before_nodes
if appeared_nodes:
_fail(
f"[{stage}] {doc_tag}: {len(appeared_nodes)} node(s) appeared during delete "
f"(e.g. {next(iter(appeared_nodes))})."
)
# Split surviving solely-owned edges by endpoint ownership. Delete has no
# explicit edge-deletion step: it deletes solely-owned NODES and lets the
# graph detach their edges. So an edge with a uniquely-owned endpoint MUST
# disappear (that node's detach sweeps it) — its survival is a real
# regression and hard-fails. An edge whose BOTH endpoints are shared is
# never reached by any detach, so it lingers as a ghost: this is a KNOWN
# PRE-EXISTING bug (reproduced on main, not introduced by the migration),
# so we warn instead of fail. The warning is forward-compatible — once
# delete learns to remove edges, nothing survives, so it neither warns nor
# fails and CI stays green with no flag to flip.
missed_edges = expected_gone_edges & after_edges
real_missed = {
edge
for edge in missed_edges
if edge[0] in expected_gone_nodes or edge[1] in expected_gone_nodes
}
ghost_missed = missed_edges - real_missed
if real_missed:
_fail(
f"[{stage}] {doc_tag}: {len(real_missed)} solely-owned edge(s) with a "
f"uniquely-owned endpoint survived the hard delete "
f"(e.g. {next(iter(real_missed))}) — detach-delete regression."
)
if ghost_missed:
print(
f" [delete] {doc_tag}: KNOWN-ISSUE {len(ghost_missed)} ghost edge(s) between "
f"shared endpoints survived (e.g. {next(iter(ghost_missed))}) — pre-existing "
"delete bug (reproduced on main), not gating CI."
)
# An edge may legitimately vanish without its own ledger ownership when
# either endpoint node was (legitimately) deleted — detach-delete.
collateral_edges = {
key
for key in before_edges - after_edges
if key not in expected_gone_edges
and key[0] not in disappeared_nodes
and key[1] not in disappeared_nodes
}
if collateral_edges:
_fail(
f"[{stage}] {doc_tag}: {len(collateral_edges)} edge(s) between surviving nodes "
f"were deleted (e.g. {next(iter(collateral_edges))}) — collateral edge loss."
)
print(
f" [delete] {doc_tag}: -{len(disappeared_nodes)} nodes "
f"-{len(before_edges - after_edges)} edges, "
f"{len(after_nodes)} nodes / {len(after_edges)} edges remain — "
"only this document's data removed — OK"
)
# No documents left → no graph left.
nodes, edges = await _collect_graph()
real_edges = [(s, t, r) for s, t, r, _ in edges if not (r == "SELF" and str(s) == str(t))]
if nodes or real_edges:
type_counts = Counter(props.get("type") for _, props in nodes)
_fail(
f"[{stage}] graph is not empty after deleting every document: "
f"{len(nodes)} node(s) {dict(type_counts)}, {len(real_edges)} edge(s) remain."
)
print(" [delete] all documents deleted; graph is completely empty — OK")
async def main():
print(f"Running Phase 2 with cognee version: {cognee.__version__}")
# ── Step 0: Run database migrations (relational + graph + vector) ─────────
await cognee.run_migrations()
# ── Step 1: legacy data must be accessible & correctly migrated ───────────
await _verify_access("Step 1 — legacy data after migration")
# ── Step 2: re-add + re-cognify with the current branch ───────────────────
# Re-cognifying the same dataset is what surfaced #2510's EntityAlreadyExistsError.
print("\n[Step 2] Re-adding + cognifying Lorem Ipsum with current branch...")
await cognee.add(LOREM_IPSUM, dataset_name=DATASET)
await cognee.cognify(datasets=[DATASET])
# ── Step 3: data must still be accessible after re-cognify ────────────────
await _verify_access("Step 3 — after re-cognify")
# ── Step 4: migrated data must be deletable (ledger-driven hard delete) ───
# Destructive on purpose, so it runs last.
await _verify_delete("Step 4 — delete migrated data")
print("\nAll Phase 2 checks passed.")
if __name__ == "__main__":
asyncio.run(main())