Graph OLAP Engine

The Graph OLAP Engine maintains a read-optimized, columnar representation of your graph alongside the live OLTP data. It uses Compressed Sparse Row (CSR) encoding and flat primitive arrays to deliver 5x–400x speedups on analytical workloads — multi-hop traversals, graph algorithms, and property aggregations — without sacrificing transactional safety.

Why Graph OLAP?

ArcadeDB’s OLTP engine is optimized for point lookups and ACID transactions. Analytical workloads — PageRank, community detection, multi-hop traversals — access millions of edges in tight loops. The row-oriented, pointer-chasing nature of OLTP storage causes cache misses, object overhead, and GC pressure.

The OLAP engine solves this by encoding graph topology as flat int[] arrays and properties as typed columns:

  • Sequential memory access — cache-line friendly, no pointer chasing

  • Zero object allocation — no GC pressure during traversal

  • SIMD-friendly — enables JVM vectorized operations

  • 9x more compact — flat arrays vs. Java object overhead

Graph Analytical View (GAV)

A Graph Analytical View is a named, schema-persisted OLAP snapshot of selected vertex types, edge types, and properties.

GraphAnalyticalView gav = GraphAnalyticalView.builder(database)
    .withName("social")
    .withVertexTypes("Person", "Company")
    .withEdgeTypes("FOLLOWS", "WORKS_AT")
    .withProperties("name", "age", "status")
    .withUpdateMode(UpdateMode.SYNCHRONOUS)
    .build();

Named views are persisted in schema.json and automatically restored on database restart. As of ArcadeDB v26.9.1, a READY view’s CSR snapshot itself can also be persisted to disk at a clean database close and reused on the next open without rescanning the graph; see CSR Persistence.

SQL

Creating a view

CREATE GRAPH ANALYTICAL VIEW social
  VERTEX TYPES (Person, Company)
  EDGE TYPES (FOLLOWS, WORKS_AT)
  PROPERTIES (name, age, status)
  UPDATE MODE SYNCHRONOUS

All clauses after the view name are optional. A minimal view covering the entire graph:

CREATE GRAPH ANALYTICAL VIEW fullGraph

Choosing the vertex properties

Without a PROPERTIES clause the view copies every vertex property. PROPERTIES (a, b) copies only the listed ones, and a list of names prefixed with !, quoted with backticks, copies every property except those: PROPERTIES (`!Body`, `!Text`). Use one form or the other: when a list mixes them, the exclusions apply and the plain names are ignored.

(Since v26.10.2) an empty list, PROPERTIES (), copies no vertex property: the view holds only the graph’s structure, which is all that algorithms such as algo.pagerank() or algo.wcc() need, and it builds faster and takes less memory. In v26.10.1 and earlier PROPERTIES () was a parsing error.

CREATE GRAPH ANALYTICAL VIEW topology
  PROPERTIES ()
  UPDATE MODE OFF

Use IF NOT EXISTS to avoid errors if the view already exists:

CREATE GRAPH ANALYTICAL VIEW IF NOT EXISTS social
  VERTEX TYPES (Person)
  EDGE TYPES (FOLLOWS)
  UPDATE MODE SYNCHRONOUS

You can also materialize edge properties (e.g., weights):

CREATE GRAPH ANALYTICAL VIEW weighted
  VERTEX TYPES (City)
  EDGE TYPES (ROAD)
  EDGE PROPERTIES (distance, toll)
  UPDATE MODE SYNCHRONOUS
  COMPACTION THRESHOLD 50000

Add CCH (…​) to keep a Customizable Contraction Hierarchy per weight property, for fast point-to-point shortest paths. A weight listed there is materialized as an edge property even when EDGE PROPERTIES does not name it:

CREATE GRAPH ANALYTICAL VIEW roads
  EDGE TYPES (ROAD)
  UPDATE MODE SYNCHRONOUS
  CCH (distance, travelTime)

Edge sub-types

Listing an edge type in EDGE TYPES also materializes its sub-types, and a hop on that type answers with the sub-types' edges too, as it does without a view. The view keeps one slice per concrete type, so the order the types are listed in does not matter. Views persisted by an earlier release are rebuilt once.

Vertex sub-types

