Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# CI for pg_plan_filter: build with warnings-as-errors and run the
# regression suite against every supported PostgreSQL major version, plus a
# static-analysis pass and a report-only benchmark.
#
# The test job builds its own throwaway cluster (initdb into /tmp) rather
# than using the Debian-packaged one so that the server, headers, and
# pg_regress all come from the same pgdg major version.
name: CI

on:
push:
branches: [master]
pull_request:

# Nothing here needs to write to the repository.
permissions:
contents: read

jobs:
test:
name: PostgreSQL ${{ matrix.pg }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
pg: [14, 15, 16, 17, 18]
env:
PG_CONFIG: /usr/lib/postgresql/${{ matrix.pg }}/bin/pg_config
steps:
- uses: actions/checkout@v4

- name: Install PostgreSQL ${{ matrix.pg }}
run: |
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get install -y --no-install-recommends \
postgresql-${{ matrix.pg }} postgresql-server-dev-${{ matrix.pg }}

- name: Build (warnings are errors)
run: make PG_CONFIG="$PG_CONFIG" COPT=-Werror

- name: Install
run: sudo make install PG_CONFIG="$PG_CONFIG"

- name: Regression tests
run: |
PGBIN=$("$PG_CONFIG" --bindir)
"$PGBIN/initdb" -D /tmp/pgdata -A trust
{
echo "port = 55432"
echo "unix_socket_directories = '/tmp'"
} >> /tmp/pgdata/postgresql.conf
"$PGBIN/pg_ctl" -D /tmp/pgdata -l /tmp/pg.log start
PGHOST=/tmp PGPORT=55432 make installcheck PG_CONFIG="$PG_CONFIG"

- name: Show regression diffs
if: failure()
run: |
cat regression.diffs 2>/dev/null || true
tail -50 /tmp/pg.log 2>/dev/null || true

lint:
name: Static analysis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install cppcheck and PostgreSQL headers
run: |
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get install -y --no-install-recommends \
cppcheck postgresql-server-dev-18

- name: cppcheck
# Findings inside the PostgreSQL headers themselves are suppressed;
# only findings in this module's code fail the job.
run: |
cppcheck --std=c99 --quiet --enable=warning,portability \
--error-exitcode=2 --inline-suppr \
--suppress='*:*/postgresql/*' \
-I "$(/usr/lib/postgresql/18/bin/pg_config --includedir-server)" \
plan_filter.c

# Report-only: shared CI runners are too noisy to gate on raw benchmark
# numbers, so this job never fails the pipeline. Results land in the job
# summary and are uploaded as an artifact for trend-tracking.
benchmark:
name: Benchmark (report only)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4

- name: Install PostgreSQL 18
run: |
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get install -y --no-install-recommends \
postgresql-18 postgresql-server-dev-18

- name: Build and install
run: |
make PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config
sudo make install PG_CONFIG=/usr/lib/postgresql/18/bin/pg_config

- name: Run benchmark
# pipefail so a bench failure fails this (non-gating) job visibly
# instead of publishing an empty results file.
run: |
set -o pipefail
PGBIN=$(/usr/lib/postgresql/18/bin/pg_config --bindir) \
./bench/run.sh | tee bench-results.txt

- name: Publish summary
run: |
{
echo '### plan_filter benchmark (report-only)'
echo '```'
cat bench-results.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- uses: actions/upload-artifact@v4
with:
name: bench-results-${{ github.sha }}
path: bench-results.txt
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Build products and pg_regress leavings
*.o
*.so
*.dylib
*.bc
.deps/
results/
regression.diffs
regression.out
log/
tmp_check/
72 changes: 72 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# ARCHITECTURE

`pg_plan_filter` is a single-file PostgreSQL loadable module (not an
extension: no `.control`, no SQL objects) that installs a `planner_hook`
and raises `ERRCODE_STATEMENT_TOO_COMPLEX` when a completed plan's
`total_cost` exceeds `plan_filter.statement_cost_limit`. Decision
rationale lives in `docs/adr/`; this file is the map and the invariants.

## Map

| Path | Role |
|---|---|
| `plan_filter.c` | Everything: GUC definitions, hook install, the filter |
| `Makefile` | PGXS `MODULE_big` build; `REGRESS = plan_filter` |
| `sql/` + `expected/` | pg_regress suite (`make installcheck`) |
| `bench/run.sh` | Self-contained pgbench overhead harness (report-only) |
| `.github/workflows/ci.yml` | Matrix CI; see ADR 0003 |
| `docs/adr/` | Decision records; index in its README.md |

## Invariants

- **Support window** is exactly the upstream-supported majors (14–18 as
of 2026); a `#error` enforces the floor. Autumn maintenance: bump the
CI matrix, raise the floor when the oldest major EOLs, update the
hard-coded PG-18 references in the lint/benchmark jobs and the README
version sentence (ADR 0001, 0003).
- **Hook discipline**: `limit_func` calls exactly one of
`prev_planner_hook` or `standard_planner`, and checks the cost only
*after* planning completes, on the finished `PlannedStmt`. The error
is thrown, never returned.
- **Both feature GUCs are `PGC_SUSET` on purpose** — an unprivileged user
must not be able to lift an administrator's limit. The regression
suite tests this; do not downgrade the context.
- **`plan_filter.module_loaded`** exists only as a presence signal
(`PGC_BACKEND`, defined at load time); nothing reads it in C.
- **No `_PG_fini`** — PostgreSQL 15 removed library unloading; do not
reintroduce it.
- **Expected output must stay version-independent**: no cost numbers, no
`EXPLAIN` without `COSTS OFF`, no version-varying messages. One
expected file serves every supported major on every platform
(ADR 0002).
- **The suite pins both GUCs immediately after `LOAD`** so it passes on
clusters that preload the module with a limit configured. Keep any new
plannable statement below that pin point out of the file.
- **CI gates on correctness only**; the benchmark job is report-only and
must stay `continue-on-error` (ADR 0003).

## Landmines

- The `plan_filter.` GUC prefix is reserved at load on 15+
(`MarkGUCPrefixReserved`): misspelled `SET plan_filter.x` errors. On
14 the older call only warns about preexisting placeholders — behavior
differs by major.
- `EXPLAIN` is filtered too (it plans). The documented escape is
`SET LOCAL plan_filter.statement_cost_limit = 0` in a transaction.
- `filter_select_only` exempts by `parse->commandType != CMD_SELECT`;
a SELECT with data-modifying CTEs is still `CMD_SELECT` and still
filtered — SELECT ≠ read-only, as the README warns.

## Working on this repo

- Build/test: `make && make install`, then `make installcheck` against a
running server (libpq env vars select it).
- Testing without install rights: add the build directory to a scratch
cluster's `dynamic_library_path`; `LOAD 'plan_filter'` finds the built
library there.
- Full pre-push rehearsal: the CI steps run verbatim in an
`ubuntu:24.04` container using `apt.postgresql.org.sh`; build with
`with_llvm=no` if clang is absent.
- New/changed C code must build warning-free (`COPT=-Werror` is the CI
gate) and follow PostgreSQL backend conventions (tabs, `/* */`
comments, errors via `ereport`).
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ MODULE_big = plan_filter
OBJS = plan_filter.o $(WIN32RES)
PGFILEDESC = "filter statements meeting plan criteria - currently by plan cost"
DOCS = $(wildcard doc/*.md)
REGRESS = plan_filter

PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
Expand Down
28 changes: 19 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Plan Filter Module for PostgreSQL
=================================

[![CI](https://github.com/pgexperts/pg_plan_filter/actions/workflows/ci.yml/badge.svg)](https://github.com/pgexperts/pg_plan_filter/actions/workflows/ci.yml)

This loadable module will test statements against specific configured criteria
before execution, raising an error if the criteria are violated. This
allows administrators to prevent execution of certain queries on
Expand All @@ -22,18 +24,13 @@ which means do not apply any filter. So a typical pair of settings in the
shared_preload_libraries = 'plan_filter'
plan_filter.statement_cost_limit = 100000.0

`ļimit_select_only` limits filtering to SELECT statements only. The default is false.
`filter_select_only` limits filtering to SELECT statements only. The default is false.

plan_filter.limit_select_only = true
plan_filter.filter_select_only = true

turns it on.
Be aware that SELECT != READONLY, since SELECT statements might also modify data.

If you're using this with a version of PostgreSQL prior to 9.2, you will
need also to have a line like this before the above lines:

custom_variable_classes = 'plan_filter'

When this module is running with a non-zero `statement_cost_limit`, it
will also prevent `EXPLAIN` on expensive queries. The solution would be
to `set statement_cost_limit` temporarily to 0 and then run the `EXPLAIN`,
Expand Down Expand Up @@ -63,8 +60,21 @@ like this:
As `pg_plan_filter` is a loadable module rather than an Extension, it cannot be
installed using PGXN or other extension-management tools.

This module has been tested on PostgreSQL 9.1.14, 9.3.6 and 9.4.1. It should work on
any version 9.0 or later, but has not necessarily been tested on every release.
This module supports all PostgreSQL major versions with upstream support,
currently 14 through 18, and requires at least 14 to build. Each supported
version is exercised in CI on every pull request and every push to master.

Testing
-------

With the module built and installed, and a server from the same build
running, the regression suite can be run with:

make installcheck

Standard libpq environment variables (`PGHOST`, `PGPORT`, and so on) select
the server to test against. The test session loads the module with `LOAD`,
so nothing needs to be added to `shared_preload_libraries` first.

Warnings
--------
Expand Down
64 changes: 64 additions & 0 deletions bench/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/bin/sh
# Measures the planner-hook overhead of plan_filter: pgbench select-only
# throughput with the module absent vs. loaded with a cost limit high enough
# that the check runs but never fires. Numbers from shared CI runners are
# too noisy to gate on, so this is for trend-tracking only.
#
# Environment:
# PGBIN bin directory of the PostgreSQL to use (default: pg_config)
# BENCH_SECONDS per-configuration pgbench duration (default: 10)
# PLAN_FILTER_DIR if set, added to dynamic_library_path so an uninstalled
# build directory can be benchmarked
set -eu

PGBIN=${PGBIN:-$(pg_config --bindir)}
DATADIR=$(mktemp -d)
export PGHOST="$DATADIR" PGPORT=55433 PGDATABASE=postgres

# On failure, dump the server log before it is removed with the datadir.
# The INT/TERM traps exist because POSIX shells do not run the EXIT trap
# when killed by an unhandled signal (Ctrl-C, CI job cancellation).
cleanup() {
status=$?
if [ "$status" -ne 0 ] && [ -f "$DATADIR/log" ]; then
echo "--- server log tail ---" >&2
tail -20 "$DATADIR/log" >&2
fi
"$PGBIN/pg_ctl" -D "$DATADIR/data" stop -m immediate >/dev/null 2>&1 || true
rm -rf "$DATADIR"
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT TERM

"$PGBIN/initdb" -D "$DATADIR/data" -A trust >/dev/null
{
echo "port = $PGPORT"
echo "listen_addresses = ''"
echo "unix_socket_directories = '$DATADIR'"
echo "fsync = off"
if [ -n "${PLAN_FILTER_DIR:-}" ]; then
echo "dynamic_library_path = '$PLAN_FILTER_DIR:\$libdir'"
fi
} >> "$DATADIR/data/postgresql.conf"
"$PGBIN/pg_ctl" -D "$DATADIR/data" -l "$DATADIR/log" start >/dev/null

"$PGBIN/createdb" bench
"$PGBIN/pgbench" -i -s 1 -q bench >/dev/null

# Capture pgbench's output in a variable so its exit status is not masked
# by a pipeline; fail if the expected tps line never appears.
run() {
out=$("$PGBIN/pgbench" -S -n -c 4 -j 4 -T "${BENCH_SECONDS:-10}" bench)
printf '%s\n' "$out" |
awk -v label="$1" '/^tps/ {printf "%-28s %s tps\n", label, $3; found = 1; exit}
END {if (!found) exit 1}'
}

run "baseline (no module)"

"$PGBIN/psql" -q -d bench \
-c "ALTER DATABASE bench SET session_preload_libraries = 'plan_filter'" \
-c "ALTER DATABASE bench SET plan_filter.statement_cost_limit = 1e9"

run "plan_filter (limit unhit)"
58 changes: 58 additions & 0 deletions docs/adr/0001-support-upstream-supported-majors-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
id: 0001
title: Support only upstream-supported PostgreSQL majors (currently 14-18)
date: 2026-07-21
status: Accepted
summary: The module targets the PostgreSQL majors with upstream support, enforces a hard floor of 14 at compile time, and drops all pre-13 compatibility scaffolding.
---

# 0001. Support only upstream-supported PostgreSQL majors (currently 14-18)

## Context

The module was written for PostgreSQL 9.x and last touched for
compatibility when PostgreSQL 13 changed the planner-hook signature (it
gained `const char *query_string`). The source carried `#if`-macro
scaffolding (`PLANNER_HOOK_PARAMS` / `PLANNER_HOOK_ARGS`) so one body
could compile against both pre-13 and 13+ signatures, plus a `_PG_fini`
unload callback. As of July 2026 the upstream-supported majors are 14
through 18; everything the macros existed for is end-of-life. PostgreSQL
15 removed library unloading entirely (so `_PG_fini` is dead code) and
added `MarkGUCPrefixReserved`, which turns a misspelled
`plan_filter.*` setting from a silently-ignored placeholder into an error
— a meaningful safety property for a module whose whole job is being a
guard rail.

## Decision

Track the upstream support window and nothing older. Concretely: a
compile-time `#error` below `PG_VERSION_NUM 140000`; the 13+ planner-hook
signature written directly with the compatibility macros deleted;
`_PG_fini` removed; the `plan_filter.` GUC prefix reserved
(`MarkGUCPrefixReserved` on 15+, `EmitWarningsOnPlaceholders` on 14); and
`PG_MODULE_MAGIC_EXT` with a module name and version on 18+, falling back
to plain `PG_MODULE_MAGIC` earlier.

## Alternatives considered

- **Keep the compatibility macros and let pre-13 keep building** — the
macros cost little, but untested-is-unsupported: nothing older than 14
is exercised by CI, and advertising support that is never verified is
how version bit-rot went unnoticed here for years in the first place.
- **Set the floor at 13** — the code is identical under 13, but 13 went
end-of-life in November 2025 and would be one more untested
configuration; users on 13 can build from an earlier checkout.
- **Version-sniff with `#ifdef` around every changed API instead of a
floor** — appropriate for extensions that must span many majors;
pointless here since the 14-18 API surface this module touches is
uniform except for the two guarded calls noted above.

## Consequences

The source is a single straight-line file with two small version guards
(GUC-prefix reservation, module magic). Each autumn's new major and
EOL is a policy update, not an archaeology project: bump the CI matrix,
raise the floor when the oldest supported major moves. Users on EOL
versions must build from an older commit — a deliberate cost. The
GUC-prefix reservation is a minor behavior change on 15+: `SET
plan_filter.typo = ...` now errors instead of being silently accepted.
Loading
Loading