pg_automergev0.1.0

CloudNativePG

CloudNativePG (CNPG) can add an extension to a cluster without a custom PostgreSQL image. The extension ships as a small image that holds only its files. The operator mounts that image read-only into every instance pod and points PostgreSQL at it. CNPG calls these image volume extensions. The repository builds such an image next to the full Docker image, from the same compile:

ImageWhat it isFor
pg-automerge:<version>the official postgres:18 image plus the extensiondocker run, compose, local development, small deployments
pg-automerge-cnpg:<version>-18-trixiethe extension's files only, FROM scratch (it cannot be run)a CNPG Cluster's spec.postgresql.extensions
mise run docker-build        # both images
mise run docker-build-cnpg   # only pg-automerge-cnpg:0.1.0-18-trixie
mise run cnpg-smoke          # build it, then test it in CNPG's PostgreSQL image (no Kubernetes)
mise run cnpg-e2e            # build it, then test it with the CNPG operator on kind

The name and tag follow CNPG's own extension images (<extension version>-<PostgreSQL major>-<Debian release>, as in pgvector:0.8.6-18-trixie). The image has a repository of its own, so its tags never get mixed up with the runnable image's.

Requirements#

  • CloudNativePG 1.27 or later (spec.postgresql.extensions). Defining the extension in an image catalog needs 1.29 or later.
  • Kubernetes with image volumes: on by default from Kubernetes 1.35, and stable from 1.36. On 1.33 and 1.34, enable the ImageVolume feature gate. The nodes need containerd 2.1 or later, or CRI-O 1.31 or later. On a managed service this depends on the provider's node images.
  • A PostgreSQL 18 operand on Debian trixie, amd64 or arm64, for example ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie or 18-standard-trixie. Both variants share one base, and the library needs only libc and libgcc_s. The extension image must match the cluster's PostgreSQL major version, Debian release and CPU architecture.

No bookworm. A -bookworm operand cannot load the library: it is built on trixie, and needs GLIBC_2.38, which bookworm's glibc 2.36 does not have. CREATE EXTENSION fails with version 'GLIBC_2.38' not found. The image is not built for bookworm either: the builder installs Rust with Debian's rustup package, which bookworm does not have. Debian 12 also reached its end of life in June 2026, and CNPG supports its bookworm images on a best-effort basis only.

A cluster with pg_automerge#

Every release publishes the image for amd64 and arm64 as ghcr.io/theknarf/pg-automerge-cnpg:<version>-18-trixie (see Releasing); a build of your own goes to a registry your cluster can pull from with scripts/docker-push.sh --cnpg. List it in the Cluster. A Database resource can create the extension for you:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: automerge
spec:
  instances: 3
  imageName: ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie   # trixie, like the extension image
  storage:
    size: 10Gi
  resources:                                  # see Memory and pod limits
    requests:
      memory: 4Gi
    limits:
      memory: 4Gi
  postgresql:
    parameters:
      shared_buffers: "1GB"
      max_connections: "20"            # a Pooler in front keeps this small
      pg_automerge.max_load_memory: "512MB"   # optional; see Configuration
    extensions:
      - name: pg-automerge                    # mounted at /extensions/pg-automerge
        image:
          reference: ghcr.io/theknarf/pg-automerge-cnpg:0.1.0-18-trixie
---
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
  name: automerge-app
spec:
  name: app
  owner: app
  cluster:
    name: automerge
  extensions:
    - name: pg_automerge                      # the SQL name
      version: "0.1.0"

For each entry, the operator adds an image volume and mounts it at /extensions/<name>. It also appends that path to two settings:

extension_control_path = '$system:/extensions/pg-automerge/share'
dynamic_library_path   = '$libdir:/extensions/pg-automerge/lib'

PostgreSQL 18 finds pg_automerge.control and the SQL scripts through the first setting. It finds pg_automerge.so through the second, because the control file names the library without a directory (module_pathname = 'pg_automerge'). The extension needs no shared_preload_libraries.

Mounting the image only makes the extension available. The Database resource above runs CREATE EXTENSION pg_automerge as the superuser (it is not trusted, see Install). Without it, a superuser can run that command, for example through kubectl cnpg psql automerge -- -d app. The app owner cannot. To check that the image was resolved:

