Repository navigation
pg_warren 0.7.0
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
- 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 nowpg_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's
sssp_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 naming
warren.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
-
An unweighted pair of nodes linked in several relations now counts
once inmodularity,contract,local_moving,leiden_refine,
louvainandleiden, as it already did inspmv,degreeand
pagerank. 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_centralityand
minimum_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.dijkstra
already took the cheapest relation through 0.6.0, and still does.
Functions that read weights as strengths (spmv,spmm,pagerank
and the other spectral centralities, the community functions,
contract,eigen,distance) still sum them;spmvwithmin_plus
reads 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 oflaplacianand
normalized_laplacianwithwhich := 'smallest', 1 ofrandom_walk
withwhich := 'largest', and each bipartite component's −1 of
random_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;walk
still 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).
New
-
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,eigen
anddistance: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 by
test/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 ofpostgresql-NN-pg-warren
packages 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's
postgresimage with the package's files and pg_warren preloaded);
a Homebrew formula (brew install exadev/tap/pg_warren, for
postgresql@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.
Fixes
-
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; tests
every_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 covers
create_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 with
persist := 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 NULL
name, 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, and
distance(..., 'subspace')with aklarger than the vectors stored,
no longer crash the server. Each made a name for every one of thek
vectors 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: abfsorreachable_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-line
text[]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_raceand
test/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 bytest/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 with
serialization_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 by
test/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 untilwarren.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_timeoutduring
label_propagation_majorityunderdebug_parallel_query).
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 andcomponents
read 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.
The apt repository's signing key has the fingerprint A7FFD766EE915EE8E97085F8DF33D7645F721A34;
check the key you fetched with gpg --show-keys /etc/apt/keyrings/pg_warren.asc.