pg_local_cache / Technical

pg_local_cache technical reference

This document describes the implementation boundaries that affect correctness, capacity planning, security, and client compatibility. For task-oriented instructions, use these guides:

Product boundary

pg_local_cache is an in-process PostgreSQL row cache, not a general query result cache. It stores complete table rows addressed by a validated primary key. The planner substitutes only narrow exact-key or bounded single-column IN/ANY shapes and retains the original PostgreSQL index path as the fallback child plan.

It does not cache joins, ranges, aggregates, arbitrary predicates, or full-table results. It does not provide TTLs, pub/sub, clustering, multi-primary cache coordination, or standby cache serving. The optional RESP endpoint exposes the same rows for trusted internal clients, but the product is not positioned as a raw-throughput replacement for Redis or Valkey.

The trade-off is operational: applications can keep ordinary PostgreSQL SQL, row types, ACLs, drivers, and ORM mappings, but the database operator must load a shared library at postmaster startup, reserve bounded shared memory, perform one controlled restart for first activation, attach eligible tables, and monitor the additional in-process state.

The extension and its GUC prefix are named pg_local_cache. User-facing SQL objects are in the local_cache schema, and the default RESP worker role is local_cache_worker. PostgreSQL reserves the pg_ prefix for system schemas and roles; these SQL names therefore omit it.

Supported deployment

The current implementation supports PostgreSQL 14–18 on Linux amd64, one configured database, and one writable primary. It is designed for attached, permanent application tables with an immediate, valid, non-partial B-tree primary key. It does not serve cache entries on standbys and does not coordinate multiple writable primaries.

local_cache.attach_table(regclass, boolean, text) discovers the primary key, records a whole-row mapping, installs extension-owned triggers, and grants the configured worker role only the table privileges required by that mapping. The supported primary-key types are int2, int4, int8, text, varchar, bpchar, and uuid; composite keys may contain 1–16 columns.

The extension rejects temporary or unlogged tables, views, partitioned or inherited tables, extension-owned or system tables, row-level security, partial or expression primary keys, nondeterministic key collations, and non-default primary-key operator classes.

Architecture

Three PostgreSQL execution contexts share the same bounded memory region:

Context Responsibility
Application backend The planner recognizes a supported SQL lookup. The executor reads shared memory or runs the original primary-key index child plan and may fill the cache.
Source-writing backend Extension triggers collect changed keys. The transaction callback publishes invalidation fences before commit visibility and releases them at commit or abort.
Optional RESP background worker The worker owns a TCP event loop. It reads mappings, performs source-table fallback and writes through SPI, and returns RESP2 replies.

The ordinary SQL path does not run in a background worker. It executes in the application’s PostgreSQL backend under that session’s table permissions and snapshot. Likewise, DML invalidation is collected in the backend executing the source transaction.

Mappings are stored in local_cache.mapping and published to shared memory by generation. A worker accepts a mapping only after validating its relation OID, primary key, triggers, ownership provenance, worker-role privileges, and row shape. local_cache.reconcile_table() and reconcile_all() repeat those checks and repair extension-owned triggers and grants before publishing a new mapping generation.

Shared memory and bounds

The cache is allocated at postmaster startup. Each key identifies a database, relation, namespace, and canonical primary-key value. Each entry can hold a positive value, a negative RESP result, invalidation state, version data, and a bounded load lease.

Important compile-time bounds are:

Item Bound
Encoded cache value 8,192 bytes
Canonical key buffer 1,024 bytes; the encoded key must be shorter
RESP request 64 KiB
RESP response value 64 KiB
Mappings per instance 128
Primary-key columns per mapping 16
RESP workers 32
Client slots per worker 128

A whole-row entry always stores a native PostgreSQL composite value. A RESP fill also embeds JSON when both representations fit; a SQL fill stores the composite and renders JSON only if a later RESP read needs it. If the native payload does not fit in 8 KiB, ordinary SQL uses the source plan. A RESP GET can still read and return a wider source row when it fits the 64 KiB response bound, but it will not create an oversized cache entry.