Since v26.11.1 listing a vertex type in VERTEX TYPES also holds the vertices of its sub-types, including sub-types created after the view was built, as a query on that type reads them without a view. A view over V with W EXTENDS V therefore covers both, and serves queries once W holds vertices. Before, a view held the listed types only, and once a sub-type held a vertex the view no longer covered every vertex, so the planner never used it (issue #10208). A CSR persisted by an earlier release is rebuilt once on the first open.

A type that already holds vertices and is attached to a listed type later (ALTER TYPE Y SUPERTYPE +V) holds vertices the view never read: the view does not use them until it is rebuilt, which it does by itself in SYNCHRONOUS and ASYNCHRONOUS mode, while an OFF view turns STALE.

The same holds for an edge type attached to a listed edge type later (ALTER TYPE G SUPERTYPE +E) when it already held edges between the view’s vertices, lightweight ones included: a hop on E reads the edges of its sub-types, so the view is not used for any query until it has rebuilt (an OFF view turns STALE and is not used even with useWhenStale). An edge type that already existed when the view was built or restored is held only once a scan has read it, even when it looks empty, because a transaction open at that moment may still commit edges of it: attaching it rebuilds the view once. An edge type created after that, and not written while it was not covered, is used at once, as is a sub-type created after the build. Before v26.11.1 such a view answered hops on E without the edges of G (issue #10214).

Altering a view

Change the update mode or compaction threshold of an existing view:

ALTER GRAPH ANALYTICAL VIEW social UPDATE MODE ASYNCHRONOUS
ALTER GRAPH ANALYTICAL VIEW social COMPACTION THRESHOLD 20000

Rebuilding a view

Force a full rebuild of the CSR snapshot:

REBUILD GRAPH ANALYTICAL VIEW social

A view whose build failed when the database opened is no longer loaded, but its definition is kept: REBUILD builds it again from the definition, and reports the error to the caller if the build fails again. Before v26.11.1 REBUILD refused such a view as "not loaded", and the only way back was DROP and CREATE (issue #10368).

Dropping a view

DROP GRAPH ANALYTICAL VIEW social
DROP GRAPH ANALYTICAL VIEW IF EXISTS social

Types a view lists

A view stores the vertex and edge types it lists, and the edge types of its contraction hierarchies, by name. Like a materialized view does with its source types, it protects them:

  • DROP TYPE of a type a view lists is refused, naming the view. Drop the view first with DROP GRAPH ANALYTICAL VIEW. The type is protected whichever of its names, or aliases, the view or the DROP TYPE uses.

  • A view whose definition names a type that no longer exists (left by a rename on an earlier version) refuses every DROP TYPE, of any type: it cannot be told whether the type being dropped is the one the view meant. The message names the view; drop and create it again. Materialized views and continuous aggregates behave the same way.

  • ALTER TYPE …​ NAME of a type a view lists renames it in the view’s definition too, and the view is rebuilt from the renamed definition. Until the rebuild completes, queries take the ordinary path, as for a view just created.

Before v26.11.1 both were accepted. The view kept serving a CSR that no longer matched the schema, and the next database open failed to build it and left it unbuilt (issue #10368).

Listing views

SELECT FROM schema:graphAnalyticalViews

Builder Options

Method Description Default

withName(String)

Named registration + schema persistence

anonymous

withVertexTypes(String…​)

Filter to specific vertex types

all

withEdgeTypes(String…​)

Filter to specific edge types

all

withProperties(String…​)

Materialize specific vertex properties (names prefixed with ! are excluded instead); an empty array materializes none

all

withEdgeProperties(String…​)

Materialize edge properties (e.g., weights)

none

withUpdateMode(UpdateMode)

OFF, SYNCHRONOUS, or ASYNCHRONOUS

OFF

withCompactionThreshold(int)

Rebuild CSR after N accumulated delta edges

10,000

withContractionHierarchy(String, String…​)

Keep a contraction hierarchy for a weight property, over the given edge types (all of the view’s when none)

none

Async Build for Large Graphs

For large graphs, use buildAsync() to avoid blocking the calling thread:

GraphAnalyticalView gav = GraphAnalyticalView.builder(database)
    .withName("large-graph")
    .withUpdateMode(UpdateMode.ASYNCHRONOUS)
    .buildAsync();

// Wait for build completion
boolean ready = gav.awaitReady(30, TimeUnit.SECONDS);

Update Modes

The GAV supports three synchronization modes between OLTP and OLAP:

Mode Behavior Staleness Use Case

OFF

Marks view STALE on commit; requires manual rebuild

Until rebuild

Batch analytics, static snapshots

SYNCHRONOUS

Applies an overlay on each commit

Zero

Real-time analytics, consistent reads

ASYNCHRONOUS

Applies an overlay on each commit, off the committing thread

Brief STALE window per commit

Write-heavy workloads, tolerable brief inconsistency

In SYNCHRONOUS mode, the engine captures transaction deltas (new/deleted vertices, added/removed edges, property changes) and merges them into an immutable overlay on top of the base CSR. Readers always see a consistent snapshot via an atomic volatile reference swap.

In ASYNCHRONOUS mode the same deltas are captured, but merged into the overlay by a background task instead of by the committing thread, so the commit does not wait for the merge; commits that arrive together are merged by one task. Until a commit’s delta is merged the view reports STALE: by default it is then not used, so a query (including the next one of the committing thread) reads the records and sees the commit, while with arcadedb.gavUseWhenStale the view keeps serving the snapshot as of the previous merge. awaitReady() waits for the pending merges. Since v26.11.1; before, every relevant commit started a full rebuild of the CSR whatever the size of the change, and the view served no query until it finished, even with arcadedb.gavUseWhenStale, so under a steady stream of small commits almost no query used it (issue #10189). If the merge falls more than 100,000 commits behind, the pending changes are dropped and the view rebuilds once, STALE in the meantime.

Commit order. In both modes the changes reach the overlay in the order their transactions committed, however many threads commit at once. A thread whose commit follows another one on the same records returns from commit() once its change is in the view, which is after the change it follows; this is why a commit can wait a moment on a slower one. arcadedb.gavCommitOrderMaxWait (30 seconds by default, 0 to not wait) bounds that wait; a transaction that never reports costs the commits behind it one such wait, logged as a WARNING, and is then skipped. Since v26.11.1; before, two commits that conflicted could reach the overlay in the opposite order, and when an edge was created in the record slot a deleted edge had just freed, the deletion arriving second withdrew the new edge from the view for good, so the counts it served were a few edges short of the same queries without the view (issue #10249).

Writes during a build or rebuild. A view starts tracking changes before it scans the graph, so a transaction that commits while the view is being built or rebuilt is never lost: in SYNCHRONOUS mode it is applied on top of the finished view (checked against what the scan already read, so nothing is counted twice) in SYNCHRONOUS and ASYNCHRONOUS modes, and in OFF mode the view is published STALE. Writers are not blocked by a running build, including REBUILD GRAPH ANALYTICAL VIEW.

A committed change that the view cannot merge, or that a build cannot replay on top of the CSR it just scanned, is not dropped: the view logs a warning, reports itself STALE and rebuilds, so it never keeps answering from a graph that never existed (issue #10338). Before 26.11.1 such a change was dropped with a warning, and a first build that hit one never published.

Before 26.11.1 a SYNCHRONOUS or ASYNCHRONOUS view could answer wrongly until the database was reopened after three kinds of commit: an edge created in the transaction that deletes one of its endpoints (a negative algo.degree(), or a lightweight edge that stayed counted; issues #10340 and #10339), a transaction committed right after CREATE GRAPH ANALYTICAL VIEW (issue #10338), and a vertex property that was removed, which kept its old value (issue #10335). Rebuild a view with REBUILD GRAPH ANALYTICAL VIEW if it served such commits on an earlier version.

In SYNCHRONOUS and ASYNCHRONOUS modes the view being rebuilt keeps serving queries, and keeps absorbing commits, until the rebuild publishes its new CSR: only the caller of REBUILD GRAPH ANALYTICAL VIEW (or of build()) waits for the scan. Since v26.10.1 the scan no longer runs while holding the view’s lock, so neither a committing transaction nor a status check waits behind it (issue #8403); before, the view served during a blocking rebuild lagged the commits until the rebuild finished. A rebuild superseded by a newer one while it scans waits for the newer one and reports its outcome (success or error); a rebuild interrupted by the database closing fails with an error rather than reporting success.

Before 26.10.1, a transaction committed while a view was building could be missing from the view permanently, with the view still reporting itself READY and not stale (issue #8378). Rebuild a view created on an earlier version while writes were running if its answers must match the graph exactly. When the overlay accumulates too many changes (configurable threshold, default 10,000 edges), a background compaction rebuilds the full CSR.

Until then the overlay costs whole-graph reads (the Cypher count operators, the algo.* procedures) one copy of the adjacency per committed change, made on the first read after the change: the overlay applied to the base CSR, kept with that snapshot for every later read. Before v26.11.1 a view with any overlay sent those reads down the per-vertex path instead (a 2-hop count measured about a quarter slower), from the first committed edge until the compaction (issue #10411).

The copy is made for the reads that pay for it. A read that starts from a handful of vertices - a 2-hop count from one indexed vertex visits some 64 adjacency entries - does not copy the adjacency of the whole graph for itself: a commit replaces the snapshot, so the copy would serve no second read. Such reads walk the vertices one by one, as they did before the overlay, and the copy is made by the read that brings the entries the reads of the snapshot have visited to what the copy costs (a whole-graph read does it at once; point reads do it only when no commit comes first). Since v26.11.1 point reads while writes are committed take about as long as before the copy existed, where they had become three to six times slower at 20 to 100 commits per second (issue #10471).

Lightweight edges and bulk loads. A lightweight edge is tracked like any other edge in every mode: its creation and its deletion reach the overlay in SYNCHRONOUS and ASYNCHRONOUS modes, and mark an OFF view STALE. Each copy of a duplicated lightweight edge counts as an edge of its own, and a delete removes one copy. Copies have no identity to tell an old one from a new one, so when lightweight edges of a pair change while the view is being built or rebuilt, the view checks that pair against the graph before serving it; if a duplicated copy made them disagree, it rebuilds rather than serve the pair one copy off. Edges loaded with GraphBatch (and so by the graph importer, the HTTP batch endpoint and the gRPC batch load) are written in bulk, without an event per edge, so the view learns of them when the batch closes: OFF goes STALE, and SYNCHRONOUS and ASYNCHRONOUS rebuild rather than absorbing millions of edges into the overlay. While that rebuild runs the view is not READY, so queries take the ordinary path and never answer without the load. During the batch itself the view still serves what it held before the batch started.

Before v26.11.1, deleting a vertex that carried a lightweight self-loop reported the self-loop deleted twice, once from each of the vertex’s edge lists. A view counted both reports, so its edge count went one lower per such self-loop, down to negative numbers (issue #10369). A vertex delete now reports each self-loop once, record-backed or lightweight, so a delete listener or trigger hears of it once. Rebuild a view that served such deletes on an earlier version.
Before 26.10.2 a view never learned about a lightweight edge created after its build, nor about any edge written by GraphBatch: a SYNCHRONOUS view answered as if those edges did not exist, and a transaction or batch that touched nothing else left an OFF view READY (issue #9572). Rebuild a view on such a graph after upgrading.

CSR Persistence

Available since ArcadeDB v26.9.1.

Before v26.9.1, a named view’s CSR was never itself persisted: only its definition (types, properties, update mode) was, in schema.json. Every database open therefore rebuilt the CSR from a full graph scan, and because shutdown() waits for any in-flight rebuild, the cost of that scan landed on close() rather than open() - at 1M vertices, closing a database with a view took over 4 seconds even for a session that ran no query at all.

A READY view (with no pending overlay changes) now writes its CSR to disk at a clean database close, alongside a freshness certificate: the database’s last committed transaction id at the time the CSR was built. On the next open, if a persisted file plausibly applies (see arcadedb.gavPersistCsr below), the view is marked READY immediately, but the file itself is not read yet - see Lazy Restore. That deferred read either:

  • finds the database’s current last committed transaction id still matches the certificate - nothing was committed to the database in between, so the persisted CSR is loaded from disk as-is instead of being rebuilt; or

  • finds a mismatch - anything was committed in between, to any type, not only the ones the view covers - and falls back to the previous behavior: an async rebuild.

This means the speedup only applies to reopening a database that was not written to since its last clean close. A database that receives writes between every session still pays the rebuild on every open, exactly as before v26.9.1.

Persistence is controlled by the arcadedb.gavPersistCsr database-level setting (default true). Set it to false to skip writing the CSR file, for example to avoid its disk footprint or the extra write at close.

Serving queries against a stale CSR while a delta is applied in the background (the way SYNCHRONOUS mode’s overlay already does for live updates) is not part of this: a CSR’s contiguous array layout makes partial coverage expensive to retrofit, so an invalidated certificate always means a full rebuild, not a partial one.

Lazy Restore

Available since ArcadeDB v26.9.1.

A persisted CSR that plausibly applies is not read from disk during open(). Reading and deserializing it is a real, if bounded, cost - up to roughly a second at 10M vertices - and a session that opens a database and never queries the view got nothing for paying it. Instead, open() marks the view READY immediately without touching the file, and the actual read is deferred to whichever of these happens first:

  • a query that actually needs the view’s data; or

  • an explicit call to the view’s awaitReady() method.

Reading the state of the view is an explicit wait too, so SELECT FROM schema:graphAnalyticalViews (and GraphAnalyticalView.getStats()) starts the restore and returns at once. Polling the row until status is READY therefore ends with the counts of the view, nodeCount and edgeCount, in the row; while the restore runs the row reads BUILDING. Before v26.11.1 the row of a view whose restore had not been triggered yet read READY with nodeCount = 0 and edgeCount = 0, for as long as no query used the view (issue #10575).

Each of these re-verifies the freshness certificate from scratch at that point (not the one implied at open() time), so a commit that lands after open() but before the first query is still caught correctly and falls back to a rebuild rather than serving stale data. A session that opens and closes a database without ever touching the view now costs what the no-view baseline costs, not what a restore costs - close() does not wait for a restore that was never triggered either.

Since v26.10.2 a query that looks a view up while its restore is in flight (for example the first count push-down, MATCH or shortestPath() after a reopen) waits for the restore instead of taking the slower record path. The wait is bounded by the arcadedb.gavQueryRestoreAwaitTimeout database-level setting (default 5000 ms, 0 disables the wait).

A query that runs while a view is not ready yet (its restore past that wait, its first build still running, or the view stale and not allowed to serve stale results) takes the record path. Since v26.10.2 that is no longer remembered: the OpenCypher plan cached for the query is planned again as soon as the view is ready, so the following runs of the same query use the view.

The arcadedb.gavRestoreAwaitTimeout database-level setting (default 0) forces the wait at open() time instead, for either the deferred restore or a full rebuild: set it to a positive number of milliseconds to trade a slower open() for the view being immediately usable by the query that triggered the reopen, rather than the first query racing (and possibly missing) it.

How CSR Works

The graph topology is stored as two pairs of arrays (forward for outgoing edges, backward for incoming):

Forward CSR (outgoing edges):
  offsets:   [0, 3, 5, 8, ...]     -- one entry per vertex + sentinel
  neighbors: [1, 5, 7, 2, 6, ...]  -- dense neighbor IDs, contiguous per source

  Outgoing neighbors of vertex v = neighbors[offsets[v] .. offsets[v+1])
  Out-degree of vertex v         = offsets[v+1] - offsets[v]   -- O(1)

This layout enables sequential memory access (cache-line friendly) and O(1) degree lookups.

Columnar Property Storage

Properties are stored as typed flat arrays — int[], long[], double[], or dictionary-encoded int[] for strings. Each column has a compact null bitmap (1 bit per vertex). Dictionary encoding maps unique string values to integer codes, achieving near-100% compression for low-cardinality fields.

A column takes its type from the schema, or for an undeclared property from the first values the build samples. Since v26.11.1 a value the column cannot give back as it is (a 2.5 or a NaN in an integer column, a FLOAT in a double one, a number in a string one) is not stored in it: a read of that vertex’s property through the view falls back to the record, so queries answer with the value the record holds. Until v26.10.2 it was converted into the column’s type, and a query over the view could read 2 where the record held 2.5 (issue #10255).

Edge Properties and Pending Changes

Behavior changed in ArcadeDB v26.9.1.

Edge property columns (EDGE PROPERTIES (…​), used as weights by the shortest-path and spanning-tree algorithms) are laid out in the order the graph had when the view was built. In SYNCHRONOUS mode a commit is absorbed into a pending-changes overlay rather than rebuilding, so from that moment the neighbour list and the columns describe two different orderings.

Before v26.9.1 the view resolved this by reporting no edge properties at all as soon as an overlay existed, and every weighted algorithm silently went back to reading edge records - after every single commit. The view now reconciles the two instead: an edge already in the base graph keeps answering from its column, and an edge the overlay added answers from the value recorded when it was committed. Weighted algorithms therefore keep the fast columnar path while a view is being updated.

Two cases remain that the view cannot answer, and for those the algorithm reads the edge records directly - exact, only slower:

  • several parallel edges join the same two vertices and only some of them were deleted, since the overlay counts deletions per vertex pair and cannot say which of the parallel edges went;

  • a property of an existing edge was changed while another edge of the same type joins the same two vertices, until the rebuild that refreshes the columns completes, since the view cannot tell which of the two changed. Since v26.10.2, a change to an edge that is the only one of its type between its two vertices is kept in the overlay and answered from there, with no rebuild (issue #9437). Adding and deleting edges never have this effect. Since v26.11.1, neither does saving an edge the view does not hold: an edge to a vertex of a type the view does not cover, or an edge whose vertex is deleted in the same transaction (issue #10350).

Memory Usage

The OLAP representation is significantly more compact than the OLTP equivalent:

  • CSR topology: ~8 bytes per edge (bidirectional)

  • Node ID mapping: ~8 bytes per vertex

  • Columnar properties: 4–8 bytes per vertex per column

  • Null bitmaps: 1 bit per vertex per column

Example: for a graph with 500K vertices and 8M edges, the GAV uses 134.6 MB compared to an estimated ~1.2 GB for the OLTP representation — 9.3x more compact.

long bytes = gav.getMemoryUsageBytes();

Sizing the Heap for a View

Behavior changed in ArcadeDB v26.11.1.

The view lives on the JVM heap, and so does the page cache (arcadedb.maxPageRAM, a quarter of the heap by default). Three things decide the smallest heap a view needs:

  • The build. Since v26.11.1 a build holds about the size of the CSR it produces, plus one forward slice while it renumbers it, at its peak: the edges are collected per edge type in fixed-size blocks, each forward slice is filled from them and sorted in place, and each backward slice is derived from the forward one. Before v26.11.1 the build held the edges as (source, target) pairs in doubling arrays next to the first CSR and the reordered one, about three to four times the size of the view, so on the LDBC graph datagen-7_5-fb (633K vertices, 34M edges) a restore needed a 3 GB heap for a view of about 300 MB (issue #11011).

  • The restore from a persisted CSR. Since v26.11.1 the file is read and written as a stream of frames of 1 MB, each checksummed and, when the database is encrypted, encrypted on its own, so a clean close and the next open need no more heap than the view itself. Before v26.11.1 both held the whole payload in a buffer besides the view, and in a heap too small for that the clean close silently skipped persisting it and the next open rebuilt the view from a full scan. Files written before v26.11.1 are not read: the first open rebuilds the view and the next clean close writes the new format.

  • The page cache. Since v26.11.1 the pages a build reads from disk are the first evicted while it runs, and leave the cache when it ends unless another reader used them meanwhile: the view answers what they hold, so keeping them would hold the graph twice. The pages the cache held before the build stay. The arcadedb.gavBuildReleasesPages database-level setting (default true) turns this off, for a workload that reads the same records through the record API right after the build.

A build or restore that runs out of heap, or fails for any other reason, ends the view in the FAILED status (or STALE, when it still serves an earlier snapshot), logs the failure at SEVERE with the view name, and reports the cause in the buildError column of schema:graphAnalyticalViews. Before v26.11.1 a build that died of an OutOfMemoryError left the view BUILDING for good. REBUILD GRAPH ANALYTICAL VIEW builds a FAILED view again from its definition.

Graph Algorithms

The module includes parallelized graph algorithms that operate directly on CSR arrays with zero GC pressure:

Algorithm Description

PageRank

Pull-based, parallel, configurable damping factor and iterations

Connected Components

Parallel union-find for weakly connected components

BFS

Breadth-first search with distance arrays

SSSP (Dijkstra)

Single-source shortest path for weighted graphs

Label Propagation

Community detection

Triangle Counting

Count 3-cliques in the graph

Local Clustering Coefficient

Per-vertex clustering coefficients

GraphAlgorithms algos = new GraphAlgorithms();

// PageRank (20 iterations, damping 0.85)
double[] ranks = algos.pageRank(gav, 20, 0.85);

// Connected Components
int[] components = algos.connectedComponents(gav);

// BFS from a source vertex
int[] distances = algos.bfs(gav, sourceNodeId);

Algorithms and Pending Changes

Behavior changed in ArcadeDB v26.9.1.

In SYNCHRONOUS mode a committed change is absorbed into a pending-changes overlay rather than rebuilding the view. The overlay keeps the slot of a deleted vertex and numbers an added vertex above the base graph, so from that moment the view’s vertex count and the range of its internal vertex numbers stop being the same figure.

Before v26.9.1 the algo.* procedures took them for one and the same, with two consequences:

  • a vertex added after the view was built could be missing from the answer of every procedure, with no error to say so;

  • once the additions outnumbered the deletions, the CSR-accelerated procedures (algo.wcc, algo.pagerank, algo.labelpropagation, algo.localClusteringCoefficient, algo.bfs) failed with an ArrayIndexOutOfBoundsException - one added vertex was enough.

Both are fixed. The vertices a view holds are renumbered onto a gapless range before any algorithm sees them, so every algo.* procedure answers for exactly the vertices that are live at the time of the call - the added ones included, the deleted ones excluded.

While a view is holding pending changes the whole-graph procedures also stop using the parallel kernels listed above, which read the base CSR arrays directly and know nothing of the overlay: they would otherwise answer for the graph as it stood at the last build. Those calls still run off the view’s adjacency, single-threaded rather than parallel, and the compaction that folds the overlay back into the base graph (by default after 10,000 pending edges) restores the parallel path.

Since v26.11.1, each algo.* call, and each SQL shortestPath() or bellmanFord() call, reads one fixed snapshot of the view from start to end, including the rows streamed after the call returns. A rebuild or compaction that completes during the call does not affect it. Before, a rebuild that landed mid-call numbered the vertices afresh while the call was still reading. One row could then come back with a null node, or a row could name the wrong vertex (issue #10350). A long call keeps the snapshot it started on in memory until it ends.

The same holds for a whole statement: every part of a Cypher query (its MATCH operators, count push-downs, CALL and COUNT sub-queries) and of a SQL SELECT, MATCH or TRAVERSE statement (its MATCH patterns, out()/in()/both(), astar()) reads the snapshot the statement first read, and so does each Gremlin traversal. Before v26.11.1 the Cypher MATCH operators read whichever snapshot the view served at each call: a rebuild while the rows streamed made the later rows name other vertices, or, for a fused chain of hops, failed the query with "was rebuilt while the query walked it" (issue #10415). Each statement of a script takes its own snapshot, so a statement that follows a COMMIT reads what was committed.

Shortest Paths with Contraction Hierarchies (CCH)

A view can keep a Customizable Contraction Hierarchy for a weight property: a precomputed structure that answers point-to-point shortest paths in a time that depends on the depth of the hierarchy rather than on how far apart the two vertices are. It is meant for road, logistics, utility and other infrastructure networks that serve many routing queries.

CREATE GRAPH ANALYTICAL VIEW roads EDGE TYPES (ROAD) UPDATE MODE SYNCHRONOUS CCH (distance)
GraphAnalyticalView roads = GraphAnalyticalView.builder(database)
    .withName("roads")
    .withEdgeTypes("ROAD")
    .withUpdateMode(UpdateMode.SYNCHRONOUS)
    .withContractionHierarchy("distance")
    .build();

Queries go through algo.cch.shortestPath (Cypher) or cchShortestPath() (SQL). A query is answered by the hierarchy when its weight property and relationship types are exactly those of a hierarchy on a ready view; otherwise, and whenever the hierarchy cannot answer for the view’s current state, it is answered by bidirectional Dijkstra. Answers are exact either way, and a transaction holding uncommitted changes always sees them.

How it works

  1. Order - a nested-dissection order of the vertices, computed from the topology alone: small vertex separators (minimum vertex cuts) split the graph recursively, and each separator is ranked above the pieces it separates.

  2. Contraction - eliminating the vertices in that order adds shortcut edges, giving the chordal supergraph of the hierarchy.

  3. Customization - the weight of every edge and shortcut, computed bottom-up from the edge weights. This is the only step that depends on the weights.

  4. Query - the two endpoints climb the hierarchy and meet at the top; shortcuts are then unpacked into the edges they stand for.

On grids, the hardest case for this technique, a query on 360,000 vertices takes about 0.5 ms for a route across the whole grid against about 50 ms for Dijkstra, and the gap grows with the size of the graph. Road networks have much smaller separators than grids and benefit more.

Keeping up with changes

The hierarchy follows the view: every change the view absorbs is followed by a background preparation, on the view’s build executor. In SYNCHRONOUS mode most changes are caught up in milliseconds:

  • A weight change, a closed road (an infinite weight) or a deleted edge re-customizes only the part of the hierarchy above the edges that changed. The view keeps its CSR: the new weight of an edge that is the only one of its type between its two vertices is served from the view’s change overlay, without rebuilding anything.

  • A new edge between two vertices the hierarchy already joins is handled the same way.

  • A new edge between two vertices it does not join, or a new vertex with edges, needs a new contraction. The existing vertex order is kept, with new vertices ranked first, so this costs a contraction and a customization rather than a full build.

  • A weight change on an edge that has a parallel twin of the same type between the same two vertices makes the view rebuild its CSR in the background, because the view cannot tell which of the two changed. The hierarchy then catches up from the rebuilt view.

  • While a preparation is running, queries are answered by bidirectional Dijkstra, so an answer never reflects an older state than the view itself.

  • Directed (OUT/IN) queries are prepared from the start. Undirected (BOTH) ones need a second metric, customized the first time a BOTH query asks for it, so that first query and any during its customization are answered by Dijkstra.

Measured on the California road network (DIMACS USA-road-d.CAL: 1.9M vertices, 4.7M arcs) through a database: a query takes 0.04 to 0.1 ms at any distance, against 18 to 230 ms for Dijkstra; updating 1, 10 and 100 weights takes about 0.7, 8 and 65 ms from commit until the hierarchy answers for it; closing a road about 4 ms; adding a road the hierarchy does not join about 0.9 s; the first build about 13 s. Each query needs a few tens of KB of scratch, sized to the depth of the hierarchy rather than to the graph.

The order is persisted next to the view’s CSR at a clean close (see CSR Persistence) and reused when the database is reopened with nothing committed in between, so a reopen only contracts and customizes.

Suitability

The technique relies on small separators. Graphs without them, such as social graphs or graphs with supernodes, would need a supergraph many times larger than themselves. A hierarchy that would exceed arcadedb.gavCchMaxArcsPerEdge supergraph arcs per edge (default 16) is not built: it reports UNSUITABLE, and queries keep using bidirectional Dijkstra. A REBUILD GRAPH ANALYTICAL VIEW tries again.

Monitoring

SELECT FROM schema:graphAnalyticalViews and GraphAnalyticalView.getStats() list each hierarchy with its status (PREPARING, READY, UNSUITABLE, UNAVAILABLE), size in arcs and memory, how many topologies were built or restored, how many customizations ran, and how many queries it answered or handed back to Dijkstra.

The weight of an edge is the numeric value of its weight property; an edge without one weighs 1, and an edge whose value is negative is not walked.

Query Planner Integration

The Cypher query planner automatically detects ready GAVs and substitutes OLTP traversal operators with CSR-based operators when:

  • A named GAV is registered and in READY state

  • The GAV covers the required vertex and edge types

  • The query does not return edge variables as first-class records (edges in CSR have no RID; edge properties are fully supported)

No query changes are needed — the optimizer transparently accelerates matching traversal patterns.

Relationship uniqueness. Cypher binds a relationship at most once per MATCH clause, so MATCH (a)-[:KNOWS]-(b)-[:KNOWS]-(c) never walks back to a over the edge it just took. A view holds adjacency, not edge records, so since v26.10.1 a hop that could reuse a relationship of another hop in its clause - they share an edge type, or either is untyped - is walked one edge type and direction at a time and remembers which relationship it took, and EXPLAIN marks it unique relationships. Such a clause is served from the view when every one of those hops is anonymous and has a fixed length; a named relationship variable or a variable-length relationship among them sends all of them to the records instead. Hops that cannot collide - (a)-[:KNOWS]→(b)-[:WORKS_AT]→(c) - are unaffected. Since v26.11.1 this also holds for an edge type with sub-types: a hop on KNOWS with KNOWS_WELL EXTENDS KNOWS walks the slice of each concrete type on its own and names a relationship after the type it actually has, so a hop on the parent and a hop on the sub-type recognize the same edge. Before, such a clause walked the edge records (issue #10213). Before v26.10.1 a view answered such patterns without the rule, counting for example every friend-of-a-friend path that returns to its start over the same edge, and every path that took a self-loop twice (issue #8394). An undirected chain of several hops also counted each self-loop once per adjacency list it sits in, even across separate MATCH clauses.

Views over some of the vertex types. A view built with VERTEX TYPES (…​) holds only the vertices of those types, so the edges that leave them are missing from its adjacency. Edge types do not declare which vertex types they connect, so the planner cannot know that a traversal stays inside the view: since v26.10.2 a view is used for SQL out(), in(), both(), MATCH, TRAVERSE, shortest path, the count push-downs and the algo.* procedures only when it covers every vertex type of the database that holds a vertex, and these statements otherwise read the records. Before that they answered with the covered part of the graph, for example 0 rows for MATCH (b:B)-[:E]→(c:C) on a view over (A, B) (issue #9301). A type with no vertex of its own does not count, because no traversal can reach it: a parent type whose records all live in the sub-types the view lists (Message over Post and Comment), or a type not used yet. The first vertex created in such a type makes the view partial again, without any schema change. A view over a subset of the types still serves a one-hop Cypher scan whose labels it holds. To accelerate the other statements, list every vertex type that holds vertices.

Stale views. A view that is STALE (in OFF mode after a commit, in ASYNCHRONOUS mode until a commit is merged, or after a failed rebuild) is not used by the planner unless arcadedb.gavUseWhenStale is true, in which case queries read the snapshot as of its last build. The setting is per database, and applies to every view of that database immediately, including views already built and views restored at open (since v26.10.1; before, only the JVM-wide -Darcadedb.gavUseWhenStale had any effect, issue #7875):

ALTER DATABASE `arcadedb.gavUseWhenStale` true

The builder’s withUseWhenStale(boolean) overrides it for one view, and that override is saved with the view’s definition.

A one-hop pattern feeding an aggregation, such as

MATCH (p:Person)-[:KNOWS]->(f:Person)
WHERE p.city = f.city
RETURN p.city AS city, avg(f.age) AS avgAge, count(*) AS n

is read entirely from the view since v26.10.1: the sources are the view’s nodes carrying the source label, both endpoints read their properties from the view’s columns, and every edge reaches the aggregation as a lightweight row rather than a copied record. EXPLAIN shows it as GAV ONE-HOP SCAN. This applies when the statement is a single MATCH of one fixed-length relationship feeding an aggregating RETURN, with no relationship or path variable and no inline property map, the optimizer would otherwise scan the source label (not seek it through an index), and the view is not stale and covers every vertex type the two labels can match. A count per endpoint alone (RETURN p.city, count(*)) keeps its own, even cheaper, edge-count plan, and an order-sensitive aggregate such as collect(), or a grouped LIMIT/SKIP without ORDER BY, keeps the regular plan.

Counts that build no row. A statement whose only output is count(*) - and a COUNT { } subquery - is answered by a count push-down when its pattern is a chain, a star around one node, or one of the LSQB shapes; EXPLAIN shows it under Using Count Push-Down. Since v26.10.2 the push-downs also take:

  • a property predicate on a node of a chain, a star, the anti-join of LSQB Q8/Q9 or the pair join of LSQB Q2, written inline ((:Message {kind: 'Post'})) or as a WHERE conjunct that reads that node alone (WHERE m.kind = 'Post', WHERE m.created > $since). It is checked once per distinct vertex the position reaches, on the vertex itself, so it costs one record read per such vertex instead of the rows of the whole pattern (issues #9595, #9608). A conjunct that compares two nodes (WHERE a.x < b.x), a property value read off another node ({kind: a.kind}), rand() and pattern predicates still leave the count to the regular plan. A predicate written at one position of a variable holds at every position the variable is written at, and two nodes whose inline maps give one property two different string, boolean or integer values ((c:Message {kind: 'Comment'}), (p:Message {kind: 'Post'})) are known to be different vertices, which proves their relationships different edges the way two unrelated labels do.

  • a MATCH made of parts that share no variable, such as MATCH (a:Person), (b:Person), (c:Person): each part is counted on its own, by its own push-down or plan, and the counts are multiplied. An OPTIONAL MATCH part counts at least one row. Two parts of one MATCH clause are multiplied only when the schema proves their relationships cannot be the same edge, since a clause binds every relationship once. A product that does not fit a 64-bit integer is an error (issue #9596).

  • a WITH that only passes variables on, * and aliases included (WITH message, creator, WITH m AS x), with no aggregation, DISTINCT, WHERE, ORDER BY, SKIP or LIMIT: it leaves the number of rows alone, so the statement is counted as if the clauses on both sides were one query part (issue #9597).

  • count(x) of a node or relationship variable a non-optional MATCH binds, which counts the same rows as count(); a single relationship between two unconstrained nodes, MATCH ()-[e:KNOWS]→() RETURN count(e) (untyped, with several types or undirected too), read from the view’s per-type totals, or without a view in one walk of the edge lists that loads no edge; and MATCH (n) RETURN count(n), the sum of the vertex types' counters (issue #9600). Unlike the SQL SELECT count() FROM KNOWS, which counts edge records, the relationship count includes lightweight edges, which keep no record.

  • the same pattern however it is written: the comma-separated parts of a MATCH are read as the graph they describe, so a chain cut into parts is counted as the chain, and a cycle such as LSQB Q2 (a KNOWS hop closed by a three-hop chain, or the same cycle written as four one-hop patterns starting from the post) is split into the probe hop and the chain by the statistics - the fan-out of each hop sampled on the label it leaves - rather than by the order of the text (issue #9599).

  • an OPTIONAL MATCH only tested for absence (OPTIONAL MATCH (c)-[h:HAS_TAG]→(t) WITH …​ WHERE h IS NULL), which is planned as WHERE NOT (c)-[:HAS_TAG]→(t) and so reaches the anti-join push-down (issue #9598, see OPTIONAL MATCH).

Since v26.11.1 a triangle written as one MATCH, MATCH (a:Person)-[:KNOWS]-(b:Person)-[:KNOWS]-(c:Person)-[:KNOWS]-(a) RETURN count(*), is a count push-down too, with no WHERE or with one that orders the three variables strictly on one property (WHERE a.id < b.id AND b.id < c.id, in any spelling of < and > that orders all three). Each triangle is found once by the sorted intersection algo.triangleCount uses and weighed by the matches it stands for, so on a 10,000-person, 180,000-edge graph the ordered count takes milliseconds instead of seconds (issue #10255). The answer is the pattern’s own, not the algorithm’s simple graph: parallel relationships are matches of their own, the comparison is on the property values (two equal values, a null, or values of two types such as a number and a string drop the triangle), and without a WHERE the six walks of every triangle and the walks over self loops are counted. The hops must be undirected, anonymous and of one type, and the three variables carry one label or none; EXPLAIN shows COUNT TRIANGLES (CSR sorted intersection) with the order it counts.

Multi-hop chains on the view. A chain of labeled hops such as MATCH (a:P)-[:K]→(b:P)-[:K]→(c:P) is walked on the view (EXPLAIN shows GAVFusedChain). Since v26.10.2 a count(), or count(x) of a variable of the chain, is counted during the walk with up to two grouping keys that are chain variables, in a RETURN or a WITH (RETURN a, count() AS n); EXPLAIN shows it on the chain as fused count(*) AS n. When the statement returns the paths' rows instead, they are produced in bounded batches, so memory no longer grows with the number of paths and a LIMIT stops the walk early (issue #9738). If the view is rebuilt while such a result is still being read, the query fails with a retryable error rather than mixing rows from two builds: run it again.

WHERE on a chain walked on the view. Since v26.10.2 each AND-ed condition of the WHERE is checked on the hop that binds the last variable it reads, so a path that fails it is not walked any further (issue #9938). In MATCH (a:P)-[:K]→(b:P)-[:K]→(c:P) WHERE a.id < b.id AND b.id < c.id, the first condition is checked as soon as b is reached and the second one once c is: EXPLAIN shows them on the chain as filter on (b)=a.id < b.id and filter on (c)=b.id < c.id. A pattern the view walks one hop at a time instead of as one chain - a cycle such as a triangle, closed by a GAVExpandInto hop, or a pattern that branches from one of its nodes - gets one Filter per condition right above the hop that binds its variables, as a pattern without a view does. A triangle counted with ordered ids (WHERE a.id < b.id AND b.id < c.id) so prunes its wedges before closing them, about three times faster on the LDBC SNB SF1 KNOWS graph. A condition that reads no node of the pattern is still checked on the complete path.

A transaction that holds uncommitted changes - any change, even to types the view does not cover - does not use the view: the view serves the committed graph only, and a query inside the transaction must see the transaction’s own writes. Such a query, Cypher, Gremlin or a graph algorithm, runs on the records until the transaction commits, which PROFILE shows as a plan without the view. Run analytical queries outside write transactions, or after committing, to keep the acceleration.

Lifecycle

// Check status
if (gav.isReady()) { /* safe to query */ }

// Status values: NOT_BUILT, BUILDING, READY, STALE, FAILED (since v26.11.1: the build threw, see getBuildError())
Status status = gav.getStatus();

// Drop (removes from registry + schema)
gav.drop();

// Shutdown (release resources, schema definition persists)
gav.shutdown();

Benchmark Results

On a graph with 500K vertices and ~8M edges:

Benchmark OLTP OLAP Speedup

1-hop count

6.9 µs

1.2 µs

5.7x

2-hop

101.4 µs

5.1 µs

19.8x

3-hop

1,037 µs

56.4 µs

18.4x

5-hop

194,046 µs

5,141 µs

37.7x

Shortest Path

394 ms/pair

7.5 ms/pair

52.8x

PageRank (20 iter)

124,563 ms

316 ms

394.2x

Connected Components

5,591 ms

197 ms

28.4x

Label Propagation

62,619 ms

645 ms

97.1x

Limitations

  • CSR uses int[] arrays — maximum ~2.1 billion vertices per bucket and ~2.1 billion edges per direction

  • Edges in CSR do not carry their own RID; the Cypher query planner falls back to OLTP only when the query returns an edge variable as a first-class record (e.g., RETURN r). Edge properties are fully supported via withEdgeProperties()

  • Dictionary encoding applies only to string properties

  • Initial build requires a full scan of selected vertex/edge types. A rebuild triggered by an invalidated persisted CSR certificate (or by OFF mode, or by exceeding the SYNCHRONOUS/ASYNCHRONOUS compaction threshold) also requires a full scan - only a clean-close-to-unchanged-reopen cycle skips it