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
35 changes: 29 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# 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.
extension: no `.control`, no SQL objects) that raises
`ERRCODE_STATEMENT_TOO_COMPLEX` when estimated plan cost exceeds a
configured limit: a `planner_hook` enforces the per-statement limit
(`plan_filter.statement_cost_limit`), and an `ExecutorStart` hook plus a
transaction callback enforce the per-transaction limit
(`plan_filter.transaction_cost_limit`). Decision rationale lives in
`docs/adr/`; this file is the map and the invariants.

## Map

Expand All @@ -27,8 +30,18 @@ rationale lives in `docs/adr/`; this file is the map and the invariants.
- **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
is thrown, never returned. `xact_limit_func` charges and checks
*before* chaining to `standard_ExecutorStart`, so an over-budget
statement never begins executing.
- **Transaction accounting is per execution, not per plan** (ADR 0004):
it lives in `ExecutorStart` so cached generic plans from repeated
`EXECUTE` are charged every time. The accumulator always accrues
(limit unset only disables the check), is zeroed only by the
end-of-transaction callback, and is never refunded by
`ROLLBACK TO SAVEPOINT`. `EXEC_FLAG_EXPLAIN_ONLY` and
`IsParallelWorker()` executions are exempt — keep them so
(double-charging and EXPLAIN-blocking are the failure modes).
- **All 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
Expand Down Expand Up @@ -56,6 +69,16 @@ rationale lives in `docs/adr/`; this file is the map and the invariants.
- `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.
- **Neither limit is a security boundary against a hostile role.** Both
compare against the planner's estimate, which comes from `USERSET`
cost GUCs (`seq_page_cost` et al.) any `SET`-capable role can zero.
`PGC_SUSET` on the limit GUCs stops a user *raising the limit* — not
the estimate bypass. Do not let the SUSET invariant above read as
adversary-proof (ADR 0004). It guards careless load, not attackers.
- Regression tests that depend on a cost *ratio* (the transaction-limit
section) use their own freshly-created table: earlier tests leave dead
tuples in `plan_filter_test`, which roughly doubles its seq-scan cost
and silently shifts thresholds.

## Working on this repo

Expand Down
41 changes: 39 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ which means do not apply any filter. So a typical pair of settings in the
turns it on.
Be aware that SELECT != READONLY, since SELECT statements might also modify data.

`transaction_cost_limit` limits the *combined* estimated cost of the
statements executed within a single transaction, raising the same error
class once the running total would be exceeded. This is aimed at batch
interfaces: many individually-cheap statements can no longer add up to
unbounded work. The default of 0 turns it off.

plan_filter.transaction_cost_limit = 500000.0

Details of the accounting:

- Cost is charged per *execution*, so repeatedly executing a prepared
statement counts every execution, even after PostgreSQL switches to a
cached generic plan.
- Plain `EXPLAIN` neither consumes budget nor is refused once the budget
is exhausted, so plans remain inspectable.
- `ROLLBACK TO SAVEPOINT` does not refund cost already charged: the limit
guards attempted resource consumption, not net effect.
- Statements that execute without a plan — most DDL, such as `CREATE
INDEX` — are not counted. Utility statements that *wrap* a query,
however, do execute a plan and are counted: `CREATE TABLE ... AS`,
`CREATE MATERIALIZED VIEW`, `COPY (SELECT ...) TO`. This mirrors
`statement_cost_limit`, which sees the same statements.
- When `filter_select_only` is on, it applies to this limit as well.

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 @@ -79,12 +103,25 @@ so nothing needs to be added to `shared_preload_libraries` first.
Warnings
--------

`statement_cost_limit` will cancel plans based on their estimated cost. The PostgreSQL
Both limits cancel plans based on their estimated cost. The PostgreSQL
planner can and does return cost estimates which are unrelated to the actual
query execution time. As such, you should be prepared for "false positive"
cancellations if you use `pg_plan_filter`, and you should set `statement_cost_limit`
cancellations if you use `pg_plan_filter`, and you should set the limits
generously.

More importantly, the estimate these limits compare against is computed from
the planner cost settings — `seq_page_cost`, `cpu_tuple_cost`, the
parallelism costs, and so on — and every one of those is `USERSET`: any role
that can run `SET` can lower them, even to zero, and so deflate the estimated
cost of its own statements below any limit. The limit GUCs themselves are
superuser-only and cannot be raised by an ordinary user, but that does not
close this gap, because the attacker changes the number being compared rather
than the limit. Treat `pg_plan_filter` as a cooperative guard against
accidental or careless load — a misconfigured ORM, a runaway report — not as
a hard security boundary against a hostile role that controls its own
session. For the batch-throttling use case in particular, keep it as one
layer behind an application-level limit rather than the sole defense.


Credits
-------
Expand Down
79 changes: 79 additions & 0 deletions docs/adr/0004-transaction-cost-limit-execution-accounting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
id: 0004
title: Transaction cost limit accumulates at execution time, without savepoint refunds
date: 2026-07-21
status: Accepted
summary: transaction_cost_limit charges each execution's plan cost in an ExecutorStart hook, resets via xact callback, and never refunds rolled-back subtransactions.
---

# 0004. Transaction cost limit accumulates at execution time, without savepoint refunds

## Context

Issue #4 (from PostgREST) asks for `plan_filter.transaction_cost_limit`:
a cap on the *sum* of statement costs within one transaction, so a batch
endpoint cannot smuggle unbounded work through many individually-cheap
statements, with the error mapped to HTTP 429 at the application edge.
The module was purely stateless per statement; this feature needs
cross-statement state and a decision about *when* cost is charged.

## Decision

Charge cost per **execution** in a new `ExecutorStart` hook: add the
finished plan's `total_cost` to a static per-backend accumulator, error
(same SQLSTATE 54001 as the statement limit, distinct message) when a
non-zero limit is exceeded, before the statement begins executing. The
accumulator always accrues, even with the limit unset, so `SET LOCAL`
can disable the check but not erase history. It is zeroed by a
`RegisterXactCallback` callback on commit, abort, and prepare (including
the parallel variants). Skipped entirely: `EXEC_FLAG_EXPLAIN_ONLY`
(plain `EXPLAIN` stays free and usable), parallel workers (the leader
already charged the plan), and non-SELECT when `filter_select_only` is
on (mirrors the statement check). `ROLLBACK TO SAVEPOINT` does not
refund cost. The existing per-statement planner-hook check is
untouched.

## Alternatives considered

- **Accumulate in the existing planner hook** — smallest diff, but
defeated by the exact threat the feature targets: repeated `EXECUTE`
of a prepared statement stops invoking the planner once a generic
plan is cached, so a PREPARE/EXECUTE loop escapes a plan-time
accumulator. It also charges plain `EXPLAIN`. The regression suite
contains a `force_generic_plan` test that fails under this design.
- **Move the per-statement check to ExecutorStart too** — one uniform
accounting point, but a silent behavior change to the existing
feature (plain `EXPLAIN` of an over-limit query is deliberately
blocked by the statement filter today, and its documented escape
hatch depends on that).
- **Refund savepoint rollbacks** — more "correct" as bookkeeping, but
the limit guards attempted resource consumption (the work was done),
and refunds would require tracking a subtransaction cost stack for a
case the security use case does not want refunded anyway.

## Consequences

*Careless* batch clients are bounded even through prepared-statement
loops. A *hostile* client is not: the charged value is the planner's
estimate, computed from cost GUCs (`seq_page_cost`, `cpu_tuple_cost`,
the parallelism costs) that are all `USERSET`, so any role that can run
`SET` can drive its own estimated costs to zero and evade the limit
entirely. This bypass is inherent to plan-cost-based limiting — it
cannot be closed from inside the hook without re-planning under pinned
coefficients — and it is shared with `statement_cost_limit`. The
feature is therefore a cooperative resource-shaping guard, not a
security boundary against an adversary that controls its own session;
the README says so, and the batch-throttling use case must keep it
behind an application-level limit. The `PGC_SUSET` context on the limit
GUCs stops a user *raising the limit* but is not what makes the feature
safe against a hostile role, and must not be mistaken for it.

The module now keeps per-backend mutable state and registers a
transaction callback it never unregisters (harmless: the library cannot
be unloaded since PG 15). Costs of statements that fail mid-execution
remain charged, which is the intended semantics but can surprise: a
transaction that keeps erroring and retrying under savepoints burns
budget with every attempt. Utility statements that run without a plan
(most DDL) are not counted; those that wrap a query (`CREATE TABLE ...
AS`, `COPY (SELECT ...)`) execute a plan and are counted — the same
statements the per-statement limit has always seen.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
| [0001](0001-support-upstream-supported-majors-only.md) | Support only upstream-supported PostgreSQL majors (currently 14-18) | Accepted | 2026-07-21 | 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. |
| [0002](0002-behavioral-pg-regress-suite.md) | Behavioral pg_regress suite with version-independent expected output | Accepted | 2026-07-21 | Testing uses a standard pg_regress suite whose output contains no cost numbers or plans, so one expected file serves every supported major on every platform. |
| [0003](0003-github-actions-ci-pgdg-matrix.md) | GitHub Actions CI on the pgdg apt matrix, with report-only benchmarks | Accepted | 2026-07-21 | CI builds with -Werror and runs installcheck against pgdg packages for every supported major, runs cppcheck as lint, and publishes pgbench numbers as a non-gating report. |
| [0004](0004-transaction-cost-limit-execution-accounting.md) | Transaction cost limit accumulates at execution time, without savepoint refunds | Accepted | 2026-07-21 | transaction_cost_limit charges each execution's plan cost in an ExecutorStart hook, resets via xact callback, and never refunds rolled-back subtransactions. |
99 changes: 99 additions & 0 deletions docs/superpowers/specs/2026-07-21-transaction-cost-limit-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Design: plan_filter.transaction_cost_limit

Implements [issue #4](https://github.com/pgexperts/pg_plan_filter/issues/4):
cap the cumulative estimated plan cost of the statements executed within a
single transaction, so a batch interface cannot smuggle unbounded work
through many individually-cheap statements. Requested by PostgREST for
mapping to HTTP 429 at the application edge.

## GUC

`plan_filter.transaction_cost_limit` — real, default 0 (feature off),
range 0..DBL_MAX, `PGC_SUSET`, covered by the reserved `plan_filter.`
prefix. Errors use the same SQLSTATE as the statement limit
(`ERRCODE_STATEMENT_TOO_COMPLEX`, 54001) so one client-side mapping
catches both, with a distinct message: `transaction cost limit exceeded`.

## Accounting model (the load-bearing decision)

Cost is accumulated **per execution, not per plan**, in a new
`ExecutorStart` hook (the existing planner-hook statement check is
untouched). Rationale: repeated `EXECUTE` of a prepared statement stops
invoking the planner once a generic plan is cached, so plan-time
accounting can be defeated by a `PREPARE` + `EXECUTE` loop — exactly the
misbehaving-batch-client threat the feature exists for. Every execution
passes through `ExecutorStart`.

The hook, before chaining to `standard_ExecutorStart` (so a doomed
statement never begins executing):

- skips `EXEC_FLAG_EXPLAIN_ONLY` (plain `EXPLAIN` neither consumes budget
nor is blocked by an exhausted one — it remains the documented way to
inspect an expensive plan);
- skips `IsParallelWorker()` (leader already counted the plan);
- skips non-SELECT when `filter_select_only` is on (mirrors the statement
check);
- otherwise **always adds** `plannedstmt->planTree->total_cost` to the
accumulator, and raises the error only when the limit is non-zero and
exceeded. Always-accumulate means `SET LOCAL` can disable the check
but cannot erase history.

Utility statements that run without a plan (most DDL) never reach the
executor and are not counted. Utility statements that wrap a query
(`CREATE TABLE ... AS`, `COPY (SELECT ...)`) do run a plan through the
executor and are counted — the same statements the per-statement limit
has always seen.

## State and reset

One `static double xact_cost_total`, zeroed in a `RegisterXactCallback`
callback on `XACT_EVENT_COMMIT`, `XACT_EVENT_PARALLEL_COMMIT`,
`XACT_EVENT_ABORT`, `XACT_EVENT_PARALLEL_ABORT`, and
`XACT_EVENT_PREPARE` (the callback only assigns a double — safe in that
context). No subtransaction callback: `ROLLBACK TO SAVEPOINT` does
**not** refund cost. The limit is a resource-consumption guard; the
work was attempted, and refusing refunds also removes the need to track
a savepoint stack.

## Tests (extend the existing suite; same one-expected-file discipline)

1. Two ~190-cost SELECTs under limit 300 in one transaction: first
passes, second errors.
2. Reset: a fresh transaction passes again after COMMIT and after
ROLLBACK of an aborted transaction.
3. No refund: pass, SAVEPOINT, error, `ROLLBACK TO`, next statement
still errors.
4. Execution accounting discriminator: `plan_cache_mode =
force_generic_plan`, `PREPARE`, two `EXECUTE`s — the second must
error (it would not under plan-time accounting).
5. Plain `EXPLAIN` consumes nothing: after one SELECT (~190/300), two
consecutive `EXPLAIN (COSTS OFF)` both succeed (if EXPLAIN consumed,
the second would error).
6. SUSET: unprivileged `SET plan_filter.transaction_cost_limit` is
denied.

The suite's post-`LOAD` pin block also pins the new GUC to 0.

## Security boundary

Both limits compare against the planner's *estimated* cost, which is
computed from `USERSET` cost GUCs (`seq_page_cost`, `cpu_tuple_cost`,
the parallelism costs). A role that can issue `SET` can zero those and
deflate its own estimates below any limit, so this is a cooperative
guard against careless load, not a hard boundary against a hostile
session. The `PGC_SUSET` limit GUCs stop a user *raising the limit* but
do not close this gap. Documented in the README; the batch-throttling
use case keeps it behind an application-level limit.

## Documentation

README section for the new GUC (semantics above, spelled out); ADR 0004
recording execution-time accounting with plan-time as the rejected
alternative; ARCHITECTURE.md map/invariants updated.

## Compatibility

`ExecutorStart_hook_type (QueryDesc *, int)`, `RegisterXactCallback`,
`IsParallelWorker()`, and `plan_cache_mode` are uniform across
PostgreSQL 14-18 (PG 18's bool-returning `ExecutorStart` experiment was
reverted before release). The CI matrix proves each major.
Loading
Loading