Eviction examines a bounded sample rather than scanning the entire cache. Write-path relation and global invalidation advance version state instead of rewriting every affected entry. Administrative invalidation calls may scan the hash to report an affected-entry count, while correctness still depends on the version change. Load leases are versioned; a late loader cannot overwrite a newer generation after invalidation or eviction.

Entries do not have TTLs. The cache retains an entry until invalidation, eviction, replacement, corruption detection, or an MVCC safety check retires it.

Memory budget and OOM boundary

pg_local_cache.memory_budget_mb is a startup limit for deterministic extension allocations: shared hashes plus RESP client buffers and worker state. Startup fails if the configured extension layout exceeds that budget. local_cache.metrics() reports shared_memory_bytes, worker_memory_bytes, estimated_memory_bytes, and memory_budget_bytes from the same model.

This budget does not include shared_buffers, backend processes, work_mem, the operating system, exporters, or other containers. Set a cgroup or container memory limit with headroom and monitor both the extension estimate and process or container memory. The optional monitoring profile can collect cgroup OOM events through cAdvisor; it requires host-level access and is disabled by default.

RESP connection memory is bounded by the global client limit, per-worker preallocated slots, fixed request/output buffers, idle timeout, output backpressure handling, and slow-client disconnects. In SQL-only mode pg_local_cache.port=0 starts no RESP workers and allocates no RESP client slots.

SQL APIs

The canonical application path is ordinary PostgreSQL SQL over the existing database connection:

SELECT * FROM public.items WHERE id = $1::bigint;
SELECT value FROM public.items WHERE id = $1::bigint;

SELECT * FROM public.items WHERE id IN (1, 7, 42);

SELECT id, value
FROM public.items
WHERE id = ANY($1::bigint[]);

This returns the table’s normal tuple type with PostgreSQL’s normal projection, ACL, prepared-statement, scalar zero-or-one-row behavior, and batch row-set semantics. No result-type witness, column definition list, custom driver, or rewritten result decoder is involved. The planner and executor fast path below is an implementation detail.

KV-style callers can opt into the JSON functions:

SELECT local_cache.get('public.items'::regclass, $1::bigint);
SELECT local_cache.mget('public.items'::regclass, $1::bigint[]);

get(regclass, anyelement) returns complete-row JSON text and mget(regclass, anyarray) returns text[]. Duplicate keys and NULL elements are preserved. Bind or cast the scalar/array element to the actual single-column primary-key type.

get(regclass, text[]) is the fallback for composite and heterogeneous primary keys. Its components are in the order recorded by attach_table(). The function converts each component with the primary-key type’s normal PostgreSQL input function.

All three functions are SECURITY INVOKER, enforce SELECT on the source relation, and fail closed to a source lookup whenever the shared entry cannot be used safely. After the current transaction writes an attached table, both the ordinary SQL and function paths bypass the cache and use the transaction’s own snapshot. The normal pre-commit invalidation fence makes the new row refillable only after commit; rollback does not publish it.

The caller needs USAGE on schema local_cache, EXECUTE on the selected function overloads, and its normal source-table SELECT privilege. No RESP listener, authentication token, or cache-specific client connection is involved.

Planner and executor fast path

pg_local_cache.sql_cache is a USERSET GUC and defaults to on. The planner adds Custom Scan (pg_local_cache_sql) only when all of these conditions hold:

Examples that can use the path:

SELECT * FROM public.items WHERE id = $1;

SELECT metadata, id, value
FROM public.items
WHERE id = $1
LIMIT 1;

SELECT *
FROM public.tenant_items
WHERE item_id = $1 AND tenant_id = $2;

SELECT * FROM public.items WHERE id IN (1, 7, 42);

SELECT id, value
FROM public.items
WHERE id = ANY($1::bigint[]);

