Changelog
0.9.0 — in development
Section titled “0.9.0 — in development”Behaviour changes
Section titled “Behaviour changes”- A graph owned by a superuser runs its source queries only for a
superuser (DESIGN.md §7, §14 item 51, decided by the owner
2026-10-29).
warren.refreshof such a graph, andwarren.set_ownerto a superuser (which opens the source queries as the new owner), now require the caller to be a superuser as both the current user and the session user; otherwise they fail withinsufficient_privilege(42501, “only a superuser may run the source queries of graph … as superuser …”) and a hint, before any of the graph’s SQL runs. Until now a non-superuser member of the superuser owner role, or any session through a superuser’sSECURITY DEFINERfunction, could refresh it. The reason is the restore route:pg_write_all_data(which RDS’s master user holds) can rewrite a graph’s owner and source queries, and arefreshwould then have run that SQL as a superuser. Graphs owned by non-superusers are unaffected. To let non-superusers refresh a superuser’s graph, give it to a non-superuser withset_ownerorreassign_owned. No SQL surface changes.
- A guide to running pg_warren beside a managed database
(
docs/beside-managed.md), for Amazon RDS, Aurora and other services that cannot install it: a server of your own with pg_warren, fed by logical replication from the managed primary and refreshed on a schedule, with results read back on the primary throughpostgres_fdw(or the graph’s sources read through it), how stale and how consistent each version is, security and limitations. Its SQL runs in CI, on a primary without pg_warren and a graph server (test/logical_replication.py).
Documentation
Section titled “Documentation”- Dumps and restores without a superuser (DESIGN.md §14 item 51,
#103): a role granted
pg_read_all_datadumps graphs, versions and vectors, and a role withCREATEon the target database andpg_write_all_datarestores them. On Amazon RDS from PG 15 the master user has both. pg_warren itself grants no role access to its catalog, and nothing in the SQL surface changes. The roles named in the catalog must exist on the target first: a missing one makes the restore fail, naming it. docs/security.md says what the two predefined roles also allow.test/dump.pynow checks the whole round trip with no superuser, as on RDS, thenwarren.drop_ownedandDROP EXTENSIONby the same non-superuser. - The README’s feature matrix (How it compares) puts pgGraph 1.2.1 first,
as the closest alternative inside PostgreSQL, with rows for the
differences both ways: where pgGraph has the tick (GQL/openCypher,
trigger sync, discovery from keys) and where pg_warren does (versions,
in-transaction builds, standbys, whole-graph algorithms, semirings).
pgGraph’s cells are sourced from its v1.2.1 documentation, and
docs/positioning.mdpoints to the matrix and spells out the nuanced differences (row-level security, parallel edge rows). - The head-to-head benchmarks now include pgGraph 1.2.1, built in release
mode and run with its recommended configuration on BFS, shortest paths
and connected components. pg_warren 0.8.0 was faster on all three, on
all 12 graphs. igraph’s shortest-path pairs now use
get_shortest_path, which stops at the target without rebuilding the adjacency list. The README’s Performance table has a pgGraph column, anddocs/positioning.mdreports the results (bench/results/corpus/head_to_head/pggraph-1.2.1/).
0.8.0 — native PageRank and Leiden, leaner calls (2026-10-29)
Section titled “0.8.0 — native PageRank and Leiden, leaner calls (2026-10-29)”Upgrading
Section titled “Upgrading”- Run
ALTER EXTENSION pg_warren UPDATE;in every database that has the extension, after installing the new build. The update replaces the PL/pgSQL definitions ofwarren.pagerankandwarren.leidenwith native ones (under Performance), keeping their privileges; it changes no table. Until the update, 0.7.0’s definitions still run with the new library, as they did.
Behaviour changes
Section titled “Behaviour changes”warren.pagerankrefuses a NULLoutordampingbefore it runs, asout must not be NULLanddamping must not be NULL(SQLSTATE 22004, as before; 0.7.0 ran first and then namedy, orvalue). An empty or unwritableoutis refused before the run, too. Its intermediates (_warren_pagerank_*) no longer exist, even during a run, and it no longer carriesSET search_path: the access guard pins it for the call (DESIGN.md §7). Everything else is kept: its signature, ranks to the bit, iteration counts, SQLSTATEs, progress, parallel label and privileges, including a NULLtolnever stopping it early and a NULLmax_iterrunning no iteration.leidenchecks that the caller may writeoutbefore it runs, not after. The error is the same (42501). The function no longer sets its ownsearch_path; like every otherwarren.*function, it pins it for the call.- On graphs with fractional weights,
leidensums a level’s edge weights in a different order from 0.7.0’scontract. A weight may differ in its last bit, and a tie between moves that it decides may then go the other way. On unweighted and integer-weighted graphs the partitions, kept levels and modularity are 0.7.0’s, to the bit.
gatheroverchar(n)messages no longer truncates them to one character forcollectandfirst: their query was cast tocharacter, which ischaracter(1); it is now cast tobpchar, asarray_aggkeeps them.gather’scollect,firstandcountovertext,varcharorcharmessages no longer depend onclient_encoding: a message it could not represent failed with 22P05 (“has no equivalent in encoding”), since each message passed throughtextsend.
Supply chain
Section titled “Supply chain”- Each release has a vendored source tarball and an SBOM, both covered
by the signed
SHA256SUMS:pg_warren-X.Y.Z-vendored.tar.gz, the source with every crateCargo.lockpins, which builds withcargo build --frozenand no network; andpg_warren-X.Y.Z.cdx.json, a CycloneDX 1.5 SBOM of pg_warren and its crates with their licences and checksums. Both are the same bytes each time they are made, and the publish job checks both against the source tarball. CONTRIBUTING.md, “Supply chain”, says how to build offline and how to verify a release. - Builds are reproducible from the same inputs. The packages ship the
committed install script,
sql/pg_warren--X.Y.Z.sql, instead of the onecargo pgrx packagegenerates, whose entity order changed from build to build: that alone made every rebuilt.debdiffer. Two builds of a package from the same source now give the same bytes (CI checks it), andpg_warren.sois the same bytes wherever it is built and whether its crates come from crates.io or the vendored tarball (packaging/reproducibly.sh; CI builds it both ways and compares). A rebuild long after a release can still differ, since the build container installs the current PostgreSQL headers and build tools; what is and is not reproducible is in CONTRIBUTING.md, “Reproducible builds”. - Dependency checks in CI (
cargo-deny): RustSec advisories, a licence allow-list, crates.io as the only source. They found two advisories, fixed by updatingcrossbeam-epoch(in the library, through rayon) to 0.9.21 and a test dependency’sctutilsto 0.4.7.
Performance
Section titled “Performance”warren.pagerankis a native kernel, not a PL/pgSQL loop over the compute primitives (DESIGN.md §6.7). The loop made eleven calls an iteration, about 1.5 ms of overhead an iteration however small the graph; the kernel makes two parallel passes and one product an iteration, and holds four vectors of the id space (five with a seed) where the loop held eleven: 0.32 GB of scratch at 10M nodes instead of 0.88 GB. Twenty iterations, in a first single before-and-after run on a loaded machine (median warm times,warren.threads = 4): lesmis 34.7 → 1.6 ms, facebook 47.3 → 6.3 ms, email-enron 74.9 → 22.6 ms, cit-hepth 65.7 → 18.6 ms and DBLP 318 → 113 ms. The later three-round run timed lesmis at 3.1 ms. Against igraph on the 12 head-to-head datasets (three interleaved rounds,bench/results/corpus/head_to_head/README.md, “PageRank in 0.8.0”): faster on 7 (roadNet-TX 0.22 s against 0.81 s, DBLP 105 ms against 251 ms) and slower on 5 (WN18RR, cit-hepth and YAGO3-10 by 1.2–1.8×, polblogs by 3.1×, and lesmis, 3.1 ms against 29 µs, by a call’s fixed cost).leidenis native (DESIGN.md §6.7, “Leiden’s levels”). Each level’s graph is contracted from the level below, not from the whole graph, and is held in memory, charged towarren.max_compute_memory. Onlyoutand the levelslevels_prefixasks for are written. No level graph, segment or WAL is written for a level, and the disk a run needed grew with its levels before.- It finds the same partitions as before. A pg_test runs the frozen 0.7.0 function beside it.
- On the head-to-head’s real graphs it is 1.3–21× faster: fb15k-237 1.44 → 0.21 s, yago3-10 2.26 → 0.73 s, roadnet-tx 7.9 → 3.8 s, lesmis 25 → 1.2 ms.
- It takes 0.76–1.6× igraph’s time on the medium tier, where it was
1.9–180×, and is faster than igraph on ss-gleich-usroads-48, dblp,
yago3-10 and roadnet-tx (
bench/results/corpus/head_to_head/, “Leiden in 0.8.0”).
gatherovertext,varchar,charandbyteamessages keeps them as their bytes, asconcatdid, instead of each going through its type’s send and receive functions, and lays a collected array out straight from them: at 10M nodes and 11.5M messages, acollectof cheap text messages takes 14.5 s rather than 16.7 s (13.5 s asbigint). Appendix A.3’s first superstep goes from 30.8 s to 29.5 s; most of it is its message query (DESIGN.md §13, “After 0.7.0”).distance('edit')across two graphs (graph2) no longer rebuilds the second graph in the first’s dense ids: it maps each edge as it reads it and looks it up in the first graph’s row (DESIGN.md §8). It charges only the id mapping, 8 bytes a node of the second graph, where it charged 96 bytes an edge for the rebuild, so Appendix A.2 at 10M asks for 88 MB instead of 10.9 GB and runs under the 4GB default rather than failing with 53400. At 2.2M nodes and 22M stored edges the call’s peak memory falls from 290 MB to 9 MB and its time from 1.33 s to 0.39 s. Results are unchanged.- A short kernel step no longer waits to be woken. The calling thread spins for up to 50 µs before it parks while the compute pool runs a job, so a job over a small graph returns in 2–4 µs instead of the 20–40 µs the operating system took to wake the caller (DESIGN.md §6.9). PageRank runs three jobs an iteration: on lesmis a call takes 1.3–1.4 ms instead of 3.5–3.7 ms (on a 3-node graph 1.0–1.2 ms instead of 2.8–3.0 ms), and larger graphs are unchanged or slightly faster.
- Measured: a traversal call’s fixed cost is 25–70 µs, not 0.2–2 ms.
Warm, with the round trip,
bfs,shortest_pathandwalktake 0.07–0.11 ms against 0.04 ms forSELECT 1, on PG 16 and 18. Half of the server’s share is the one catalog read that resolves the graph (DESIGN.md §13, “After 0.7.0”). Nothing in traversal changed. - Persisted vector writes reuse saved plans. The statements that lock
the graph and version and write a vector’s row and chunks are planned
once per backend and kept, instead of parsed and planned on every call
(DESIGN.md §6.2). On a 77-node graph a persisted
vector_consttakes 0.22 ms instead of 0.28 ms andpagerank0.43 ms instead of 0.53 ms (pgbench, one connection,synchronous_commit = off, PG 18). They are planned again afterDISCARD ALLor a change to the catalog’s tables, and a standby still refuses the write with 25006.
Documentation
Section titled “Documentation”- DESIGN.md §5.1 says what a managed service’s lifecycle needs of
pg_warren: nothing. A base backup restored and recovered to a point in
time holds the graphs, versions and vectors committed at the target.
pg_upgradeto a later major, copying the files or hard-linking them (--link, as managed services upgrade), keeps them byte for byte, and they read the same afterwards. Adding pg_warren toshared_preload_librarieson a cluster with graphs, or removing it, changes only where versions are cached. New tests check each of these (test/point_in_time.py,test/major_upgrade.py,test/preload.py), as does a terminate, cancel, kill or crash duringpagerankand during a load into the shared cache (test/crash_recovery.py). CI runs them, with the whole SQL suite with and without the preload, on PG 15–18.
0.7.0 — proven on real graphs and real deployments (2026-10-27)
Section titled “0.7.0 — proven on real graphs and real deployments (2026-10-27)”Milestone M11 of DESIGN.md §13: proven on real graphs and in real
deployments. pg_warren now runs a suite of 19 real graphs pinned through
keepset (bench/corpus), checked against NetworkX and igraph and timed
head to head with igraph and Neo4j, with and without GDS (README, “How it
compares”), and Appendix A’s use cases at 10M nodes. A pair of nodes
linked in several relations is now read one way everywhere: strengths
are summed and an unweighted pair counts once, costs take the pair’s
best edge, and per_relation := true reads an edge in each relation; on
graphs with such pairs some results differ from 0.6.0’s (under Behaviour
changes). A relation with few edges stores sparse offsets, so every graph
needs a refresh after the upgrade (under Upgrading). Concurrent
refreshes, crash recovery, streaming replicas, a connection pooler and a
SQL fuzzer found the bugs under Fixes, two of them server crashes.
Packages are published from this release on: a signed apt repository for
Debian and Ubuntu, Docker images, a Homebrew tap and tarballs, for amd64
and arm64 (under New).
Upgrading
Section titled “Upgrading”- If
pg_warrenis inshared_preload_libraries, restart the server after installing the new build: the postmaster keeps the library it loaded at start, and every backend inherits it. - Restart the server if the shared cache is in use. The shared cache’s
registry is now
pg_warren.cache.4, so backends of the old and the new build never share a version; the old registry’s segments are freed by a restart. - Run
ALTER EXTENSION pg_warren UPDATE;in every database that has the extension, after installing the new build and before using the new version. The update drops and creates again the 27 functions that gainper_relation(under New), keeping their privileges; it changes no table. Until the update, 0.6.0’s definitions still work with the new library, reading a pair as the new default does, except 0.6.0’ssssp_vector(PL/pgSQL), which still sums a pair’s weights until the update. - The update drops those functions without
CASCADE, so it fails if a view, or a function with aBEGIN ATOMICbody, calls one of them (“cannot drop function … because other objects depend on it”). Drop the dependent objects, runALTER EXTENSION pg_warren UPDATE, then create them again. - Refresh every graph after installing the new build. A relation’s
CSR can now store sparse offsets, so CSR segments have a new format
(2). Reading a version built by an earlier release fails with
object_not_in_prerequisite_state(55000) and a message namingwarren.refresh; arefreshbuilds on it as usual, and the version it builds reads. Old versions are not converted. Vectors are bound to a version, so recreate them on the new one.
Behaviour changes
Section titled “Behaviour changes”-
An unweighted pair of nodes linked in several relations now counts once in
modularity,contract,local_moving,leiden_refine,louvainandleiden, as it already did inspmv,degreeandpagerank. They counted it once per relation, so on graphs with such pairs their results differ from 0.6.0’s. A countingcontractcounts the pair in the first of its relations. Weighted pairs are unchanged: their weights are summed across relations. -
Functions that read edge weights as costs now take a weighted pair linked in several relations at its best edge, not the sum of its relations’ weights:
sssp_vector,betweenness_centrality,closeness_centrality,harmonic_centralityandminimum_spanning_forestits lightest relation’s weight,dag_longest_pathits heaviest. Strengths add, costs don’t: a road of 5 km and a railway of 3 km between two towns are a route of 3 km, not 8. Through 0.6.0 these functions summed the weights, so on weighted graphs with such pairs their results differ from 0.6.0’s.dijkstraalready took the cheapest relation through 0.6.0, and still does. Functions that read weights as strengths (spmv,spmm,pagerankand the other spectral centralities, the community functions,contract,eigen,distance) still sum them;spmvwithmin_plusreads the sum too unless givenper_relation := true. Unweighted calls are unchanged. See docs/semantics.md, “A pair in several relations”. -
per_relation := truegives the cost functions the same result as the default (dijkstra,sssp_vector, the three shortest-path centralities,dag_longest_path,minimum_spanning_forest): as parallel edges, a path takes the cheapest (longest paths the heaviest). They accept it, and refuse NULL (22004), so thatper_relationmeans one thing on every function. -
Undirected weighted centralities refuse a pair whose weight is NaN in one direction (22023), as they refuse a NaN anywhere else. They used the other direction’s weight.
-
eigen(anddistance('spectral')) returns every copy of the eigenvalue each connected component gives: 0 oflaplacianandnormalized_laplacianwithwhich := 'smallest', 1 ofrandom_walkwithwhich := 'largest', and each bipartite component’s −1 ofrandom_walkand 2 ofnormalized_laplacianwithwhich := 'largest'. Each copy is one component’s eigenvector, found exactly and without Lanczos steps, components in the order of their lowest node, and Lanczos computes the rest of thekorthogonal to them. Before, Lanczos found one copy and mistook the next eigenvalues for the rest: 30 disjoint 40-cycles’ three smallest Laplacian eigenvalues came back as 0, 0.025 and 0.098, and on roadNet-TX (424 components) the call ran past a 10-minute timeout; four paths’ random walk gave 1, 1, 1, 1, −1, 0.9709, −0.9709 for its 7 largest, against four −1s. A call all of whose pairs are the components’ no longer warns of several components; one with pairs from Lanczos still does. Other results change in the last digits; a connected graph’s 0 is now exactly 0. -
A graph’s number of relations no longer refuses its build on its own.
warren.max_build_memoryused to be checked against 8 bytes per node for every relation’s CSR (and the merged CSR), in each direction; it is now checked against the merged CSR’s offsets and 8 bytes per relation, and each relation’s offsets count in the build’s estimate by its edges. -
bfsexpands each depth’s nodes in ascending dense id order (the graph’s internal node order), so within a depth its rows can come in a different order than in 0.6.0, and a node reachable from several nodes of the previous depth reports asviathe one of smallest dense id. Nodes and depths are unchanged. -
walkandneighboursmake each row’s external ids as the row is returned, rather than all rows before the first, ascomponentsand the others have since 0.6.0. Their rows and order are unchanged;walkstill runs the whole walk in its first call. Such a call keeps the version it reads, private or mapped from the shared cache, until its last row is read or it ends (under Performance).
-
per_relation boolean DEFAULT falseonmodularity,contract,local_moving,leiden_refine,louvain,leiden,spmv,spmm,pagerank,katz_centrality,eigenvector_centrality,hits,sssp_vector,dijkstra,betweenness_centrality,closeness_centrality,harmonic_centrality,dag_longest_path,minimum_spanning_forest,degree,vector_degree,degree_vector,aggregate_neighbours,gather,label_propagation_majority,eigenanddistance:truereads a pair linked in several relations as an edge in each (one per relation, as parallel edges), for when that multiplicity is the signal. -
docs/limits.md, “Behind a connection pooler”: what transaction pooling loses and leaks between clients (settings, transient vectors,last_stats(), SQLPREPARE) and what to use instead, checked bytest/pooler_soak.py, a soak behind PgBouncer in transaction and session pooling that watches pooled backends, the shared cache and the catalog for growth (CI runs it for 2 minutes on PG 16). -
Packages, published with each release from this one on (README.md, “Install”): a signed apt repository of
postgresql-NN-pg-warrenpackages for PostgreSQL 15–18 on Debian 12 and 13 and Ubuntu 22.04 and 24.04, amd64 and arm64, built against PGDG’s PostgreSQL; Docker images on GHCR (ghcr.io/exadev/pg_warren:pgNNandX.Y.Z-pgNN, PGDG’spostgresimage with the package’s files and pg_warren preloaded); a Homebrew formula (brew install exadev/tap/pg_warren, forpostgresql@17and@18, bottled on Apple silicon); and tarballs for other glibc Linux. Each GitHub Release has a signedSHA256SUMS. The packages do not editpostgresql.conf: add the preload yourself. macOS is not yet a supported platform (its tests run in CI without gating), and RPM packages and PGXN are not built yet.
-
A read of a graph inside a parallel
CREATE TABLE AS(orCREATE MATERIALIZED VIEW,REFRESH MATERIALIZED VIEW) no longer fails with “cannot start commands during a parallel operation”. Such a statement has a transaction id before its plan runs, so pgrx ran every catalog read non-read-only, with a command counter increment, which Postgres refuses in parallel mode once the command has used its command id: in the leader as soon as it ran the function, and in a worker. In parallel mode the catalog reads now run read-only, as cursors under the active snapshot (DESIGN.md §6.9; found running Appendix A.3 at 10M nodes, whose sampled oracle builds a table fromwarren.id_map; testsevery_read_runs_in_a_parallel_create_table_as,a_parallel_create_table_as_runs_a_safe_read_in_its_leader_too). -
On a physical standby, every function that writes fails with
read_only_sql_transaction(25006), “cannot change pg_warren’s catalog in a read-only transaction”, with a hint that the server is a standby. It failed with an internal error (XX000), “cannot assign TransactionIds during recovery”: pgrx’s writable SPI calls assign a transaction id before the write, which a standby refuses. That coverscreate_graph,refresh,promote,compact, thedrop_*functions, access and quota changes,contract, persisted vector writes and the library functions, which persist their result. Reads, and compute withpersist := false, work on a standby and answer as on the primary (DESIGN.md §5.1, Standbys; found bytest/replicas.py). A read-only transaction on a primary now gets the same refusal before it assigns a transaction id. -
refreshwith a NULLnameorpromote, andprewarmwith a NULLname, are refused withnull_value_not_allowed(22004), “… must not be NULL”. They failed with an internal error, “argument 0 must not be null” (XX000). Found by the SQL fuzzer. -
eigenwithvectors_prefixand aklarger than the graph, anddistance(..., 'subspace')with aklarger than the vectors stored, no longer crash the server. Each made a name for every one of thekvectors before anything else, and withknear 2^31 that allocation failed and aborted the backend (signal 6), which restarted every backend. Withvectors_prefix,eigennow admits the eigenproblem before it makes or checks a name for each eigenvector: akpast the dimension is refused withinvalid_parameter_value(22023), “cannot compute k eigenpairs of a matrix of dimension n”, and a working set that cannot fit with 53400, which now comes before a refusal to replace another role’s eigenvectors (42501).subspacemakes each name as it reads it, failing at the first missing vector withundefined_object(42704). Found by the SQL fuzzer. -
An array of node ids whose last element is NULL no longer fails with an internal error (“assertion failed: array.is_within_bounds”, XX000) in debug builds: a
bfsorreachable_setstart, anode_filter, a step’sfilter(warren.s). The NULL is skipped, as a NULL elsewhere in the array always was. Found by the SQL fuzzer (test/sql_fuzz.py). The ids are read while the array is alive, including an array read from a table column, which a function gets detoasted into a copy (tested with short, compressed and out-of-linetext[]anduuid[]arrays). -
A persisted vector written to a version that retention or
drop_versiondeleted meanwhile fails withundefined_object(42704), “version … no longer exists: vector … was not written to it”. It failed with a raw foreign-key violation onwarren.vectors(23503). Every persisted write (spmv,pagerank,vector_constand the rest) now locks the graph’s and the version’s rows before it writes, waiting for a deletion in progress (harness testsvector_retention_raceandtest/concurrent_refresh.py). Under REPEATABLE READ or SERIALIZABLE such a write fails withserialization_failure(40001) instead: retry it. -
A read of a shared version that another backend was evicting at that moment could fail with an internal error, “cannot pin a segment that is already pinned” (XX000): the evicting backend unpinned the version’s segment after releasing the shared cache’s lock, so a reader could revive the version first. It now unpins under the lock a segment that another backend still maps, which frees nothing; one nobody maps, which unpinning frees, is still unpinned after the lock is released, so no lookup waits for it (found by
test/concurrent_refresh.py, preloaded). -
compactcould fail withdata_corrupted(XX001), “vector has 0 bytes”, when a vector bound to the version it compacts was dropped while it ran. It now locks each vector before moving it and skips one dropped since it listed them (found bytest/concurrent_refresh.py). A vector thatreassign_ownedgave to another role whilecompactran is moved under that role (harness testcompact_reassign_race). Under REPEATABLE READ or SERIALIZABLE either change fails the compaction withserialization_failure(40001) instead: retry it. -
DISCARD ALLfrees the backend’s transient vectors, of every role, and forgetslast_stats(). It left both, so behind a session pooler (PgBouncer’sserver_reset_query), the next client on a server backend could read the transient vectors and the stats the last client left, and the vectors stayed until the backend exited (found bytest/pooler_soak.py; regression testtest/sql/discard_all.sql). -
A persisted vector dropped by another backend no longer stays in the cache of the backend that wrote it. That backend pruned dead vectors only when it next loaded one privately; with the shared cache serving its reads it never did, so behind a transaction pooler, where a vector is often written on one server backend and dropped on another, each backend kept the vectors it wrote until
warren.max_cache_memory(4 GB by default) evicted them: 8 MB a minute per backend in the pooler soak. A write now prunes as a load does (found bytest/pooler_soak.py). -
An ERROR raised while pg_warren read a query through a cursor that had a parallel plan running no longer crashes the server (SIGSEGV, which restarts every backend). Closing the cursor as the ERROR unwound detached the parallel query’s shared memory before the (sub)transaction’s abort ended the parallel query, which then read the unmapped segment. The cursors pg_warren reads (the node and edge queries,
gather’s messages, the other queries a function is given, and segment chunks) are now left open on an ERROR, for the abort to close in Postgres’s own order (DESIGN.md §6.9; found by the SQL fuzzer: astatement_timeoutduringlabel_propagation_majorityunderdebug_parallel_query).
Performance
Section titled “Performance”- A relation whose edges start from at most about a sixth of the nodes (an eighth above 2³² nodes) stores its rows’ ids and an offset per row (12 bytes a row, 16 above 2³²) instead of 8 bytes per node, in each direction on its own; the merged CSR is unchanged (DESIGN.md §5.3). FB15k-237 (237 relations, 14.5K nodes, 310K edges) now takes 8.0 MB where it took 60.5 MB, refreshes in about 0.2 s instead of 0.45 s and loads cold in 60 ms instead of 113 ms. Reads restricted to relations pay a binary search per row looked up: on FB15k-237, a BFS over its 10 largest relations takes 1.1× as long and an SpMV over them 1.2–1.4×; reads over all relations are unchanged.
- Without the merged CSR (
store_merged := false), BFS andcomponentsread the relations’ rows in ascending order; on FB15k-237 they are 1.5–2.2× faster than in 0.6.0, and the version ten times smaller, but an all-relationsspmvtakes 1.6–2× as long (docs/limits.md). triangles(andclustering, which counts them) orients each edge from its endpoint of lower degree and finds each triangle once, about M·√M at worst, where it intersected every neighbour row of every node: on SNAP’s facebook graph 156 → 7.0 ms and on Hetionet 4.09 → 0.116 s, and on a skewed (R-MAT) graph of 2M edges 14.6 → 0.53 s on one thread. It holds 8 bytes per stored edge, half the symmetric adjacency it built before. Counts are unchanged.contractfinds which of a pair’s entries count in one sweep of the rows, solouvainandleidenon graphs with many relations are not slowed by counting a pair once: on FB15k-237 (237 relations) they take 0.42 s and 1.49 s, against 0.70 s and 1.84 s in 0.6.0 (YAGO3-10 0.80 / 2.49 s against 1.26 / 3.30 s; Hetionet 1.61 / 3.05 s against 1.77 / 2.78 s; medians of 5). The partitions differ from 0.6.0’s where pairs are linked in several relations (under Behaviour changes).walkandneighbourshold only the current row’s external ids: over 50,000 IRIs, a cursor on one held 3.7 MB (neighbours,walkends) or 17.9 MB (walkpaths) after its first row, and now holds 0.09 MB, against 0.05 MB over 1,000.
0.6.0 — identity, lifecycle and visibility (2026-10-24)
Section titled “0.6.0 — identity, lifecycle and visibility (2026-10-24)”Milestone M10 of DESIGN.md §13. Nodes can be keyed on strings: text is
a fourth id type, for IRIs and slugs, and every function takes and
returns text ids with no new signature. A graph’s ownership can be
transferred (warren.set_owner), and the graphs, vectors and reader
entries of a dropped role no longer linger unseen: reassign_owned and
drop_owned run before DROP OWNED, a superuser may opt into a DROP OWNED trigger, and orphans() and drop_orphans() find what is left. A
graph’s owner can bound the vectors other roles persist on it. A
monitoring query can see what each backend’s cache holds and counts
(warren.cache(), warren.backends()), and how far each long call has
got (warren.progress()). In the 100M-edge soak with IRIs as node ids, a
text graph’s version is 28% larger than with bigint ids and builds in
1.39× the time; pagerank and eigen are within 12%, and the
community detections within 3%. The build itself, for every id type,
now maps edge endpoints on the compute pool, and the bigint graph
builds in 79 s where 0.5.0’s soak took 173 s (under Performance).
Upgrading
Section titled “Upgrading”- If
pg_warrenis inshared_preload_libraries, restart the server after installing the new build. The postmaster keeps the library it loaded at start, and every backend inherits it, so until a restart the server runs the old code against the updated catalog. Without the preload, new sessions load the new library. - Run
ALTER EXTENSION pg_warren UPDATE;in every database that has the extension, after installing the new build and before using the new version. The new library is loaded at once, but the catalog changes only with the update, so calls in between can fail. The update:- creates twelve functions:
set_owner;reassign_owned,drop_owned,on_drop_owned,orphansanddrop_orphans;set_vector_quotaandvector_usage;cacheandbackends;progressandreport_progress(all under New); - adds the column
warren.graphs.reader_vector_quota(existing graphs have no quota), the tablewarren.vector_quotas(dumped with the extension) and an index onwarren.vectors (graph_id, created_by); - replaces the library’s PL/pgSQL functions,
aggregate_neighbours,components_vector,eigenvector_centrality,hits,katz_centrality,label_propagation,label_propagation_majority,pagerank,sssp_vector,louvainandleiden, with the same signatures, so that they report their progress. Their results are unchanged.
- creates twelve functions:
- The update never creates the
DROP OWNEDevent trigger: a superuser opts into it (under New). textids change nothing in the catalog. Existinginteger,bigintanduuidgraphs, their segments and their shared versions are unchanged, and read, refresh and compact as before. A build older than 0.6.0 cannot read atextgraph, and downgrading is not supported.- The shared cache’s registry is unchanged, so on PostgreSQL 17 and 18 versions stay shared across the upgrade without a restart.
Behaviour changes
Section titled “Behaviour changes”refreshmaps each batch of edge rows’ endpoints to dense ids on the compute pool, on up towarren.threadsthreads, for every id type; the main thread still reads the rows. The edge list, and so the version, is the one the row-by-row intake made. Each batch is charged towarren.max_build_memory.grant,revokeanddrop_graphtake the graph’s row lock, asrefreshand the other writers do, so they wait behind a runningrefreshof the same graph (under Fixes).components,minimum_spanning_forest,vector_topkandmost_similarmake each row’s external ids as the row is returned, rather than all rows before the first. Their rows and order are unchanged. Such a call keeps the version it reads, private or mapped from the shared cache, until its last row is read or it ends: a cursor on one, read across statements, keeps the version after the statement that opened it, even once it is evicted (under Performance).
-
textnode ids (DESIGN.md §5.7, §14 items 41 and 42; docs/semantics.md, “Text ids”). A graph’s node query may returntextorvarcharids, such as IRIs or slugs; its id type is thentext. Every function that takes or returns ids does so intext, with no new signature: a string literal needs its cast (warren.neighbours('kg', 'https://example.org/a'::text)),ARRAY['…']is alreadytext[], and the type witness isNULL::text.- Ids compare bytewise in the database encoding (
COLLATE "C"order), which is SQL’s own equality under every deterministic collation. An id column whose collation is nondeterministic is refused bycreate_graphandrefresh(42804, hintingCOLLATE "C"orlower()), as arebpchar,nameandcitextids, each with the cast to write. There is no length limit beyond memory. - A text version stores each id’s bytes once, in dense-id order, with
their offsets and their sorted order: at 10M nodes 120 MB plus the
ids’ bytes, against 240 MB for
bigint. It is shared across backends and read by parallel workers like any other version. One restored into a database of another encoding is refused (55000). - Calls that map more than 65,536 ids (
vector_from_query,gather’s messages,similarity’s pairs) build a hash index of the id map once per call, charged towarren.max_compute_memory(80 MB at 10M nodes), and keep the binary search if it does not fit.
- Ids compare bytewise in the database encoding (
-
warren.set_owner(name, owner regrole)gives a graph a new owner, asALTER MATERIALIZED VIEW … OWNER TOdoes (DESIGN.md §5.7, §14 item 43). The caller must be a member of the current owner and able toSET ROLEto the new one (on PostgreSQL 15, a member of it). The source queries are opened as the new owner first, so one that cannot read them is refused (42501), as are queries whose columns no longer fit (42804). Versions and vectors stay as built until the nextrefresh; readers are kept, except the new owner; the old owner keeps no access unless granted (docs/semantics.md, “Access”).ALTER EXTENSION pg_warren UPDATEcreates it. -
Cleanup after a dropped role (DESIGN.md §5.7, §14 items 44 and 45; docs/semantics.md, “Dropping a role”).
DROP ROLEcannot see the roles a graph records, andREASSIGN OWNEDmoves no graph, so:warren.reassign_owned(old, new)andwarren.drop_owned(role), SQL counterparts ofREASSIGN OWNEDandDROP OWNEDfor graphs, their vectors and reader entries, each needing the privileges of the role as the commands do. Run one beforeDROP OWNEDin each database.warren.on_drop_owned(), an event-trigger function a superuser may opt into withCREATE EVENT TRIGGER … ON ddl_command_start WHEN TAG IN ('DROP OWNED'). With it,DROP OWNEDis refused (2BP01) while a role it names owns graphs, naming them, and otherwise also removes the role’s reader entries and vectors. The extension never creates the trigger, so trusted installs work without it; while it exists,DROP EXTENSIONneedsCASCADE, and a non-superuser’s restore of a dump fails at that statement.warren.orphans()andwarren.drop_orphans()list and remove graphs, reader entries and vectors that name roles dropped without any of the above.- A graph whose owner was dropped may now be dropped by the extension’s
owner (or a member with its privileges or the right to
SET ROLEto it) as well as by a superuser (drop_graph,drop_orphans), and adopted by a superuser withwarren.set_owner, which refused it before (“the owner no longer exists”). - A
drop_vector, a replacement orcompact’s move that checked a vector’s creator, and then waited behind areassign_ownedthat changed it, fails with 40001 instead of deleting the new creator’s vector.
ALTER EXTENSION pg_warren UPDATEcreates the five functions. -
Cache observability (DESIGN.md §5.7, §14 items 48 and 49).
warren.cache()lists this backend’s private cache: each cached version, persisted vector and transient vector of the caller’s role, with its bytes and state (in use,pinned,idle,held), then abackendrow with the total againstwarren.max_cache_memory. Only graphs the caller may read are listed.warren.backends()has a row per backend that has used pg_warren, with what it holds privately and maps from the shared cache, and its cache counters (lookups, hits, waits, loads and their bytes and seconds, evictions, and refusals by reason), counted once per statement and version; then aclusterrow that includes backends that have exited. Another role’s backend shows only who it is, unless the caller has that role’s privileges orpg_read_all_stats, as inpg_stat_activity; a backend’s role is its authenticated user, aspg_stat_activity’susesysid, whateverSET SESSION AUTHORIZATIONsets. Parallel workers have no row. It needs no configuration on PostgreSQL 17 and 18 andshared_preload_librarieson 15 and 16; without it, the caller’s own row only, with aNOTICE.- A backend waiting for another’s load of a shared version shows
wait_eventWarrenSharedLoadinpg_stat_activityon PostgreSQL 17 and 18 (Extensionon 15 and 16).
ALTER EXTENSION pg_warren UPDATEcreates both functions; the shared cache’s registry is unchanged, so on PostgreSQL 17 and 18 versions stay shared across the upgrade without a restart. -
Progress (DESIGN.md §5.7, §14 item 49).
warren.progress()has a row per backend running a long call: its command (the outermost call, such asrefresh,compact,eigenor a library function) and the native call running inside it now, each with its graph, version, phase, units done of a total, start time and, for the native call, named counters. Arefreshshowsreading nodes,reading edges(rows of the planner’s estimate),building(CSRs),writingandpromoting; a loadloading(bytes);eigenanddistancetheir Lanczos steps, with the pairs converged;louvainandleidentheir levels, with local moving’s sweeps and refinement inside. Another role’s row shows what runs and since when, not how far, unless the caller has that role’s privileges orpg_read_all_stats; a graph’s name shows only to callers who may read the graph. Other backends’ rows need no configuration on PostgreSQL 17 and 18 andshared_preload_librarieson 15 and 16.warren.report_progress(command, graph, phase, done, total)reports a PL/pgSQL program’s own progress until the statement ends. The library’s iterative functions call it once per iteration, level or hop.
ALTER EXTENSION pg_warren UPDATEcreates both functions and replaces the library’s. -
Quotas on readers’ vectors (DESIGN.md §5.7, §14 items 46 and 47; docs/semantics.md, “Quotas on readers’ vectors”). A graph’s owner may bound the persisted vectors each other role stores on the graph, counted as their elements’ bytes across all versions:
warren.set_vector_quota(name, bytes, role DEFAULT NULL)sets the graph’s default (info‘s newreader_vector_quota) or a role’s own quota, andwarren.vector_usage(name)shows each role’s vectors, bytes and quota (a reader sees its own row). A persisted write that would take the writer past its quota, and above what it stored, is refused with 53400; replacing a vector with one no larger always works. Members of the owner role are exempt, and transient vectors are not counted. One role’s writes to one graph take a transaction-scoped lock on (graph, role), shown inpg_locksasadvisorywithobjsubid22353.reassign_ownedcounts the vectors it moves against the new role’s quotas.drop_ownedand theDROP OWNEDtrigger remove a role’s quotas only on graphs whose owner the caller is a member of;orphans()anddrop_orphans()handle those of dropped roles. A quota bounds a role’s vectors on other owners’ graphs, not the catalog’s total storage.ALTER EXTENSION pg_warren UPDATEadds the column, the tablewarren.vector_quotas(dumped with the extension), an index onwarren.vectors (graph_id, created_by)and the two functions; existing graphs have no quota.
refresh,compact,promote,drop_version,grant,revoke,drop_graphandcontractinto an existing quotient check ownership again once they hold the graph’s row lock (grant,revokeanddrop_graphnow take it). They checked only before it, so a call queued behind an ownership transfer ran with an ownership its caller no longer had (harness testset_owner_race).
Performance
Section titled “Performance”-
The 100M-edge soak with
textids (DESIGN.md §12, “Text node ids”;bench/results/soak/a-text-m10.jsonanda-m10.json; PG 16, 4 vCPUs,warren.threads = 4, private cache). Case a with IRIs of 33–36 bytes as node ids, against the same graph withbigintids, every stage run:- the version is 1.44 GB against 1.12 GB, and
refreshtakes 110.2 s against 79.1 s, peaking 0.25 GB higher; - calls that return a row per reached node build each id’s
textdatum:bfs(1.47M rows) 1.42 s against 0.66 s,components10.6 s against 5.2 s,reachable_set1.42× as long; similarityof 100,000 pairs builds its per-call index: 2.15 s against 0.47 s;pagerankandeigenare within 12%;eigenvector_centrality,katz_centralityandhitstake 1.2–1.4× as long, the rest of the library andlouvainandleidenwithin 8%, with the same modularity to 0.001.
§12’s targets missed there, for both id types:
reachable_set(13 and 19M edges/s, against 50M) andbfsagainst the recursive CTE (5.2× and 10.5× faster, against 20×); withtextids,componentsalso misses its 10 s. - the version is 1.44 GB against 1.12 GB, and
-
The build maps edge endpoints in parallel (under Behaviour changes). In the same soak the
bigintgraph builds in 79.1 s from its table (0.5.0’s soak: 173.1 s), 99.1 s from the generator query (184.5 s) and 88.3 s again with 0.1% fewer edges (165.3 s), on the same machine. -
0.5.0’s fixes to level graphs and Leiden’s refinement at 10M nodes, run at that scale for the first time in the same soak: on the one-relation graph with little community structure,
leidencompletes in 804 s (16 levels, modularity 0.186, an 8.10 GB peak), where 0.5.0’s soak was refused (53400), andlouvaintakes 528 s (627.5 s). The two wrote 13.1 and 4.6 GB of level segments and 15.1 and 6.1 GB of WAL, held until the run’s transaction ends (docs/limits.md). -
Rows made as they are returned (DESIGN.md §12):
components,minimum_spanning_forest,vector_topkandmost_similarno longer hold every row’s ids until the call ends. At 5M nodes and 50M edges,componentspeaks at 0.84 GB withtextids (1.28 GB before) and 0.73 GB withbigint(0.80 GB), andminimum_spanning_forestat 1.10 GB withtext(1.77 GB), itsbigintpeak unchanged; times are unchanged.walkandneighboursstill build their rows first, bounded by their arguments or by one node’s degree (M11). -
Progress reporting costs a few atomic stores per statement and per poll: within noise for one
degreeper transaction and forpagerank, and about 1.5 µs areport_progresscall.
0.5.0 — the shared cache (2026-10-23)
Section titled “0.5.0 — the shared cache (2026-10-23)”Milestone M9 of DESIGN.md §13, the first on the road to 1.0. A loaded
graph version, and a persisted vector, is kept once per cluster in dynamic
shared memory and mapped by every backend that reads it, instead of once
per backend: behind PgBouncer, 50 pooled connections reading a 112 MB
version hold 338 MB instead of 6.63 GB, and a pool’s cold start is one
load (DESIGN.md §12, bench/results/pooler/). It is on by default, with
nothing to configure on PostgreSQL 17 and 18 and
shared_preload_libraries on 15 and 16. With it the graph reads that use
no vector are PARALLEL SAFE, so a scan that calls one per row runs in
parallel workers. The 100M-edge soak, re-run with the shared cache, reads
the shared version in every query stage, with each backend’s private
memory lower by the version’s size; where 0.4.0 reloaded a version larger
than warren.max_cache_memory on every statement, it now reads it in
place (under Performance).
Upgrading
Section titled “Upgrading”- Run
ALTER EXTENSION pg_warren UPDATE;in every database that has the extension, after installing the new build and before using the new version: the update createswarren.shared_cache(), replaceswarren.gatherandwarren.label_propagation_majority, and marks the graph readsPARALLEL SAFE(below). The new library is loaded at once, but the catalog changes only with the update, so calls in between can fail. - The update drops and recreates
warren.gather(it gainsinclude_self; grants on it are kept). A view or aBEGIN ATOMICSQL function that callswarren.gatherdepends on it and makesALTER EXTENSION pg_warren UPDATEfail: drop it before the update and create it again after. Functions with a string body (PL/pgSQL, or SQL written asAS $$ … $$) do not depend on it and need nothing. - Until the update,
warren.gatherkeeps its 0.4.0 definition, which calls the new library withoutinclude_self; such a call behaves asinclude_self := false, as in 0.4.0. - The shared cache is on by default. A loaded graph version is kept
once per cluster in dynamic shared memory, up to
warren.shared_cache_memory(default4GB, a server setting: set it inpostgresql.confor withALTER SYSTEMand reload;0turns it off), and mapped by every backend that reads it. On PostgreSQL 17 and 18 it needs nothing configured. On 15 and 16 it needsshared_preload_libraries = 'pg_warren'and a restart; without it, backends load their own copies as in 0.4.0, silently at the default cap. Shared versions are files in/dev/shm: a version that does not fit there with a quarter of it left free is loaded privately (with aNOTICEonly if you set the cap yourself;warren.shared_cache()shows what is shared), so in a Docker container (64 MB by default) raise--shm-size(docs/limits.md, “The shared cache”). An idle cluster keeps up to the cap of shared versions until they are evicted or it restarts.
Behaviour changes
Section titled “Behaviour changes”- Graph reads can run in parallel workers (DESIGN.md §14 item 33):
neighbours,degree,has_edge,id_map,infoand the traversalsreachable,shortest_path,dijkstra,bfs,reachable_set,componentsandwalkarePARALLEL SAFE(they werePARALLEL RESTRICTED), so a scan that calls one per row can run in parallel: at 1M rows,has_edgeper row takes 3.7 s with 2 workers and 2.9 s with 4 with the shared cache, against 9.8–10.3 s in the leader alone. A worker maps the shared version; wherever a version is read privately, each worker loads its own copy: on PostgreSQL 15 and 16 withoutshared_preload_libraries = 'pg_warren'or with the shared cache off, and also for a version larger thanwarren.shared_cache_memory, one the shared cache has no room for (Docker’s 64 MB/dev/shm, for one), and one the current transaction created. Each copy is within the worker’swarren.max_cache_memoryandwarren.max_compute_memoryand held until the query ends, so a plan can hold one per worker plus the leader’s, and a short query forced parallel can be slower (1.8–3× over 10k rows without the preload).last_stats()reports only calls the leader ran: a traversal in a worker records nothing; and the cache’sNOTICEs are raised by the leader only. To avoid parallel plans, any role canSET max_parallel_workers_per_gather = 0(per session, transaction or role); a superuser can keep 0.4.0’s behaviour for a function withALTER FUNCTION … PARALLEL RESTRICTED(docs/semantics.md, “Parallel query labels”). Vector reads,explain_map,diff,distance,prewarm,evictandlast_statsstayPARALLEL RESTRICTED.ALTER EXTENSION pg_warren UPDATEchanges the labels. - A level graph (
contract(graph, labels, NULL), the graphslouvainandleidencontract each level into) stores each relation’s forward CSR only, without a reverse or merged CSR whatever the input’s layout. The partitions are the same; on a directed graph each level writes about half the segments and WAL (at 1M nodes and 10M random edges,louvain’s levels 293 MB instead of 536 MB,leiden’s 945 MB instead of 1.76 GB).
- The shared cache (DESIGN.md §5.6):
warren.shared_cache_memory. A version, or a persisted vector, is loaded once per cluster by the first backend that misses it (the others wait for that load) and is mapped by every backend that reads it; it is counted by neitherwarren.max_cache_memorynorwarren.max_compute_memory. Entries that no statement uses are evicted least recently used first to make room; a version that does not fit is loaded privately, as before. A promotion,drop_version, retention andwarren.evictby the graph’s owner evict shared versions. warren.shared_cache()(PARALLEL SAFE): what the shared cache (warren.shared_cache_memory) holds. A row per shared version and persisted vector of a graph the caller may read (its state, bytes, pins, mappers and last use), then oneclusterrow of totals across every database, with the cap. A role sees only graphs it may read (superusers read all); dropped entries are listed to no one.ALTER EXTENSION pg_warren UPDATEcreates it.- Persisted vectors are shared across backends like versions: loaded once,
mapped by every backend that reads them, and charged to no call’s
warren.max_compute_memory.boolvectors and vectors written by the reading transaction are still read privately. Dropping or replacing a vector evicts it, andwarren.evictby the graph’s owner evicts its shared vectors. warren.gathertakescombine := 'mode': the most frequent message, the smallest on a tie (integer, float and boolean messages), andinclude_self := false: withtrue, each receiver also receives its own messages, ahead of its senders’.ALTER EXTENSION pg_warren UPDATEreplacesgather.
leiden_refine(and soleiden) charged 88 bytes a node towarren.max_compute_memoryup front, every community’s state at once, and was refused (53400) in the first refinement of a 10M-node, 100M-edge random graph at the default limit. It now charges what it holds: 24 bytes a node beside the result’s label vector (8 bytes a node), then 48 a community and the state of the communities being refined, at most one a thread.- After
DROP EXTENSION pg_warrenandCREATE EXTENSION pg_warrenin a database, a backend that had read graphs before could serve the old installation’s cached versions and vectors for the new installation’s (version, graph and vector ids restart). A backend now drops what it cached when pg_warren is installed again.
Performance
Section titled “Performance”- The 100M-edge soak with the shared cache (DESIGN.md §12,
bench/results/soak/*-m9-shared.json; PG 16 preloaded, 4 vCPUs). Every query stage reads the shared version, and a backend’s private memory falls by the version (case a’s stages peak at 0.01–3.11 GB private, from 1.22–4.31 GB in 0.4.0). On the 6.0 GB five-relation graph with its merged CSR, at an 8GBwarren.shared_cache_memoryand the defaultwarren.max_cache_memory, which no longer counts the version,bfstakes 0.89 s (25.1 s in 0.4.0, which reloaded it each statement),components7.6 s (28.6 s) andpagerank16.5 s (38.4 s), andeigen, refused there before (53400), runs. On the one-relation graphlouvaincompletes (627.5 s, five levels) where 0.4.0’s soak stopped it for disk at 383 s. Other stages are within the machine’s run-to-run spread; a build is private, sorefreshis unchanged. The soak ran before the two fixes to level graphs and Leiden’s refinement (under Behaviour changes and Fixes), so itsleidenrefusal (53400) at 10M nodes is 0.4.0’s; neither fix is re-run at that scale yet (docs/limits.md). - Every graph read checked the caller’s access with a catalog query planned
on each call; it now reads the role caches. A point lookup such as
warren.degreetakes about a seventh of the time (200,000 calls in one statement: 2.1 s, from 14.5 s), andpgbenchof onedegreeper transaction with 4 clients runs at about 140,000 tps, from 35,000. vector_map’s no-NULL path (warren-core’scompute::map; DESIGN.md §6.3). An expression built only from vectors, non-NULL literals, casts, arithmetic, comparisons,NOTandCASE … ELSEis never NULL. It is now evaluated without NULL flags, with each operator chosen once per chunk rather than per element. Results are bit-identical, and any error is still reported by the general path. Measured inwarren-coreat 4M nodes and 40M edges (benches/spmv_out_of_cache.rs), on a shared 4-vCPU machine, so the spread is wide, and not re-measured through SQL at 10M nodes:pagerank’s four maps: 2.1–2.7× faster on one thread (340–390 ms before, 145–165 ms after);- a whole iteration: 1.2–1.4× faster on one thread (790–840 → 580–665 ms), 1.3–2.2× on four (480–550 → 250–375 ms).
- Multi-source BFS for unweighted closeness and harmonic centrality
(
warren-core’scompute::centrality; DESIGN.md §6.7). Sources are searched 64 at a time, one bit per source, each step in parallel over the frontier, with 40 B of scratch per node.- Measured at 1M nodes / 10M edges on 4 threads
(
benches/centrality.rs): 64 sources of closeness or harmonic in 0.65–1.23 s instead of 10.1–20.7 s (9–32× faster). - Closeness is bit-identical to before. Harmonic sums in a new, deterministic order, within 1e-12 relative of the old result.
- Weighted graphs (Dijkstra) and betweenness are unchanged.
- Measured at 1M nodes / 10M edges on 4 threads
(
label_propagation_majoritycounts its votes in-engine, with onegather(mode,include_self) per iteration instead of counting every received label in the executor: at 1M nodes and 20M stored edges an iteration takes ~3 s, against ~37 s before on the same machine. Its results are unchanged.
0.4.0 — locality, the library, and communities (2026-10-22)
Section titled “0.4.0 — locality, the library, and communities (2026-10-22)”Milestone M8 of DESIGN.md §13. Dense ids can be ordered for locality; the algorithm library gains the planned groups 1 and 2 (centralities, HITS, clustering, majority-vote label propagation, k-cores, strong components, DAG order and longest paths, spanning forests, sampled shortest-path centralities, neighbourhood similarity); and contraction gives community-level graphs, with Louvain and Leiden written over it in PL/pgSQL. The 100M-edge soak was re-run: no §12 row moved beyond the machine’s run-to-run spread, and Louvain and Leiden have known limits at 10M nodes (under Performance).
Upgrading
Section titled “Upgrading”- Run
ALTER EXTENSION pg_warren UPDATE;in every database that has the extension, after installing the new build and before calling anywarren.*function. 0.4.0 changes the schema: the update adds the columnwarren.graphs.node_order(existing graphs are backfilled'id', the order they were built in) and thenode_orderarguments ofcreate_graphandcompact, and replacespagerank(a fix below). For contraction it adds aCHECKonwarren.graphs.source, makeswarren.versions.parent_versionON DELETE SET NULL, indexesversions.parent_versionandversions.label_vector, and adds the trigger that clearsversions.label_vectorwhen that vector is deleted (its trigger functionwarren.label_vector_is_forgotten()is revoked fromPUBLIC). It creates every new function below. The new library is loaded at once, but the catalog changes only with the update, so calls in between can fail. create_graphandcompactare dropped and re-created by the update (each gains an argument). Unlike 0.3.0’s update, this one grants again every privilege that was set on them, so aGRANTorREVOKEyou made onwarren.create_graphorwarren.compactsurvives it, in the catalog and inpg_dumpoutput. The grants are recorded as made by the functions’ owner: the original grantor, and so grant-option chains, are not kept.
Behaviour changes
Section titled “Behaviour changes”- Graph names beginning with
_warren_are reserved for the level graphs oflouvainandleiden:create_graphandcontractrefuse them with 42939 (reserved_name), as vector names with that prefix already were. Graphs so named before 0.4.0 keep working. compactis no longerSTRICT:compact(NULL)raises 22004 (null_value_not_allowed,name must not be NULL) instead of returning NULL.- Errors from
warren-core’s graph structures incompactanddiffnow carry their own SQLSTATE rather than 22023: running out of memory is 53200 (out_of_memory), the build memory limit 53400, a size limit 54000 (program_limit_exceeded), and a corrupt or inconsistent version (an internal error) XX001 (data_corrupted).diff’s refusals across a compaction, and other version mismatches, stay 22023.
-
Locality ordering of dense ids (DESIGN.md §14 item 22):
create_graph(…, node_order := 'locality')(default'id'; anything else is refused with 22023,invalid_parameter_value) numbers the first build’s nodes so that neighbours get nearby dense ids (Reverse Cuthill–McKee over the undirected view of the edges), so the per-node data a kernel reads at each edge shares cache lines. Ids are ordered only where they may change: the first build andcompact. Later refreshes keep surviving ids and append new nodes, so locality decays as the graph changes;compactrestores it. Until a graph is first promoted each staged first build gets its own order, sodiff,distanceandvector_norm_diffrefuse two whose orders differ (22023). Results are the same in either order, read by external id.warren.inforeportsnode_order. The ordering’s scratch is charged towarren.max_build_memory; it is not cancellable (about 10 s at 10M nodes and 100M edges). Gains are under Performance. -
compact(name, node_order DEFAULT NULL): compacts in the given order (NULL: the graph’s own); an order given becomes the graph’s, so later compactions follow it. This is how an existing graph gets locality order. The order’s scratch is charged towarren.max_build_memory(53400 past it). Persisted vectors move by node, as before. -
Library, group 1 (DESIGN.md §6.7), PL/pgSQL over the primitives like
pagerank, each checked against NetworkX on random graphs:eigenvector_centrality(graph, out, tol, max_iter, rels, weighted, version),katz_centrality(graph, out, alpha, beta, seed, normalized, tol, max_iter, …)(a per-node β from a seed vector) andhits(graph, hubs, authorities, tol, max_iter, normalized, …)(two vectors), following NetworkX’s iteration, normalisation, defaults and stopping rule, so they agree to rounding;clustering(graph, out, rels, version), the local clustering coefficient fromtrianglesand degrees; andlabel_propagation_majority(graph, out, seed, max_iter, rels, version), a deterministic, synchronous majority-vote label propagation (smallest label on a tie; NetworkX’s is random or semi-synchronous, so it is checked against a Python reimplementation of its rule). Where NetworkX raises on non-convergence, these write the last iterate with aWARNING; a run whose values stop being finite (Katz withalphatoo large for the graph) is an error, 22003, and writes nothing. NULL arguments are refused with 22004. -
Structural kernels (DESIGN.md §6.7, planned library group 2), each checked against NetworkX on a random graph with self-loops, parallel edges in one relation and across two, a deleted node and, for the DAG, negative weights (
test/sql/oracle_structure.sql):core_number(graph, out, rels, persist, version): each node’s k-core number in the undirected simple graph (Batagelj–Zaversnik peeling); returns the degeneracy.strong_components_vector(graph, out, rels, persist, version): each node’s strongly connected component, labelled by its smallest dense id ascomponents_vectorlabels weak ones (iterative Tarjan, so long paths need no recursion); returns the component count.topological_order(graph, out, rels, persist, version): each node’s position in the topological order that takes the smallest ready dense id first (Kahn), NetworkX’slexicographical_topological_sort; returns the vector id.dag_longest_path(graph, out, rels, weighted, persist, version): the longest path ending at each node, asnx.dag_longest_path; returns the longest. Both DAG functions raise 22023 on a cycle.minimum_spanning_forest(graph, id_type, rels, weighted, version): rows(source, target, weight)of a minimum spanning tree of each component, directions ignored (Borůvka).
They run on one compute thread, charge their scratch to
warren.max_compute_memoryand can be cancelled. -
Shortest-path centralities (DESIGN.md §6.7, planned library group 2):
betweenness_centrality(graph, out, k, seed, normalized, endpoints, directed, sources, rels, weighted, persist, version),closeness_centrality(…, wf_improved, …)andharmonic_centrality(…)write anf64vector and return their number of sources. Exact withk := NULL(the default: every live node a source); otherwise fromksources sampled withseed(default 0; the same sample on any thread count, and after a refresh unless a new node’s key displaces a sampled one or a sampled node is deleted;compactre-keys every node), or from the nodes a width-1 vectorsourcesselects (non-zero values, as a mask). Brandes’ algorithm, breadth-first or (weighted: every edge followed must weigh more than 0 and every distance stay finite, else 22023; self-loops are ignored) Dijkstra, along edges or (directed := false) in the undirected simple graph, one source per compute thread at a time, summed in source order so results are bit-identical on any thread count. Betweenness is rescaled exactly as NetworkX 3.7 (including its sampling adjustment); closeness (withwf_improved) and harmonic centrality use incoming distances as NetworkX does, and from a sample are estimated from each node’s sampled sources (docs/compute.md has the estimator and when its error bound holds). Checked against NetworkX given the same sources (test/sql/oracle_paths*.sql). Exact forms are for small graphs: a source costs 20–30 s on one thread at 10M nodes. -
Neighbourhood similarity for link prediction (DESIGN.md §6.7):
similarity(graph, pairs, id_type, measure, rels, version)scores the pairs a query returns,most_similar(graph, node, k, measure, rels, version)ranks one node’s candidates andsimilar_pairs(graph, id_type, k, measure, nodes, rels, version)each node’s (kdefault 10), byjaccard(the default),adamic_adarorcommon_neighboursover the undirected simple graph, as NetworkX’s link-prediction functions (test/sql/oracle_similarity.sql). Candidates are non-neighbours sharing a neighbour, best first, ties by dense id; no all-pairs form. With reverse CSRs,similarityandmost_similarread only the rows they need. A node paired with itself scores Adamic–Adar +∞ where it shares a degree-1 neighbour (NetworkX raises). -
Contraction, projection and lifting (DESIGN.md §6.6, docs/communities.md):
contract(graph, label_vector, out_graph, weighted, drop_self_loops, promote, version)builds a version of a quotient graph: one node per distinct label of an integral label vector (itsbigintnode ids are the labels), the edges between communities summed by weight or counted, the edges inside a community its self-loop (or dropped):PᵀAPof the stored matrix, so an undirected edge inside a community counts in both directions and every community’s weighted degree is the sum of its members’; relations are kept. The first contraction creates the graph (source'contraction', owned by the caller); later ones add versions, staged or promoted, with stable dense ids per label, and must come from the same parent graph (22023). Without_graph := NULLit creates a new graph named_warren_level_<version id>. The version records the parent version and label vector it came from, and stores the membership.refreshandcompactrefuse such a graph (42809). Non-integral or NaN labels are refused (22023). Its build scratch is charged towarren.max_build_memory.project(graph, x, out_graph, y, agg, persist, version): each community’ssum(the default),mean,max,minorcountofxover its members, row by row for matrices (meanof embeddings gives centroids);lift(out_graph, y, graph, x, persist, version): each node gets its community’s row. Both use exactly the version the quotient was contracted from.- A parent version a quotient version derives from is kept beyond
retain_versions(with aNOTICE); dropping it unlinks the quotient (project/liftthen fail with 55000).warren.inforeports the graph’ssource(queryorcontraction) and each version’sparent_version,parent_graphandlabel_vector(NULL to a caller who cannot read the parent graph). - Checked against NetworkX’s
quotient_graphand SQLGROUP BY/joins (test/sql/oracle_contraction*.sql).
-
Louvain and Leiden (DESIGN.md §6.7 group 3, docs/communities.md):
louvain(graph, out, resolution, seed, max_levels, tolerance, weighted, levels_prefix, version)andleiden(graph, out, resolution, seed, max_levels, tolerance, randomness, iterations, weighted, levels_prefix, version)write a label vector of communities that maximise modularity and return its modularity;levels_prefixkeeps each level’s partition. Leiden’s communities are always connected. Leiden runsiterationspasses (2 by default, as igraph’scommunity_leiden), each after the first starting from the partition the last found, and stops early after a pass that changes nothing; a negative value runs until then (at most 100, then aWARNING), and 0 is refused (22023). On the bench’s 20 planted blocks (modularity 0.855) one pass scores 0.794 and two reach 0.855, as igraph’s two do (the planted blocks with one node moved). Both are PL/pgSQL overcontract,project,liftand two new kernels,local_moving(a level’s moving, parallel and deterministic: the same result for any thread count and a given seed) andleiden_refine(Traag et al.’s refinement); each level’s graph is a quotient ofgraphmade bycontract(graph, labels, NULL), and only one exists at a time.modularity(graph, labels, resolution, weighted, version)scores any partition; it counts an undirected self-loop once, ascontractdoes: on an undirected graph it equals NetworkX’scommunity.modularitywith each self-loop’s weight halved (test/sql/oracle_modularity.sql), and on a directed graph it scores the undirected view (an edge and its reverse add up), not NetworkX’s directed (Leicht–Newman) modularity. Checked against NetworkX’slouvain_communitieson stochastic block models (test/sql/oracle_louvain*.sql: modularity, NMI against the planted partition, connectedness).
pagerankwith aseedfailed with “division by zero” on any version with a deleted node: it divided by a constant vector, which is 0 at tombstoned rows, andvector_mapevaluates those rows too. It now multiplies by the reciprocal of the seed’s sum.
Performance
Section titled “Performance”Measured on the 4-vCPU Xeon of DESIGN.md §12, release builds,
warren.threads = 4, every limit at its default unless stated.
- Locality order (
warren-core’s kernels,examples/locality.rs; DESIGN.md §14 item 22): at 10M nodes and 100M edges, on a community-structured graph whose ids carry no structure, SpMV is 1.51× faster on one thread and 1.63× on four, BFS 1.25× andcomponents1.66×; on a power-law graph 1.04–1.25×. The ordering costs 10–12 s at the first build orcompact. On the soak’s uniform random graph, which has no locality to recover, it gains nothing measurable through SQL, and its first build peaks at 2.51 GB of private memory against 1.55 GB in id order (163 s against 160 s; within the 4 GB and 8 min targets). - The 100M-edge soak, re-run for 0.4.0 (DESIGN.md §12,
bench/results/soak/*-040*.json): no §12 row moved beyond the machine’s run-to-run spread, every peak is unchanged to 0.01 GB, and no target is newly met. An A/B of the one-relation case against 0.3.0’s build, three runs each, found no stage reproducibly slower (median ratios 0.87–1.05). The five-relation graph without the merged CSR now misses thecomponents()target (11.8 s against 10.1 s; 10.2 s at an 8GB cap in the same run), as borderline as before. - The new library at 10M nodes and 100M edges (the soak’s
one-relation graph, 1.2 GB cached; seconds, and the backend’s private
peak with the version): eigenvector, Katz and HITS for 20 iterations
15.0, 9.8 and 17.3 s (1.8–2.0 GB);
clustering47.5 s (3.0 GB);core_number22.8 s (3.2 GB);strong_components_vector15.8 s;minimum_spanning_forest54.4 s; betweenness, closeness and harmonic from 16 sources 61.1, 29.4 and 30.9 s (2.0–2.6 GB);most_similar2 ms;similarityof 100,000 pairs 0.55 s;similar_pairsover every 100th node 10.2 s (3.6 GB). With five weighted relations and no merged CSR each takes 1.4–3.5× as long; with the merged CSR (6.0 GB, over the cache cap) each call also pays the ~22 s load, andsimilar_pairsis refused (53400). At 1M nodes and 10M undirected edges the SpMV centralities take 40–90 ms an iteration,clustering4.5 s, andlabel_propagation_majority, a tier-2 program, about 20 s an iteration (docs/limits.md). - Louvain and Leiden on a graph with communities (blocks of 1,000
nodes, five undirected edges per node, 90% inside the block;
bench/results/communities/): at 1M nodeslouvaintakes 5.8 s and oneleideniteration 8.3 s (modularity 0.837 each); the default two iterations took 20.9 s on a slower day (one iteration 10.4 s there) and raise modularity to 0.898 (the planted blocks: 0.899). At 10M nodes, at their defaults (blocks-10m-defaults.json):louvain115.4 s, 5 levels, modularity 0.840, peak 4.31 GB private, 2.5 GB of segments and 2.6 GB of WAL written;leiden246.5 s, 18 levels, modularity 0.8996 (the planted blocks: 0.8995), peak 5.28 GB, 7.8 GB of segments and 8.2 GB of WAL written. - Known limits at 10M nodes (docs/limits.md, “Known limitations”):
- Leiden on graphs with little community structure is refused. On the
soak’s uniform random graphs (10M nodes, 100M edges)
leidenfails with 53400 at the default limits in its first level’s refinement, whose 88 bytes a node do not fitwarren.max_compute_memorybeside the 3.2 GB undirected view and the moving state. Raisewarren.max_compute_memoryfor such graphs. - Louvain’s disk. Every level writes its graph as a version, and a
level’s rows stay on disk until
VACUUMafter the run’s transaction ends. On a random graph every level’s quotient stays nearly as large as the graph: the soak stoppedlouvainfor disk after 383 s, having written 7.5 GB of segments and 9.1 GB of WAL (0.61 GB of segments at 1M nodes). Even on the 10M block graph (0.72 GB of segments) the defaults write 2.5 GB (louvain) and 7.8 GB (leiden). The planned fix is an in-memorycontract(DESIGN.md §6.7, §13). - A backend’s peak during
refreshorcontractis bounded by the cache, compute and build limits together, not by the first two: on the five-relation graphlouvainpeaked at 9.50 GB private against 8.59 GB for cache and compute, the restcontract’s build scratch (at 1M nodes, with all three limits at the least it needs, its peak was within 1.4% of their sum, that 1.4% Postgres’s own memory). - On the five-relation graph with its merged CSR (over the cache cap)
louvainandleidenare refused (53400): the undirected view does not fit beside the version’s excess over the cap. - As in 0.3.0, BFS through SQL is about 10–11× a recursive CTE rather
than 20×,
reachable_set23–26M rather than 50M edges/s, andeigendoes not converge within 10 minutes on a uniform random graph of this size.
- Leiden on graphs with little community structure is refused. On the
soak’s uniform random graphs (10M nodes, 100M edges)
Documentation
Section titled “Documentation”- docs/communities.md: contraction, projection,
lifting, Louvain, Leiden and modularity, with examples that run in CI
as the other docs’ do;
docs/compute.md covers the group 1 and 2 functions (the sampled
estimators and their error bounds); docs/semantics.md covers
node_order; docs/limits.md adds node order, the library’s timings at 1M and 10M nodes, contraction at scale, the 0.4.0 soak and the cost of each new function. - DESIGN.md records the 0.4.0 decisions (locality, §14 item 22; the
library’s groups 1–3 and contraction, §6.6, §6.7; open questions, §14
items 23–24), the deferral of a native in-memory Louvain/Leiden kernel
in favour of an in-memory
contract(§6.7), the road to 1.0 (§13 M9–M12, §14 item 25), and the design of 0.5.0’s shared cache (§5.6, §14 items 26–39). - The upgrade check also covers grants on re-created functions (in the catalog and in dumps), initial privileges against a fresh install’s, a planted operator that the update must not run, and contraction state written by each release; the dump check round-trips a quotient. Miri runs on pushes to main rather than on pull requests.
0.3.0 — hardening: bounded memory, trusted install, 100M-edge scale (2026-10-20)
Section titled “0.3.0 — hardening: bounded memory, trusted install, 100M-edge scale (2026-10-20)”Milestone M7 of DESIGN.md §13. A graph of 10M nodes and 100M edges now
builds and runs within the default limits (each 4 GB; a backend’s peak is
bounded by the cache and compute limits together) when it has one relation,
or five weighted relations with store_merged := false (the 100M-edge soak,
DESIGN.md §12; what still misses is under Performance).
Upgrading
Section titled “Upgrading”- Run
ALTER EXTENSION pg_warren UPDATE;in every database that has the extension, after installing the new build and before calling anywarren.*function. 0.3.0 changes the schema: the update adds the columnwarren.graphs.store_merged(existing graphs are backfilledtrue), drops the foreign keysgraphs.current_versionandversions.label_vectorand adds the triggers that replace them (and their trigger functionswarren.current_version_is_the_graphs()andwarren.current_version_is_kept(), revoked fromPUBLIC), addscreate_graph’sstore_mergedargument, and changes thetoldefault ofeigenanddistance(max_iter’s new default comes with the library, whether or not the update has run). The new library is loaded at once, but the catalog changes only with the update, so calls in between can fail. create_graphis dropped and re-created by the update (it gains an argument), so its privileges return to the defaults: re-apply anyGRANTorREVOKEyou made onwarren.create_graphafter updating.- Every backend’s memory is now bounded by default (below). A graph or
call that 0.2.0 ran outside every limit can now be reloaded per
statement or refused with 53400; raise
warren.max_cache_memory,warren.max_compute_memoryorwarren.max_build_memoryfor it.
Behaviour changes
Section titled “Behaviour changes”- A size bound on the cache:
warren.max_cache_memory(default4GB) bounds what a backend’s cache holds between statements; it was unbounded. Cached graph versions and persisted vectors are evicted least recently used first and reloaded when next used. Transient vectors count against it but are never evicted, and a transient write that would take them past it fails with 53400. A graph version larger than the cap, or a version beside the library’s transient scratch (pagerankkeeps about eleven full-lengthf64vectors, ~88 bytes per node) above it, is held only for the statement that reads it and reloaded by the next (a version alone over the cap says so with aNOTICEonce per statement). Raisewarren.max_cache_memoryfor such graphs. - The statement pin: what a top-level statement reads stays cached
until it ends. Every version and persisted vector it reads or writes is
pinned, then released when the statement ends, normally or with an
error, or when its transaction does (so a
CALLthat commits holds only what each of its transactions read), and the cache is trimmed to the cap. A statement is one client message: each extended-protocolExecute, or one query string of several statements. A library function (pagerank, …) is one statement, so it loads its version once even when the version and its transient scratch together exceed the cap, and a statement alternating between two graphs that do not fit the cap together loads each once. What the cache holds past the cap and cannot evict (transient vectors, what calls are using, and versions open cursors still hold) is charged to each call’swarren.max_compute_memory, so a backend’s peak is bounded by the two settings together (DESIGN.md §5.5, §14 item 20). What a statement read but no call is using is charged to each call past the cap too, and gives way, least recently used first, to a load or to a call that needs the room, so a statement reading more graphs in sequence than both settings hold still works. A vector the cache already holds is not charged to a call that reads it.diff,distanceandvector_norm_diffread their two versions through the cache under the same rule. warren.max_build_memorybounds the whole build, not only the CSR offsets: the node ids, the id map, the relation dictionary, the edge list, the CSRs being built and the sort and write buffers. The offsets check is kept, and now also runs as edge rows add relations, and the relation dictionary is charged as it grows, so a relation column with a name per row is refused while it is read. The budget does not include what the backend’s cache holds. Versions built are byte-identical to 0.2.0’s.- New 53400 (
configuration_limit_exceeded) refusals, where 0.2.0 ran past the limits or failed late:- a
refreshoverwarren.max_build_memory(build memory limit exceeded), up front where it can be (before the edge query runs, or before the version is written), else when the edge list grows past it; - a version load that would take what the cache cannot evict past
warren.max_cache_memoryandwarren.max_compute_memorytogether, refused before loading (the message names both settings and the bytes);diff,distanceandvector_norm_diffrefuse two versions that do not fit together before loading either; - a transient vector write that would take transient vectors alone past
warren.max_cache_memory; eigen, anddistance('spectral')with a fresh solve, when the solve’s working set cannot fitwarren.max_compute_memory: once the operator’s degrees give the dimension, everything the solve holds at its peak (the operator’s work vectors and degrees, the basis, the projected matrices, now charged too, and the k eigenvectors in the target dtype) is checked at once, evicting idle pinned versions if that makes room, before the first step (the eigenproblem needs N bytes, X of Y in use beside it). Before, such a solve ran its steps and failed only when it allocated its eigenvectors.distance('spectral')checks both versions before solving either;- buffers grown on compute worker threads (
gather’s result and a multi-relation product’s row scratch) cannot evict what a statement read, so they can fail with 53400 where eviction would have made room.
- a
eigenanddistancedefaults:tol := 1e-6(was1e-8), about the rounding of the defaultf32vectors, whose explicit residual does not fall below about 10⁻⁷·‖M‖; the old default bought 5–16% more steps and no accuracy.max_iterdefaults to 1,000 operator applications per basis vector (41,000 for k = 20), up from 100 (4,100): the normalised Laplacian of a random graph of 1M nodes needs 4,590 (DESIGN.md §6.5, §14 item 21). A solve that does not converge therefore runs about 10× longer before itsWARNING;statement_timeoutor a cancel stops it. Passtolandmax_iterexplicitly to keep the old behaviour (withdtype := 'f64'for a tightertolto count). The update changes thetoldefault; themax_iterdefault changes with the library.reachable_setis direction-optimising. When the version has the reverse CSR, a depth is pulled from it if its frontier holds more than 1/24 of the id space and the unvisited ids have fewer in-edges than the frontier has out-edges (Beamer’s switch), so each unvisited id scans its in-edges only until it meets the frontier. It returns the same nodes at the same depths, but within a pulled depth rows come in dense id order instead of the order reached (so which rows a select-listLIMITkeeps can differ frombfs’s; useORDER BYwhen the order matters), andlast_stats().edges_traversedcounts the row entries scanned rather than every edge of the visited nodes: 17.9M rather than 100M on a G(n, m) graph of 10M nodes and 100M edges. That is never more than a push-only search would scan, except that it can be on a graph of several relations without the merged CSR, read with every relation.bfsis unchanged: it keeps push for itsviaand its order (DESIGN.md §9.3).- Trusted install, and a new catalog owner. pg_warren is a trusted
extension: a role with
CREATEon the database can runCREATE EXTENSION pg_warrenwithout being a superuser (DESIGN.md §7). Catalog statements now run as the owner of thewarren.*tables (the installing superuser, or for a trusted install the bootstrap superuser), not aspg_extension.extowner. The installer gets no privilege on those tables. A non-superuser’spg_dumpfails while pg_warren is installed: dump as a superuser, or pass--exclude-table-data='warren.*', then create the graphs again withcreate_graphandrefresh. create_graph(…, store_merged boolean DEFAULT true): a graph of several relations may leave its merged (all-relations) CSR unstored. The default, and every graph created before 0.3.0, keep it; the choice is fixed when the graph is created, andcompactkeeps it. All-relations reads then merge the relations’ rows on the fly, with identical results, in 44% fewer CSR bytes for five weighted relations and 35–45% less build time, but at 2–5 relations BFS is 2.1–3.2× slower,components2.7–4.5×,gather1.5–1.9× andspmv4.6–8.3×, and at 10 relations BFS 6×,components10× andspmv11× (DESIGN.md §5.3, §14 item 19).warren.inforeportsstore_merged.vector_mapexpressions that take the executor path run in Postgres’s read-only transaction state, as 0.2.0 made source queries,vector_from_queryandgatherdo, so an expression that writes through a function it calls (a PL/pgSQLw(x)that inserts) fails with 25006 instead of writing, as doesnextvalon a non-temporary sequence. The library functions, which callvector_map, inherit this. An integrity guard, not a sandbox (DESIGN.md §7).- Query functions,
refreshandcompactrefuse (55000) a graph whose stored current version is not one of its versions, as a restore or a direct write can leave it;promoteone of its versions to repair it. Before, they read or built on that version, even another graph’s.
warren.max_cache_memoryand the trusted install, above.create_graphraises aNOTICEwhen the graph’ssearch_pathcontains a schema that roles other than its owner can create objects in. Those roles could plant functions or operators thatrefreshwould run as the graph’s owner.refreshreports its build memory peak atDEBUG1.
- Dumps restore.
graphs.current_versionandversions.label_vectorare no longer foreign keys. Together withversions.graph_idandvectors.version_idthey made cycles that pg_dump cannot order, so a dump of a database with a promoted graph failed to restore. Triggers now check that a graph’s current version is one of its own versions, and that it is not deleted while current. Restoring needs the roles that own or read graphs and wrote vectors. diffloaded its two versions outside every limit: on two 6.0 GB versions a backend reached 12.8 GB and was killed. It now reads them through the cache, and refuses two that do not fit (above).vector_norm_diffrefuses (22023,dense id … names different nodes in the two versions) two versions whose shared dense ids name different nodes, such as sibling staged versions that added different nodes, asdiffanddistancealready did. It used to align their vectors row by row and return a meaningless value.promoteof a graph that another session drops while the promote waits fails with “graph does not exist” (42704), not an internal error.- Reading the edge query of a graph with relations held Postgres memory
for every row’s relation name until the
refreshended (about 1.7 GB at 100M edges); names are now read in place. - A cold load peaked at 2.7× the version (3.2 GB for a 1.2 GB version), because each segment’s chunks were held in SPI memory and again in Rust; chunks are now read one at a time, and the peak is the version plus one CSR’s raw segments (1.69 GB). Vector reads share the chunk reader.
vector_from_querywith array values held a copy of every row’s array until the transaction ended (16 MiB for 20,000 rows of 64 values), andgatherfrom a query held what each message’s send function allocated until the read ended (about 230 B perint[]message). Both now read a batch of rows at a time and free it after; results are unchanged.
Performance
Section titled “Performance”Measured at 10M nodes and 100M edges on 4 vCPUs, at the default limits
(DESIGN.md §12, bench/results/soak/*-m7*.json).
refreshstreams each relation’s CSRs towarren.segmentsand frees them, and encodes segments a chunk at a time. It peaks at 1.55 GB for one unweighted relation (was 2.0 GB), and 3.26 GB for five weighted relations with or without the merged CSR (was 9.0 and 5.5 GB), all within the default4GB, in 153, 260 and 228 s.reachable_setover the full component: the kernel takes 1.24 s instead of 5.96 s, and the call through SQL 3.86 s (26M graph edges/s) instead of 8.3–9.2 s; 6.15 s without the merged CSR. At 1M nodes and 10M edges, 0.21 s instead of 0.49 s.eigen’s steps are cheaper: an undirected graph’s operator symmetrises with one product, and the basis passes sum their dot products in eight lanes and rotate by row chunks. On one relation a step takes 0.59–0.61 s (was 0.96 s).pagerank(20 iterations) takes 15.6 s on one relation, and loads its version once on five weighted relations: 35.6 s without the merged CSR (3.44 GB, 30 MB over the cap beside its scratch) and 40.1 s with it (6.0 GB, over the cap, 23 s of it the load).- What still misses §12’s targets: BFS to depth 6 is 11.0× the recursive
CTE rather than 20× and
reachable_set26M rather than 50M edges/s, both mostly row emission through SQL;eigen(k = 20, normalised Laplacian) does not converge within 10 minutes on a uniform random graph of 10M nodes (~14,500 steps projected, about 2 h), while a community graph’s ~400 steps would take 3–4 minutes; and five weighted relations with the merged CSR (6.0 GB) are over the cap, so each statement reloads them (~23 s) anddiff,distanceandeigenare refused at the default limits (docs/limits.md, “Sizing”).
Documentation
Section titled “Documentation”- A user-facing README, and
docs/: semantics (versions, dense ids, staleness, transactions, retention, access, id types), traversal, compute (vectors, semirings, masks,vector_map,gather, the library,eigen,distance), the two change-detection patterns, and limits (settings, the memory model, a sizing table from the soak, measured performance, known limitations). Every SQL example runs in CI (test/sql/docs_examples.sql). - DESIGN.md records the M7 decisions: native kernels, with GraphBLAS,
LAGraph and OneSparse read rather than bound, and the kernel seam a GPU
backend would implement (§6.10, §14 item 9); the cache and compute bound
(§14 item 20);
eigenat scale (§14 item 21); the default for the merged CSR (§14 item 19); and, for after 0.3.0, locality ordering of dense ids (§14 item 22) and the planned library (§6.7). - The upgrade check now builds each release from its recorded commit and
writes state with that release’s own library, and runs with a trusted
install too; a
pg_dump/restore round trip runs in CI on PostgreSQL 15–18.
0.2.0 — vectors, compute and spectral analysis
Section titled “0.2.0 — vectors, compute and spectral analysis”Milestones M4–M6 of DESIGN.md §13.
- Named vectors (
vector_from_query,vector_const,vector_degree,vector_dense_id,vector_values,matrix_rows,vector_topk,vector_stats,id_map,drop_vector), persisted or transient (persist := false), with per-vector ownership (created_by). - Compute: semiring
spmv/spmmwith masks and accumulation,vector_map(compiled subset plus executor fallback) andexplain_map,vector_reduce,vector_norm_diff;warren.threadsandwarren.max_compute_memory. compactmoves persisted vectors into the new id space.gather: messages of any SQL type from a query, relation or vector, fanned out over the graph and combined per receiver (collect,count,sum,min,max,first,concat,bool_or,bool_and).- Algorithm library in PL/pgSQL over the primitives:
pagerank(personalised by a seed vector),components_vector,label_propagation,sssp_vector,degree_vector,aggregate_neighbours; andtriangles, a per-node triangle count. - Spectral analysis and version distance:
eigen(Lanczos top-k eigenpairs of the adjacency, Laplacian, normalised Laplacian or random walk, eigenvectors stored as<prefix>_1..kvectors,f32orf64) anddistance(edit,degree,spectral,subspace,embedding, asjsonb), between versions of a graph or, withgraph2, of two graphs aligned by external id. - Behaviour change: SQL a user wrote now runs in Postgres’s read-only
transaction state while warren reads it, so a graph’s source queries
(at
refresh),vector_from_query’s query andgather’s message query that write through a function they call now fail with 25006 (cannot execute … in a read-only transaction). This is an integrity guard, not a sandbox (DESIGN.md §7). - Fix: functions that read a graph or vector no longer fail with
“cannot assign XIDs during a parallel operation” when the query runs in
parallel mode (
debug_parallel_query, or a plan that costs out parallel, e.g. an InitPlan over a parallel scan). Parallel labels: every function that reads a graph or vector isPARALLEL RESTRICTED(diffandinfochange fromPARALLEL UNSAFE), and writers arePARALLEL UNSAFE(DESIGN.md §9.3). - Behaviour change:
evictalso frees the graph’s cached and transient vectors in this backend (transient vectors are gone afterwards), and counts them in its result. - Upgrade from 0.1.0: install the new build, then run
ALTER EXTENSION pg_warren UPDATE;in each database before using it.
0.1.0 — a usable traversal index
Section titled “0.1.0 — a usable traversal index”Milestones M0–M3 of DESIGN.md §13.
- Named graphs over user tables (
create_graph), rebuilt as immutable versions (refresh) with stable dense ids, staging, promotion, retention,compact,diff,info,grant/revoke,drop_version,drop_graph. - Transactional, chunked persistence in extension-owned tables; a staged build inside a rolled-back transaction leaves nothing behind.
- int4, int8 and uuid node ids; optional relations and weights; forward and reverse CSR.
- Traversal:
bfs,neighbours,degree,has_edge,reachable,reachable_set,shortest_path,dijkstra,components, typed path patterns withwalk/warren.step/warren.s, andlast_stats. - Backend-local cache with
prewarm/evict. - Every SQL entry point runs under a pinned
search_path; source queries run as the graph owner under the search path captured atcreate_graph. - Tested on PostgreSQL 15–18; traversal functions checked against recursive CTE oracles.