> **Plain-language companion:** [v0.50.0.md](v0.50.0.md) ## v0.50.0 — Performance, Security & Operational Hardening **Status: ✅ Released.** > **Release Theme** > Remove the last known performance inefficiencies in the differential refresh > hot path; close the security defense-in-depth gap in Citus dblink escaping; > and complete the operational story for production users with CNPG graceful > drain, digest-pinned Docker images, and comprehensive Prometheus coverage. --- ### Deliverables | ID | Title | Effort | Priority | Status | |----|-------|--------|----------|--------| | PERF-10-01 | Batch preflight source-table existence check | M | P1 | ✅ Done | | PERF-10-02 | CDC trigger SQL string-building optimisation | S | P2 | ✅ Done | | PERF-10-03 | Single-query watermark computation | S | P2 | ✅ Done | | SEC-10-01 | Replace manual dblink escaping with `pg_quote_literal` | M | P1 | ✅ Done | | OPS-10-01 | CNPG preStop lifecycle drain hook | S | P1 | ✅ Done | | OPS-10-02 | Prometheus reliability counters | M | P2 | ✅ Done | | OPS-10-03 | Docker base-image digest pinning | S | P3 | ✅ Done | | SCAL-10-01 | Invalidation ring observability & docs | S | P2 | ✅ Done | | COR-10-01 | Deep join chain threshold documentation | S | P3 | ✅ Done | --- ### PERF-10-01 — Batch Preflight Source-Table Existence Check **Problem:** `execute_differential_refresh()` in `src/refresh/merge/mod.rs` made one `to_regclass()` SPI call **per source OID** in the preflight loop, adding O(N) SPI round-trips for every refresh cycle. For a stream table with five sources this was five individual queries. **Fix:** Replaced the per-OID loop with a single batch query: ```sql SELECT o.oid::bigint, to_regclass('.changes_' || o.oid::text) IS NOT NULL FROM unnest(ARRAY[oid1::oid, oid2::oid, ...]) AS o(oid) ``` All existence checks are resolved in a single SPI call. The first missing OID short-circuits the check identically to the previous per-loop logic. **Impact:** Saves approximately 2–4 ms per refresh cycle for every source beyond the first. At 100 refreshes/sec with 5-source STs this eliminates ~400 ms/sec of SPI overhead. **Files changed:** `src/refresh/merge/mod.rs` --- ### PERF-10-02 — CDC Trigger SQL String-Building Optimisation **Problem:** `build_stmt_trigger_fn_sql()` in `src/cdc.rs` accumulated column name lists using `Vec` + `.join(",")`, allocating one heap string per column per CDC trigger SQL rebuild. For a 50-column table this was 50 intermediate allocations per CDC registration. **Fix:** Switched to `String::with_capacity(columns.len() * 20)` with direct `push_str()` appends, eliminating the intermediate `Vec` and its per-element allocations. **Files changed:** `src/cdc.rs` --- ### PERF-10-03 — Single-Query Watermark Computation (Confirmed) **Problem:** The task called for consolidating three separate queries in the watermark computation path into one compound SELECT. **Finding:** `compute_safe_upper_bound()` in `src/cdc.rs` already uses a single compound CTE that returns `pg_current_wal_lsn()`, `pg_stat_activity` xmin probe, and `pg_prepared_xacts` in one round-trip. The consolidation was done in a prior release. **Action:** Added an explanatory comment referencing PERF-10-03 to document why the code is written as a single CTE. **Files changed:** `src/cdc.rs` (comment only) --- ### SEC-10-01 — Replace Manual dblink Escaping **Problem:** Four `dblink(...)` call sites in `src/citus.rs` escaped connection strings and remote query strings using manual `.replace('\'', "''")`. While the inputs are sourced from `pg_dist_node` (a system table), this pattern violates defense-in-depth: an attacker with write access to `pg_dist_node` hostnames could inject arbitrary SQL through the `worker_conn_string()` output, and the options string `options='-c enable_seqscan=on'` contained literal single quotes that the old `.replace()` left intact. **Fix:** Added `pg_quote_literal(s: &str) -> String` helper in `src/citus.rs` that delegates to `pg_catalog.quote_literal($1)` via SPI. PostgreSQL's own literal quoting is correct by definition. All four call sites now use this helper; `worker_conn_string()` no longer performs any internal escaping (callers are responsible). **Security note:** `pg_quote_literal()` returns the value **including** surrounding single quotes, so all SQL format strings were updated from `"dblink('{esc}', '{esc}')"` to `"dblink({esc}, {esc})"`. **Files changed:** `src/citus.rs` --- ### OPS-10-01 — CNPG preStop Lifecycle Drain Hook **Problem:** When CNPG terminates a primary pod (rolling upgrade, scale-down, eviction), in-flight refresh workers were killed mid-execution. The stream tables recovered safely on restart but the full-rebuild latency after the upgrade was unnecessary. **Fix:** Added `lifecycle.preStop` hook to `cnpg/cluster-production.yaml`: ```yaml lifecycle: preStop: exec: command: - /bin/sh - -c - psql -U postgres -c "SELECT pgtrickle.drain(timeout_s => 120)" || true ``` The `|| true` ensures the pod terminates even if the database is unavailable (e.g., primary failover has already occurred). The 120-second timeout matches Kubernetes's default `terminationGracePeriodSeconds`. Added a new "Kubernetes Rolling Upgrade" section to `docs/RUNBOOK_DRAIN.md` documenting the hook, when it fires, what `pgtrickle.drain()` does, and a post-upgrade verification procedure. **Files changed:** `cnpg/cluster-production.yaml`, `docs/RUNBOOK_DRAIN.md` --- ### OPS-10-02 — Prometheus Reliability Counters **Problem:** Three important reliability counters (`invalidation_ring_overflows`, `dag_cycles_detected`, `template_cache_stale_evictions`) existed as ideas but were missing from both the shared-memory layer and the Prometheus export. **Fix — shared memory (`src/shmem.rs`):** Added two new `PgAtomic` statics: - `TEMPLATE_CACHE_STALE_EVICTIONS` (`pg_trickle_tmpl_stale_evict`) - `DAG_CYCLES_DETECTED` (`pg_trickle_dag_cycles_detected`) Both are registered in `init_shared_memory()` and have matching `increment_*()` helper functions. **Fix — stale eviction counting (`src/dvm/mod.rs`):** `generate_delta_query_cached()` now detects hash-mismatch stale entries explicitly: ```rust match map.get(&pgt_id) { Some(entry) if entry.defining_query_hash == query_hash => (Some(entry.clone()), false), Some(_) => (None, true), // stale: hash changed None => (None, false), // cold miss } ``` Stale entries are evicted and `increment_template_cache_stale_evictions()` is called. **Fix — cycle counter (`src/dag.rs`):** `detect_cycles()` now calls `crate::shmem::increment_dag_cycles_detected()` before returning `Err(PgTrickleError::CycleDetected(...))`. **Fix — SQL function (`src/monitor.rs`):** New `pgtrickle.reliability_counters()` pg_extern returns one row with three `i64` columns: `invalidation_ring_overflows`, `dag_cycles_detected`, `template_cache_stale_evictions`. Safely returns `(0, 0, 0)` when shared memory is not available. **Fix — Prometheus queries (`monitoring/prometheus/pg_trickle_queries.yml`):** New `pg_trickle_reliability` query block exports all three counters as `COUNTER` metrics. **Files changed:** `src/shmem.rs`, `src/dvm/mod.rs`, `src/dag.rs`, `src/monitor.rs`, `monitoring/prometheus/pg_trickle_queries.yml` --- ### OPS-10-03 — Docker Base-Image Digest Pinning **Problem:** All three Dockerfiles used floating tags (`postgres:18.3-bookworm`, `postgres:18.3`). A change to the upstream image could silently change build behaviour or introduce a security regression without a code change. **Fix:** All three Dockerfiles pin to the `linux/amd64` SHA256 digest of `postgres:18.3-bookworm`: ``` @sha256:a40f5f7a480e2555f57baeaaf36450446c6b36ee21bdd223afd382d499ac7375 ``` Added `scripts/update_base_image_digests.sh` which resolves the current digest via `docker manifest inspect` and patches all Dockerfiles in-place. Run quarterly or when a PostgreSQL patch release is needed. Added a "Updating base image digests" section to `CONTRIBUTING.md` documenting the process. **Files changed:** `Dockerfile.demo`, `Dockerfile.ghcr`, `tests/Dockerfile.e2e`, `scripts/update_base_image_digests.sh` (new), `CONTRIBUTING.md` --- ### SCAL-10-01 — Invalidation Ring Observability & Documentation **Problem:** The hard ceiling of 1,024 entries in the invalidation ring was undocumented. Operators of large deployments (1,000+ stream tables) had no guidance on how to configure `pg_trickle.invalidation_ring_capacity` or what would happen on overflow. **Fix:** Added a detailed `pg_trickle.invalidation_ring_capacity` section to `docs/CONFIGURATION.md` (new "Invalidation Ring & Deep-Join Tuning" chapter) covering: - Default (128), maximum (1,024), and shared-memory cost per slot (~8 bytes) - Overflow behaviour: full DAG rebuild triggered, overflow counter incremented - A capacity sizing table for deployments by ST count - The `pg_trickle_reliability_invalidation_ring_overflows_total` Prometheus counter for alerting **Files changed:** `docs/CONFIGURATION.md` --- ### COR-10-01 — Deep Join Chain Threshold Documentation **Problem:** `pg_trickle.part3_max_scan_count` (default 5) controls when the differential engine skips the Part 3 correction term for deep join chains. The GUC existed but was not documented in `docs/CONFIGURATION.md`. **Fix:** Added a `pg_trickle.part3_max_scan_count` section to the new "Invalidation Ring & Deep-Join Tuning" chapter in `docs/CONFIGURATION.md` covering: - Default (5), range (1–10000) - The trade-off table (low/default/high threshold effects) - Recommendations by join-chain depth (≤6 tables vs. >6 tables) - Diagnostic advice (verbose logging to observe Part 3 selection) **Files changed:** `docs/CONFIGURATION.md` --- ### SQL Upgrade Script `sql/pg_trickle--0.49.1--0.50.0.sql` — No DDL schema changes. All changes are in Rust/shared memory or GUC documentation. The migration script records the extension version bump only. --- ### Testing All tests pass on `v0.50.0-implementation`: - `just test-unit` ✅ - `just test-integration` ✅ - `just test-light-e2e` ✅