Predicate order does not need to match composite-key order. Parameters must be type-compatible with the key. The implementation accepts the same text/varchar representation and lossless widening from int2 to int4/int8 or from int4 to int8; other cross-type forms use the normal planner.

The custom path retains the ordinary primary-key index path as its child. Scalar execution follows this sequence:

  1. Revalidate mapping generation, relation shape, triggers, isolation level, snapshot type, and transaction state.
  2. Canonicalize primary-key parameters.
  3. Read the versioned shared entry.
  4. On a valid positive hit, verify the stored tuple’s source transaction against the current MVCC snapshot and decode the composite row.
  5. On a miss, negative RESP entry, unsafe snapshot, malformed payload, or stale row-shape fingerprint, execute the child index plan.
  6. Fill only after a latest-snapshot visibility proof and only if the read token still matches all relevant generations.

For IN/ANY, the executor evaluates the array once, ignores NULL elements, canonicalizes and deduplicates at most 1,024 keys, and copies at most 16 MiB of validated composite data into query-local memory. It returns cached rows only when every distinct key is a safe positive hit. One miss, negative entry, snapshot rejection, malformed payload, or budget overflow switches the entire statement to the retained PostgreSQL scalar-array index plan. The executor never merges a partial cache result with source rows, so PostgreSQL’s duplicate, NULL, and missing-key semantics are preserved. Rows returned by a cold child scan can refill their individual keys after the normal latest-snapshot proof.

Composite tuple IN, ALL, extra predicates, array queries with LIMIT, and arrays containing unsupported expressions use normal PostgreSQL paths. Runtime safety failures use the retained child plan. A missing row is fetched through that plan and returns zero rows. The SQL path does not trust negative RESP entries as authoritative for an application snapshot.

Application roles need only their normal source-table privileges. They do not need USAGE on local_cache to benefit from a cached SELECT. Attach rejects RLS tables rather than attempting to share rows across policies.

Consistency model

The source table remains authoritative. The cache provides a single-primary, transaction-aware invalidation model rather than asynchronous invalidation from WAL or a message bus.

Source writes

Extension-owned ENABLE ALWAYS row triggers canonicalize the complete old and new primary keys and collect them in backend-local transaction memory. TRUNCATE and selected DDL collect relation or global invalidation. A primary key update collects both keys.

At the pre-commit callback, before the source transaction can become visible, the extension reserves shared dirty markers, advances versions, cancels affected loads, and invalidates existing entries. Readers that overlap this window may observe the previously committed row or the newly committed row, but cannot publish a stale fill across the version fence. Commit releases the dirty markers. Abort releases transaction state without publishing source data. If abort follows publication of the pre-commit fence, affected entries may remain invalid, but the cache cannot expose the uncommitted row.

The per-transaction key list is bounded by pg_local_cache.max_dirty_keys. When that bound is reached, invalidation widens to the relation instead of growing backend memory without limit. If the required shared marker cannot be reserved, the transaction uses a global fence. Both fallbacks preserve the commit boundary at the cost of a colder cache.

PREPARE TRANSACTION is rejected after a transaction modifies an attached mapping. Read-only two-phase transactions are unaffected.

Fills and snapshots

Every miss captures entry, relation, global, and configuration generations. The fill is accepted only if those generations still match after the source read. RESP same-key cold reads use a bounded single-flight lease: followers wait up to pg_local_cache.singleflight_wait_ms, then read the table themselves if necessary without overwriting the leader’s generation. Ordinary SQL backends do not wait on the RESP single-flight lease; a non-owner executes its child plan.

For ordinary SQL, a positive entry records the source tuple’s xmin and the observed full transaction ID. The executor checks snapshot membership before returning the row. The full-XID observation horizon prevents treating a wrapped 32-bit transaction ID as current indefinitely. When that horizon is exceeded, the exact entry is retired and the source plan runs. This is not TTL expiration.

REPEATABLE READ and SERIALIZABLE always use the ordinary plan. A READ COMMITTED transaction that has written an attached mapping also bypasses the cache for its remaining statements, preserving read-your-own-write behavior.