kubectl get cluster automerge -o jsonpath='{.status.pgDataImageInfo.extensions}'

Pick the name once (pg-automerge above) and keep it. Renaming it moves the mount, which triggers a rolling update.

On kind (or any cluster), you can use a locally built image without a registry. Run kind load docker-image pg-automerge-cnpg:0.1.0-18-trixie and set pullPolicy: IfNotPresent (already the default for any tag but latest) or Never on the image. tests/cnpg_e2e.sh does the same with kind load image-archive and pullPolicy: Never.

Configuration#

The three settings of Configuration (pg_automerge.max_load_memory, pg_automerge.verify_writes, pg_automerge.trim_threshold) go in spec.postgresql.parameters, like any other PostgreSQL setting. CNPG passes custom names with a dot through to postgresql.conf (it only fixes or refuses a known list of its own), and PostgreSQL keeps such a value as a placeholder until a backend loads the library, which then applies it. All three can be changed at runtime: CNPG reloads the configuration without a restart. To check:

kubectl cnpg psql automerge -- -d app -c 'SHOW pg_automerge.max_load_memory'

A per-role or per-database value (ALTER ROLE app SET pg_automerge.verify_writes = off, say) works as on any server; run it as the superuser.

Memory and pod limits#

Loading a document takes memory outside shared_buffers and work_mem, and pg_automerge's memory is not counted by PostgreSQL. In a pod, the container's memory limit is what the kernel enforces: a backend that goes over it is OOM-killed, and PostgreSQL then restarts every session of that instance (CNPG does not fail over for it, but every client reconnects). So size the pod as Sizing memory describes for a container, with resources.limits.memory as the limit:

limits.memory ≥ shared_buffers
              + max_connections × (20 MB + pg_automerge.trim_threshold)
              + concurrent loads × largest document's peak
              + room for the page cache

and set pg_automerge.max_load_memory above the estimate of the largest document you accept and below (limits.memory − shared_buffers − max_connections × (20 MB + trim_threshold)) / concurrent writers. The example above (4 GiB, 1 GB of shared_buffers, 20 connections, the default 64 MB trim threshold) leaves about 3 GB − 20 × 84 MB ≈ 1.3 GB: room for two concurrent loads at its 512 MB max_load_memory, before the page cache. Connections are the expensive term: with 100 (PostgreSQL's default) the same pod would have nothing left, so keep max_connections small (CNPG's Pooler in front helps), lower trim_threshold, or raise the limit. Measure your own largest documents with automerge_memory_usage() (Monitoring) rather than trusting these numbers.

