Skip to content

Storage

The storage layer lives in src/storage/. Its base schema is created by sql/schema_v0_2_0.sql and sql/schema_v0_4_0_graphs.sql, which extension_sql_file! in src/lib.rs wires into the generated install script. Later columns and functions arrive through the upgrade scripts in sql/.

The tables below are internal. Clients should use the supported functions (graph_inventory(), get_term(), SPARQL) instead of reading them; see Managing graphs.

Tables

_pgrdf_dictionary: terms

ColumnTypeNotes
idBIGINT GENERATED ALWAYS AS IDENTITYprimary key
term_typeSMALLINT1 = IRI, 2 = blank node, 3 = literal
lexical_valueTEXTthe IRI, blank-node label or literal text
datatype_iri_idBIGINTdictionary id of a literal's datatype IRI
language_tagTEXTset for language-tagged literals
lexical_md5BYTEA, generated and storedmd5 of lexical_value

A term is unique on (term_type, lexical_md5, datatype_iri_id, language_tag) (constraint unique_term). Keying the constraint on an md5 rather than on the text keeps the index entry small however long a literal is. A hash index, _pgrdf_dict_val_idx, on lexical_value serves exact-match lookups; the primary key serves id → term.

_pgrdf_quads: triples

ColumnTypeNotes
subject_id, predicate_id, object_idBIGINTdictionary ids
graph_idBIGINT, default 0the partition key
is_inferredBOOLEAN, default falsetrue for rows written by materialize

The table is PARTITION BY LIST (graph_id). add_graph creates one partition per graph, named _pgrdf_quads_g<id>. A default partition, _pgrdf_quads_default, catches every other graph_id. Graph 0 (the default graph) has no partition of its own unless one is created, so its rows live in the default partition.

Three covering indexes are declared on the parent and cascade to every partition:

IndexKeyINCLUDE
_pgrdf_idx_spo(subject_id, predicate_id, object_id)is_inferred
_pgrdf_idx_pos(predicate_id, object_id, subject_id)is_inferred
_pgrdf_idx_osp(object_id, subject_id, predicate_id)is_inferred

Each partition's copies get generated names, for example _pgrdf_quads_g1_subject_id_predicate_id_object_id_is_inferr_idx.

Why three indexes, not six. These three cover all eight bound/unbound combinations of a triple pattern: SPO serves S- and SP-bound patterns, POS serves P- and PO-bound, OSP serves O- and OS-bound, and a fully bound pattern can use any of them as a prefix scan. The other three permutations (SOP, PSO, OPS) add no coverage. They would add sort orders, letting more joins run as merge joins without a sort, but at full-graph scale each is another full-size index and more write amplification on ingest, so they are not built.

_pgrdf_graphs: the graph registry

ColumnTypeNotes
graph_idBIGINTprimary key
iriTEXTunique; the name SPARQL uses
locked, lock_reason, locked_atBOOLEAN, TEXT, TIMESTAMPTZthe graph's write lock
last_materialize_at, materialized_base_countTIMESTAMPTZ, BIGINTrecorded by materialize; drive freshness

Row 0 is seeded at CREATE EXTENSION with the IRI urn:pgrdf:graph:0. Both _pgrdf_graphs and _pgrdf_dictionary are registered with pg_extension_config_dump, so pg_dump includes their rows as well as their definitions.

_pgrdf_staged_ping is a small bookkeeping table used by the staged loader's worker-pool self-test.

Interning terms

Every write path turns terms into ids through dict::put_term_full and its batched siblings:

text
term ──► per-call HashMap ──hit──► id
            │ miss

         shared-memory cache ──hit──► id                 (no SQL)
            │ miss

         SELECT id FROM _pgrdf_dictionary ──found──► id  (published to the cache)
            │ not found

         INSERT … ──► new id                             (staged; published on COMMIT)

The per-call HashMap lives for one load call and catches the terms a file repeats (predicates, common subjects, datatype IRIs).

The shared-memory cache (shmem_cache.rs) is shared by every backend on the server:

  • Layout. 16,384 slots of 32 bytes (512 KiB), guarded by a PostgreSQL LWLock. Each slot holds a 128-bit fingerprint of the term, a generation number and the dictionary id. Open addressing with linear probing up to 8 slots; when a probe run is full, the term's home slot is evicted. A hit takes the lock in shared mode.
  • Keyed per database. The dictionary is an ordinary, per-database table, but the cache is server-wide, so the database OID is part of the key. Without it, a term cached by one database would resolve to an unrelated id in another.
  • Transaction-safe. A freshly inserted id is staged in a per-backend list and published only when the transaction commits; on abort (including a subtransaction abort) the staged entries are dropped, so a rolled-back insert never leaves a phantom id in the cache. Ids found by SELECT are already committed and go straight in.
  • Generation counter. Shared memory outlives DROP EXTENSION, but a re-created extension starts a new id sequence. pgrdf.shmem_reset() bumps a generation counter that invalidates every slot, and the install script calls it, so a freshly created extension cannot inherit stale ids.
  • Counters. shmem_hits, shmem_misses, shmem_inserts, shmem_evictions, shmem_slots and shmem_ready appear in pgrdf.stats().
  • Preload only. The cache is set up in the postmaster, so it exists only when pgRDF is in shared_preload_libraries. Otherwise lookups skip it and go to the table.