Trigger and DDL provenance

Workers and the SQL path validate the managed statement, row, and truncate triggers, including function OIDs, arguments, enable state, and extension ownership. A mapping with missing or altered provenance is not served.

DDL event triggers reload or invalidate mappings when attached relations or dependent type/output semantics change. Whole-row payloads include a tuple descriptor fingerprint and CRC32C, so bytes saved for an old row shape are not decoded as a new row shape. DROP TABLE forgets the mapping by relation OID; recreating the same name does not attach the new table automatically.

RESP2 wire API

The optional endpoint is KVik-inspired and uses the documented whole-row CRUD key shape below. It has not been conformance-tested against Postgres Pro Enterprise KVik. Client integrations should rely only on the surface documented here.

The whole-row key format is:

CRUD:database.schema.table:{"pk_column":<json-scalar>,...}

The JSON object must contain every primary-key field exactly once. Member order is irrelevant; the worker parses values through PostgreSQL input functions and canonicalizes them in primary-index order. Database, schema, and table names in whole-row mappings cannot contain . or :.

Command Behavior
AUTH token Authenticate with the shared token.
AUTH username token Authenticate when username equals the configured worker role.
GET CRUD:db.schema.table:{pk-json} Return complete row JSON; on a cache miss, read the source table. Return RESP null when the source row does not exist.
SET CRUD:db.schema.table:{pk-json} row-json Perform a whole-row PostgreSQL upsert for a writable mapping and reply after commit.
DEL CRUD:db.schema.table:{pk-json} Delete the source row for a writable mapping and reply after commit.
INVALIDATE <scope> Invalidate an exact key, table, configured database, or the complete instance.
STAT / STATS Return native statistics plus a subset of KVik-style counter aliases.
PING, ECHO, HELLO 2, INFO, QUIT Supported connection and diagnostic subset.
CLIENT, COMMAND, SELECT 0 Minimal compatibility responses for common Redis clients.

SET treats the wire key as authoritative. Primary-key fields may be omitted from the row JSON; when present, they must match the wire key. The value is a complete row, not a JSON patch. Omitted non-key fields become NULL; column defaults do not fill them because the upsert supplies every non-generated column. Unknown JSON fields are rejected. PostgreSQL casts, generated-column rules, constraints, triggers, WAL, and commit still apply. SET and DEL each execute as a separate PostgreSQL transaction.

If the TCP connection closes before a write reply arrives, the client cannot infer whether the database transaction committed. Retrying a non-idempotent operation without reading the source can repeat application effects.

Compatibility boundary

Area pg_local_cache status Boundary
CRUD:database.schema.table:{pk-json} keys Implemented Whole-row mappings only.
Composite primary keys Implemented 1–16 supported PK columns.
Source fallback on GET Implemented Missing rows may be stored as negative RESP entries.
Whole-row SET and DEL Implemented Mapping must be attached with writable=true; operations are PostgreSQL transactions.
Key/table/database/global invalidation Implemented Uses version fences in shared memory.
KVik-style statistic names Partial aliases Native counters remain authoritative; internal meanings are not claimed identical.
Common Redis client handshake Partial RESP2 subset only; unsupported commands return an error.
PostgreSQL per-user authentication and ACLs Not provided on RESP One shared token and worker role cover every mapping.
TLS Not provided Use loopback or an authenticated TLS proxy.
TTL and expiration commands Not provided Entries have no TTL.
Redis Cluster, Lua, Pub/Sub, transactions Not provided No MGET, MULTI/WATCH, scripting, or RESP3.
Multiple databases, standbys, multi-primary Not provided One configured database on one writable primary.

Row payload format

Whole-row cache values use a versioned binary header. All header integers are big-endian; this is an internal shared-cache format, not the RESP wire format.