Set requests.memory equal to limits.memory (CNPG's recommendation, for the Guaranteed QoS class), so the node does not overcommit the memory a large load relies on.

Updating#

0.1.0 is the first release; a later version is rolled out like this:

  1. Change extensions[].image.reference to the new tag. CNPG restarts the instances one by one (image volumes are resolved when a pod starts). Every image holds all the upgrade scripts, so any older version can update.
  2. Bump version in the Database resource. The operator then runs ALTER EXTENSION pg_automerge UPDATE TO '<version>'. Without a Database resource, run ALTER EXTENSION pg_automerge UPDATE as a superuser in every database that has the extension.

Between the two steps the new library serves the old SQL definitions, exactly as after recreating a container (Updating). The Changelog says whether a version needs more. A rebuilt image of the same version under a new tag (a newer base, say) needs only step 1: the pods roll and the catalog stays as it is.

To remove the extension, first set ensure: absent in the Database resource (or run DROP EXTENSION), then delete the extensions entry, which triggers a rolling update.

Limitations#

  • Superuser only. The extension is not trusted, so only the superuser (the Database resource's reconciler, or kubectl cnpg psql) can create, update or drop it. A database owner cannot.
  • Trixie, PostgreSQL 18, amd64 or arm64. No bookworm image (see Requirements), no other PostgreSQL major. A major upgrade to a future PostgreSQL 19 with image volume extensions needs CNPG 1.29 or later and a PostgreSQL 19 build of the extension, which does not exist yet.
  • No image catalog of ours. With CNPG 1.29 or later you can list the image in your own ClusterImageCatalog (spec.images[].extensions, then just - name: pg-automerge in each Cluster). The official CNPG catalogs only carry extensions packaged by PGDG, which pg_automerge is not.
  • Kubernetes image volumes. Clusters older than 1.33, or whose nodes run an older containerd or CRI-O, cannot mount the image. There, build your own operand image instead: FROM ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie, then copy the image's /lib/pg_automerge.so to /usr/lib/postgresql/18/lib/ and /share/extension/* to /usr/share/postgresql/18/extension/, and tag it starting with 18 so CNPG detects the major version. This repository does not build or test that variant.
  • Memory outside PostgreSQL's accounting, as everywhere: see Memory and pod limits, and Limitations.

What is tested#

tests/cnpg_smoke.sh (mise run cnpg-smoke, and part of mise run docker-test and the CI docker job) needs no Kubernetes. It runs CNPG's pinned 18-minimal-trixie operand image, mounts the extension image read-only at /extensions/pg-automerge (a Docker image mount, which is what an image volume is), and sets the two settings exactly as the operator writes them. It checks:

  • the image's labels and that it holds exactly CNPG's layout: /lib/pg_automerge.so, the control file and SQL scripts in /share/extension, and the license in /licenses/pg_automerge;
  • that the operand has no copy of its own;
  • that CREATE EXTENSION works for the superuser and is refused for a plain role;
  • that the backend maps the library from the mount, and that a setting given before the library loads applies;
  • merges, jsonb reads with a GIN index, automerge_spans, and that the image offers exactly the versions of its SQL scripts, with an update path from each older one;
  • that the server log shows no crash.

tests/cnpg_e2e.sh (mise run cnpg-e2e, and the CI cnpg-e2e job for every release, nightly and on manual runs, on the image the docker job built) covers the operator itself. It takes a few minutes and needs kind, so it is not part of mise run ci or docker-test. It creates a throwaway kind cluster (pinned kind and Kubernetes 1.36 node image), installs the pinned CNPG release (1.30.1), loads the images into the node (kind load, pullPolicy: Never, no registry), and walks the steps of this page:

  1. A Cluster on the same pinned operand, with the image under test in spec.postgresql.extensions. It checks the pod's image volume and status.pgDataImageInfo, the two settings, the pg_automerge.max_load_memory parameter, and that the app owner cannot CREATE EXTENSION.
  2. A Database resource with the image's version: the operator creates the extension. Data is written, and the backend maps the library from /extensions/pg-automerge/lib.
  3. The Cluster's image changed to a rebuild of the same version under another tag (the image under test with one more label): the pod rolls (a new pod with the new image volume), the data survives, and the extension, still at its version, works: merges, jsonb reads with a GIN index, automerge_spans and automerge_memory_usage() on the regress fixtures.
  4. Removal as described above: ensure: absent drops the extension, and deleting the extensions entry rolls the pod without the image volume. The server log shows no crash.

With only one version so far, the test does not run the update of step 2 under Updating. For the first version after 0.1.0 it gets that step back: the Cluster starts on the 0.1.0 image with version: "0.1.0" in the Database, rolls to the image under test, and bumps the Database's version, so the operator runs ALTER EXTENSION ... UPDATE TO (the top of tests/cnpg_e2e.sh describes how).

kind's node runs as root of the Docker daemon, so its inotify instances count against root's fs.inotify.max_user_instances. kind asks for 512, and Debian's default is 128. Where root already uses most of them (a k3s on the same host, for example), kube-proxy and the operator crash-loop with too many open files, and the test prints that diagnosis. Raise the limit (sudo sysctl -w fs.inotify.max_user_instances=512), or run kind on a rootless Docker daemon of your own user, whose containers count against your user instead. CNPG_E2E_DOCKER_HOST points the test at it; the images are still built by the default daemon:

dockerd-rootless.sh --data-root ~/.local/share/docker-e2e \
  -H unix://$XDG_RUNTIME_DIR/e2e/docker.sock &
CNPG_E2E_DOCKER_HOST=unix://$XDG_RUNTIME_DIR/e2e/docker.sock mise run cnpg-e2e

Neither test covers more than one instance (replicas, switchover), image catalogs, or a managed Kubernetes service. Docker image has the packaging decisions.