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

Backfill missing Neo4j relationships a loader never wrote

Diff Neo4j against its relational source of record, then create only the declared relationships a half finished loader never wrote, behind approval.

Categories
DataInfrastructure

Safety

A half finished relationship loader is the quiet kind of graph incident. The nodes are all committed, so every node count looks right and no constraint is violated. Only the edges are missing, so a recommendation traversal returns an empty list instead of an error, a shipping query cannot route an order plus a product page shows a customer with no orders. Nothing in Neo4j reports this, because an absent relationship is not a violation of anything the database knows about.

Writing the missing edges back is dangerous for a different reason. MERGE on a pattern that includes a node will create that node when it does not match, so a careless backfill invents endpoints that no loader ever committed. Those phantom nodes then satisfy the next run. A reversed edge, a cancelled order or an edge type another system owns all look like gaps to a naive diff. So this flow refuses by default. Every guard below is load bearing. Each one was proven by its own execution.

Guard What it stops
The authoritative view is the only source of truth about which edges should exist inventing an edge the system of record never declared
rel_types allowlist, each name matched against ^[A-Z][A-Z0-9_]*$ writing a relationship type another system owns, even when the view declares it
source_view must be a plain lower case identifier, optionally schema qualified a view name widening the statement the flow sends to Postgres
Labels plus keys checked against a character allowlist inside the query, unsafe_total must be 0 a key from the source relation reaching Cypher as syntax
An empty view or more rows than max_expected fails trusted_source approving a plan built on a truncated or empty snapshot
Both endpoint nodes are matched with MATCH, never MERGE, never CREATE the backfill inventing a node that no loader committed
Direction is part of the diff reporting a correctly placed edge as missing because it was read the wrong way
max_edges ceiling, ordered by type then source key then destination key, no unlimited mode one approved run writing more than the operator read
dry_run true, auto_remediate false, Pause read as onResume.approved ?? false three separate gates before one write
recheck plus fresh_plan re-read every approved pair after the approval acting on a plan that went stale while a person was deciding
concurrency: limit 1 on the flow two executions backfilling the same graph at once
Loop with concurrencyLimit: 1 parallel MERGEs on the same endpoint nodes contending with each other
Per relationship read back in verify, landed must be exactly 1 a MERGE that returned without error being reported as a fix
Census drift check in invariants against counts taken immediately before the first write a run that changed node counts, an out of scope type or an undeclared edge