pgrdf.ingest_dict_path selects how a load resolves terms: combined (the default) resolves distinct terms in batches of pgrdf.dict_batch_size (default 500) and consults the shared cache first. baseline, batched and shmem_warm select narrower strategies that are kept for comparison; a regression test checks that all four produce identical stores. pgrdf.shmem_prewarm_on_init (default off) warms the shared cache before the first ingest. parse_turtle_verbose and load_turtle_verbose report the path taken and the cache hit counts.

Loaders

PathEntry pointsInputHow it writes
Standardparse_turtle, load_turtle, parse_trig, parse_nquads (+ _verbose forms)Turtle, N-Triples, TriG, N-Quadsoxttl parse → intern terms → buffer quads → prepared INSERT … SELECT FROM unnest(…), 1,000 quads per batch
Parallel bulkload_turtle(…, bulk_load => true)N-Triples onlyparse and resolve in parallel with rayon; quads inserted in 50,000-row batches; indexes deferred on large loads
Streamingload_turtle_streamingline-oriented N-Tripleswindowed parallel ingest (window_triples, id_reserve_block) for files too large to hold at once
Stagedload_turtle_staged_run, CALL load_turtle_stagedN-Triples onlya pool of background workers, one committed transaction per phase
CONSTRUCT re-ingestput_construct_row, put_construct_rowsrows from pgrdf.constructdecodes structured term cells back into quads

The standard path

loader.rs parses with oxttl, interns terms as described above, and buffers quads until 1,000 are pending. It then flushes them with one statement:

sql
INSERT INTO pgrdf._pgrdf_quads (subject_id, predicate_id, object_id, graph_id)
SELECT s, p, o, $4
  FROM unnest($1::bigint[], $2::bigint[], $3::bigint[]) AS t(s, p, o)

The statement is prepared once per backend through the same plan cache as SPARQL, so repeated flushes skip parse and plan. Loading the same triples twice does not duplicate them.

Rows are written with the caller's graph_id, and PostgreSQL routes them to that graph's partition. Always create the graph first: rows written under an id that has no partition land in _pgrdf_quads_default and are not reported by graph_inventory().

Parallel bulk and streaming

With bulk_load => true, parsing and triple → id resolution run on all cores with rayon. The parallel regions are pure CPU work (no SPI, no PostgreSQL memory contexts), which is what makes them safe inside a single backend. For large loads into an empty store, indexes are dropped first and rebuilt at the end; the threshold is pgrdf.bulk_defer_index_min (default 100,000 triples).

The bulk path reads N-Triples only. On prefixed or multi-line Turtle it loads zero triples without raising an error (parse_skipped in the verbose report counts the skipped lines). Leave bulk_load off for Turtle.

The staged loader

The staged loader (src/storage/staged/) exists because a single SQL function cannot commit part-way through, run several CREATE INDEX statements at once, or own several input streams. A thin coordinator, callable from SQL, spawns dynamic background workers. Each worker is its own backend with its own transaction and runs one phase or one shard of a phase:

PhaseWorkersWhat happens
Prepare1Checks that the dictionary is empty, defers the quad indexes, creates an UNLOGGED staging table.
STAGENEach worker parses a byte range of the file into the staging table.
DICTparallel SQLDistinct terms are deduplicated in parallel CREATE UNLOGGED TABLE … AS SELECT … row_number() statements that pre-assign ids, then copied into _pgrdf_dictionary with one INSERT … OVERRIDING SYSTEM VALUE. Inserting into the identity column directly would force a serial plan and one nextval per row.
RESOLVE1, parallel SQLA parallel hash join turns staged triples into quads in a new table, which is then ATTACHed as the graph's partition.
INDEXseveralThe deferred index builds run at the same time across workers.

Each phase commits in its worker, so a finished phase is a recovery point rather than part of one huge transaction. The result is {"ok": true, "triples", "quads", "n_workers", "phase_ms": {stage, dict, resolve, index}}.

