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
5 changes: 5 additions & 0 deletions docs/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,11 @@ If the source is rewritten, for example by `pgcolumnar.vacuum`, `refresh_rollup`
refuses with `55000`. If the rollup was current before the rewrite, run
`pgcolumnar.rebase_rollup` to record the rewritten table and continue.

**A rollup depends on its source.** `DROP TABLE` of the source is refused while
a rollup reads from it, as it is for a view. `DROP TABLE ... CASCADE` drops the
rollup too. To remove only the rollup, run `DROP VIEW` on it, which also drops
its partials table and its catalog rows.

Run `refresh_rollup` at READ COMMITTED. A role that is not a superuser needs
`USAGE` on schema `pgcolumnar`, which a superuser grants. Each role then sees
and refreshes only the rollups that it owns.
Expand Down
8 changes: 8 additions & 0 deletions pgcolumnar--1.0-alpha4--1.0-alpha5.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,14 @@ CREATE POLICY rollup_group_owner ON pgcolumnar.rollup_group
(SELECT c.relowner FROM pg_class c WHERE c.oid = rollup), 'USAGE'));
GRANT SELECT, INSERT, UPDATE, DELETE ON pgcolumnar.rollup, pgcolumnar.rollup_group TO PUBLIC;

-- The view depends on its source, and the partials on the view, recorded by this
-- trigger whenever a rollup row is written, a restore included (#1376, #1377).
CREATE FUNCTION pgcolumnar.rollup_record_dependencies() RETURNS trigger
LANGUAGE C AS 'MODULE_PATHNAME', 'pgcolumnar_rollup_record_dependencies';
CREATE TRIGGER rollup_dependencies
AFTER INSERT OR UPDATE OF rollup, partials, source ON pgcolumnar.rollup
FOR EACH ROW EXECUTE FUNCTION pgcolumnar.rollup_record_dependencies();

/*
* The source's row groups, with each one's deleted count. SECURITY DEFINER
* because row_group and delete_vector carry no GRANT; the caller must be able to
Expand Down
8 changes: 8 additions & 0 deletions pgcolumnar--1.0-alpha5.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,14 @@ CREATE POLICY rollup_group_owner ON pgcolumnar.rollup_group
(SELECT c.relowner FROM pg_class c WHERE c.oid = rollup), 'USAGE'));
GRANT SELECT, INSERT, UPDATE, DELETE ON pgcolumnar.rollup, pgcolumnar.rollup_group TO PUBLIC;

-- The view depends on its source, and the partials on the view, recorded by this
-- trigger whenever a rollup row is written, a restore included (#1376, #1377).
CREATE FUNCTION pgcolumnar.rollup_record_dependencies() RETURNS trigger
LANGUAGE C AS 'MODULE_PATHNAME', 'pgcolumnar_rollup_record_dependencies';
CREATE TRIGGER rollup_dependencies
AFTER INSERT OR UPDATE OF rollup, partials, source ON pgcolumnar.rollup
FOR EACH ROW EXECUTE FUNCTION pgcolumnar.rollup_record_dependencies();

/*
* The source's row groups, with each one's deleted count. SECURITY DEFINER
* because row_group and delete_vector carry no GRANT; the caller must be able to
Expand Down
30 changes: 30 additions & 0 deletions src/columnar_metadata.c
Original file line number Diff line number Diff line change
Expand Up @@ -4159,6 +4159,36 @@ PgColumnarDeleteOptions(Oid relid)
table_close(rel, RowExclusiveLock);
}

/*
* PgColumnarForgetRollup
* Delete a dropped rollup view's rows from pgcolumnar.rollup and
* pgcolumnar.rollup_group (#1376). Both are keyed by the view's OID in their
* first column. Before this, DROP VIEW left both behind, keyed to a dead OID
* that nothing could reach.
*/
void
PgColumnarForgetRollup(Oid viewOid)
{
static const char *const catalogs[] = {"rollup", "rollup_group"};
int i;

for (i = 0; i < lengthof(catalogs); i++)
{
Relation rel = open_columnar_table(catalogs[i], RowExclusiveLock);
ScanKeyData key[1];
SysScanDesc scan;
HeapTuple tuple;

ScanKeyInit(&key[0], 1, BTEqualStrategyNumber, F_OIDEQ,
ObjectIdGetDatum(viewOid));
scan = systable_beginscan(rel, InvalidOid, false, NULL, 1, key);
while (HeapTupleIsValid(tuple = systable_getnext(scan)))
CatalogTupleDelete(rel, &tuple->t_self);
systable_endscan(scan);
table_close(rel, RowExclusiveLock);
}
}

/*
* PgColumnarIsColumnarRelation
* Whether a relation uses the columnar table access method AND has
Expand Down
1 change: 1 addition & 0 deletions src/columnar_metadata.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ extern NativeBloomMetadata *PgColumnarReadBloomForColumn(uint64 storageId,
Snapshot snapshot);

extern void PgColumnarDeleteOptions(Oid relid);
extern void PgColumnarForgetRollup(Oid viewOid);

extern List *PgColumnarReadSortBy(Oid relid);

Expand Down
143 changes: 143 additions & 0 deletions src/columnar_tableam.c
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
#include "catalog/storage.h"
#include "commands/defrem.h"
#include "commands/extension.h"
#include "commands/trigger.h"
#include "catalog/dependency.h"
#include "access/htup_details.h"
#include "utils/acl.h"
#include "commands/vacuum.h"
#include "executor/executor.h"
#include "executor/tuptable.h"
Expand Down Expand Up @@ -3715,6 +3719,134 @@ pgcolumnar_process_utility(PlannedStmt *pstmt, const char *queryString,
}
}

/*
* pgcolumnar_forget_dropped_rollup
* Delete the catalog rows of a dropped view if it was a rollup (#1376). Gated
* as the options cleanup below is: the hook fires in every database, including
* one without the extension, and DROP EXTENSION drops the extension's own views
* while the catalogs themselves are going.
*/
static void
pgcolumnar_forget_dropped_rollup(Oid viewOid)
{
Oid columnarNsp = get_namespace_oid(COLUMNAR_SCHEMA_NAME, true);
Oid rollupOid = OidIsValid(columnarNsp) ?
get_relname_relid("rollup", columnarNsp) : InvalidOid;
Oid extOid = get_extension_oid("pgcolumnar", true);

if (!OidIsValid(rollupOid))
return;
if (OidIsValid(extOid) &&
getExtensionOfObject(RelationRelationId, viewOid) == extOid)
return;
PgColumnarForgetRollup(viewOid);
}

/*
* pgcolumnar_rollup_record_dependencies
* AFTER INSERT OR UPDATE trigger on pgcolumnar.rollup (#1376). The catalog
* holds the view, its partials table and the source as bare OIDs, so nothing
* stopped DROP TABLE of the source, after which the rollup could never be
* refreshed again (or, with the OID reused, folded another table's rows), and
* DROP VIEW left the partials and the catalog rows behind. It records:
* - the view on the source, NORMAL, as a view over the source would have:
* DROP TABLE of the source is refused, and CASCADE takes the rollup;
* - the partials on the view, AUTO: dropping the view takes its partials.
* The view's own rule already depends on the partials, which is why this is
* not an INTERNAL dependency: that would make DROP TABLE of the partials
* redirect to the view instead of refusing.
*
* A trigger rather than a call in create_rollup, so a restore that writes the
* catalog rows back records the dependencies again (#1377). The caller must
* hold SELECT on the source, as creating a view over it would require.
*/
PG_FUNCTION_INFO_V1(pgcolumnar_rollup_record_dependencies);

Datum
pgcolumnar_rollup_record_dependencies(PG_FUNCTION_ARGS)
{
TriggerData *trigdata = (TriggerData *) fcinfo->context;
HeapTuple tuple;
TupleDesc desc;
bool isnull;
Oid view;
Oid partials;
Oid source;
ObjectAddress viewAddr;
ObjectAddress partialsAddr;
ObjectAddress sourceAddr;

if (!CALLED_AS_TRIGGER(fcinfo) ||
!TRIGGER_FIRED_AFTER(trigdata->tg_event) ||
!TRIGGER_FIRED_FOR_ROW(trigdata->tg_event))
elog(ERROR, "pgcolumnar_rollup_record_dependencies: not called as an AFTER ROW trigger");

desc = RelationGetDescr(trigdata->tg_relation);

/*
* NOTHING CHANGES WHAT A ROW NAMES ONCE IT IS WRITTEN (#1406 review). No
* function issues such an UPDATE, and re-recording would have to remove the
* old row's dependencies too; a no-op SET of the same values is let through.
*/
if (TRIGGER_FIRED_BY_UPDATE(trigdata->tg_event))
{
int att;

for (att = 1; att <= 3; att++)
{
bool oldnull;
bool newnull;
Oid before = DatumGetObjectId(heap_getattr(trigdata->tg_trigtuple, att,
desc, &oldnull));
Oid after = DatumGetObjectId(heap_getattr(trigdata->tg_newtuple, att,
desc, &newnull));

if (before != after)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("a rollup's view, partials and source cannot be changed")));
}
return PointerGetDatum(NULL);
}

