Neo4j Index Population Failure Recovery Guard
Find Neo4j indexes left FAILED or stuck POPULATING, alert Slack, drop and recreate each one from its own definition, wait for population, then verify every index is ONLINE.
Find Neo4j node pairs with duplicate relationships, delete the surplus edges after approval, then re-count and fail the run if any duplicate remains.
Neo4j constraints can make a property unique. None can cap a node pair at one
relationship of a type. Nothing stops two identical (a)-[:FOLLOWS]->(b) edges existing side
by side, so one CREATE where a MERGE was meant, one retried load or one import
replayed after a partial failure leaves parallel edges behind and the database
accepts all of them.
The damage is quiet. Every degree calculation is inflated. Every count(r) is wrong.
Weighted path and recommendation queries count the same edge two or three times, so
the result is not just slower, it is different. Nobody gets an error, so the drift
sits in the graph until someone compares a number against the source system.
This blueprint groups the relationships of the types you name by start node, end node and type, keeps the groups that hold more than one edge and reports them to Slack. When you turn remediation on and a human approves, it deletes the surplus edges so exactly one survives per group, runs the identical detection again and fails the run if any pair it reported is still duplicated.
This blueprint was created by zkasuran.
Deleting a relationship cannot be undone, so three things sit between a finding and
any delete: auto_remediate must be true, dry_run must be false and a human must
resume a Pause with approved: true. The default run only reports.
The survivor is deterministic, never arbitrary. Each group is ordered by the property
named in survivor_timestamp_property (ascending, so the oldest edge wins), then by
elementId as a tie-break. Edges missing that property sort last, so a load that
forgot to stamp a timestamp loses to one that did not. Two runs over the same graph
therefore keep the same edge.
Grouping is always scoped to one type, so a pair joined by :FOLLOWS and :BLOCKS
is never treated as duplicated. relationship_types narrows the scan further.
max_pairs caps how much one transaction touches.
audit (io.kestra.plugin.neo4j.Query, read-only, storeType: FETCH) matches every relationship whose type is in relationship_types, groups by start node, end node and type, then keeps the groups where size(collect(r)) > 1. In match_all_properties mode the full property map joins the grouping key, so only edges that are identical property for property group together. It returns the pair, the type, the edge count, the surplus count, both label sets and a readable hint from name, title or id when the nodes carry one.summary logs every duplicated group it found.route (io.kestra.plugin.core.flow.If on outputs.audit.size > 0):clean logs that no pair is duplicated.alert posts the pair count, the total surplus and the worst group to Slack, saying whether it is about to delete.remediate_gate runs only when auto_remediate is true and dry_run is false.approve is a Pause. A human resumes it with approved true to go ahead or false to stop.apply_gate runs the delete only when approved is true.dedupe (io.kestra.plugin.neo4j.Query) re-groups and deletes in one statement, so the survivor is chosen against the graph at delete time. It orders each group by the survivor property then elementId, keeps the head and deletes tail(), then returns the deleted count with a sample of deleted element ids.reaudit runs the identical detection query again.verify_result fails the run if any pair the audit reported still comes back duplicated.errors alerts Slack when the flow itself fails, for example an unreachable instance or a user without delete rights, so a failed audit is never read as "nothing wrong".CREATE in place of a MERGE leaves parallel edges.The detection is one Cypher query. Doing something about it safely is a sequence:
capture the duplicated pairs, tell the team, wait for a human, delete the surplus with
a survivor rule you can defend, read the graph again and fail if the cleanup did not
take. Kestra runs that sequence on a schedule behind two inputs and an approval, keeps
a history of which pairs were cleaned and when. It alerts Slack on both the finding
and on a failure. A cron job with a DELETE in it has no approval step and no proof.
On Kestra 2.0.5 OSS against Neo4j 5 Community, with a local HTTP sink standing in for
Slack. The graph was seeded with ten :User nodes and five pairs: one pair joined by
three :FOLLOWS edges, one pair joined by two byte-identical :FOLLOWS edges, one
pair joined by two :FOLLOWS edges that differ only in a source property, one single
:FOLLOWS edge plus one pair joined by one :FOLLOWS and one :BLOCKS. That is 9
:FOLLOWS edges, 4 of them surplus. cypher-shell counted the relationships per pair
before and after every run.
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults, graph already clean | 0 groups, clean logged. No alert |
| 2 | defaults, seeded duplicates | 3 groups reported (3, 2 and 2 edges), alert sent, nothing deleted |
| 3 | auto_remediate: true, dry_run: true |
Same report, delete held. cypher-shell still counts 9 :FOLLOWS |
| 4 | auto_remediate: true, dry_run: false, approved false |
Report, approve resumed with false, nothing deleted |
| 5 | auto_remediate: true, dry_run: false, approved true |
4 surplus edges deleted, all 3 pairs down to exactly 1, reaudit empty, verify_result passed. The oldest edge survived in every group. The single edge and the two different-type edges untouched |
| 6 | property_match_mode: match_all_properties |
Only the byte-identical group reported. The pair differing in source and the pair differing in created_at are left alone |
| 7 | unreachable URL | audit failed, errors alert sent |
| 8 | max_pairs: 3, a writer adds a bigger duplicated pair during the approval window |
The new pair takes a slot in the capped batch, one audited pair is not cleaned, verify_result fails the run and names it |
Exact execution ids and the before and after relationship counts are in the pull request notes.
MATCH, collect, tail, DELETE), so it works on Community and Enterprise.docker run -d --name neo4j -p 7687:7687 -e NEO4J_AUTH=neo4j/testpassword neo4j:5;cypher-shell -u neo4j -p testpassword "CREATE (a:User {name:'ada'}), (b:User {name:'bob'});" then run MATCH (a:User {name:'ada'}),(b:User {name:'bob'}) CREATE (a)-[:FOLLOWS]->(b); twice.NEO4J_PASSWORD: password for the Neo4j user, the default for the password input.SLACK_WEBHOOK_URL: Slack incoming webhook used by alert and the errors block.SECRET_, for example SECRET_NEO4J_PASSWORD=$(echo -n 'testpassword' | base64).| Input | Default | Purpose |
|---|---|---|
url |
bolt://localhost:7687 |
Bolt endpoint |
user |
neo4j |
Neo4j user |
password |
{{ secret('NEO4J_PASSWORD') }} |
Password, from the secret by default |
relationship_types |
FOLLOWS |
Comma-separated types to group and deduplicate |
property_match_mode |
ignore_properties |
ignore_properties or match_all_properties |
survivor_timestamp_property |
created_at |
Lowest value survives, elementId breaks ties |
max_pairs |
50 |
Cap on duplicated groups handled per run |
auto_remediate |
false |
Allow delete |
dry_run |
true |
Must be false for any delete to run |
NEO4J_PASSWORD and SLACK_WEBHOOK_URL secrets.url and user.relationship_types and run it once with the defaults.property_match_mode to match.survivor_timestamp_property to the property your loader stamps, so the oldest edge is the one that survives.auto_remediate: true and dry_run: false, approve the Pause, then the surplus edges are deleted and verified.weekly trigger, which always runs in safe mode.dedupe, for example when the latest load carries the correct weight.MATCH (a:User)-[r]->(b:User), on a graph where only a few labels are loaded non-idempotently.(a)-[r]-(b) and ordering the pair by elementId before grouping.MERGE-based loader change, so the duplicates stop arriving instead of being swept weekly.