Purge expired Neo4j sessions in approved bounded batches
Remove eligible expired Neo4j session and event nodes while preserving labels and relationships outside the retention policy.
Diff Neo4j against its relational source of record, then create only the declared relationships a half finished loader never wrote, behind approval.
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 |
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.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.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.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.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.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.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.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.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.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.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.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.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.
MERGE matched nothing, with the edges that did land left in place for the next run to build on.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.
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.
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:
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.PLACED edges pointing the wrong way. The correct direction edge is created while the reversed one is left alone, because nothing is deleted.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.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.
rel_type, src_label, src_key, dst_label, dst_key, one row per edge that should exist. Tested on PostgreSQL 16.15.key property that matches the source keys. This flow reconciles relationships, so the node loader has to have run.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.| 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 |
| 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 |
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.rel_types to the types you own plus set max_edges to a number you are willing to read in one go.dry_run: false plus auto_remediate: true. The execution pauses. Read the plan in the report task, then resume with approved: true.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.hourly_graph_reconcile trigger once you trust the report. It forces report only mode.source_view at a view that only declares edges newer than the last successful load, which turns a full reconcile into an incremental one.SHIPPED_TO approves only that.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.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.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.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.
This blueprint was created by zkasuran.
