feat: pgcolumnar.maintenance_due() reports when an online maintenance verb is worth running (#415) - #607
Conversation
…th running (commandprompt#415) A pure report over stats() and sort_status(): whether compact_rewrite (deleted fraction) or recluster (clustering decay) is worth running, with thresholds as parameters carrying the defaults measured on commandprompt#415 (0.2 deleted, 0.05 appended). It takes no lock and rewrites nothing; a cron job or an operator consults it, and a background worker -- if one is ever built -- is a thin consumer of the same verdict, keeping documentation-plus-cron the recommendation. recluster_due gates on the sort key existing: sort_status() reports a never-ordered table as entirely appended (no sorted run), which is not decay, so a naive appended-groups gate would recommend reclustering a table with no ordering to restore. test/maintenance_due.sh pins this as its headline arm and the guard is proven load-bearing by removal (dropping the sort-key term reds it). Thresholds are parameters rather than GUCs because the pgcolumnar GUC prefix is reserved; a report is configured at the call site.
jdatcmd
left a comment
There was a problem hiding this comment.
One blocking defect, found by running the caller the function exists for: every non-superuser is refused
Verified on my PG17 non-assert lane first: 26/26 green, all five fixtures behave as described, the thresholds respond as live parameters, and the never-ordered guard logic reads correctly. The policy content (measured defaults, parameters-not-GUCs, report-not-daemon) is right and I have no notes on it. But:
The defect
A role holding SELECT on the relation — the cron/monitoring caller this report is for — gets 42501:
CREATE ROLE md_reader LOGIN;
GRANT USAGE ON SCHEMA pgcolumnar TO md_reader;
GRANT SELECT ON mdt TO md_reader;
-- as md_reader:
SELECT * FROM pgcolumnar.maintenance_due('mdt');
ERROR: permission denied for table row_group
CONTEXT: SQL function "sort_status" statement 1
Mechanism: stats() passes (SECURITY DEFINER, and require_caller_select checks GetOuterUserId(), so the gate correctly evaluates the real caller), but sort_status() is SECURITY INVOKER and reads pgcolumnar.row_group directly, which ordinary roles cannot SELECT. So the function works for superusers and false-denies everyone else.
Why the suite passed anyway, and this is the part worth keeping
The privilege arm only tests the deny case, and every positive arm runs as postgres. With this defect, md_none and md_reader produce the same refusal — meaning the deny check passes in a world where the function denies everyone. A deny arm without its positive control is too loose to be believed (the assert-work-done mirror). The fix needs both arms:
- Positive control:
md_reader(SELECT on rel, nothing on the internal catalogs) gets a row. This is the arm that reddens today. - Deny arm on SQLSTATE, not text: the current
grep -c "permission denied"is the exact trap the working rules name — it is also satisfied by a login FATAL or an unrelated catalog denial (as it just was, by this defect).\set VERBOSITY sqlstate+ assert42501; the sqlstate_of idiom is in-tree inbatch_fold_explain.shand the objstore suites.
Suggested minimal fix
Make maintenance_due SECURITY DEFINER with SET search_path = pg_catalog, pg_temp, exactly as stats() is. The gate still evaluates the real caller (require_caller_select uses GetOuterUserId(), which is why stats() itself is safe as DEFINER), and sort_status then runs as the owner and can read the internal catalog. Body references are already schema-qualified.
Non-blocking note for a follow-up issue rather than this PR: sort_status() has the same invoker-only property standalone, so it is superuser-practical today; if it is meant as a user-facing report it wants the same DEFINER + require_caller_select treatment separately.
Re-review on sight once the positive arm exists and reddens on the current shape; I will re-run the never-ordered removal proof on the updated branch at the same time.
…itive privilege control Review catch (jdatcmd on commandprompt#607): maintenance_due ran with invoker rights, but sort_status() reads pgcolumnar's internal catalogs, which ordinary roles cannot SELECT -- so the report false-denied every non-superuser, the cron/monitoring caller it exists for. The suite missed it because its privilege arm tested only the deny case, which a deny-everyone function passes vacuously. Make maintenance_due SECURITY DEFINER with a pinned search_path, exactly as stats() is: require_caller_select (inside stats()) still gates the real caller via GetOuterUserId(), so definer rights do not widen who may read a table's statistics, and sort_status now runs as the owner and can read the catalog. test/maintenance_due.sh gains a positive control (a SELECT-holder with nothing on the internal catalogs gets a verdict -- red on the invoker shape, the arm that would have caught this) and asserts the deny on SQLSTATE 42501, not a 'permission denied' grep a login FATAL also satisfies.
Fixed in 3ccb72d, and you were right that the vacuous deny arm is the real lesson.The defect reproduces exactly as you found it, and — the part that stings — my The fix
The suite now has both arms, and the positive one reddens on the old shapeProven in that order before committing: The positive control (a role with SELECT on the table and nothing on the Gate on 3ccb72d: 27/27 pg18a + pg19a, the never-ordered removal proof still Your non-blocking noteConfirmed and filed as its own issue (#608) with a reproduction rather than Gate, 3ccb72d: preflight (build + suite) pg15a/16a/17a PASSED; full assert matrix ALL VERSIONS PASSED — PG18 (154 ran, 2 skipped), PG19 (156 ran, 0 skipped), |
jdatcmd
left a comment
There was a problem hiding this comment.
Approved. Re-verified on my lane, both removal proofs re-run by the reviewer.
Against 3ccb72d, PG17 non-assert (the lane your gate did not run):
- 27/27 green, including the two new arms.
- Removal proof of the fix itself, re-run: reverting
SECURITY DEFINERback to invoker rights reds exactly the new positive arm (positive: a SELECT-holder gets a verdict, not a false deny: got [] want [f]) while the deny arm stays green at 42501 — the pair discriminates now, which is precisely what the original review asked the suite to be able to do. The arm that would have caught the defect exists and I watched it catch it. - The never-ordered guard proof, re-run on the updated branch: dropping the
sorted_groups > 0term reds both GUARD arms (got [t] want [f],got [f] want [t]). - The reproduction from my review (SELECT-holder role, nothing on the internal catalogs) now returns a verdict instead of
permission denied for table row_group.
The definer/GetOuterUserId reasoning is correct and matches stats()'s own shape: definer rights reach the internal catalog, the ACL still evaluates the real caller, so no report is available to anyone who could not already read the table. Thanks for filing #608 for sort_status() standalone rather than folding it in.
Merge when ready.
…ort_status owner fix Three changes merged to main today shipped without their user docs. pgcolumnar.maintenance_due() (commandprompt#607): the policy report the autovacuum daemon consults. Added a sql-reference entry documenting every OUT column and the SECURITY DEFINER caller-SELECT gate, and a CHANGELOG "Added" entry. pgcolumnar.recluster self-gate (commandprompt#614): it records the clustering key it establishes and returns 0 without rewriting when the same key still covers every row group. The sql-reference recluster entry now describes the no-op, and sort_status now explains that sort_key is the recorded key with a fallback to the declared sort_by. Added a CHANGELOG "Changed" entry. pgcolumnar.sort_status owner fix (commandprompt#611, closes commandprompt#608): now SECURITY DEFINER with a caller-SELECT check, so a non-superuser table owner can read it. Added a CHANGELOG "Fixed" entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TgAk1gqeME7DHpJw8xxybu
Implements the policy report agreed on #415, grounded in the measurement I
posted there.
pgcolumnar.maintenance_due(rel)answers, from table statisticsalone, whether an online maintenance verb is worth running — a pure report: it
takes no lock and rewrites nothing. A cron job or an operator consults it, and
a background worker (if one is ever built) is a thin consumer of the same
verdict, which keeps documentation-plus-cron the recommendation rather than
introducing a daemon.
What it reports
compact_rewrite_duewhen the deleted fraction (sum(deletedrows) / sum(rowcount)overstats()) reachescompact_due_fraction.recluster_duewhen a sorted run exists, there is at least one appendedgroup beyond it, and the appended fraction reaches
recluster_due_fraction.recommendationis the verbs joined, or NULL when nothing is due.Why the defaults are these numbers
They are the values measured on #415, not invented:
slower than its compacted equivalent at 20% deleted, rising to +150% at 80%.
per unit than deletes (the smallest decay measured, ~5% appended rows, already
cost +161% on a pruned range query, because
groups read = 1 + appended groupsexactly). The gate is deliberately low, and mainly exists so the verbis never recommended on a table with no decay.
Thresholds are parameters, not GUCs: the
pgcolumnarGUC prefix is reserved(
MarkGUCPrefixReserved), so an unregisteredpgcolumnar.*GUC is rejected, anda report is better configured at the call site — cron passes its own tolerance.
The correctness point, and it is proven by removal
sort_status()reports a never-ordered table as entirely appended, becauseit has no sorted run. A naive
appended_groups > 0gate would thereforerecommend reclustering a table that has no ordering to restore.
recluster_duegates on the sorted run existing (
sorted_groups > 0).test/maintenance_due.shmakes this its headline arm, and the driver proves theguard load-bearing: dropping the
sorted_groups > 0term flips the never-orderedtable to
recluster_due = trueand reddens the two GUARD checks.TDD note, recorded because it changed the implementation: the guard was first
written as
sort_key IS NOT NULL, and the suite caught thatvacuum_sorted()establishes a sorted run without setting
options.sort_by— sosort_keyisNULL even on an ordered table, and the run (
sorted_groups) is the correctsignal.
sort_keyis reported for information only.Proofs and gate
main(no function) the suite fails everymaintenance_duearm while its premises pass — recorded before the function existed.
stats()(require_caller_select): a role withoutSELECT on the relation is refused, asserted with a direct role connection.
pg18a + pg19a: results below.
Scope
Pure SQL, no C, no new GUC, no lock, no write path. The function is added to the
base extension script (the alpha convention, as
analyze()was); it introducesno C link name, so the extension-upgrade gate is unaffected.
analyze()staleness is deliberately not a third axis here — its cost is plan-shaped,
needs its own fixture, and is tracked on #414/#415.
Closes the measurement-and-policy step of #415; the daemon question stays open
and unscheduled, now with a report it could consume if it is ever built.
Gate, commit 9b5dbe1:
maintenance_duegreen on both, no regressions.