fix(falkordb): make the graph-DB push converge, and add a repo-keyed delta (#3057) - #2
fix(falkordb): make the graph-DB push converge, and add a repo-keyed delta (#3057)#2galshubeli wants to merge 2 commits into
Conversation
…delta (Graphify-Labs#3057) `push_to_falkordb` was MERGE-only, so once `global add` pruned a repo out of the global graph a full re-push left every one of those nodes in the target forever: the database diverged from the source and never converged back. The same code path also wrote one query per node and one per edge, matched edge endpoints without a label (so no index could serve them), and labelled nodes by file_type only. That last part was its own bug: graphify's own store matches on `:Entity`, so a graph produced by `export falkordb --push` was unreadable by `graphify query`/`serve` — measured at 4000 nodes pushed, 0 read back. The FalkorDB writer now goes through GraphStore, the same batched UNWIND writer graphify uses for its own graphs, which supplies the `:Entity` label, the `n.id` index and the batching for free and deletes the duplicated writer. Pre-existing nodes are labelled on the way in so the MERGE switch does not duplicate them. Convergence is opt-in via --prune, covering nodes AND edges: DETACH DELETE takes a pruned node's edges with it, but an edge dropped between two surviving endpoints needs its own sweep. Opt-in rather than default because --graph-name never existed, so existing users may have several projects merged into the one `graphify` key and a default prune would delete all but the last. Delta mode (repo_manifest=) mirrors global_add's own contract: the repo is the unit of change, keyed on the manifest's per-repo source_hash. Only repos whose hash moved are re-sent. The "what I last pushed" state lives in the target as :GraphifyPushState nodes and is cross-checked against the target's own per-repo counts, so a wipe or a half-landed run is repaired instead of silently skipped. Cross-repo edges are restored by selecting every edge incident to a re-pushed repo, not only edges internal to it. Deletes are guarded at 20% of the target unless --allow-shrink, the same "refuse to SILENTLY drop nodes" rule as the Graphify-Labs#479 build guard, and paged with the LIMIT inside a WITH — FalkorDB's LIMIT does not short-circuit an eager DELETE, so `... DETACH DELETE n LIMIT $page` would delete the whole label. Also adds `graphify global push`: the global graph is a named FalkorDB graph with no output directory, so `export --push` (which resolves its source from a directory's falkordb.json pointer) could never reach the one graph this whole contract is about. Measured against a local FalkorDB, 20 repos / 20k nodes: full push, nothing changed 1.46s 39999 rows delta, nothing changed 0.01s 0 rows delta, 1 of 20 repos changed 0.33s 2001 rows (5.0%) Delta output is identical to a from-scratch load on node and edge counts. The Neo4j writer is deliberately untouched and still has the same defects; its fix needs a Neo4j instance to verify. The new flags are refused on that subcommand rather than silently ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Ran this branch on the box that produced the Graphify-Labs#3057 numbers before answering, so the answers below are informed by the code rather than the description. Verified here: clean clone of On the One small addition that would close the remaining gap without touching the default: when an add-only full push finishes and the target holds more nodes than the source, print one line — Changelog: agree, fold at release — that matches the repo's own history (e.g. 1c6b3db). Neo4j: refusing the new flags on Nothing else I'd hold it for. Thanks for turning the report into the fix — the drift repair via |
Review follow-up from @Azeem1985 on Graphify-Labs#3057. Keeping --prune opt-in is right, but that leaves the reported failure mode — the target quietly holding nodes the source pruned — still silent on the default path. An add-only push now counts the nodes it did not stamp (the same query the prune path deletes by) and returns it as `target_surplus`; the CLI prints one line naming the count and the flag that fixes it. Zero risk: nothing is deleted, and a converged target reports 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for running it on the box that produced the original numbers — that is the verification I could not do myself, and independently reproducing 15/15 vs 13/15 on the parent settles it better than my own run did. On the count difference: you are right and your explanation is the whole of it. My "6 on the parent" was from an earlier, smaller version of the test file, before the prune/delta tests existed. Against the final file the parent fails 13, exactly as you measured. A test dying on an unknown kwarg is the test catching a missing API, so I would count it the same way you do. Drift notice: done, pushed as c1d0a7e. Implemented the way you framed it — the surplus is computed with the same "not stamped by this push" count the prune path deletes by, so there is one definition of drift rather than two that can disagree: It is returned as --prune default stays opt-in, and your reasoning is better than mine was. I had "users may have several projects merged into the one key"; you have the actual failure split — silent deletion under the guard, hard refusal over it — and "neither is acceptable fallout from a patch upgrade" is the right test to apply. Also agree on where the converging default belongs: Changelog folded at release, Neo4j as a follow-up when there is an instance to verify against. One thing you should know, since it changes where this lands: this PR cannot go to Graphify-Labs as written. Upstream has only So I am building the v8-targeted version now — same contract, hand-rolled batched UNWIND writer instead of GraphStore, delta bucketed over the in-memory graph. Numbers so far on 20 repos / 20k nodes: old per-row full push 77.2s, batched 2.9s (27x), and the old push writes a graph that reads back as 0 nodes under a single-label query. Will link it here when it is up so the review carries over. |
|
v8-targeted PR is up: Graphify-Labs#3069. Same contract, Two things worth reporting from porting it, since both were caught by testing rather than by reading: The And a placement bug that only a CLI smoke test would have found: my |
|
Ran the v8 PR on the same box before answering — full verification posted on Graphify-Labs#3069. Short version: 17/17 on On the two porting findings: the worse v8 "before" makes sense — your branch had already batched its own writes, so the exporter was the last per-row path standing, which is exactly why the 27x belongs upstream. And the Nothing left open on this thread from my side — the review continues on Graphify-Labs#3069. Thanks for carrying the whole contract over rather than just the diff. |
|
Closing this in favour of Graphify-Labs#3069, which you have now verified on the same rig (there). Not abandoned — superseded, and the review you did here is what got it into shape. The reason this one is redundant rather than parallel is a portability asymmetry I only checked after opening Graphify-Labs#3069, and it went the direction I had assumed it would not: the v8 exporter passes all 16 of this branch's own tests unmodified. I should have run that check before opening two PRs rather than after. It would have saved you a second review pass. One thing genuinely lost by closing, worth recording so it is not rediscovered later. This PR routed the push through Everything from your review lives on in Graphify-Labs#3069: |
|
Ran the portability check on the same rig before closing the loop, since it was the one new claim here: grafted Graphify-Labs#3069's Agreed on what closing actually costs, and it's worth the record you made: once Graphify-Labs#3069 lands here on a v8 merge, collapsing the exporter back onto Nothing further from my side — the thread continues on Graphify-Labs#3069 if anything moves. Thanks for closing this one with the asymmetry check on the record instead of just the button. |
Fixes Graphify-Labs#3057.
The defect
push_to_falkordbis MERGE-only. Oncegraphify global addprunes a repo out of the global graph, a full re-push leaves every one of those nodes in the target permanently — the database diverges from the source and never converges back, no matter how many times you re-push. @Azeem1985 measured it: +1,250 nodes / +1,151 edges surplus, and 25 of 25 sampled pruned ids still present after a full re-push.While verifying that, a second bug surfaced that isn't in the report and is arguably worse:
The exporter labelled nodes by
file_typeonly (:Python). Every read path inGraphStorematches:Entity. So the push reported 4000 nodes written andgraphify query/serveread back zero — on a branch where FalkorDB is the backend,export falkordb --pushproduced a graph the product itself cannot open.What changed
The FalkorDB writer now goes through
GraphStore— the same batchedUNWINDwriter graphify uses for its own graphs. That supplies the:Entitylabel, then.idindex and the batching for free, and deletes a duplicated, worse writer. Pre-existing nodes are labelled on the way in so theMERGEswitch doesn't create a duplicate beside each one. This is also the Graphify-Labs#2258 diagnosis (unindexed edge-endpoint MATCH), fixed by reuse rather than a new code path.--graph-name.push_to_falkordbhas always takengraph_name; the CLI never passed it, so every push through the CLI landed on thegraphifykey with no way to aim it elsewhere. That is what turned @Azeem1985's mistake into a 1.2M-node deletion.--pruneconverges the target — nodes and edges.DETACH DELETEon a stale node takes its edges with it, but an edge dropped between two surviving endpoints needs its own sweep; without it the surplus-edge half of the report survives. Opt-in rather than default, because--graph-namenever existed and existing users may have several projects merged into the onegraphifykey, where a default prune would delete all but the last. That's the contract question from the issue — say the word and I'll flip the default.Repo-keyed delta (
graphify global push, default). Mirrorsglobal_add's own contract instead of inventing one: the repo is the unit of change, keyed on the manifest's per-reposource_hash, whichglobal_addalready records and already uses to returnskipped=True.Drift repair. The "what I last pushed" state lives in the target as
:GraphifyPushStatenodes, cross-checked against the target's own per-repo counts (one indexed aggregate). A ledger alone still reads clean after a wipe or a half-landed run and never repairs it — @Azeem1985's finding #2. Covered by a test that deletes part of a repo behind the push's back and asserts it gets rebuilt.Cross-repo edges.
global_addremaps external-library nodes onto whichever repo first contributed them, so aB -> Aedge is owned by neither alone. Pruning A drops it; re-adding only A's internal edges would silently lose cross-repo connectivity on every delta. The delta selects every edge incident to the repo, in either direction.Delete guards. Capped at 20% of the target unless
--allow-shrink— the same "refuse to SILENTLY drop nodes" rule as the Graphify-Labs#479 build guard. Only net removal counts against it; a re-pushed repo is pruned and immediately re-added, so charging it would refuse any delta touching more than 20% of a small global graph. Deletes are paged with theLIMITinside aWITH, because FalkorDB'sLIMITdoes not short-circuit an eagerDELETE—... DETACH DELETE n LIMIT $pagedeletes the whole label. That's finding #1, and the reason is in a comment at the call site.graphify global push. The global graph is a named FalkorDB graph with no output directory, andexport --pushresolves its source from a directory'sfalkordb.jsonpointer — so the one graph this entire contract is about had no CLI route at all. It does now.Measured
Local FalkorDB, 20 repos / 20k nodes / 20k edges:
Delta output is identical to a from-scratch load on node and edge counts.
Convergence, same corpus:
Tests
13 new integration tests against a live FalkorDB, covering: the
:Entitylabel, add-only default, node convergence, edge-only convergence, prune idempotence, the size guard refusing and not deleting, target isolation by--graph-name, delta skip/re-send/removal, cross-repo edge preservation, drift repair, and a foreign manifest being refused.Verified they actually catch the bugs: 6 fail on the parent commit, all pass here. Full suite is +13 passing with no new failures (this environment has 390 pre-existing failures from missing tree-sitter grammars, identical before and after).
Deliberately not included
The Neo4j writer is untouched — byte-identical to the parent commit. It has the same defects (per-row, unindexed, never deletes, no
databaseparameter) but verifying a fix needs a Neo4j instance I don't have. The new flags are refused onexport neo4jrather than silently ignored, since accepting--prunethere would report a converged push that deleted nothing. Happy to do that path in a follow-up.No CHANGELOG entry — this repo folds those in at release time (
chore: bump to X; changelog for #...). Say if you'd rather have one in the PR.Relationship to Graphify-Labs#2312
Graphify-Labs#2312 proposes a Neo4j source-of-truth backend, which is largely what this branch already is. This isn't competing with it — it's the push-side piece that design needs regardless.