How it works

  1. safe_scope (io.kestra.plugin.core.execution.Assert) refuses the run before Postgres or Neo4j is touched unless rel_types holds between 1 and 20 upper case type names plus source_view is a plain lower case relation identifier.
  2. read_source (io.kestra.plugin.jdbc.postgresql.Query) reads the authoritative view once with a connect timeout, a socket timeout plus a server side statement_timeout. The statement filters to rel_types, orders the rows, takes max_expected + 1 of them, then checks every label plus every key against a character allowlist in SQL. It returns the row count, the count of unsafe rows plus the safe pairs as JSON.
  3. trusted_source (Assert) fails when any row was unsafe, when the view returned nothing at all or when it returned more rows than max_expected. An empty view is treated as a broken read rather than as a healthy graph.
  4. detect (io.kestra.plugin.neo4j.Query) unwinds those pairs in one Cypher statement. For each declared edge it reports whether the source node exists, whether the destination node exists, how many matching relationships of that type already run in that direction plus a single writable flag that is true only when the edge is absent while both endpoints are already in the graph. census_detect counts nodes, labels plus relationships per type in the same read.
  5. plan (io.kestra.plugin.core.output.OutputValues) splits the diff into the bounded set of writable edges capped at max_edges, the blocked edges that are missing an endpoint node, plus a gap summary per relationship type. targets derives the approved pair list plus the exact expected increment per type.
  6. report (io.kestra.plugin.core.log.Log) prints the whole plan before anything is written: how many edges the view declares, how many are missing, which ones are writable now, which ones are blocked because the node loader has to run first plus the graph census.
  7. findings (If) ends through healthy when the gap is zero. Otherwise alert posts the finding to Slack, then mode (If) requires auto_remediate true with dry_run false plus a non empty plan. Anything else ends at report_only with the plan still in the log.
  8. approval (io.kestra.plugin.core.flow.Pause) waits up to an hour. approved_only reads onResume.approved ?? false, so a resume that answers nothing is a refusal recorded by declined.
  9. recheck re-reads exactly the approved pairs from the live graph. fresh_plan (Assert) fails the run before any write unless every one of them is still absent with both endpoints still present. census_before then takes the counts the drift check will be measured against.
  10. backfill (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one merge_one per planned edge. Each statement does MATCH on the source node, MATCH on the destination node, then MERGE on the relationship between the two bound nodes. Because both endpoints are bound by MATCH, a missing node makes the statement match nothing instead of creating anything.
  11. verify reads every approved pair back out of Neo4j and reports landed per edge. census_after recounts the graph. invariants compares the before census with the after census against the expected increment per type, so node counts, out of scope relationship types plus undeclared edges all have to be unchanged.
  12. verdict (Assert) fails the run unless every approved relationship is present exactly once plus the census drift is exactly the approved edges. write_outcome logs the per edge read back either way, so a failed run still says which edges did land. Edges that did land are kept, because the next run plans from the live graph.
  13. errors.failure_alert posts any failure to Slack. The hourly_graph_reconcile Schedule trigger ships disabled: true plus forces dry_run: true with auto_remediate: false.

The outputs worth reading afterwards: {{ outputs.detect.rows }} is the full per edge diff with endpoint existence plus direction, {{ outputs.plan.values.selected_json }} is the bounded plan, {{ outputs.plan.values.blocked_json }} names the edges waiting on the node loader, {{ outputs.verify.rows }} is the per edge read back plus {{ outputs.invariants.values.drift_json }} is the census comparison.

What you get

  • A gap report that names every relationship the system of record declares while the graph does not have it, per type, with direction taken into account.
  • A separate list of edges that cannot be written yet because an endpoint node is missing, which tells you the node loader is the thing to fix first.
  • A bounded, approved backfill that matches nodes instead of creating them. Nothing is ever deleted.
  • Proof the traversal recovered, not just that a write returned: the per edge read back plus a census that has to be unchanged outside the approved edges.
  • A failed run when a MERGE matched nothing, with the edges that did land left in place for the next run to build on.

Who it's for

Teams that keep a Neo4j graph in step with a relational system of record through a batch or streaming loader, where an interrupted relationship pass leaves a graph that looks complete yet silently answers traversals with nothing.

Why orchestrate this with Kestra

The diff has to read two systems at once: the relational source that declares which edges should exist plus the graph that may be missing them. Kestra runs both reads in one execution, keeps the credentials in secrets, holds the write behind a Pause with the plan in the log the approver can read, re-reads eligibility after the approval, then fails the execution when the read back disagrees with the plan. Doing it by hand means pasting keys into cypher-shell from a spreadsheet, which is exactly how a phantom node gets created at two in the morning. The flow also makes the safe answer the default one: shipped as is, it reports plus writes nothing.

Tested end to end

Kestra 2.0.5, Neo4j plugin 1.4.9, JDBC Postgres plugin 2.1.0, Slack plugin 2.1.0, against Neo4j 5.26.32 community plus PostgreSQL 16.15. Validate on stock Kestra 2.0.5 was clean: no constraints, warnings or deprecations.

The fixture is a graph of 33 nodes whose relationship pass was killed partway, beside a Postgres relation declaring 52 edges across the four types in scope. Every number below was read with Neo4j cypher-shell plus psql directly, never from a flow output. The fixture deliberately carries cases a naive diff gets wrong:

  • 8 AUDITED_BY edges the source declares that are absent from the graph, with AUDITED_BY left out of rel_types. It stayed at 4 edges through every single run.
  • 2 PLACED edges pointing the wrong way. The correct direction edge is created while the reversed one is left alone, because nothing is deleted.
  • A cancelled order O-9001 the view declares no PLACED edge for. It never received one.
  • O-1099, declared in 3 source rows with no Order node in the graph. Those 3 were reported blocked. No node was invented.
  • 1 relationship in the graph the view does not declare. Kept in every run.

All 14 cases ran on the final revision 10:

Case Execution Result
Healthy 63XHL26rtUPHnACgvnmbsd SUCCESS. Gap 0 with auto_remediate true, so no approval was offered. All 61 edges untouched
Out of scope type, control 6ICSZKN0vTdwAI0arbtz2f SUCCESS. With rel_types: [IN_CATEGORY] the flow called the graph healthy, while 34 edges of other types were still missing
Degraded dry run 5Dhr0VxZj3okTO3UlhghBS SUCCESS. 52 declared, 34 missing, 31 writable, 3 blocked on a missing node. Graph still at 25 edges afterwards
Rejected relationship type 6H2ddC71zywCxSigJijuty FAILED as intended. safe_scope refused a lower case type name before either system was read
Rejected view name 4fRa7ngiYOzEwUE5kqJoRx FAILED as intended. safe_scope refused a view name carrying --x
Declined approval 34RcblsbREvdrqOFbfwpbf SUCCESS. Resumed with approved: false, graph still at 25 edges
Boundary, ceiling at 5 419ijioFXGDMFoEAZYqA9d SUCCESS. Exactly 5 of 31 written, 25 to 30 edges, traversal paths 9 to 11, remaining 26 left for the next run
Change during the Pause 7UcFOrNDnCiXqZRjoK0THU FAILED as intended. Another loader created C-006 PLACED O-1012 while the approval waited. fresh_plan refused, nothing was written, the new edge survived
Remediation 4mw4jUXsQqwpyAiwbFluS4 SUCCESS. 31 edges created, 25 to 56. Measured by cypher-shell: recommendation traversal paths 9 to 25, customers a recommendation query returns nothing for 3 to 0, orders the shipping query cannot route 13 to 5. Nodes stayed at 33, AUDITED_BY stayed at 4, duplicates 0, the undeclared edge kept, the Postgres source unchanged
Repeat 1jMZYQqUhFqf94stcpXKgu SUCCESS. Planned 0, wrote nothing, still 56 edges with 0 duplicates
Ineffective write 4wqVjD8mtqfooEJNXqrZoO FAILED as intended. The ADDR-4 node was removed during the loop, so 2 MERGE statements matched nothing. Neither errored. verify read landed: false for both plus verdict failed the run. The other 29 edges were kept
Partial recovery 6b2yKrOHhFnjcPf8GqLzLu SUCCESS. Once ADDR-4 was restored the next run planned exactly the 2 edges still missing, reached 56 with 0 duplicates, kept the 29 already done
Interrupted write 2kiDBeRmX8OiA5477R0uJl KILLED 4 seconds into the loop. 11 of 31 edges had landed, 0 duplicates, nodes unchanged at 33
Interrupted recovery 78zAbYR6oPv9c1OvlgOaEP SUCCESS. The next run planned the remaining 20, reached 56 edges, 0 duplicates, nothing written twice

A second fixture on fresh containers repeated the three that matter most, with its own novelty scan plus review recorded beside the evidence. Slack was a local HTTP sink that recorded both the finding plus the failure alerts.

Prerequisites

  • Neo4j 5 reachable over Bolt. Tested on 5.26.32 community only.
  • A relational system of record exposing one relation with columns rel_type, src_label, src_key, dst_label, dst_key, one row per edge that should exist. Tested on PostgreSQL 16.15.
  • Nodes already carry a key property that matches the source keys. This flow reconciles relationships, so the node loader has to have run.
  • Kestra 2.0.5 with the Neo4j, JDBC Postgres plus Slack plugins.
  • Minimum permissions. Neo4j: read on the graph plus CREATE_RELATIONSHIP on the types in rel_types. No node write right is needed, no CREATE_NODE, no delete right. Postgres: SELECT on that one relation.

Secrets

Secret What it is
NEO4J_USERNAME Neo4j user with graph read plus relationship create on the types in scope
NEO4J_PASSWORD password for that user
SOURCE_USERNAME read only database user for the authoritative relation
SOURCE_PASSWORD password for that user
SLACK_WEBHOOK_URL Slack incoming webhook for the finding plus the failure alert

Inputs

Input Type Default What it does
neo4j_url STRING bolt://neo4j:7687 Bolt URI of the graph
source_url STRING jdbc:postgresql://postgres:5432/app JDBC URL of the system of record. The flow appends its own timeouts
source_view STRING public.expected_edges the relation declaring which edges should exist
rel_types ARRAY [PLACED, CONTAINS, IN_CATEGORY, SHIPPED_TO] the only types this flow may ever create
max_expected INT 5000 refuse the run when the view returns more rows than this
max_edges INT 50 ceiling on relationships created per approved run
dry_run BOOL true report the gap plus the plan without writing
auto_remediate BOOL false allow remediation to be considered at all

Quick start

  1. Add the five secrets above. Grant the Neo4j user relationship create rights only on the types you intend to backfill.
  2. Build or point source_view at a relation with one row per edge that should exist. A view over your existing tables is enough, it does not have to be a table.
  3. Execute with the defaults. Nothing is written. Read the report: the gap per type, the bounded plan plus the edges blocked on a missing node.
  4. If edges are blocked, run your node loader first. This flow will not invent an endpoint.
  5. Narrow rel_types to the types you own plus set max_edges to a number you are willing to read in one go.
  6. Execute with dry_run: false plus auto_remediate: true. The execution pauses. Read the plan in the report task, then resume with approved: true.
  7. Read write_outcome: every approved edge has to come back landed: true with a census drift of exactly those edges. Re-run until the gap reaches zero.
  8. Enable the hourly_graph_reconcile trigger once you trust the report. It forces report only mode.

How to extend

  • Point source_view at a view that only declares edges newer than the last successful load, which turns a full reconcile into an incremental one.
  • Run the flow once per relationship type with its own schedule plus its own approver, so the team that owns SHIPPED_TO approves only that.
  • Swap io.kestra.plugin.jdbc.postgresql.Query for the MySQL, Snowflake or BigQuery task when the system of record lives elsewhere. The contract is the five column result plus the character allowlist.
  • Feed outputs.plan.values.blocked_json into the ticket or flow that owns node loading, since those are the gaps a relationship backfill can never close.
  • Track missing_total per run as a loader health metric. A number that climbs between runs means the loader is still dropping edges rather than catching up.
  • Add a second Pause for the types you consider highest risk, keeping the current single approval for the rest.

Limits: this flow only ever creates relationships. It never creates or deletes a node, never deletes a relationship plus never updates a property, so it cannot fix an edge that exists with wrong properties, a duplicate edge or a reversed edge. It leaves the reversed edge in place and adds the correct one. Relationship properties are not written, so a graph whose edges carry required properties needs the merge_one statement extended with an ON CREATE SET. The diff is one row per edge, so max_expected bounds how large a graph it can reconcile in one execution. recheck narrows the approval window but it is not a lock: Neo4j community has no way to reserve the pattern, so an edge created by another writer between recheck and MERGE is absorbed by the MERGE rather than duplicated, while a node deleted in that window makes the statement match nothing, which the read back then fails the run on. Neo4j Enterprise, multi database setups, clustering plus Neo4j 4.x were not tested.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.