A PostgreSQL extension for cache invalidation tracking, built with pgrx.
pgcache_pgrx provides generation-based tracking infrastructure for PostgreSQL tables. It tracks which rows have been accessed at what "generation" (a monotonically increasing version number), enabling efficient cache invalidation strategies.
The extension uses PostgreSQL's shared memory (dshash backed by DSA) to store generation data, making it accessible across all backend processes.
Note: This extension also includes an experimental in-memory Table Access Method (TAM). The TAM is a work in progress and not the focus of this documentation.
- Generation tracking via CustomScan: Automatically tracks row access during SELECT queries
- Trigger-based tracking: Track INSERT and UPDATE operations via triggers
- Shared memory storage: Generation data stored in PostgreSQL shared memory (dshash)
- Primary key hashing: Rows identified by a hash of their primary key values
- Bulk purge operations: Efficiently delete stale cached rows based on generation threshold
- Rust (stable)
- PostgreSQL 13-18
- pgrx 0.16.1
# Install pgrx if not already installed
cargo install cargo-pgrx
# Initialize pgrx for your PostgreSQL version
cargo pgrx init --pg18 $(which pg_config)
# Build and install the extension
cargo pgrx install --releaseAdd to postgresql.conf:
shared_preload_libraries = 'pgcache_pgrx'
Restart PostgreSQL, then create the extension:
CREATE EXTENSION pgcache_pgrx;The mem.query_generation GUC controls generation tracking:
-- Enable tracking with generation 100
SET mem.query_generation = 100;
-- Run queries - accessed rows will be recorded with generation 100
SELECT * FROM my_table WHERE id = 1;
-- Disable tracking
SET mem.query_generation = 0;When mem.query_generation > 0, SELECT queries on heap tables automatically record each accessed row's primary key hash along with the current generation.
For INSERT and UPDATE operations, use triggers:
-- Enable tracking on a table (creates an AFTER INSERT OR UPDATE trigger)
SELECT pgcache_enable_tracking('my_table'::regclass);
-- Now INSERTs and UPDATEs are tracked when mem.query_generation > 0
SET mem.query_generation = 100;
INSERT INTO my_table VALUES (1, 'data');
UPDATE my_table SET value = 'new_data' WHERE id = 1;
SET mem.query_generation = 0;
-- Disable tracking (removes the trigger)
SELECT pgcache_disable_tracking('my_table'::regclass);-- Check if shared memory is available
SELECT pgcache_generation_available();
-- Dump all generation entries
SELECT * FROM pgcache_generation_dump();
-- Returns: table_oid, pk_hash, generation
-- Look up a specific entry
SELECT pgcache_generation_lookup('my_table'::regclass, pk_hash);-- Get the pk_hash for a row using its ctid
SELECT pgcache_pk_hash('my_table'::regclass, ctid) FROM my_table WHERE id = 1;
-- Join generation data with table rows
SELECT t.*, d.generation
FROM my_table t
JOIN pgcache_generation_dump() d
ON d.table_oid = 'my_table'::regclass
AND d.pk_hash = pgcache_pk_hash('my_table'::regclass, t.ctid);-- Purge generation entries for a specific table (threshold inclusive)
-- Removes entries where 0 < generation <= threshold
SELECT pgcache_generation_purge('my_table'::regclass, 150);
-- Purge generation entries for all tables
SELECT pgcache_generation_purge_all(150);
-- Delete actual rows AND purge generation entries
-- This deletes rows from tables where their generation <= threshold
SELECT pgcache_purge_rows(150);| Setting | Type | Default | Description |
|---|---|---|---|
mem.query_generation |
integer | 0 | Current query generation. Set > 0 to enable tracking. |
pgcache.fold_interval_ms |
integer | 1000 | Interval between background fold passes. Lower for more responsiveness to bursty writes; higher for less idle wakeup cost. |
pgcache.fold_enabled |
boolean | on | Kill switch for the fold worker (debug). |
pgcache.reverse_log_cap_kb |
integer | 65536 | Reverse-log size at which a recording backend folds inline before appending; bounds the backlog when the fold worker is starved of CPU. 0 disables. |
pgcache.fold_budget_items |
integer | 65536 | Items one inline fold detaches before stopping (whole reverse entries), bounding the stall a tripped backend pays. |
| Function | Returns | Description |
|---|---|---|
pgcache_generation_available() |
boolean | Check if shared memory is initialized |
pgcache_generation_record(table_oid, pk_hash, generation) |
boolean | Manually record a generation entry (appends to the reverse log) |
pgcache_generation_lookup(table_oid, pk_hash) |
bigint | Look up generation for a specific row (folds first) |
pgcache_generation_fold() |
bigint | Drain the reverse log into the forward hash. Returns items folded |
pgcache_generation_reverse_bytes() |
bigint | Current DSA bytes held in reverse-log chunks |
pgcache_generation_dsa_bytes() |
bigint | DSM bytes backing the DSA area (its /dev/shm footprint; only shrinks when a segment is entirely free) |
pgcache_generation_fold_budgeted(budget) |
bigint | Fold at most budget items plus the entry crossing it. Returns items folded |
pgcache_generation_inline_folds() |
bigint | Folds run by recording backends because the reverse log passed pgcache.reverse_log_cap_kb |
pgcache_generation_purge(table_oid, threshold) |
bigint | Purge entries for a table, returns count |
pgcache_generation_purge_all(threshold) |
bigint | Purge entries for all tables, returns count |
pgcache_generation_dump() |
SETOF record | Dump all entries (table_oid, pk_hash, generation); folds first |
pgcache_pk_hash(table_oid, ctid) |
bigint | Compute pk_hash for a row |
pgcache_enable_tracking(table_oid) |
boolean | Create tracking trigger on table |
pgcache_disable_tracking(table_oid) |
boolean | Remove tracking trigger from table |
pgcache_purge_rows(threshold) |
bigint | Delete rows and purge entries, returns deleted count |
- Generation values must be > 0 for threshold-based purges to touch a row. Generation 0 is a placeholder for rows ingested via CDC; it is promoted periodically via
pgcache_generation_zero_promote. - When a row is recorded at multiple generations, the maximum wins after fold.
- Purge operations remove entries where
0 < generation <= threshold. - Scan writes append to the reverse log and do not touch the forward hash directly. A background worker (
pgcache_fold) drains the reverse log into the forward hash on a timer;pgcache_generation_purge*,pgcache_generation_lookup, andpgcache_generation_dumpeach fold inline so callers see current state. - The reverse log is bounded by
pgcache.reverse_log_cap_kb: at or above it, the backend recording a generation foldspgcache.fold_budget_itemsitself before appending (one folder at a time via a conditional lock; the rest keep appending). A CPU-saturated box starves the fold worker while producers keep running, so only producers can hold the backlog; the DSA area never returns a segment while any page is live, so an unbounded backlog is a permanent /dev/shm footprint until restart.
┌───────────────────────────────────────────────────────────────────────┐
│ PostgreSQL Shared Memory │
├───────────────────────────────────────────────────────────────────────┤
│ │
│ Writers (CustomScan + Trigger) Readers (purge / lookup / │
│ ─────────────────────────────── dump) │
│ │ │ │
│ │ append only │ read, evict │
│ ▼ ▼ │
│ ┌─────────────────────────┐ ┌──────────────────────────┐ │
│ │ Reverse Log │ fold → │ Forward Hash │ │
│ │ gen → chunk list of │════════════▶│ (table_oid, pk_hash) → │ │
│ │ (table_oid, pk_hash) │ max-wins │ max_generation │ │
│ └──────────▲──────────────┘ └──────────▲───────────────┘ │
│ │ drain │ │
│ │ │ │
│ ┌─────┴───────────────┐ ┌──────┴──────┐ │
│ │ pgcache_fold │ maintenance │ purge / │ │
│ │ bgworker (timer + │ lock serialize│ fold inline │ │
│ │ pressure latch) │◀═══════════════▶│ callers │ │
│ └─────────────────────┘ └─────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
The forward hash is a lazily-maintained materialized view of the reverse log; see ADR/ADR-001-lazy-forward-hash-generation-tracking.md for the rationale.
# Run all tests. Serial execution is required: several tests assert on
# the process-global shared-memory generation state (dshash + reverse
# log), which concurrent test backends would mutate underneath them.
RUST_TEST_THREADS=1 cargo pgrx test pg18
# Run a specific test
cargo pgrx test pg18 test_pgcache_purge_rows