tuple = trigdata->tg_trigtuple;
view = DatumGetObjectId(heap_getattr(tuple, 1, desc, &isnull));
partials = DatumGetObjectId(heap_getattr(tuple, 2, desc, &isnull));
source = DatumGetObjectId(heap_getattr(tuple, 3, desc, &isnull));

/*
* THE WRITER MUST OWN WHAT IT TIES TOGETHER (#1406 review). A drop deletes
* AUTO dependents with no ownership check, so a row naming another role's
* table as its partials would hand that table to the writer's DROP VIEW. The
* catalog's row-level security asks only that the writer own the view.
*/
if (get_rel_relkind(view) != RELKIND_VIEW)
ereport(ERROR,
(errcode(ERRCODE_WRONG_OBJECT_TYPE),
errmsg("a rollup must be a view")));
if (get_rel_relkind(partials) != RELKIND_RELATION)
ereport(ERROR,
(errcode(ERRCODE_WRONG_OBJECT_TYPE),
errmsg("a rollup's partials must be a table")));
if (!COLUMNAR_TABLE_OWNERCHECK(view))
aclcheck_error(ACLCHECK_NOT_OWNER, OBJECT_VIEW, get_rel_name(view));
if (!COLUMNAR_TABLE_OWNERCHECK(partials))
aclcheck_error(ACLCHECK_NOT_OWNER, OBJECT_TABLE, get_rel_name(partials));
if (pg_class_aclcheck(source, GetUserId(), ACL_SELECT) != ACLCHECK_OK)
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
errmsg("permission denied for table %s", get_rel_name(source))));

