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
7 changes: 7 additions & 0 deletions test/check_ledger.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2265,6 +2265,7 @@ ttl_expire ttl_expire a group whose only NULLs have been deleted is retired 15;1
ttl_expire ttl_expire a later expire on a column of only NULLs reads no zone map 15;16;17;18;19 2026-10-13 expire's early exit removed (the tree before #1310);the early exit not taken for a column with no value
ttl_expire ttl_expire a negative ttl_interval is refused with 22023 15;16;17;18;19 never -
ttl_expire ttl_expire a no-op expire reads no zone map when the extent's minimum is past the cutoff 15;16;17;18;19 2026-10-13 expire's early exit removed (the tree before #1310)
ttl_expire ttl_expire a role that does not own the table cannot expire it, even holding DELETE (42501) 15;16;17;18;19 2026-10-14 expire without its owner check
ttl_expire ttl_expire a rolled-back expire records nothing 15;16;17;18;19 never -
ttl_expire ttl_expire a row dated exactly on the cutoff is kept, so truncation errs toward keeping 15;16;17;18;19 never -
ttl_expire ttl_expire a table with no declared retention is an error, not a silent success 15;16;17;18;19 2026-10-14 the no-retention refusal raised as XX000 rather than 55000
Expand All @@ -2283,6 +2284,7 @@ ttl_expire ttl_expire but the zone map still records the deleted NULLs 15;16;17;
ttl_expire ttl_expire compact_rewrite retired a group 15;16;17;18;19 2026-10-14 compact_rewrite returning 0 before rewriting anything
ttl_expire ttl_expire control: a group whose NULLs are still live is NOT retired 15;16;17;18;19 never -
ttl_expire ttl_expire control: and a positive one is still accepted 15;16;17;18;19 never -
ttl_expire ttl_expire control: and its owner's expire retires every aged group 15;16;17;18;19 never -
ttl_expire ttl_expire control: and the rows are all still there 15;16;17;18;19 never -
ttl_expire ttl_expire control: and those NULL rows survive 15;16;17;18;19 never -
ttl_expire ttl_expire dropping the table drops its retention record 15;16;17;18;19 never -
Expand All @@ -2295,6 +2297,8 @@ ttl_expire ttl_expire expire in the transaction that wrote aged rows retires the
ttl_expire ttl_expire expire on a date column retires at least one group 15;16;17;18;19 2026-10-14 expire returning 0 whatever it retired;the early exit compares the extent's maximum to the cutoff instead of its minimum
ttl_expire ttl_expire expire on a timestamp column retires the groups wholly before the session's local cutoff 15;16;17;18;19 2026-10-14 expire's timestamp cutoff taken from the UTC wall clock, not the session's local time
ttl_expire ttl_expire expire records exactly the groups it retired 15;16;17;18;19 2026-10-13 the early exit compares the extent's maximum to the cutoff instead of its minimum
ttl_expire ttl_expire expire refuses a retention column that is not a time type (42804) 15;16;17;18;19 2026-10-14 expire returning 0 for a retention column that is not a time type
ttl_expire ttl_expire expire refuses a retention column that no longer exists (42703) 15;16;17;18;19 2026-10-14 expire returning 0 for a retention column that no longer exists
ttl_expire ttl_expire expire retired the three aged groups 15;16;17;18;19 2026-10-14 expire returning 0 whatever it retired;the early exit compares the extent's maximum to the cutoff instead of its minimum
ttl_expire ttl_expire expire retires at least one group (#403 item 5a) 15;16;17;18;19 2026-10-14 expire returning 0 whatever it retired;the early exit compares the extent's maximum to the cutoff instead of its minimum
ttl_expire ttl_expire expire retires the aged groups and keeps those of NULLs 15;16;17;18;19 2026-10-14 expire returning 0 whatever it retired
Expand All @@ -2304,6 +2308,7 @@ ttl_expire ttl_expire premise: an index-only scan is chosen on the all-visible t
ttl_expire ttl_expire premise: and every live row is past the retention 15;16;17;18;19 never -
ttl_expire ttl_expire premise: and rows dated exactly ON the cutoff, which decide the rounding 15;16;17;18;19 never -
ttl_expire ttl_expire premise: and the SAME row group holds expired rows and NULL ones 15;16;17;18;19 never -
ttl_expire ttl_expire premise: each table holds 3000 rows in three row groups 15;16;17;18;19 never -
ttl_expire ttl_expire premise: in UTC the fixture straddles that session's cutoff 15;16;17;18;19 never -
ttl_expire ttl_expire premise: in a zone on another date the fixture straddles that session's cutoff 15;16;17;18;19 never -
ttl_expire ttl_expire premise: no LIVE row holds a NULL retention value any more 15;16;17;18;19 never -
Expand All @@ -2313,6 +2318,7 @@ ttl_expire ttl_expire premise: rows exist on both sides of the cutoff within one
ttl_expire ttl_expire premise: some rows are past the retention and some are not 15;16;17;18;19 never -
ttl_expire ttl_expire premise: the date fixture has rows on both sides of the cutoff 15;16;17;18;19 never -
ttl_expire ttl_expire premise: the date fixture is laid out in several row groups 15;16;17;18;19 never -
ttl_expire ttl_expire premise: the declaration still names the dropped column 15;16;17;18;19 never -
ttl_expire ttl_expire premise: the fixture is ONE row group, so the NULLs share it 15;16;17;18;19 never -
ttl_expire ttl_expire premise: the fixture is laid out in several row groups 15;16;17;18;19 never -
ttl_expire ttl_expire premise: the no-op fixture has many row groups 15;16;17;18;19 never -
Expand All @@ -2322,6 +2328,7 @@ ttl_expire ttl_expire premise: the table holds NULL retention rows at all 15;16;
ttl_expire ttl_expire premise: the timestamp fixture is six groups of 1,000 rows 15;16;17;18;19 never -
ttl_expire ttl_expire running it again retires nothing, because nothing new expired 15;16;17;18;19 never -
ttl_expire ttl_expire seqscan agrees the expired rows are gone 15;16;17;18;19 never -
ttl_expire ttl_expire set_options accepts an int retention column, so the refusal is expire's 15;16;17;18;19 never -
ttl_expire ttl_expire the index-only scan is still the plan after expire 15;16;17;18;19 never -
ttl_expire ttl_expire the retention is recorded where the other options live 15;16;17;18;19 never -
ttl_expire ttl_expire with the extent, expire retires the same groups 15;16;17;18;19 2026-10-13 the early exit compares the extent's maximum to the cutoff instead of its minimum
Expand Down
16 changes: 15 additions & 1 deletion test/check_ledger_budget.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1871,4 +1871,18 @@ suites_not_covered 246
# never 1798 - 1 = 1797
# dated 555 + 2 = 557
# 1797 + 557 == 2354
checks_never_observed_red 1797
#
# #1341, what expire refuses (OffgridwithJD): 1797 -> 1801. Seven new ttl_expire checks
# on tables whose every row is past retention, so a missing refusal drops rows. Three
# arms are dated by a mutation of expire: no owner check (a non-owner holding DELETE
# then retired all three groups), and a silent return 0 in place of the 42703 and of
# the 42804 refusal. Four arrive never: two premises on the fixture, set_options
# accepting an int retention column, and the owner's control, which has its own table
# so no refusal's mutation can reach it. Five-major runs on the files merged with
# #1353 and #1355: 77/77 on 15-19.
#
# ledger rows 2354 + 7 = 2361
# never 1797 + 4 = 1801
# dated 557 + 3 = 560
# 1801 + 560 == 2361
checks_never_observed_red 1801
1 change: 1 addition & 0 deletions test/pytest/TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6228,6 +6228,7 @@ restored byte-identical.
| test | what it pins |
| --- | --- |
| `test_expire_retires_expired_groups_and_keeps_the_straddling_one` | the feature, and the straddling group that constrains it |
| `test_expire_refuses_what_it_cannot_honour` | #1341: a non-owner holding DELETE (`42501`), a retention column that is not a time type (`42804`) and a dropped one (`42703`) are refused with every row still there, on tables whose rows are all past retention; the owner's `expire` is the control that retires them |
| `test_a_null_retention_value_keeps_its_group` | a live NULL the maximum cannot speak for |
| `test_an_index_only_scan_does_not_return_retired_rows` | the visibility-map bits over a group `expire` removed |
| `test_a_non_positive_retention_is_refused` | a cutoff in the future would retire live groups: `22023`, with a positive control |
Expand Down
4 changes: 3 additions & 1 deletion test/pytest/expected_tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -629,4 +629,6 @@ guard_tests 429
# kind and the --partition mode), by collection over the cluster files on this branch.
# cluster_tests 532 -> 533 for #1339's test_a_timestamp_retention_column_uses_the_sessions_local_time
# in test_ttl_expire.py, by collection over the cluster files on this branch.
cluster_tests 533
# cluster_tests 533 -> 534 for #1341's test_expire_refuses_what_it_cannot_honour in
# test_ttl_expire.py, by collection over the cluster files on this tree.
cluster_tests 534
76 changes: 76 additions & 0 deletions test/pytest/test_ttl_expire.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,82 @@ def test_a_non_positive_retention_is_refused(pgc_conn, expect):
"control: and the rows are all still there")


def test_expire_refuses_what_it_cannot_honour(pgc_conn, expect):
"""A non-owner, a retention column that is not a time type, and a dropped one.

Each refused table holds 3,000 rows ten days old, every one past a one-day
retention, so a missing refusal is not a quiet no-op: it is retired groups. The
row count beside each SQLSTATE is what says nothing was dropped (#1341). ttl_ctl
is the same fixture as ttl_own on its own table, so the owner's control cannot be
reached by whatever a missing refusal did to ttl_own.
"""
with pgc_conn.cursor() as cur:
cur.execute("DO $$ BEGIN CREATE ROLE ttl_nobody; "
"EXCEPTION WHEN duplicate_object THEN NULL; END $$")
cur.execute("GRANT USAGE ON SCHEMA pgcolumnar TO ttl_nobody")
cur.execute("SELECT current_schema()")
cur.execute(f'GRANT USAGE ON SCHEMA "{cur.fetchone()[0]}" TO ttl_nobody')
for t, ty in (("ttl_own", "timestamptz"), ("ttl_ctl", "timestamptz"), ("ttl_int", "int"),
("ttl_gone", "timestamptz")):
cur.execute(f"CREATE TABLE {t} (id int, ts {ty}, ts2 timestamptz) USING pgcolumnar")
cur.execute(f"SELECT pgcolumnar.set_options('{t}', stripe_row_limit => 1000)")
for t in ("ttl_own", "ttl_ctl"):
cur.execute(f"INSERT INTO {t} SELECT g, now() - interval '10 days', now()"
" FROM generate_series(1,3000) g")
cur.execute("INSERT INTO ttl_int SELECT g, g - 1500, now() FROM generate_series(1,3000) g")
cur.execute("INSERT INTO ttl_gone SELECT g, now() - interval '10 days', now() - interval '10 days'"
" FROM generate_series(1,3000) g")
def count(t):
return _one(pgc_conn, f"SELECT count(*) FROM {t}")

def groups(t):
return _one(pgc_conn, "SELECT count(*) FROM pgcolumnar.row_group"
" WHERE storage_id = pgcolumnar.get_storage_id(%s::regclass)", (t,))

lay = "".join(f"{groups(t)}:{count(t)} " for t in ("ttl_ctl", "ttl_gone", "ttl_int", "ttl_own"))
expect.text(lay, "3:3000 3:3000 3:3000 3:3000 ",
"premise: each table holds 3000 rows in three row groups")

# Ownership: DELETE on the table is not enough, expire drops whole row groups.
with pgc_conn.cursor() as cur:
for t in ("ttl_own", "ttl_ctl"):
cur.execute(f"SELECT pgcolumnar.set_options('{t}', ttl_column => 'ts', ttl_interval => '1 day')")
cur.execute("GRANT SELECT, DELETE ON ttl_own TO ttl_nobody")
cur.execute("SET ROLE ttl_nobody")
try:
state = _sqlstate(pgc_conn, "SELECT pgcolumnar.expire('ttl_own')")
finally:
with pgc_conn.cursor() as cur:
cur.execute("RESET ROLE")
expect.text(f"{state}|{count('ttl_own')}", "42501|3000",
"a role that does not own the table cannot expire it, even holding DELETE (42501)")
retired = _one(pgc_conn, "SELECT pgcolumnar.expire('ttl_ctl')")
expect.text(f"{retired}|{count('ttl_ctl')}", "3|0",
"control: and its owner's expire retires every aged group")

# Type: set_options takes a column of any type, and expire is what refuses one
# that is not date, timestamp or timestamptz.
expect.text(
_sqlstate(pgc_conn, "SELECT pgcolumnar.set_options('ttl_int', ttl_column => 'ts',"
" ttl_interval => '1 day')") or "accepted",
"accepted", "set_options accepts an int retention column, so the refusal is expire's")
int_state = _sqlstate(pgc_conn, "SELECT pgcolumnar.expire('ttl_int')")
expect.text(f"{int_state}|{count('ttl_int')}", "42804|3000",
"expire refuses a retention column that is not a time type (42804)")

# A dropped retention column: the declaration outlives it, and expire refuses
# rather than reading some other column.
with pgc_conn.cursor() as cur:
cur.execute("SELECT pgcolumnar.set_options('ttl_gone', ttl_column => 'ts', ttl_interval => '1 day')")
cur.execute("ALTER TABLE ttl_gone DROP COLUMN ts")
expect.text(_one(pgc_conn, "SELECT ttl_column FROM pgcolumnar.options"
" WHERE regclass = 'ttl_gone'::regclass"), "ts",
"premise: the declaration still names the dropped column")
gone_state = _sqlstate(pgc_conn, "SELECT pgcolumnar.expire('ttl_gone')")
expect.text(f"{gone_state}|{count('ttl_gone')}", "42703|3000",
"expire refuses a retention column that no longer exists (42703)")


def test_a_deleted_null_stops_pinning_its_group(pgc_conn, expect):
"""`null_count` is recorded at WRITE time and never revised.

Expand Down
49 changes: 49 additions & 0 deletions test/ttl_expire.sh
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,55 @@ check "control: and a positive one is still accepted" \
check "control: and the rows are all still there" \
"$(q 'SELECT count(*) FROM ttl_neg')" "100"

# ---- what expire refuses, with rows it could have dropped (#1341) ----------
#
# Each refused table holds 3,000 rows ten days old, every one past a one-day
# retention, so a missing refusal is not a quiet no-op: it is retired groups. The
# row count beside each SQLSTATE is what says nothing was dropped. ttl_ctl is the
# same fixture as ttl_own on its own table, so the owner's control cannot be
# reached by whatever a missing refusal did to ttl_own.
psql_run "CREATE ROLE ttl_nobody;
GRANT USAGE ON SCHEMA pgcolumnar TO ttl_nobody;"
for _t in ttl_own ttl_ctl ttl_int ttl_gone; do
_ty=timestamptz; [ "$_t" = ttl_int ] && _ty=int
psql_run "CREATE TABLE $_t (id int, ts $_ty, ts2 timestamptz) USING pgcolumnar;
SELECT pgcolumnar.set_options('$_t', stripe_row_limit => 1000);"
done
psql_run "INSERT INTO ttl_own SELECT g, now() - interval '10 days', now() FROM generate_series(1,3000) g;
INSERT INTO ttl_ctl SELECT g, now() - interval '10 days', now() FROM generate_series(1,3000) g;
INSERT INTO ttl_int SELECT g, g - 1500, now() FROM generate_series(1,3000) g;
INSERT INTO ttl_gone SELECT g, now() - interval '10 days', now() - interval '10 days' FROM generate_series(1,3000) g;"
_lay=""
for _t in ttl_ctl ttl_gone ttl_int ttl_own; do
_lay="$_lay$(q "SELECT count(*) FROM pgcolumnar.row_group WHERE storage_id = pgcolumnar.get_storage_id('$_t');"):$(q "SELECT count(*) FROM $_t;") "
done
check "premise: each table holds 3000 rows in three row groups" "$_lay" "3:3000 3:3000 3:3000 3:3000 "

# Ownership: DELETE on the table is not enough, expire drops whole row groups.
psql_run "SELECT pgcolumnar.set_options('ttl_own', ttl_column => 'ts', ttl_interval => '1 day');
SELECT pgcolumnar.set_options('ttl_ctl', ttl_column => 'ts', ttl_interval => '1 day');
GRANT SELECT, DELETE ON ttl_own TO ttl_nobody;"
check "a role that does not own the table cannot expire it, even holding DELETE (42501)" \
"$(ttl_state "SET ROLE ttl_nobody; SELECT pgcolumnar.expire('ttl_own');")|$(q 'SELECT count(*) FROM ttl_own')" "42501|3000"
check "control: and its owner's expire retires every aged group" \
"$(q "SELECT pgcolumnar.expire('ttl_ctl');")|$(q 'SELECT count(*) FROM ttl_ctl')" "3|0"

# Type: set_options takes a column of any type, and expire is what refuses one
# that is not date, timestamp or timestamptz.
check "set_options accepts an int retention column, so the refusal is expire's" \
"$(ttl_state "SELECT pgcolumnar.set_options('ttl_int', ttl_column => 'ts', ttl_interval => '1 day');")" ""
check "expire refuses a retention column that is not a time type (42804)" \
"$(ttl_state "SELECT pgcolumnar.expire('ttl_int');")|$(q 'SELECT count(*) FROM ttl_int')" "42804|3000"

# A dropped retention column: the declaration outlives it, and expire refuses
# rather than reading some other column.
psql_run "SELECT pgcolumnar.set_options('ttl_gone', ttl_column => 'ts', ttl_interval => '1 day');
ALTER TABLE ttl_gone DROP COLUMN ts;"
check "premise: the declaration still names the dropped column" \
"$(q "SELECT ttl_column FROM pgcolumnar.options WHERE regclass = 'ttl_gone'::regclass;")" "ts"
check "expire refuses a retention column that no longer exists (42703)" \
"$(ttl_state "SELECT pgcolumnar.expire('ttl_gone');")|$(q 'SELECT count(*) FROM ttl_gone')" "42703|3000"

# ---- a NULL that has been DELETED must stop pinning its group ---------------
#
# z->nullCount is recorded at WRITE time and never revised, so it still counts
Expand Down
Loading