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
| Column | Type | Notes |
|---|---|---|
id | BIGINT GENERATED ALWAYS AS IDENTITY | primary key |
term_type | SMALLINT | 1 = IRI, 2 = blank node, 3 = literal |
lexical_value | TEXT | the IRI, blank-node label or literal text |
datatype_iri_id | BIGINT | dictionary id of a literal's datatype IRI |
language_tag | TEXT | set for language-tagged literals |
lexical_md5 | BYTEA, generated and stored | md5 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
| Column | Type | Notes |
|---|---|---|
subject_id, predicate_id, object_id | BIGINT | dictionary ids |
graph_id | BIGINT, default 0 | the partition key |
is_inferred | BOOLEAN, default false | true 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:
| Index | Key | INCLUDE |
|---|---|---|
_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
| Column | Type | Notes |
|---|---|---|
graph_id | BIGINT | primary key |
iri | TEXT | unique; the name SPARQL uses |
locked, lock_reason, locked_at | BOOLEAN, TEXT, TIMESTAMPTZ | the graph's write lock |
last_materialize_at, materialized_base_count | TIMESTAMPTZ, BIGINT | recorded 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:
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
SELECTare 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_slotsandshmem_readyappear inpgrdf.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
| Path | Entry points | Input | How it writes |
|---|---|---|---|
| Standard | parse_turtle, load_turtle, parse_trig, parse_nquads (+ _verbose forms) | Turtle, N-Triples, TriG, N-Quads | oxttl parse → intern terms → buffer quads → prepared INSERT … SELECT FROM unnest(…), 1,000 quads per batch |
| Parallel bulk | load_turtle(…, bulk_load => true) | N-Triples only | parse and resolve in parallel with rayon; quads inserted in 50,000-row batches; indexes deferred on large loads |
| Streaming | load_turtle_streaming | line-oriented N-Triples | windowed parallel ingest (window_triples, id_reserve_block) for files too large to hold at once |
| Staged | load_turtle_staged_run, CALL load_turtle_staged | N-Triples only | a pool of background workers, one committed transaction per phase |
| CONSTRUCT re-ingest | put_construct_row, put_construct_rows | rows from pgrdf.construct | decodes 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:
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:
| Phase | Workers | What happens |
|---|---|---|
| Prepare | 1 | Checks that the dictionary is empty, defers the quad indexes, creates an UNLOGGED staging table. |
| STAGE | N | Each worker parses a byte range of the file into the staging table. |
| DICT | parallel SQL | Distinct 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. |
| RESOLVE | 1, parallel SQL | A parallel hash join turns staged triples into quads in a new table, which is then ATTACHed as the graph's partition. |
| INDEX | several | The 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 … COMMITraises instead. - Sizing.
n_workers = 0means one STAGE worker per host core, capped so a large machine cannot exhaustmax_worker_processes.pgrdf.staged_resolve_strategy(auto,hashorindex; defaultindex) forces the RESOLVE join strategy, andpgrdf.staged_temp_tablespacesroutes 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.
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):
| Function | Mechanism | Notes |
|---|---|---|
add_graph(iri) → bigint | allocates COALESCE(MAX(graph_id), 0) + 1 under LOCK TABLE _pgrdf_graphs IN SHARE ROW EXCLUSIVE MODE, then creates the partition | idempotent on the IRI |
add_graph(id) → boolean | creates the partition and binds the placeholder IRI urn:pgrdf:graph:<id> | idempotent |
add_graph(id, iri) → bigint | binds an explicit IRI; replaces a placeholder IRI in place | a conflicting binding is refused (42710) |
clear_graph | TRUNCATE ONLY on the partition | partition and IRI binding kept; returns rows removed |
drop_graph(g, cascade) | DETACH PARTITION + DROP TABLE, then deletes the registry row | cascade => false refuses if inferred rows exist (2BP01); graph 0 cannot be dropped; an absent id returns 0 |
copy_graph | INSERT … SELECT with graph_id rebound | appends; carries is_inferred; the id form creates a missing destination; cost grows with the source |
move_graph | copy_graph then drop_graph of the source, in one transaction | destination must be empty (55000) |
carve_graph | copies a predicate slice or a seed neighbourhood up to max_hops | reports 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.
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:
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.
| Function | Source | Method |
|---|---|---|
graph_digest(g) | canon.rs | rdfc-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.rs | pgrdf-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.rs | canonical N-Triples, one triple per line, byte-sorted |
graph_manifest(g) | export.rs | three 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
| Setting | Default | Effect |
|---|---|---|
pgrdf.ingest_dict_path | combined | term-resolution strategy for loads |
pgrdf.dict_batch_size | 500 | terms per batch in the batched paths |
pgrdf.shmem_prewarm_on_init | off | warm the shared cache before the first ingest |
pgrdf.bulk_defer_index_min | 100000 | smallest bulk load that defers index builds |
pgrdf.staged_resolve_strategy | index | RESOLVE-phase join strategy |
pgrdf.staged_temp_tablespaces | empty | tablespaces 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.