Constraints that follow from the design:

  • Preload required. Workers are coordinated through a shared-memory job segment. A worker receives only its slot index; the path, byte range, graph id and database OID live in the segment as fixed-width fields, and each worker connects to the database by OID.
  • Empty dictionary required. DICT deduplicates only within the staging set. If the dictionary already holds terms, the coordinator returns {"ok": false, "fallback": true, "reason": "dictionary already populated …"}.
  • Not inside a transaction block. The workers commit their own transactions and would wait forever on locks held by an open caller transaction, so a direct call inside BEGIN … COMMIT raises instead.
  • Sizing. n_workers = 0 means one STAGE worker per host core, capped so a large machine cannot exhaust max_worker_processes. pgrdf.staged_resolve_strategy (auto, hash or index; default index) forces the RESOLVE join strategy, and pgrdf.staged_temp_tablespaces routes temporary spill files.

The user-facing description, with the 8.2-billion-triple Wikidata run, is on the staged loader page.

How load_turtle chooses a path

load_turtle samples the first 64 KiB of the file (up to 200 statements). It hands the file to the staged loader only when it is confident the input is N-Triples, pgRDF is preloaded, and the call is not inside a transaction block. If the staged loader answers with the fallback sentinel, or any condition fails, the file goes through the standard Turtle parser. Turtle input produces a NOTICE recommending N-Triples for large loads. load_turtle reads from the database server's filesystem.

TriG and N-Quads

parse_trig and parse_nquads share the standard batch path, with one batch buffer per destination graph. Each graph IRI is resolved before any quad is buffered: a known IRI maps to its id; an unknown IRI is created with add_graph(iri), or refused with 42704 under strict => true. A strict refusal therefore raises before any row is written. The JSONB report lists the destination graph ids in "graphs", in first-seen order.

CONSTRUCT re-ingest

put_construct_rows(rows jsonb[], graph_id) is the inverse of pgrdf.construct. It keeps one blank-node label map for the whole batch, so a blank node that appears in several rows of one result is stored as one node. Typed and language-tagged literals round-trip with their datatype and language.

sql
SELECT pgrdf.put_construct_rows(
  (SELECT array_agg(j) FROM pgrdf.construct(
     'CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <http://example.com/src> { ?s ?p ?o } }') AS t(j)),
  pgrdf.graph_id('http://example.com/dst'));

Graph lifecycle

The lifecycle functions work on partitions rather than on rows wherever possible (graphs.rs, hexastore.rs):

FunctionMechanismNotes
add_graph(iri) → bigintallocates COALESCE(MAX(graph_id), 0) + 1 under LOCK TABLE _pgrdf_graphs IN SHARE ROW EXCLUSIVE MODE, then creates the partitionidempotent on the IRI
add_graph(id) → booleancreates the partition and binds the placeholder IRI urn:pgrdf:graph:<id>idempotent
add_graph(id, iri) → bigintbinds an explicit IRI; replaces a placeholder IRI in placea conflicting binding is refused (42710)
clear_graphTRUNCATE ONLY on the partitionpartition and IRI binding kept; returns rows removed
drop_graph(g, cascade)DETACH PARTITION + DROP TABLE, then deletes the registry rowcascade => false refuses if inferred rows exist (2BP01); graph 0 cannot be dropped; an absent id returns 0
copy_graphINSERT … SELECT with graph_id reboundappends; carries is_inferred; the id form creates a missing destination; cost grows with the source
move_graphcopy_graph then drop_graph of the source, in one transactiondestination must be empty (55000)
carve_graphcopies a predicate slice or a seed neighbourhood up to max_hopsreports a continuing neighbourhood with a NOTICE

Every partition-creating path goes through partition.rs, which takes one transaction-scoped advisory lock. CREATE TABLE … PARTITION OF needs an ACCESS EXCLUSIVE lock on the parent. Two sessions that both already hold weaker locks on the parent and both try to escalate would deadlock; with the advisory lock they queue instead. drop_graph also holds ACCESS EXCLUSIVE on the parent briefly, which blocks queries on other graphs for the duration of the catalogue change.

The IRI-keyed overloads (clear_graph(iri), drop_graph(iri), copy_graph(src_iri, dst_iri), …) resolve the IRI and dispatch to the id form. One difference is deliberate: an unknown IRI raises 42704, where the id form treats an absent id as a no-op.

sql
SELECT pgrdf.add_graph('http://example.org/g1');               -- 1   (next free id)
SELECT pgrdf.add_graph(42::bigint);                             -- t
SELECT pgrdf.graph_iri(42::bigint);                             -- urn:pgrdf:graph:42
SELECT pgrdf.add_graph(42::bigint, 'http://example.org/g42');   -- 42  (placeholder replaced)
SELECT pgrdf.graph_iri(42::bigint);                             -- http://example.org/g42
SELECT pgrdf.graph_id('http://example.org/unbound');            -- NULL

