Neo4j Duplicate Relationship Cleanup Guard
Find Neo4j node pairs joined by more than one relationship of the same type, alert Slack, delete the surplus edges after approval, then re-count and fail if any pair is still duplicated.
Find Neo4j nodes sharing a business key, keep one per key after approval, re-read the graph, then create the uniqueness constraint the duplicates blocked.
A label with no uniqueness constraint will take as many nodes for a key as you send it.
One CREATE where a MERGE was meant, one retried load, one import replayed after a
partial failure. Now (:Account {account_id: 'ACC-1001'}) exists three times. Every
lookup by that key returns several nodes, every count is inflated. A MERGE from
then on matches every one of them.
The obvious fix makes it worse. CREATE CONSTRAINT ... IS UNIQUE is rejected while the
duplicates exist: Both Node(3) and Node(4) have the label Account and property account_id = 'ACC-1001'. So the constraint cannot be added until the data is clean,
which makes this cleanup the prerequisite rather than a tidy-up. The flow finishes
by creating that constraint once the label is clean.
This blueprint was created by zkasuran.
Deleting a node is not reversible, 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.
A duplicate that owns relationships is never deleted. That is the decision this flow
makes. It is the whole reason it is safe to run. DETACH DELETE would remove the
node and silently take its edges with it, which can destroy the only record that an
account held a card or that a user wrote a document. So the flow splits the surplus in
two:
DELETE.review_queue log with the edge count. A human decides where each edge belongs.
Nothing is merged automatically.Two things enforce that. The delete statement selects on size([(n)--() | 1]) = 0, read
again inside the delete transaction rather than taken from the audit, so a node that
gained an edge during the approval window drops out. And the statement is a plain
DELETE, so if such a node were selected anyway Neo4j refuses the whole statement with
Cannot delete node<4>, because it still has relationships. The failure is loud, the
edge survives, the re-read then fails the run.
The keeper is deterministic. Under the default most_connected_then_oldest the node
holding the most relationships is kept, so the edges survive where they are and the key
becomes cleanable instead of blocked. Ties go to the lowest
keeper_timestamp_property, then the lowest elementId. Under oldest the age wins
whatever the degree, which is stricter about provenance but refuses more keys, because a
connected surplus node is still never deleted.
audit (io.kestra.plugin.neo4j.Query, read-only) groups the label by the key, keeps the values held by more than one node, orders each group by the keeper rule and returns the keeper, the surplus element ids, the relationship count owned by the surplus plus an actionable flag that is true only when no surplus node owns an edge. max_keys caps the batch.constraint_state reads SHOW CONSTRAINTS and reports whether a uniqueness constraint already covers this label and key.summary logs every duplicated key with its node count, its surplus, the edges that surplus owns and whether it is cleanable.route (io.kestra.plugin.core.flow.If on outputs.audit.size > 0):clean logs that no key is duplicated.alert posts the key count, the total surplus, how much of that surplus owns relationships and the worst key to Slack.review_queue names the keys the flow refuses to clean, with their edge counts.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 or false.remediate_keys (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) takes one cleanable key per iteration and deletes its relationship-free surplus nodes by element id.reaudit runs the detection again against the graph.verify_result fails the run if a key the audit marked cleanable still has more than one node.constraint_gate runs on every path when create_unique_constraint, auto_remediate and not dry_run are all set. final_key_check counts duplicated keys across the whole label, then create_constraint adds the uniqueness constraint and verify_constraint reads it back from SHOW CONSTRAINTS. When a key still stands in the way, constraint_blocked logs how many.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".DELETE as the last line of defence.CREATE in place of a MERGE leaves a key duplicated.IS UNIQUE to an existing label and found the statement rejected by data already in the database.MERGE statements started returning one row per duplicate.The detection is one Cypher query. Doing something about it safely is a sequence: group
the label, work out which duplicates own data, tell the team, wait for a human, delete
only the safe ones with a keeper rule you can defend, read the graph again, fail if the
cleanup did not take, then add the constraint that was blocked. Kestra runs that on a
schedule behind two gates plus an approval, keeps the history of which keys were cleaned
and when. It alerts Slack on the finding and on a failure. A cron job with a
DETACH DELETE in it has no approval step, no proof it worked, no record of the edges it
destroyed.
On Kestra 2.0.5 OSS against Neo4j 5 Community, with a local HTTP sink standing in for
Slack. The graph was seeded with six :Account nodes: ACC-1001 three times where the
newest of the three owns an :OWNS edge to a :Card, ACC-1002 twice where both nodes
own an :OWNS edge, plus ACC-1003 once. cypher-shell counted nodes per key before and
after every run.
| Run | Inputs | Result |
|---|---|---|
| 1 | defaults, graph clean | 0 keys, clean logged. No alert |
| 2 | defaults, seeded duplicates | ACC-1001 and ACC-1002 reported, ACC-1003 not. Alert sent, nothing deleted |
| 3 | auto_remediate: true, dry_run: true |
Same report, delete held. cypher-shell still counts 3, 2 and 1 |
| 4 | auto_remediate: true, dry_run: false, approved false |
Report, nothing deleted |
| 5 | auto_remediate: true, dry_run: false, approved true |
ACC-1001 3 nodes to 1, its :OWNS edge intact on the keeper. ACC-1002 refused and reported, both edges intact. ACC-1003 untouched |
| 6 | keeper_rule: oldest on the same graph |
ACC-1001 becomes uncleanable, because the oldest node is not the one holding the edge, so the flow refuses it instead of deleting the connected node |
| 7 | a writer inserts another node for a cleaned key during the approval window | The surplus is deleted, the new node keeps the key duplicated, verify_result fails the run and names the key |
| 8 | create_unique_constraint: true with ACC-1002 still duplicated |
constraint_blocked logged, no constraint created |
| 9 | create_unique_constraint: true on the cleaned label |
Constraint created, read back from SHOW CONSTRAINTS. A later insert of a duplicate key is rejected by the database |
| 10 | unreachable URL | audit failed, errors alert sent |
Exact execution ids and the before and after node counts per key are in the pull request notes.
MATCH, collect, tail, DELETE, SHOW CONSTRAINTS, CREATE CONSTRAINT). Node property uniqueness constraints are available in Community as well as Enterprise.docker run -d --name neo4j -p 7687:7687 -e NEO4J_AUTH=neo4j/testpassword neo4j:5;cypher-shell -u neo4j -p testpassword "CREATE (:Account {account_id:'ACC-1001', created_at:'2026-01-01'}), (:Account {account_id:'ACC-1001', created_at:'2026-02-01'});".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 |
label |
Account |
Node label to group |
key_property |
account_id |
The business key that should identify one node |
keeper_rule |
most_connected_then_oldest |
most_connected_then_oldest or oldest |
keeper_timestamp_property |
created_at |
Tie-break, lowest value wins |
max_keys |
50 |
Cap on duplicated keys handled per run |
create_unique_constraint |
false |
Add the uniqueness constraint once the label is clean |
auto_remediate |
false |
Allow the delete |
dry_run |
true |
Must be false before anything is written |
NEO4J_PASSWORD and SLACK_WEBHOOK_URL secrets.url and user, then set label and key_property to the key your ingest is supposed to be idempotent on.review_queue keys by hand. Re-point their relationships onto the node you want to keep, so the next run can finish them.auto_remediate: true and dry_run: false, approve the Pause, then the relationship-free surplus is deleted and verified.create_unique_constraint: true to add the constraint the duplicates were blocking.weekly trigger, which always runs in safe mode.MATCH (dup)-[r:OWNS]->(x) MERGE (keeper)-[:OWNS]->(x) per relationship type you are willing to move, then let the next run delete the emptied duplicates.review_queue key instead of logging it, so the manual cases are tracked.