Product plus its Category, its
Supplier, and the supplier’s Region — a two-hop neighborhood
flattened into one JSON object, kept current as any of those nodes
change. That’s why VentStream’s Neo4j source exists: it consumes Neo4j
Enterprise CDC, walks projection paths declared in YAML, and streams
denormalized documents into OpenSearch or Redis — snapshot bootstrap
included, deletes handled, crash-safe resume.
Fan-out inversion
The interesting engineering problem is that denormalization inverts the graph’s direction of reference. In the graph, aRegion points at
nothing — products point toward it through suppliers. But in
document-land, when a region renames, every affected product document
must recompose. Your reads’ fan-in becomes your writes’ fan-out.
The engine finds recompose targets by anchoring a Cypher match on the
changed element:
$eids is fed from the event’s element IDs — and for a
relationship event, that includes both endpoints. That sentence is
the bug.
The hot-node cascade
Real graphs contain low-cardinality hub nodes: onePublishStatus node
linked from every Author, one Currency node on every account, a
handful of Region nodes. Delete one Author and its relationship-delete
event carries two endpoints: the Author — and the hub. The hub lands in
$eids, the anchor query asks “which Authors reference this
PublishStatus?”, and the honest answer is all of them.
Measured at 100k-Author scale: one delete triggered a
59,498-document recompose cascade, and a 1,000-operation burst
spawned roughly 5,800 such cascades — hours of CPU recomputing
documents that hadn’t changed.
The cruelty of this class of bug is that it’s invisible in tests. Ten
authors, one status node: the cascade recomposes ten documents in
milliseconds and CI stays green. It’s scale-activated — the output is
correct, the cost is catastrophically wrong.
The fix: know your hubs before traffic arrives
At spec-validation time (startup), the engine walks every projection path and probes the cardinality of the node set at every hop depth — every prefix, not just the leaf, which is what catches a hub sitting mid-path. Any endpoint below a threshold is recorded as hot, keyed by the relationship type that reaches it. At event time, the filter is two hash lookups:Supplier is
the far side of SUPPLIED_BY (filter it — a product’s edge change must
not fan out through its supplier) but the near side of LOCATED_IN
(keep it — a region change must cascade through the supplier to its
products). A flat “always ignore this node” set breaks the second case;
a per-relationship-type map expresses both.
Probe prefixes, not just leaves. Hubs sit mid-path as often as at
path ends.
Fail open, in the cheap direction. Anything static analysis cannot
prove — undirected hops, a relationship type whose orientation
conflicts across paths, a spec that can’t be walked — gets no
filtering. Under-filtering wastes CPU on recomputes that are still
correct; over-filtering would silently skip a real update and leave a
stale document. Waste is recoverable; staleness is not.
One thing the filter deliberately does not touch: a property update
on the hub itself still cascades to every referencing document. If
the PublishStatus text changes, all 59k author documents genuinely
embed a stale value — that cascade is the feature working as intended.
Cost
Startup: one count query per path prefix, a few milliseconds each, once. Memory: a relationship-type → element-ID map, kilobytes. Runtime: O(1) per relationship event. The 59,498-document cascade became a single-document recompose.The transferable lesson
Denormalization inverts fan-in into write-time fan-out, and every real dataset hides a few low-cardinality hubs that make that fan-out pathological. Whatever the stack, the shape of the answer holds: measure cardinality before traffic arrives, scope suppression to the exact edge that makes a hub a hub, and when analysis is uncertain, fail toward wasted work rather than stale data. The implementation lives incrates/ventstream-sources/src/neo4j/hot_endpoints.rs;
the demo
streams a seeded Neo4j graph and Postgres database into OpenSearch in
one command.