ObjectAddressSet(viewAddr, RelationRelationId, view);
ObjectAddressSet(partialsAddr, RelationRelationId, partials);
ObjectAddressSet(sourceAddr, RelationRelationId, source);

recordDependencyOn(&viewAddr, &sourceAddr, DEPENDENCY_NORMAL);
recordDependencyOn(&partialsAddr, &viewAddr, DEPENDENCY_AUTO);

return PointerGetDatum(NULL);
}

static void
pgcolumnar_object_access(ObjectAccessType access, Oid classId, Oid objectId,
int subId, void *arg)
Expand All @@ -3729,6 +3861,17 @@ pgcolumnar_object_access(ObjectAccessType access, Oid classId, Oid objectId,
{
Relation rel;

/*
* A dropped rollup view takes its catalog rows with it (#1376). Its
* partials table goes by the auto dependency the rollup catalog's trigger
* records; these rows have no dependency of their own to go by.
*/
if (get_rel_relkind(objectId) == RELKIND_VIEW)
{
pgcolumnar_forget_dropped_rollup(objectId);
return;
}

/*
* Ordinary tables AND materialized views. A columnar matview has
* storage of its own, so it can carry an options row -- and until
Expand Down
8 changes: 8 additions & 0 deletions test/check_ledger.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2376,6 +2376,8 @@ rewrite_storage_oid rewrite_storage_oid the repair gate does not read a partitio
rewrite_storage_oid rewrite_storage_oid the repair gate does not read a partition the ALTER did not touch: lp DETACH PARTITION lp_2 15;16;17;18;19 2026-10-12 the repair gate opens every inheritor with NoLock again, so it reads the metapage of a partition the ALTER never locked (an Assert on assert builds)
rewrite_storage_oid rewrite_storage_oid the repair gate does not read a partition the ALTER did not touch: lp DETACH PARTITION lp_2 CONCURRENTLY 15;16;17;18;19 2026-10-12 the repair gate opens every inheritor with NoLock again, so it reads the metapage of a partition the ALTER never locked (an Assert on assert builds)
rollup rollup * with anything but count is refused 15;16;17;18;19 2026-10-13 create_rollup allowing * with any function
rollup rollup DROP TABLE of a rollup's source is refused while the rollup exists (2BP01, #1376) 15;16;17;18;19 2026-10-14 a rollup's view, partials and source with no dependency between them, and no catalog cleanup (main before #1376's fix)
rollup rollup DROP VIEW of a rollup takes its partials and its catalog rows with it (#1376) 15;16;17;18;19 2026-10-14 a rollup's view, partials and source with no dependency between them, and no catalog cleanup (main before #1376's fix)
rollup rollup a delete in a bucket that holds retired history is refused, and the rollup is unchanged 15;16;17;18;19 2026-10-13 refresh_rollup not storing the retired groups' ranges as history;refresh_rollup recording new groups without their time range;refresh_rollup without the history refusal
rollup rollup a delete in a bucket whose history the same refresh retires is refused, and the rollup is unchanged 15;16;17;18;19 2026-10-13 refresh_rollup blocking on stored history only, not on groups the same refresh retires;refresh_rollup recording new groups without their time range;refresh_rollup without the history refusal
rollup rollup a delete in a group that expire then retires is refused, and the rollup is unchanged 15;16;17;18;19 2026-10-13 expire recording a retired group's deleted count as 0;refresh_rollup not refusing a retiring group whose deleted count moved
Expand All @@ -2390,8 +2392,12 @@ rollup rollup a refresh at REPEATABLE READ is refused 15;16;17;18;19 2026-10-13
rollup rollup a refresh folds a bulk append 15;16;17;18;19 2026-10-13 refresh_rollup folding every group, ignoring the recorded snapshot;refresh_rollup folding only the first new group;row_tid mapping 290 rows a block instead of the AM's
rollup rollup a refresh in a zone with a DST change recomputes a whole day bucket 15;16;17;18;19 2026-10-13 refresh_rollup adding the width in the session's zone;refresh_rollup recomputing every bucket once any group changed;refresh_rollup recording new groups without their time range;refresh_rollup refusing any change but appends (phase 1, main before this branch)
rollup rollup a refresh with nothing new folds nothing 15;16;17;18;19 2026-10-13 refresh_rollup folding every group, ignoring the recorded snapshot;refresh_rollup folding only the first new group
rollup rollup a role cannot name another role's table as its rollup's partials (42501), and that table survives its view's drop (#1376) 15;16;17;18;19 2026-10-14 the rollup trigger with no ownership, kind or no-change checks (8a7fab2, #1406 review)
rollup rollup a role cannot tie a rollup row to a table it cannot read (42501, #1376) 15;16;17;18;19 2026-10-14 a rollup's view, partials and source with no dependency between them, and no catalog cleanup (main before #1376's fix)
rollup rollup a role that cannot read the source cannot create a rollup of it 15;16;17;18;19 2026-10-13 get_storage_id without its SELECT check, so create_rollup no longer refuses a caller that cannot read the source
rollup rollup a rollup kept in a heap table refreshes and equals a full recompute 15;16;17;18;19 2026-10-13 create_rollup ignoring access_method;refresh_rollup folding only the first new group;row_tid mapping 290 rows a block instead of the AM's
rollup rollup a rollup row whose rollup is not a view is refused (42809, #1376) 15;16;17;18;19 2026-10-14 the rollup trigger with no ownership, kind or no-change checks (8a7fab2, #1406 review)
rollup rollup a rollup row's view, partials and source cannot be changed once written (0A000, #1376) 15;16;17;18;19 2026-10-14 the rollup trigger with no ownership, kind or no-change checks (8a7fab2, #1406 review)
rollup rollup a rollup with NULL keys and NULL timestamps equals a full recompute 15;16;17;18;19 2026-10-13 refresh_rollup combining min with greatest;refresh_rollup folding every group, ignoring the recorded snapshot;refresh_rollup folding only the first new group;refresh_rollup matching keys with = instead of IS NOT DISTINCT FROM;row_tid mapping 290 rows a block instead of the AM's
rollup rollup a sum over a column that cannot be folded is refused 15;16;17;18;19 2026-10-14 create_rollup without its cannot-be-folded check
rollup rollup a table's owner, not a superuser, creates a rollup of it 15;16;17;18;19 2026-10-13 the rollup catalogs without their grants
Expand All @@ -2405,6 +2411,7 @@ rollup rollup after compact_rewrite the rollup equals a full recompute 15;16;17;
rollup rollup aggregate functions and columns of different lengths are refused 15;16;17;18;19 2026-10-13 create_rollup without the equal-length check
rollup rollup an aggregate column the source does not have is refused 15;16;17;18;19 2026-10-13 create_rollup without the column-exists check
rollup rollup an aggregate that does not combine is refused 15;16;17;18;19 2026-10-13 create_rollup without the five-function allow-list
rollup rollup and DROP TABLE ... CASCADE takes the rollup's view, partials and catalog rows with it (#1376) 15;16;17;18;19 2026-10-14 a rollup's view, partials and source with no dependency between them, and no catalog cleanup (main before #1376's fix)
rollup rollup and after a delete the rollup equals a full recompute 15;16;17;18;19 2026-10-13 rebase_rollup recording no time ranges;refresh_rollup recomputing every bucket once any group changed;refresh_rollup refusing any change but appends (phase 1, main before this branch)
rollup rollup and after retention an append still folds 15;16;17;18;19 2026-10-13 refresh_rollup folding every group, ignoring the recorded snapshot;refresh_rollup folding only the first new group;refresh_rollup not dropping groups retention retired;row_tid mapping 290 rows a block instead of the AM's
rollup rollup and after the bulk append the rollup equals a full recompute 15;16;17;18;19 2026-10-13 refresh_rollup combining min with greatest;refresh_rollup folding every group, ignoring the recorded snapshot;refresh_rollup folding only the first new group;refresh_rollup recording the folded groups from a fresh read after the fold;row_tid mapping 290 rows a block instead of the AM's
Expand All @@ -2430,6 +2437,7 @@ rollup rollup and the rollup still equals a full recompute 15;16;17;18;19 2026-1
rollup rollup another role cannot add a group row to the owner's rollup (42501) 15;16;17;18;19 2026-10-14 the rollup catalog policies given WITH CHECK (true)
rollup rollup another role cannot read the source's groups through rollup_source_groups (42501) 15;16;17;18;19 2026-10-14 rollup_source_groups without its require_caller_select guard
rollup rollup another role sees none of its rollup catalog rows 15;16;17;18;19 2026-10-13 the rollup catalogs without row-level security;the rollup catalogs without their grants
rollup rollup both rollups over the source refreshed, so each has a view, partials and catalog rows 15;16;17;18;19 never -
rollup rollup each row group's TID range holds exactly that group's rows 15;16;17;18;19 2026-10-13 row_tid mapping 290 rows a block instead of the AM's
rollup rollup nor its retired groups through rollup_source_retired (42501) 15;16;17;18;19 2026-10-14 rollup_source_retired without its require_caller_select guard
rollup rollup premise: and the fixture has NULL-key and NULL-bucket groups 15;16;17;18;19 never -
Expand Down
18 changes: 17 additions & 1 deletion test/check_ledger_budget.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2210,4 +2210,20 @@ suites_not_covered 234
# never 2099 + 1 - 14 = 2086
# dated 644 + 14 = 658
# 2086 + 658 == 2744
checks_never_observed_red 2086
#
# #1376, a rollup and its source are dropped together (jdatcmd): 2086 -> 2087. rollup
# gains eight checks from five-major runs (90/90 on each of 15-19). Four are dated by
# main before the fix, where the view, partials and source had no dependency between
# them and nothing cleaned the catalog rows: DROP VIEW left the partials and rows,
# DROP TABLE of the source succeeded, CASCADE then had nothing to drop, and a role
# could tie a table it cannot read to its own view. Three more are dated by the first
# version of the trigger (#1406 review): it let a role name another role's table as
# its partials (and dropped that table with the role's view), a non-view as its
# rollup, and change a written row. The control that both rollups refreshed arrives
# never.
#
# ledger rows 2744 + 8 = 2752
# never 2086 + 1 = 2087
# dated 658 + 7 = 665
# 2087 + 665 == 2752
checks_never_observed_red 2087
Loading
Loading