[← Back to Blog Index](README.md)

# pg_trickle on CloudNativePG

## Running Incremental View Maintenance in Production Kubernetes

---

Running a stateful PostgreSQL extension in Kubernetes requires more thought than deploying a stateless web service. The extension has background workers, shared memory segments, and per-database state. Upgrades must be coordinated with the schema migration process. High availability means the extension must survive primary failover.

This post covers running pg_trickle on [CloudNativePG](https://cloudnative-pg.io/) — the CNCF-sandbox Kubernetes operator for PostgreSQL — in a production setup. The focus is on the operational mechanics: getting the extension installed, keeping it healthy across restarts and failovers, and monitoring it from outside the database.

---

## Prerequisites

- Kubernetes 1.27+
- CloudNativePG operator 1.24+ installed in the cluster
- A container image with both PostgreSQL 18 and pg_trickle installed
- The pg_trickle shared library preloaded

---

## The Container Image

CloudNativePG manages the PostgreSQL binary and data directory lifecycle. You need to provide a container image that includes the extension.

A minimal Dockerfile:

```dockerfile
FROM ghcr.io/cloudnative-pg/postgresql:18

# Install build dependencies
USER root
RUN apt-get update && apt-get install -y \
    build-essential \
    postgresql-server-dev-18 \
    git \
    curl \
    pkg-config \
    libssl-dev \
    && rm -rf /var/lib/apt/lists/*

# Install Rust (required to build pg_trickle)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"

# Install pgrx
RUN cargo install cargo-pgrx --version 0.18.0 && \
    cargo pgrx init --pg18 /usr/lib/postgresql/18/bin/pg_config

# Build and install pg_trickle
ARG PGTRICKLE_VERSION=0.36.0
RUN git clone --depth 1 --branch v${PGTRICKLE_VERSION} \
    https://github.com/grove/pg-trickle.git /tmp/pg-trickle && \
    cd /tmp/pg-trickle && \
    cargo pgrx install --release --pg-config /usr/lib/postgresql/18/bin/pg_config && \
    rm -rf /tmp/pg-trickle

USER 26
```

For production, pin to a specific digest rather than a tag. Build the image in CI and push to your internal registry.

---

## The Cluster Manifest

A production-ready CNPG `Cluster` resource:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pgtrickle-cluster
  namespace: production
spec:
  instances: 3
  imageName: your-registry/postgresql-pgtrickle:18-0.36.0

  postgresql:
    parameters:
      # Required: load pg_trickle's shared library
      shared_preload_libraries: "pg_trickle"

      # pg_trickle configuration
      pg_trickle.enabled: "on"
      pg_trickle.max_parallel_workers: "4"
      pg_trickle.backpressure_enabled: "on"
      pg_trickle.backpressure_max_lag_mb: "128"
      pg_trickle.log_format: "json"

      # Standard PostgreSQL tuning
      shared_buffers: "2GB"
      effective_cache_size: "6GB"
      maintenance_work_mem: "512MB"
      work_mem: "64MB"
      max_connections: "200"
      wal_level: "logical"       # required for pg_trickle's WAL features
      max_wal_senders: "10"
      max_replication_slots: "10"

  bootstrap:
    initdb:
      database: app
      owner: app
      postInitSQL:
        # Install extension in the target database
        - CREATE EXTENSION IF NOT EXISTS pg_trickle;
        # Optionally install pgvector if using vector features
        - CREATE EXTENSION IF NOT EXISTS vector;

  storage:
    size: 100Gi
    storageClass: fast-ssd  # Use NVMe-backed storage for pg_trickle workloads

  resources:
    requests:
      memory: "8Gi"
      cpu: "4"
    limits:
      memory: "16Gi"
      cpu: "8"

  # Monitoring integration
  monitoring:
    enablePodMonitor: true
    customQueriesConfigMap:
      - name: pgtrickle-metrics
        key: queries.yaml
```

The critical parameters:
- `shared_preload_libraries: "pg_trickle"` — required for the background worker to start
- `wal_level: "logical"` — required for pg_trickle's WAL decoder
- `postInitSQL` — runs once during database creation, installs the extension

---

## High Availability and Failover

CNPG runs one primary and N-1 standbys. When the primary fails, CNPG promotes a standby and updates the service endpoints. Your application reconnects to the new primary.

pg_trickle's background workers run only on the primary. On a standby:
- The extension code is present (the shared library loads fine)
- The pg_trickle catalog tables are replicated
- The background workers don't start (the standby is read-only)

On failover:
1. CNPG promotes a standby to primary
2. PostgreSQL starts up
3. pg_trickle's background workers start as part of the `shared_preload_libraries` initialization
4. The workers load the stream table catalog and begin processing any pending change buffer entries

The change buffers are regular PostgreSQL tables, replicated via streaming replication. Changes captured before the failover are in the buffers on the new primary and will be processed after startup.

**Expected staleness on failover:** Up to the maximum of:
- The change buffer accumulation during the failover window (typically 10–60 seconds)
- The stream table's configured `schedule`

For a `schedule = '5 seconds'` stream table, expect up to ~60 seconds of staleness after failover on a fast cluster.

---

## Configuration Management with ConfigMaps

Rather than hardcoding GUC values in the Cluster manifest, use a ConfigMap for pg_trickle settings and reference it:

```yaml
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: pgtrickle-config
data:
  pg_trickle.enabled: "on"
  pg_trickle.max_parallel_workers: "4"
  pg_trickle.backpressure_enabled: "on"
  pg_trickle.backpressure_max_lag_mb: "128"
  pg_trickle.default_schedule: "5 seconds"
  pg_trickle.log_format: "json"
```

```yaml
# In the Cluster spec
spec:
  postgresql:
    parameters:
      shared_preload_libraries: "pg_trickle"
    configMapRef:
      name: pgtrickle-config
```

This lets you update GUC values via `kubectl apply` without modifying the Cluster resource, and enables configuration review in git via normal PR workflow.

---

## Prometheus Metrics

pg_trickle exposes metrics via the PostgreSQL query interface. Export them with a custom queries ConfigMap for the CNPG PodMonitor:

```yaml
# pgtrickle-metrics.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: pgtrickle-metrics
data:
  queries.yaml: |
    pg_trickle_stream_tables:
      query: |
        SELECT
          name,
          sla_tier,
          EXTRACT(EPOCH FROM (NOW() - last_refresh_at))::float AS staleness_seconds,
          rows_changed_last_cycle,
          avg_refresh_ms / 1000.0 AS avg_refresh_seconds,
          pending_change_rows
        FROM pgtrickle.stream_table_status()
      metrics:
        - name:
            usage: LABEL
            description: Stream table name
        - sla_tier:
            usage: LABEL
            description: SLA tier
        - staleness_seconds:
            usage: GAUGE
            description: Seconds since last refresh
        - rows_changed_last_cycle:
            usage: GAUGE
            description: Rows changed in last refresh cycle
        - avg_refresh_seconds:
            usage: GAUGE
            description: Average refresh duration in seconds
        - pending_change_rows:
            usage: GAUGE
            description: Pending rows in change buffer

    pg_trickle_change_buffers:
      query: |
        SELECT
          source_table,
          pending_rows,
          EXTRACT(EPOCH FROM (NOW() - oldest_change_at))::float AS backlog_age_seconds
        FROM pgtrickle.change_buffer_status()
      metrics:
        - source_table:
            usage: LABEL
        - pending_rows:
            usage: GAUGE
            description: Pending rows in change buffer for this source
        - backlog_age_seconds:
            usage: GAUGE
            description: Age of the oldest pending change in seconds
```

With this, Grafana can visualize stream table staleness per SLA tier, change buffer depth per source table, and refresh timing trends.

---

## Alerting Rules

Essential Prometheus alerts:

```yaml
groups:
- name: pgtrickle
  rules:
  - alert: StreamTableCriticalStaleness
    expr: |
      pg_trickle_stream_tables_staleness_seconds{sla_tier="critical"} > 30
    for: 2m
    labels:
      severity: critical
    annotations:
      summary: "Critical stream table {{ $labels.name }} is {{ $value | humanizeDuration }} stale"

  - alert: StreamTableStandardStaleness
    expr: |
      pg_trickle_stream_tables_staleness_seconds{sla_tier="standard"} > 300
    for: 5m
    labels:
      severity: warning
    annotations:
      summary: "Standard stream table {{ $labels.name }} is {{ $value | humanizeDuration }} stale"

  - alert: ChangeBufferBacklog
    expr: |
      pg_trickle_change_buffers_backlog_age_seconds > 600
    for: 5m
    labels:
      severity: warning
    annotations:
      summary: "Change buffer for {{ $labels.source_table }} has {{ $value | humanizeDuration }} backlog"
```

The critical staleness alert fires before users notice. The backlog alert fires when the refresh workers can't keep up with the write load.

---

## Upgrading pg_trickle

Upgrading pg_trickle follows the standard extension upgrade pattern, coordinated with CNPG's rolling update mechanism.

**Step 1: Build the new image**

Build a new container image with the upgraded extension version. Push to your registry.

**Step 2: Update the Cluster manifest**

```yaml
spec:
  imageName: your-registry/postgresql-pgtrickle:18-0.37.0  # new version
```

**Step 3: CNPG performs a rolling update**

CNPG stops the old primary, starts a new one with the new image, and promotes it. The standby instances update one at a time.

**Step 4: Run the extension migration**

After the cluster is running the new image, the extension schema may need to be upgraded:

```sql
ALTER EXTENSION pg_trickle UPDATE TO '0.37.0';
```

For CNPG, this is most cleanly done as a `postUpgradeSQL` hook in the Cluster spec, or as a Job that runs after the rolling update completes.

---

## Sizing Guidance

pg_trickle's resource consumption scales with:
- Number of stream tables
- Write volume on source tables (drives change buffer size and refresh frequency)
- Complexity of stream table queries (drives delta computation cost)

For a typical deployment with 10–20 stream tables and moderate write volume:

| Resource | Minimum | Recommended |
|----------|---------|-------------|
| CPU (cores) | 2 | 4–8 |
| Memory | 4GB | 8–16GB |
| Storage IOPS | 3,000 | 10,000+ (NVMe preferred) |
| `max_parallel_workers` | 2 | 4 |
| `shared_buffers` | 1GB | 25% of RAM |
| `maintenance_work_mem` | 256MB | 1–2GB (for REINDEX operations) |

The most important factor is storage latency. pg_trickle's refresh cycles are I/O-bound when processing large deltas or maintaining HNSW indexes. SSDs (NVMe preferred) make the difference between 20ms refresh cycles and 200ms ones.

---

## Production Checklist

Before going live with pg_trickle on CNPG:

- [ ] `shared_preload_libraries` includes `pg_trickle`
- [ ] `wal_level = logical` is set
- [ ] Extension installed in target database via `postInitSQL`
- [ ] Custom metrics ConfigMap deployed and referenced in Cluster manifest
- [ ] Prometheus alerts configured (staleness, backlog)
- [ ] Grafana dashboard imported
- [ ] SLA tiers configured for all stream tables
- [ ] Failover tested: `kubectl cnpg promote` to simulate primary failure, verify stream tables resume within expected window
- [ ] Upgrade path tested in staging: new image + `ALTER EXTENSION UPDATE`
- [ ] Backpressure enabled and threshold tuned for your write volume

---

*pg_trickle is an open-source PostgreSQL extension for incremental view maintenance. Source and documentation at [github.com/grove/pg-trickle](https://github.com/grove/pg-trickle).*