Offset Width Field
0 4 Magic (PGLC)
4 2 Payload version
6 2 Flags
8 4 Composite type OID
12 4 Type modifier
16 4 Attribute count
20 4 Native composite length
24 4 JSON length
28 4 CRC32C checksum
32 8 Tuple-descriptor fingerprint

Version 1 has a 40-byte header followed by the native composite bytes and, when the HAS_JSON flag is set, stored JSON bytes. Decode checks the magic, version, known flags, total lengths, checksum, type OID, typmod, attribute count, descriptor fingerprint, and native tuple header before exposing a row.

Configuration

Except for pg_local_cache.sql_cache, these GUCs are postmaster settings and require a PostgreSQL restart.

GUC Default Purpose
pg_local_cache.port 6380 RESP listener port; 0 disables RESP workers.
pg_local_cache.bind_address 127.0.0.1 RESP listener address: 127.0.0.1 or 0.0.0.0.
pg_local_cache.workers 4 RESP worker count when the listener is enabled.
pg_local_cache.database postgres Database served by mappings and RESP workers.
pg_local_cache.role local_cache_worker Dedicated RESP worker role.
pg_local_cache.cache_entries 16384 Shared cache entry capacity, 128–65,536.
pg_local_cache.relation_states 1024 Shared relation-state capacity, 128–8,192.
pg_local_cache.max_clients 256 Global RESP client limit, 1–4,096.
pg_local_cache.max_clients_per_worker 64 Preallocated slots per worker, 1–128.
pg_local_cache.memory_budget_mb 384 Deterministic extension-memory startup budget, 64–8,192 MiB.
pg_local_cache.idle_timeout_ms 300000 RESP idle/slow-client deadline.
pg_local_cache.statement_timeout_ms 2000 RESP worker database-operation deadline.
pg_local_cache.lock_timeout_ms 250 RESP worker lock-wait deadline.
pg_local_cache.singleflight_wait_ms 25 RESP follower wait for a same-key loader, 0–1,000 ms.
pg_local_cache.max_pipeline_commands 256 Per-client commands processed in one event-loop turn.
pg_local_cache.max_dirty_keys 4096 Per-transaction key bound before relation invalidation, 128–16,384.
pg_local_cache.auth_token_file empty Preferred RESP token source.
pg_local_cache.auth_token empty Inline development/test token; hidden from SHOW ALL.
pg_local_cache.allow_superuser off Permit a superuser RESP role for local development only.
pg_local_cache.sql_cache on Session-level ordinary-SQL fast-path switch; no restart required.

max_clients must not exceed workers * max_clients_per_worker. Enabling RESP also consumes PostgreSQL background-worker slots; preserve capacity for replication, parallel queries, and other extensions when setting max_worker_processes.

Security boundary

The SQL and RESP surfaces have different identities:

Keep RESP disabled when it is unnecessary. When enabled, bind to loopback by default, store the token in an OS-user-owned mode 0400 or 0600 regular file, and place any remote access behind network isolation and authenticated TLS. Do not use the inline token in a production configuration or pass tokens on a command line that may be visible in process listings.

The worker role must be LOGIN NOSUPERUSER NOINHERIT; the extension refuses a superuser worker unless allow_superuser=on. Attach and reconcile manage only the grants required by configured mappings.

Metrics and operations

The SQL monitoring surface is:

SELECT * FROM local_cache.metrics();
SELECT local_cache.health();
SELECT local_cache.stats();

metrics() returns a typed one-row snapshot. health() evaluates memory budget, worker count, client limit, and mapping-generation readiness. stats() returns the detailed JSON diagnostic snapshot. Counters reset at PostgreSQL restart; rate and alert calculations must tolerate resets.

Watch at least:

The bundled Prometheus rules and Grafana dashboard are described in the monitoring guide. They are templates; alert windows and memory thresholds must be calibrated against the deployment.

Operational limits

For rollout and rollback procedures, including Patroni and operator-managed clusters, follow the existing-database guide. For performance claims, use the evidence rules in the benchmark guide.