SELECT pgrdf.copy_graph(pgrdf.graph_id('http://example.org/g1'), 100::bigint);
SELECT pgrdf.graph_iri(100::bigint);                            -- urn:pgrdf:graph:100 (created)
SELECT pgrdf.drop_graph('http://example.org/nope');
-- ERROR:  drop_graph: unknown iri "http://example.org/nope"

Custody

Locks (lock.rs). Lock state is the three locked* columns on _pgrdf_graphs. require_unlocked(graph_id, operation) is called by every engine write path: every loader, put_quad, put_construct_row(s), SPARQL UPDATE, clear_graph, drop_graph, move_graph (source and destination), copy_graph and carve_graph (destination), and materialize. A locked graph refuses with 55P03 and a message that names the cure:

text
ERROR:  pgrdf: graph 45 is locked (review): clear_graph refused. Unlock with pgrdf.unlock_graph(45, '<reason>').

Reads are never blocked. A lock is a coordination tool, not a security boundary: anyone who can write the graph can lock or unlock it (with a mandatory reason). Access control remains table privileges.

Inventory (inventory.rs). graph_inventory() answers, in one supported call, what otherwise needs joins over _pgrdf_graphs, _pgrdf_quads, pg_class and pg_inherits: id, IRI, asserted and inferred counts, lock state and materialisation freshness. orphan_partitions() lists partitions with no registry row.

Freshness (freshness.rs). materialize records when it ran and the asserted count it ran over. Nothing on the ordinary write paths touches the registry row: stamping it on every write would hold a row lock until commit and serialise concurrent writers against add_graph. Instead graph_inventory() derives the state at read time: never (no run, no inferred rows), unknown (inferred rows but no record, for example after a copy), stale (the asserted count has changed since the run) or current. An edit that leaves the asserted count unchanged still reads current.

Integrity (integrity.rs). graph_integrity(g) checks every quad's terms for position legality (no literal subjects; predicates must be IRIs) and for ids with no dictionary row. It runs with the caller's privileges and returns {"clean": true, "counts": {…}, "dangling_refs": 0, "illegal_terms": 0} on a healthy graph.

Identity and export

All three digests and the export read asserted triples only. Inferred rows are derived, so they are not part of a graph's content. The user-facing explanation is on Identity and export.

FunctionSourceMethod
graph_digest(g)canon.rsrdfc-1.0-sha256: W3C RDFC-1.0 canonical blank-node relabelling, canonical N-Quads, SHA-256. Equal and unequal are both conclusive.
structural_digest(g)fd1.rspgrdf-fd1-sha256: ground triples plus a first-degree signature per blank node. Unequal is conclusive; equal is evidence only, because symmetric blank-node structures can collide.
export_graph(g)export.rscanonical N-Triples, one triple per line, byte-sorted
graph_manifest(g)export.rsthree digests (bytes, identity, structure), each labelled with its method, engine identity, counts, and a non-empty not_carried list

RDFC-1.0 is exponential in the worst case on highly symmetric blank-node structures, so canon.rs enforces a complexity budget and raises 54000 rather than running unbounded. A graph that does not exist raises 42704; an empty graph digests to the SHA-256 of nothing. The W3C rdf-canon fixtures used by the canonicalisation tests are in tests/fixtures/rdfc10/.

Settings used by storage

SettingDefaultEffect
pgrdf.ingest_dict_pathcombinedterm-resolution strategy for loads
pgrdf.dict_batch_size500terms per batch in the batched paths
pgrdf.shmem_prewarm_on_initoffwarm the shared cache before the first ingest
pgrdf.bulk_defer_index_min100000smallest bulk load that defers index builds
pgrdf.staged_resolve_strategyindexRESOLVE-phase join strategy
pgrdf.staged_temp_tablespacesemptytablespaces for staged-loader spill

All are USERSET. Settings for queries and reasoning are listed on Query and Inference.

Tests

The storage behaviour above is pinned by #[pg_test]s next to the code and by regression files such as 10-dict-roundtrip.sql, 50-shmem-dict-cache.sql, 63-shmem-reset-invalidation.sql, 88-drop-graph.sql to 92-lifecycle-end-to-end.sql, 118-lifecycle-iri-overloads.sql, 130-ingest-dict-paths-parity.sql and 136-staged-multiload-dedup.sql in tests/regression/sql/. See Testing.

pgRDF is released under the MIT license. Documentation built with VitePress, served via GitHub Pages.