Assert icon
Query icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Fail icon
Schedule icon

Neo4j Duplicate Key Node Dedup Guard

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.

Categories
DataInfrastructure

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.

Safety

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:

  • Surplus nodes that own no relationship are deleted with a plain DELETE.
  • Surplus nodes that own relationships are not touched. Their key is reported in the 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.

How it works

  1. 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.
  2. constraint_state reads SHOW CONSTRAINTS and reports whether a uniqueness constraint already covers this label and key.
  3. summary logs every duplicated key with its node count, its surplus, the edges that surplus owns and whether it is cleanable.
  4. route (io.kestra.plugin.core.flow.If on outputs.audit.size > 0):
    • clean: clean logs that no key is duplicated.
    • findings: 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.
  5. 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.
  6. 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".

What you get

  • The key values that are genuinely duplicated, with the node count per key, straight from Cypher.
  • A split between the duplicates that can be removed safely and the ones that own data, so a cleanup never costs you an edge.
  • A keeper rule you can state in a review: most connected, then oldest, then lowest element id.
  • Three gates before an irreversible delete, plus a plain DELETE as the last line of defence.
  • Proof the cleanup landed: the graph is read again and the run fails if a key it claimed is still duplicated, which is what happens when a writer inserts another node during the approval window.
  • The uniqueness constraint created at the end, so the same duplicates cannot come back.

Who it's for

  • Teams loading a graph from a relational source, a CSV drop or an event stream, where a retry or a CREATE in place of a MERGE leaves a key duplicated.
  • Anyone who tried to add IS UNIQUE to an existing label and found the statement rejected by data already in the database.
  • Graph engineers whose MERGE statements started returning one row per duplicate.
  • Platform teams who want duplicates found on a schedule and cleaned only with an approval, with the relationship-owning cases escalated rather than swallowed.

Why orchestrate this with Kestra

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.

Tested end to end

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.

Prerequisites

  • A reachable Neo4j 5.x instance. The flow uses plain Cypher (MATCH, collect, tail, DELETE, SHOW CONSTRAINTS, CREATE CONSTRAINT). Node property uniqueness constraints are available in Community as well as Enterprise.
  • For the audit, a user that can read the label. For remediation, a user allowed to delete nodes. For the constraint step, the schema privilege on the database.
  • A Slack incoming webhook for alerts.
  • Local testing:
    • docker run -d --name neo4j -p 7687:7687 -e NEO4J_AUTH=neo4j/testpassword neo4j:5;
    • seed a duplicate: 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'});".

Secrets

  • 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.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_, for example SECRET_NEO4J_PASSWORD=$(echo -n 'testpassword' | base64).

Inputs

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

Quick start

  1. Add the NEO4J_PASSWORD and SLACK_WEBHOOK_URL secrets.
  2. Import the flow, set url and user, then set label and key_property to the key your ingest is supposed to be idempotent on.
  3. Run it with the defaults. Read the report: which keys are duplicated, how many of those duplicates own relationships.
  4. Deal with the review_queue keys by hand. Re-point their relationships onto the node you want to keep, so the next run can finish them.
  5. Run it with auto_remediate: true and dry_run: false, approve the Pause, then the relationship-free surplus is deleted and verified.
  6. Once the label comes back clean, run it once more with create_unique_constraint: true to add the constraint the duplicates were blocking.
  7. Enable the weekly trigger, which always runs in safe mode.

How to extend

  • Point the relationships at the keeper before deleting, with an explicit 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.
  • Merge properties onto the keeper first, so it inherits values only the duplicates carried.
  • Add a second key to the grouping for a composite business key, for example tenant plus account.
  • Open a ticket for every review_queue key instead of logging it, so the manual cases are tracked.
  • Drive the ingest fix from the same flow by failing the build when the duplicate count rises between runs.
  • Swap the delete for an archive, writing the surplus nodes to a file or another label before removing them.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.