From fba2f78679fdaa3f50aa58feb67da44472e6539d Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:39:47 +0000 Subject: [PATCH 01/18] build: add pg_cron release-gate image Keep the Docker tag independent from the numeric package major so the same image definition supports PostgreSQL 14 through 19beta2. Relates to https://github.com/NikolayS/pg_ash/issues/160 --- devel/docker/Dockerfile | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 devel/docker/Dockerfile diff --git a/devel/docker/Dockerfile b/devel/docker/Dockerfile new file mode 100644 index 0000000..bd7f5b5 --- /dev/null +++ b/devel/docker/Dockerfile @@ -0,0 +1,26 @@ +# pg_cron-capable PostgreSQL image for the release gate. +# +# Keep the image tag separate from PostgreSQL's numeric major. In particular, +# postgres:19beta2 exports PG_MAJOR=19, which is the value required by the +# postgresql-19-cron package name. +ARG POSTGRES_TAG=18 +FROM postgres:${POSTGRES_TAG} + +# pg_cron versions differ by PostgreSQL major and follow the pinned base +# image's PGDG repository snapshot. +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends "postgresql-${PG_MAJOR}-cron" \ + && rm -rf /var/lib/apt/lists/* + +# initdb copies this sample into each new cluster, so both libraries are loaded +# on first start. cron.database_name is intentionally not baked in: callers can +# select the database at runtime with: +# +# postgres -c cron.database_name= +RUN printf '%s\n' \ + '' \ + '# pg_ash release-gate defaults' \ + "shared_preload_libraries = 'pg_cron,pg_stat_statements'" \ + 'cron.use_background_workers = on' \ + >> /usr/share/postgresql/postgresql.conf.sample From e809a21bb20b3109e578587fab7b6047842920b0 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:39:54 +0000 Subject: [PATCH 02/18] test: share release-gate SQL assertions Source cron, degraded-mode, and schema snapshots from shared SQL while preserving all 594 workflow assertions. Include attnum in schema snapshots so column-order divergence is visible. Relates to https://github.com/NikolayS/pg_ash/issues/160 --- .github/workflows/test.yml | 232 ++----------------------------- devel/tests/cron_path.sql | 28 ++++ devel/tests/degraded_no_cron.sql | 56 ++++++++ devel/tests/degraded_no_pgss.sql | 63 +++++++++ devel/tests/schema_snapshot.sql | 58 ++++++++ 5 files changed, 216 insertions(+), 221 deletions(-) create mode 100644 devel/tests/cron_path.sql create mode 100644 devel/tests/degraded_no_cron.sql create mode 100644 devel/tests/degraded_no_pgss.sql create mode 100644 devel/tests/schema_snapshot.sql diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a57147c..fe4a811 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -105,8 +105,8 @@ jobs: strategy: fail-fast: false matrix: - # PG19 is still beta, so the official image tag is 19beta1 until GA. - postgres: [14, 15, 16, 17, 18, 19beta1] + # PG19 is still beta, so the official image tag is 19beta2 until GA. + postgres: [14, 15, 16, 17, 18, 19beta2] # Quoted so YAML doesn't coerce on/off into booleans — GitHub Actions # compares these as strings in the step-level `if`/conditionals. cron: ["on"] @@ -2627,36 +2627,8 @@ jobs: # Give the sleepers a moment to register in pg_stat_activity. sleep 2 - psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 << 'EOF' - -- Clean slate: stop any prior sampler, truncate raw partitions - -- so the post-baseline count is unambiguous. - select ash.stop(); - truncate ash.sample_0, ash.sample_1, ash.sample_2; - - -- Capture pre-start baseline (should be 0 across all slots). - do $$ - declare - v_baseline bigint; - begin - select count(*) into v_baseline from ash.sample; - assert v_baseline = 0, - format('expected 0 samples after truncate, got %s', v_baseline); - end $$; - - -- Schedule sampling at 1 Hz via pg_cron. - select ash.start('1 second'); - - -- Sanity: cron.job has the ash row. - do $$ - declare - v_jobs int; - begin - select count(*) into v_jobs - from cron.job where jobname like 'ash_%'; - assert v_jobs >= 1, - format('expected ash_* job(s) in cron.job, got %s', v_jobs); - end $$; - EOF + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -f devel/tests/cron_path.sql # Poll for actual job runs. With cron.use_background_workers=on # the jobs fire as bgworkers — but cron.job_run_details is only @@ -11020,67 +10992,6 @@ jobs: --dbname=postgres ) - cat > /tmp/ash_schema_snapshot.sql << 'SNAPSQL' - \pset tuples_only on - \pset format unaligned - select 'FUNC', p.proname, - pg_get_function_identity_arguments(p.oid), - pg_get_function_result(p.oid), - md5(p.prosrc) - from pg_proc p - join pg_namespace n on n.oid = p.pronamespace - where n.nspname = 'ash' - order by p.proname, pg_get_function_identity_arguments(p.oid); - - select 'COL', c.relname, a.attname, - pg_catalog.format_type(a.atttypid, a.atttypmod), - a.attnotnull, - pg_get_expr(d.adbin, d.adrelid) - from pg_class c - join pg_namespace n on n.oid = c.relnamespace - join pg_attribute a on a.attrelid = c.oid - left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum - where n.nspname = 'ash' and c.relkind in ('r','p') and a.attnum > 0 and not a.attisdropped - order by c.relname, a.attname; - - select 'IDX', c.relname, pg_get_indexdef(i.indexrelid) - from pg_index i - join pg_class c on c.oid = i.indexrelid - join pg_namespace n on n.oid = c.relnamespace - where n.nspname = 'ash' - order by c.relname; - - select 'VIEW', viewname, md5(definition) - from pg_views - where schemaname = 'ash' - order by viewname; - - -- Issue #66: include CHECK / NOT NULL / PK / UNIQUE / FK constraints. - -- Covers parent partitioned tables AND their partitions (relkind r,p). - -- pg_get_constraintdef gives a stable canonical text form, so any - -- divergence (e.g. sample_data_check `>= 2` vs `>= 3` from #49) - -- surfaces here. - select 'CON', c.relname, con.conname, pg_get_constraintdef(con.oid) - from pg_constraint con - join pg_class c on c.oid = con.conrelid - join pg_namespace n on n.oid = c.relnamespace - where n.nspname = 'ash' and c.relkind in ('r','p') - order by c.relname, con.conname; - - -- Issue #107: privilege state must be equivalent too. proacl is - -- NULL until the first GRANT/REVOKE touches a function; the - -- installer's REVOKE-from-PUBLIC hardening always runs, so a fresh - -- install and the full upgrade chain must converge to identical - -- explicit function ACLs. - select 'FACL', p.proname, - pg_get_function_identity_arguments(p.oid), - coalesce(p.proacl::text, '') - from pg_proc p - join pg_namespace n on n.oid = p.pronamespace - where n.nspname = 'ash' - order by p.proname, pg_get_function_identity_arguments(p.oid); - SNAPSQL - python3 devel/scripts/ash_sql_chain.py full-upgrade-chain > /tmp/ash_full_upgrade_chain.sql git show v1.5:sql/ash-install.sql > /tmp/ash-v1.5-install.sql @@ -11091,7 +11002,7 @@ jobs: > /dev/null 2>&1 "${psql_base[@]}" \ --set=ON_ERROR_STOP=1 \ - --file=/tmp/ash_schema_snapshot.sql \ + --file=devel/tests/schema_snapshot.sql \ > /tmp/fresh_schema.txt "${psql_base[@]}" \ --command="select ash.uninstall('yes')" \ @@ -11104,7 +11015,7 @@ jobs: > /dev/null 2>&1 "${psql_base[@]}" \ --set=ON_ERROR_STOP=1 \ - --file=/tmp/ash_schema_snapshot.sql \ + --file=devel/tests/schema_snapshot.sql \ > /tmp/upgraded_schema.txt "${psql_base[@]}" \ --command="select ash.uninstall('yes')" \ @@ -11124,7 +11035,7 @@ jobs: > /dev/null 2>&1 "${psql_base[@]}" \ --set=ON_ERROR_STOP=1 \ - --file=/tmp/ash_schema_snapshot.sql \ + --file=devel/tests/schema_snapshot.sql \ > /tmp/upgraded_v15_schema.txt "${psql_base[@]}" \ --command="select ash.uninstall('yes')" \ @@ -11201,71 +11112,8 @@ jobs: -c "drop extension if exists pg_stat_statements" psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -f "$(python3 devel/scripts/ash_sql_chain.py fresh-install-path)" > /dev/null - psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 << 'EOF' - -- Degraded mode: pg_ash freshly installed WITHOUT pg_stat_statements. - -- Every 2.0 reader must work; query_text degrades to NULL and is never spoofed - -- by a user-created public.pg_stat_statements relation (#87). - insert into ash.wait_event_map (state, type, event) values - ('active', 'CPU*', 'CPU*') on conflict do nothing; - insert into ash.query_map_0 (query_id) values (999999) on conflict do nothing; - - do $$ - declare - v_cpu smallint; v_q1 int4; v_ts int4; v_from timestamptz; - n int; tcnt int; j jsonb; - begin - select id into v_cpu from ash.wait_event_map where type = 'CPU*'; - select id into v_q1 from ash.query_map_0 where query_id = 999999; - -- Minute-aligned 3 minutes back so a window from here reads raw. - v_ts := (ash.ts_from_timestamptz(date_trunc('minute', now() - interval '3 minutes')) / 60) * 60; - v_from := ash.ts_to_timestamptz(v_ts); - insert into ash.sample (sample_ts, datid, active_count, data, slot) - values (v_ts, 1, 1, array[-v_cpu, 1, v_q1]::integer[], 0); - perform ash.rollup_minute(); - - -- Every reader runs without pgss; query_text is NULL, no error, no WARNING. - perform ash.periods(); - perform * from ash.aas(v_from, now()); - perform * from ash.timeline(v_from, now()); - perform * from ash.compare(v_from, now(), v_from, now()); - perform * from ash.summary(v_from, now()); - perform * from ash.chart(v_from, now()); - assert (select query_text from ash.top('query_id', v_from, now()) limit 1) is null, - 'top query_text should be NULL without pgss'; - assert (select query_text from ash.samples(v_from, now()) limit 1) is null, - 'samples query_text should be NULL without pgss'; - - -- report still produces query ids (they come from samples, not pgss). - j := ash.report(v_from, now()); - assert j is not null, 'report should work without pgss'; - assert j->'top_queryids_worst1m' ? 'total', 'report top_queryids total should be present'; - - perform ash.take_sample(); - perform ash.status(); - perform ash.set_debug_logging(); - - -- #87: a user-made public.pg_stat_statements must NOT be trusted as the real - -- extension — no row fan-out, no spoofed query_text. - create table public.pg_stat_statements ( - queryid bigint, query text, calls bigint, - total_exec_time double precision, mean_exec_time double precision, dbid oid); - insert into public.pg_stat_statements values - (999999, 'select same from user_a', 10, 100.0, 10.0, 1::oid), - (999999, 'select same from user_b', 20, 400.0, 20.0, 2::oid); - - select count(*), count(query_text) into n, tcnt - from ash.top('query_id', v_from, now()) where key = '999999'; - assert n = 1 and tcnt = 0, - format('top must ignore spoofed pg_stat_statements, got rows=%s text_rows=%s', n, tcnt); - select count(*), count(query_text) into n, tcnt - from ash.samples(v_from, now()) where query_id = 999999; - assert n = 1 and tcnt = 0, - format('samples must ignore spoofed pg_stat_statements, got rows=%s text_rows=%s', n, tcnt); - - drop table public.pg_stat_statements; - raise notice 'All degraded-mode (no pgss) 2.0 reader tests PASSED'; - end $$; - EOF + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -f devel/tests/degraded_no_pgss.sql psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -c "select ash.uninstall('yes')" @@ -11287,65 +11135,8 @@ jobs: psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -f "$(python3 devel/scripts/ash_sql_chain.py fresh-install-path)" > /dev/null cronless_out="$( - psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 2>&1 << 'EOF' - do $$ - declare - v_rec record; - v_status_val text; - begin - -- start() should work without pg_cron (no error, prints hints) - select status into v_status_val - from ash.start('1 second') - where job_type = 'sampler'; - assert v_status_val like '%schedule externally%', - 'start() without pg_cron should say schedule externally, got: ' || v_status_val; - - /* - * A preloaded pg_cron registers cron.database_name as - * postmaster-only even after DROP EXTENSION. Exercise mutable - * placeholder values only on the dedicated cron-off axis, where - * the setting starts absent. - */ - if current_setting('cron.database_name', true) is null then - perform set_config( - 'cron.database_name', current_database(), false - ); - perform * from ash.start('1 second'); - perform set_config( - 'cron.database_name', 'cron_control', false - ); - perform * from ash.start('1 second'); - end if; - - -- stop() should work without pg_cron - select status into v_status_val - from ash.stop() - limit 1; - assert v_status_val like '%external scheduler%', - 'stop() without pg_cron should mention external scheduler, got: ' || v_status_val; - - -- status() should show pg_cron_available = no - select value into v_status_val - from ash.status() - where metric = 'pg_cron_available'; - assert v_status_val like '%no%', - 'status() should show pg_cron not available, got: ' || v_status_val; - - -- take_sample() should work without pg_cron - perform ash.take_sample(); - - -- All 2.0 reader functions should work - perform ash.periods(); - perform * from ash.top('wait_event', now() - interval '1 hour', now()); - perform * from ash.samples(now() - interval '1 hour', now()); - perform * from ash.chart(now() - interval '1 hour', now()); - - raise notice 'All degraded-mode (no pg_cron) tests PASSED'; - end; - $$; - - select ash.uninstall('yes'); - EOF + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -f devel/tests/degraded_no_cron.sql 2>&1 )" printf '%s\n' "$cronless_out" if ! printf '%s\n' "$cronless_out" | grep -Fq \ @@ -11380,7 +11171,6 @@ jobs: echo "FAIL: ash.start emitted the old cluster-wide pg_cron claim" >&2 exit 1 fi - - name: Final summary env: PGPASSWORD: postgres diff --git a/devel/tests/cron_path.sql b/devel/tests/cron_path.sql new file mode 100644 index 0000000..01a7bed --- /dev/null +++ b/devel/tests/cron_path.sql @@ -0,0 +1,28 @@ +-- Clean slate: stop any prior sampler, truncate raw partitions +-- so the post-baseline count is unambiguous. +select ash.stop(); +truncate ash.sample_0, ash.sample_1, ash.sample_2; + +-- Capture pre-start baseline (should be 0 across all slots). +do $$ +declare + v_baseline bigint; +begin + select count(*) into v_baseline from ash.sample; + assert v_baseline = 0, + format('expected 0 samples after truncate, got %s', v_baseline); +end $$; + +-- Schedule sampling at 1 Hz via pg_cron. +select ash.start('1 second'); + +-- Sanity: cron.job has the ash row. +do $$ +declare + v_jobs int; +begin + select count(*) into v_jobs + from cron.job where jobname like 'ash_%'; + assert v_jobs >= 1, + format('expected ash_* job(s) in cron.job, got %s', v_jobs); +end $$; diff --git a/devel/tests/degraded_no_cron.sql b/devel/tests/degraded_no_cron.sql new file mode 100644 index 0000000..9c681b6 --- /dev/null +++ b/devel/tests/degraded_no_cron.sql @@ -0,0 +1,56 @@ +do $$ +declare + v_rec record; + v_status_val text; +begin + -- start() should work without pg_cron (no error, prints hints) + select status into v_status_val + from ash.start('1 second') + where job_type = 'sampler'; + assert v_status_val like '%schedule externally%', + 'start() without pg_cron should say schedule externally, got: ' || v_status_val; + + /* + * A preloaded pg_cron registers cron.database_name as postmaster-only even + * after DROP EXTENSION. Exercise mutable placeholder values only on the + * dedicated cron-off axis, where the setting starts absent. + */ + if current_setting('cron.database_name', true) is null then + perform set_config( + 'cron.database_name', current_database(), false + ); + perform * from ash.start('1 second'); + perform set_config( + 'cron.database_name', 'cron_control', false + ); + perform * from ash.start('1 second'); + end if; + + -- stop() should work without pg_cron + select status into v_status_val + from ash.stop() + limit 1; + assert v_status_val like '%external scheduler%', + 'stop() without pg_cron should mention external scheduler, got: ' || v_status_val; + + -- status() should show pg_cron_available = no + select value into v_status_val + from ash.status() + where metric = 'pg_cron_available'; + assert v_status_val like '%no%', + 'status() should show pg_cron not available, got: ' || v_status_val; + + -- take_sample() should work without pg_cron + perform ash.take_sample(); + + -- All 2.0 reader functions should work + perform ash.periods(); + perform * from ash.top('wait_event', now() - interval '1 hour', now()); + perform * from ash.samples(now() - interval '1 hour', now()); + perform * from ash.chart(now() - interval '1 hour', now()); + + raise notice 'All degraded-mode (no pg_cron) tests PASSED'; +end; +$$; + +select ash.uninstall('yes'); diff --git a/devel/tests/degraded_no_pgss.sql b/devel/tests/degraded_no_pgss.sql new file mode 100644 index 0000000..cff8523 --- /dev/null +++ b/devel/tests/degraded_no_pgss.sql @@ -0,0 +1,63 @@ +-- Degraded mode: pg_ash freshly installed WITHOUT pg_stat_statements. +-- Every 2.0 reader must work; query_text degrades to NULL and is never spoofed +-- by a user-created public.pg_stat_statements relation (#87). +insert into ash.wait_event_map (state, type, event) values + ('active', 'CPU*', 'CPU*') on conflict do nothing; +insert into ash.query_map_0 (query_id) values (999999) on conflict do nothing; + +do $$ +declare + v_cpu smallint; v_q1 int4; v_ts int4; v_from timestamptz; + n int; tcnt int; j jsonb; +begin + select id into v_cpu from ash.wait_event_map where type = 'CPU*'; + select id into v_q1 from ash.query_map_0 where query_id = 999999; + -- Minute-aligned 3 minutes back so a window from here reads raw. + v_ts := (ash.ts_from_timestamptz(date_trunc('minute', now() - interval '3 minutes')) / 60) * 60; + v_from := ash.ts_to_timestamptz(v_ts); + insert into ash.sample (sample_ts, datid, active_count, data, slot) + values (v_ts, 1, 1, array[-v_cpu, 1, v_q1]::integer[], 0); + perform ash.rollup_minute(); + + -- Every reader runs without pgss; query_text is NULL, no error, no WARNING. + perform ash.periods(); + perform * from ash.aas(v_from, now()); + perform * from ash.timeline(v_from, now()); + perform * from ash.compare(v_from, now(), v_from, now()); + perform * from ash.summary(v_from, now()); + perform * from ash.chart(v_from, now()); + assert (select query_text from ash.top('query_id', v_from, now()) limit 1) is null, + 'top query_text should be NULL without pgss'; + assert (select query_text from ash.samples(v_from, now()) limit 1) is null, + 'samples query_text should be NULL without pgss'; + + -- report still produces query ids (they come from samples, not pgss). + j := ash.report(v_from, now()); + assert j is not null, 'report should work without pgss'; + assert j->'top_queryids_worst1m' ? 'total', 'report top_queryids total should be present'; + + perform ash.take_sample(); + perform ash.status(); + perform ash.set_debug_logging(); + + -- #87: a user-made public.pg_stat_statements must NOT be trusted as the real + -- extension — no row fan-out, no spoofed query_text. + create table public.pg_stat_statements ( + queryid bigint, query text, calls bigint, + total_exec_time double precision, mean_exec_time double precision, dbid oid); + insert into public.pg_stat_statements values + (999999, 'select same from user_a', 10, 100.0, 10.0, 1::oid), + (999999, 'select same from user_b', 20, 400.0, 20.0, 2::oid); + + select count(*), count(query_text) into n, tcnt + from ash.top('query_id', v_from, now()) where key = '999999'; + assert n = 1 and tcnt = 0, + format('top must ignore spoofed pg_stat_statements, got rows=%s text_rows=%s', n, tcnt); + select count(*), count(query_text) into n, tcnt + from ash.samples(v_from, now()) where query_id = 999999; + assert n = 1 and tcnt = 0, + format('samples must ignore spoofed pg_stat_statements, got rows=%s text_rows=%s', n, tcnt); + + drop table public.pg_stat_statements; + raise notice 'All degraded-mode (no pgss) 2.0 reader tests PASSED'; +end $$; diff --git a/devel/tests/schema_snapshot.sql b/devel/tests/schema_snapshot.sql new file mode 100644 index 0000000..7c12586 --- /dev/null +++ b/devel/tests/schema_snapshot.sql @@ -0,0 +1,58 @@ +\pset tuples_only on +\pset format unaligned +select 'FUNC', p.proname, + pg_get_function_identity_arguments(p.oid), + pg_get_function_result(p.oid), + md5(p.prosrc) +from pg_proc p +join pg_namespace n on n.oid = p.pronamespace +where n.nspname = 'ash' +order by p.proname, pg_get_function_identity_arguments(p.oid); + +select 'COL', c.relname, a.attnum, a.attname, + pg_catalog.format_type(a.atttypid, a.atttypmod), + a.attnotnull, + pg_get_expr(d.adbin, d.adrelid) +from pg_class c +join pg_namespace n on n.oid = c.relnamespace +join pg_attribute a on a.attrelid = c.oid +left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum +where n.nspname = 'ash' and c.relkind in ('r','p') and a.attnum > 0 and not a.attisdropped +order by c.relname, a.attnum; + +select 'IDX', c.relname, pg_get_indexdef(i.indexrelid) +from pg_index i +join pg_class c on c.oid = i.indexrelid +join pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'ash' +order by c.relname; + +select 'VIEW', viewname, md5(definition) +from pg_views +where schemaname = 'ash' +order by viewname; + +-- Issue #66: include CHECK / NOT NULL / PK / UNIQUE / FK constraints. +-- Covers parent partitioned tables AND their partitions (relkind r,p). +-- pg_get_constraintdef gives a stable canonical text form, so any +-- divergence (e.g. sample_data_check `>= 2` vs `>= 3` from #49) +-- surfaces here. +select 'CON', c.relname, con.conname, pg_get_constraintdef(con.oid) +from pg_constraint con +join pg_class c on c.oid = con.conrelid +join pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'ash' and c.relkind in ('r','p') +order by c.relname, con.conname; + +-- Issue #107: privilege state must be equivalent too. proacl is +-- NULL until the first GRANT/REVOKE touches a function; the +-- installer's REVOKE-from-PUBLIC hardening always runs, so a fresh +-- install and the full upgrade chain must converge to identical +-- explicit function ACLs. +select 'FACL', p.proname, + pg_get_function_identity_arguments(p.oid), + coalesce(p.proacl::text, '') +from pg_proc p +join pg_namespace n on n.oid = p.pronamespace +where n.nspname = 'ash' +order by p.proname, pg_get_function_identity_arguments(p.oid); From 398472ab5e2a7c58c7f0b8050cd83dcc17fd0894 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:39:59 +0000 Subject: [PATCH 03/18] feat: add Docker release-gate runner Run every gate surface across bounded parallel PostgreSQL workers, reuse exact CI step bodies, emit validated TSV and human summaries, and guarantee ID-scoped container and volume teardown. Relates to https://github.com/NikolayS/pg_ash/issues/160 --- devel/scripts/ci_step_script.py | 697 +++++++++++++++++++ devel/scripts/release_gate.sh | 997 +++++++++++++++++++++++++++ devel/scripts/test_ci_step_script.py | 174 +++++ 3 files changed, 1868 insertions(+) create mode 100755 devel/scripts/ci_step_script.py create mode 100755 devel/scripts/release_gate.sh create mode 100755 devel/scripts/test_ci_step_script.py diff --git a/devel/scripts/ci_step_script.py b/devel/scripts/ci_step_script.py new file mode 100755 index 0000000..68abb6a --- /dev/null +++ b/devel/scripts/ci_step_script.py @@ -0,0 +1,697 @@ +#!/usr/bin/env python3 +"""Select canonical Bash step bodies from the pg_ash CI workflow. + +This intentionally implements only the small YAML subset used by +`.github/workflows/test.yml`: mapping keys, sequence entries, scalar step +names, and literal (`|`) run blocks. Keeping the parser strict makes workflow +format drift fail loudly instead of silently extracting the wrong script. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_WORKFLOW = ROOT / ".github" / "workflows" / "test.yml" +ENV_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z") +LITERAL_BLOCK_RE = re.compile(r"\|(?P[+-]?)(?:\s+#.*)?\Z") + + +class WorkflowError(ValueError): + """The workflow cannot be selected or parsed unambiguously.""" + + +@dataclass(frozen=True) +class Step: + """One named step in jobs.test.""" + + ordinal: int + line: int + name: str + run: str | None + + +def _without_eol(line: str) -> str: + return line.rstrip("\r\n") + + +def _indent(line: str, *, line_number: int) -> int | None: + """Return structural indentation, ignoring blank and comment-only lines.""" + text = _without_eol(line) + leading = text[: len(text) - len(text.lstrip(" \t"))] + if "\t" in leading: + raise WorkflowError( + f"line {line_number}: tabs are not valid YAML indentation" + ) + if not text.strip() or text.lstrip().startswith("#"): + return None + return len(leading) + + +def _is_mapping_key(line: str, indent: int, key: str) -> bool: + text = _without_eol(line) + prefix = " " * indent + key + ":" + if not text.startswith(prefix): + return False + remainder = text[len(prefix) :] + return not remainder or remainder.isspace() or remainder.lstrip().startswith("#") + + +def _unique_key( + lines: Sequence[str], + *, + start: int, + end: int, + indent: int, + key: str, + context: str, +) -> int: + matches = [ + idx + for idx in range(start, end) + if _is_mapping_key(lines[idx], indent, key) + ] + if not matches: + raise WorkflowError(f"{context}: missing {key!r} key") + if len(matches) > 1: + locations = ", ".join(str(idx + 1) for idx in matches) + raise WorkflowError( + f"{context}: duplicate {key!r} keys at lines {locations}" + ) + return matches[0] + + +def _block_end( + lines: Sequence[str], *, start: int, end: int, parent_indent: int +) -> int: + for idx in range(start, end): + indentation = _indent(lines[idx], line_number=idx + 1) + if indentation is not None and indentation <= parent_indent: + return idx + return end + + +def _split_inline_comment(value: str) -> str: + """Strip an unquoted YAML comment (` # ...`) from a plain scalar.""" + match = re.search(r"\s+#", value) + return value[: match.start()] if match else value + + +def _parse_scalar(value: str, *, line_number: int, field: str) -> str: + value = value.strip() + if not value: + raise WorkflowError(f"line {line_number}: empty {field}") + + if value.startswith('"'): + try: + parsed, offset = json.JSONDecoder().raw_decode(value) + except json.JSONDecodeError as exc: + raise WorkflowError( + f"line {line_number}: invalid double-quoted {field}: {exc.msg}" + ) from exc + remainder = value[offset:].strip() + if remainder and not remainder.startswith("#"): + raise WorkflowError( + f"line {line_number}: unexpected text after {field}: {remainder!r}" + ) + if not isinstance(parsed, str): + raise WorkflowError(f"line {line_number}: {field} must be a string") + result = parsed + elif value.startswith("'"): + chars: list[str] = [] + idx = 1 + while idx < len(value): + if value[idx] != "'": + chars.append(value[idx]) + idx += 1 + continue + if idx + 1 < len(value) and value[idx + 1] == "'": + chars.append("'") + idx += 2 + continue + idx += 1 + remainder = value[idx:].strip() + if remainder and not remainder.startswith("#"): + raise WorkflowError( + f"line {line_number}: unexpected text after {field}: " + f"{remainder!r}" + ) + break + else: + raise WorkflowError( + f"line {line_number}: unterminated single-quoted {field}" + ) + result = "".join(chars) + else: + result = _split_inline_comment(value).rstrip() + + if not result: + raise WorkflowError(f"line {line_number}: empty {field}") + if "\n" in result or "\r" in result or "\0" in result: + raise WorkflowError( + f"line {line_number}: {field} contains an unsupported control character" + ) + return result + + +def _mapping_value(line: str, *, indent: int, key: str) -> str | None: + text = _without_eol(line) + prefix = " " * indent + key + ":" + if not text.startswith(prefix): + return None + return text[len(prefix) :].strip() + + +def _sequence_mapping_value(line: str, *, indent: int, key: str) -> str | None: + text = _without_eol(line) + prefix = " " * indent + "- " + key + ":" + if not text.startswith(prefix): + return None + return text[len(prefix) :].strip() + + +def _deindent_literal_block( + lines: Sequence[str], + *, + start: int, + end: int, + parent_indent: int, + chomp: str, + run_line: int, +) -> str: + block_end = end + for idx in range(start, end): + indentation = _indent(lines[idx], line_number=idx + 1) + if indentation is not None and indentation <= parent_indent: + block_end = idx + break + + block_lines = list(lines[start:block_end]) + nonblank_indents = [ + len(_without_eol(line)) + - len(_without_eol(line).lstrip(" ")) + for line in block_lines + if _without_eol(line).strip() + ] + if not nonblank_indents: + return "" + + content_indent = min(nonblank_indents) + if content_indent <= parent_indent: + raise WorkflowError( + f"line {run_line}: run block content must be indented more than " + "the run key" + ) + + deindented: list[str] = [] + for offset, line in enumerate(block_lines, start=start + 1): + text = _without_eol(line) + newline = line[len(text) :] + if not text.strip(): + deindented.append(newline) + continue + indentation = len(text) - len(text.lstrip(" ")) + if indentation < content_indent: + raise WorkflowError( + f"line {offset}: inconsistent indentation in run block " + f"starting at line {run_line}" + ) + deindented.append(text[content_indent:] + newline) + + body = "".join(deindented) + if chomp == "+": + return body + stripped = body.rstrip("\r\n") + if chomp == "-": + return stripped + return stripped + "\n" if body else "" + + +def _parse_step( + lines: Sequence[str], + *, + start: int, + end: int, + step_indent: int, + ordinal: int, +) -> Step | None: + field_indent = step_indent + 2 + name_matches: list[tuple[int, str]] = [] + run_matches: list[tuple[int, str]] = [] + inline_name = _sequence_mapping_value( + lines[start], indent=step_indent, key="name" + ) + if inline_name is not None: + name_matches.append((start, inline_name)) + inline_run = _sequence_mapping_value( + lines[start], indent=step_indent, key="run" + ) + if inline_run is not None: + run_matches.append((start, inline_run)) + for idx in range(start + 1, end): + value = _mapping_value(lines[idx], indent=field_indent, key="name") + if value is not None: + name_matches.append((idx, value)) + value = _mapping_value(lines[idx], indent=field_indent, key="run") + if value is not None: + run_matches.append((idx, value)) + + # `uses:`-only steps may legitimately be unnamed and are not selectable, + # but silently omitting an executable step would weaken a selected range. + if not name_matches: + if run_matches: + locations = ", ".join(str(idx + 1) for idx, _ in run_matches) + raise WorkflowError( + f"executable step at line {start + 1} has no name " + f"(run key at line(s) {locations})" + ) + return None + if len(name_matches) > 1: + locations = ", ".join(str(idx + 1) for idx, _ in name_matches) + raise WorkflowError( + f"step at line {start + 1}: duplicate name keys at lines {locations}" + ) + name_line, name_value = name_matches[0] + name = _parse_scalar(name_value, line_number=name_line + 1, field="step name") + + if len(run_matches) > 1: + locations = ", ".join(str(idx + 1) for idx, _ in run_matches) + raise WorkflowError( + f"step {name!r}: duplicate run keys at lines {locations}" + ) + if not run_matches: + return Step(ordinal=ordinal, line=name_line + 1, name=name, run=None) + + run_idx, indicator = run_matches[0] + match = LITERAL_BLOCK_RE.fullmatch(indicator) + if not match: + raise WorkflowError( + f"line {run_idx + 1}: step {name!r} must use a literal run block " + f"(`run: |`, `|-`, or `|+`), got {indicator!r}" + ) + body = _deindent_literal_block( + lines, + start=run_idx + 1, + end=end, + parent_indent=field_indent, + chomp=match.group("chomp"), + run_line=run_idx + 1, + ) + return Step(ordinal=ordinal, line=name_line + 1, name=name, run=body) + + +def parse_workflow(path: Path, *, job: str = "test") -> list[Step]: + """Parse and validate all named steps under jobs..steps.""" + try: + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + except OSError as exc: + raise WorkflowError(f"cannot read workflow {path}: {exc}") from exc + + jobs_idx = _unique_key( + lines, + start=0, + end=len(lines), + indent=0, + key="jobs", + context=str(path), + ) + jobs_end = _block_end( + lines, start=jobs_idx + 1, end=len(lines), parent_indent=0 + ) + job_idx = _unique_key( + lines, + start=jobs_idx + 1, + end=jobs_end, + indent=2, + key=job, + context=f"{path}: jobs", + ) + job_end = _block_end(lines, start=job_idx + 1, end=jobs_end, parent_indent=2) + steps_idx = _unique_key( + lines, + start=job_idx + 1, + end=job_end, + indent=4, + key="steps", + context=f"{path}: jobs.{job}", + ) + + step_indent = 6 + starts: list[int] = [] + for idx in range(steps_idx + 1, job_end): + indentation = _indent(lines[idx], line_number=idx + 1) + if indentation != step_indent: + continue + text = _without_eol(lines[idx])[step_indent:] + if text == "-" or text.startswith("- "): + starts.append(idx) + + if not starts: + raise WorkflowError(f"{path}: jobs.{job}.steps has no sequence entries") + + parsed: list[Step] = [] + for offset, start in enumerate(starts): + end = starts[offset + 1] if offset + 1 < len(starts) else job_end + step = _parse_step( + lines, + start=start, + end=end, + step_indent=step_indent, + ordinal=offset + 1, + ) + if step is not None: + parsed.append(step) + + by_name: dict[str, list[Step]] = {} + for step in parsed: + by_name.setdefault(step.name, []).append(step) + duplicates = {name: values for name, values in by_name.items() if len(values) > 1} + if duplicates: + details = "; ".join( + f"{name!r} at lines {', '.join(str(step.line) for step in values)}" + for name, values in sorted(duplicates.items()) + ) + raise WorkflowError(f"duplicate step names in jobs.{job}: {details}") + return parsed + + +def _step_map(steps: Sequence[Step]) -> dict[str, Step]: + return {step.name: step for step in steps} + + +def select_named(steps: Sequence[Step], names: Sequence[str]) -> list[Step]: + repeated = sorted(name for name, count in Counter(names).items() if count > 1) + if repeated: + raise WorkflowError( + "step requested more than once: " + + ", ".join(repr(name) for name in repeated) + ) + available = _step_map(steps) + missing = [name for name in names if name not in available] + if missing: + raise WorkflowError( + "unknown step name(s): " + ", ".join(repr(name) for name in missing) + ) + return [available[name] for name in names] + + +def select_range( + steps: Sequence[Step], + start_name: str, + end_name: str, + excludes: Sequence[str], +) -> list[Step]: + available = _step_map(steps) + missing_endpoints = [ + name for name in (start_name, end_name) if name not in available + ] + if missing_endpoints: + raise WorkflowError( + "unknown range endpoint(s): " + + ", ".join(repr(name) for name in missing_endpoints) + ) + + repeated = sorted( + name for name, count in Counter(excludes).items() if count > 1 + ) + if repeated: + raise WorkflowError( + "step excluded more than once: " + + ", ".join(repr(name) for name in repeated) + ) + + positions = {step.name: idx for idx, step in enumerate(steps)} + start = positions[start_name] + end = positions[end_name] + if start > end: + raise WorkflowError( + f"range start {start_name!r} occurs after end {end_name!r}" + ) + + unknown_excludes = [name for name in excludes if name not in available] + if unknown_excludes: + raise WorkflowError( + "unknown excluded step(s): " + + ", ".join(repr(name) for name in unknown_excludes) + ) + outside = [ + name for name in excludes if not start <= positions[name] <= end + ] + if outside: + raise WorkflowError( + "excluded step(s) outside selected range: " + + ", ".join(repr(name) for name in outside) + ) + + excluded = set(excludes) + return [step for step in steps[start : end + 1] if step.name not in excluded] + + +def _require_run_bodies(steps: Sequence[Step]) -> None: + without_run = [step.name for step in steps if step.run is None] + if without_run: + raise WorkflowError( + "selected step(s) have no run block: " + + ", ".join(repr(name) for name in without_run) + ) + + +def _parse_env(assignments: Sequence[str]) -> dict[str, str]: + result: dict[str, str] = {} + for assignment in assignments: + if "=" not in assignment: + raise WorkflowError( + f"invalid --env {assignment!r}; expected NAME=VALUE" + ) + name, value = assignment.split("=", 1) + if not ENV_NAME_RE.fullmatch(name): + raise WorkflowError(f"invalid environment variable name {name!r}") + if name in result: + raise WorkflowError(f"environment variable {name!r} set more than once") + result[name] = value + return result + + +def _emit_or_run(args: argparse.Namespace, steps: Sequence[Step]) -> int: + _require_run_bodies(steps) + mode = args.mode + + if args.env and mode != "run": + raise WorkflowError("--env is only valid with --run") + if args.cwd is not None and mode != "run": + raise WorkflowError("--cwd is only valid with --run") + + if mode == "names": + for step in steps: + print(step.name) + return 0 + + if mode == "json": + json.dump( + [ + { + "ordinal": step.ordinal, + "line": step.line, + "name": step.name, + "run": step.run, + } + for step in steps + ], + sys.stdout, + ensure_ascii=False, + indent=2, + ) + sys.stdout.write("\n") + return 0 + + if mode == "null": + output = sys.stdout.buffer + for step in steps: + output.write(step.name.encode("utf-8")) + output.write(b"\0") + output.write((step.run or "").encode("utf-8")) + output.write(b"\0") + return 0 + + if mode == "run": + bash = shutil.which("bash") + if bash is None: + raise WorkflowError("cannot execute steps: bash is not on PATH") + environment = os.environ.copy() + environment.update(_parse_env(args.env)) + cwd = args.cwd.resolve() if args.cwd is not None else None + for position, step in enumerate(steps, start=1): + print( + f"[ci-step {position}/{len(steps)}] {step.name}", + file=sys.stderr, + flush=True, + ) + completed = subprocess.run( + [ + bash, + "--noprofile", + "--norc", + "-e", + "-o", + "pipefail", + "-c", + step.run or "", + "ci-step", + ], + cwd=cwd, + env=environment, + check=False, + ) + if completed.returncode != 0: + print( + f"[ci-step FAILED rc={completed.returncode}] {step.name}", + file=sys.stderr, + ) + return completed.returncode + return 0 + + if len(steps) != 1: + raise WorkflowError( + f"raw body output selected {len(steps)} steps; use --names-only " + "and request each step separately, --null, --json, or --run so " + "step boundaries remain unambiguous" + ) + sys.stdout.write(steps[0].run or "") + return 0 + + +def _add_selection_output_options(parser: argparse.ArgumentParser) -> None: + modes = parser.add_mutually_exclusive_group() + modes.add_argument( + "--names-only", + dest="mode", + action="store_const", + const="names", + help="emit selected names, one per line", + ) + modes.add_argument( + "--json", + dest="mode", + action="store_const", + const="json", + help="emit selected step metadata and exact bodies as JSON", + ) + modes.add_argument( + "--null", + dest="mode", + action="store_const", + const="null", + help="emit repeating NAME NUL BODY NUL records", + ) + modes.add_argument( + "--run", + dest="mode", + action="store_const", + const="run", + help=( + "run each selected body in a fresh GitHub-style Bash process; " + "workflow `if` and `env` fields are not evaluated" + ), + ) + parser.set_defaults(mode="body") + parser.add_argument( + "--env", + action="append", + default=[], + metavar="NAME=VALUE", + help="environment override for --run; may be repeated", + ) + parser.add_argument( + "--cwd", + type=Path, + help="working directory for --run (default: inherit current directory)", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Extract exact Bash run blocks from named jobs.test workflow steps." + ) + ) + parser.add_argument( + "--workflow", + type=Path, + default=DEFAULT_WORKFLOW, + help=f"workflow file (default: {DEFAULT_WORKFLOW})", + ) + parser.add_argument( + "--job", + default="test", + help="job under jobs to inspect (default: test)", + ) + commands = parser.add_subparsers(dest="command", required=True) + + list_parser = commands.add_parser("list", help="list named steps") + list_parser.add_argument( + "--long", + action="store_true", + help="include sequence ordinal, source line, and run/uses kind", + ) + + step_parser = commands.add_parser( + "step", help="select one or more exact step names" + ) + step_parser.add_argument("names", nargs="+", metavar="NAME") + _add_selection_output_options(step_parser) + + range_parser = commands.add_parser( + "range", help="select an inclusive range in workflow order" + ) + range_parser.add_argument("start", metavar="START") + range_parser.add_argument("end", metavar="END") + range_parser.add_argument( + "--exclude", + action="append", + nargs="+", + default=[], + metavar="NAME", + help="exact step name(s) to omit; may be repeated", + ) + _add_selection_output_options(range_parser) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + steps = parse_workflow(args.workflow.resolve(), job=args.job) + if args.command == "list": + for step in steps: + if args.long: + kind = "run" if step.run is not None else "no-run" + print(f"{step.ordinal}\t{step.line}\t{kind}\t{step.name}") + else: + print(step.name) + return 0 + + if args.command == "step": + selected = select_named(steps, args.names) + else: + excludes = [name for group in args.exclude for name in group] + selected = select_range(steps, args.start, args.end, excludes) + return _emit_or_run(args, selected) + except WorkflowError as exc: + parser.error(str(exc)) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/devel/scripts/release_gate.sh b/devel/scripts/release_gate.sh new file mode 100755 index 0000000..389691a --- /dev/null +++ b/devel/scripts/release_gate.sh @@ -0,0 +1,997 @@ +#!/usr/bin/env bash +# Run the pg_ash release-gate surfaces against disposable Docker PostgreSQL. +# +# Usage: +# devel/scripts/release_gate.sh <14|15|16|17|18|19beta2> [surface|all] +# PG_MAJORS="14 15 16 17 18 19beta2" devel/scripts/release_gate.sh all +# +# The GitHub Actions workflow remains the canonical source for the large +# regression and upgrade assertion sets. ci_step_script.py selects its exact +# run blocks, and this script executes every selected block in a fresh Bash +# process, matching GitHub Actions' process boundaries. + +set -Eeuo pipefail +IFS=$'\n\t' + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +readonly REPO_ROOT +readonly DOCKERFILE="${REPO_ROOT}/devel/docker/Dockerfile" +readonly CHAIN_HELPER="${SCRIPT_DIR}/ash_sql_chain.py" +readonly CI_STEP_HELPER="${SCRIPT_DIR}/ci_step_script.py" +readonly DEFAULT_MAJORS="14 15 16 17 18 19beta2" +readonly IMAGE_REPOSITORY="pg-ash-release-gate" +readonly POSTGRES_USER="postgres" +readonly POSTGRES_DATABASE="postgres" +readonly POSTGRES_PASSWORD="pg_ash_release_gate" + +readonly -a SUPPORTED_MAJORS=(14 15 16 17 18 19beta2) +readonly -a SURFACES=( + fresh-install + upgrade-chain + degraded-no-cron + degraded-no-pgss + degraded-neither + cron-path +) + +declare -a owned_containers=() +declare -a owned_workers=() +output_dir="" +summary_file="" +surface_temp_dir="" +container_name="" +container_id="" +image_name="" +any_failure=0 + +err() { + printf 'release_gate: %s\n' "$*" >&2 +} + +usage() { + cat <<'EOF' +Usage: + devel/scripts/release_gate.sh [surface|all] + devel/scripts/release_gate.sh all [surface|all] + +Supported majors: + 14 15 16 17 18 19beta2 + +Supported surfaces: + fresh-install + upgrade-chain + degraded-no-cron + degraded-no-pgss + degraded-neither + cron-path + +Environment: + PG_MAJORS Space-separated majors for the "all" command. + RELEASE_GATE_JOBS Parallel major workers (default: 2). + RELEASE_GATE_PULL Set to 0 to skip docker pull (default: 1). + RELEASE_GATE_OUTPUT_DIR Directory for logs and the default summary. + RELEASE_GATE_SUMMARY Path for the machine-readable TSV summary. +EOF +} + +cleanup() { + local exit_code=$? + local cleanup_failed=0 + local id + local owned_id + local pid + local candidate_is_owned=0 + local -a cleanup_ids=("${owned_containers[@]}") + + trap - EXIT INT TERM + + for pid in "${owned_workers[@]}"; do + kill -TERM "${pid}" >/dev/null 2>&1 || true + done + for pid in "${owned_workers[@]}"; do + wait "${pid}" >/dev/null 2>&1 || true + done + + if [[ "${container_id}" =~ ^[a-f0-9]{12,64}$ ]]; then + for owned_id in "${cleanup_ids[@]}"; do + if [[ "${owned_id}" == "${container_id}" ]]; then + candidate_is_owned=1 + break + fi + done + if ((candidate_is_owned == 0)); then + cleanup_ids+=("${container_id}") + fi + fi + + for id in "${cleanup_ids[@]}"; do + if ! docker rm --force --volumes "${id}" >/dev/null 2>&1; then + err "could not tear down owned container ${id}" + cleanup_failed=1 + fi + done + if ((cleanup_failed != 0 && exit_code == 0)); then + exit_code=1 + fi + exit "${exit_code}" +} + +signal_exit() { + local exit_code=$1 + + trap - INT TERM + exit "${exit_code}" +} + +require_commands() { + local command_name + local -a commands=( + awk + bash + diff + docker + grep + pg_isready + psql + python3 + sed + sort + ) + + for command_name in "${commands[@]}"; do + if ! command -v "${command_name}" >/dev/null 2>&1; then + err "required command not found: ${command_name}" + return 1 + fi + done +} + +contains_value() { + local wanted=$1 + shift + local value + + for value in "$@"; do + if [[ "${value}" == "${wanted}" ]]; then + return 0 + fi + done + return 1 +} + +validate_major() { + local major=$1 + + if ! contains_value "${major}" "${SUPPORTED_MAJORS[@]}"; then + err "unsupported PostgreSQL major: ${major}" + return 1 + fi +} + +validate_surface() { + local surface=$1 + + if [[ "${surface}" != "all" ]] \ + && ! contains_value "${surface}" "${SURFACES[@]}"; then + err "unknown surface: ${surface}" + return 1 + fi +} + +safe_component() { + local value=$1 + value="${value//[^a-zA-Z0-9_.-]/_}" + printf '%s\n' "${value}" +} + +psql_gate() { + PAGER="cat" psql \ + --no-psqlrc \ + --host=localhost \ + --port="${PGPORT}" \ + --username="${POSTGRES_USER}" \ + --dbname="${POSTGRES_DATABASE}" \ + --set=ON_ERROR_STOP=1 \ + "$@" +} + +wait_for_postgres() { + local attempt + + for ((attempt = 1; attempt <= 60; attempt++)); do + if PGPASSWORD="${POSTGRES_PASSWORD}" pg_isready \ + --host=localhost \ + --port="${PGPORT}" \ + --username="${POSTGRES_USER}" \ + --dbname="${POSTGRES_DATABASE}" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + + err "PostgreSQL did not become ready on container port ${PGPORT}" + return 1 +} + +start_container() { + local major=$1 + local surface=$2 + local preload=$3 + local port_mapping + local -a postgres_args=( + postgres + -c + "shared_preload_libraries=${preload}" + ) + + if [[ "${preload}" == *pg_cron* ]]; then + postgres_args+=( + -c + "cron.database_name=${POSTGRES_DATABASE}" + -c + "cron.use_background_workers=on" + ) + fi + + printf 'Starting PostgreSQL %s for %s as %s\n' \ + "${major}" "${surface}" "${container_name}" + if ! container_id="$(docker create \ + --name="${container_name}" \ + --label="org.pg-ash.release-gate=true" \ + --env="POSTGRES_DB=${POSTGRES_DATABASE}" \ + --env="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \ + --publish="127.0.0.1::5432" \ + "${image_name}" \ + "${postgres_args[@]}")"; then + err "could not create the release-gate container" + return 1 + fi + if [[ ! "${container_id}" =~ ^[a-f0-9]{12,64}$ ]]; then + err "docker create returned an invalid container ID: ${container_id}" + return 1 + fi + owned_containers+=("${container_id}") + + if ! docker start "${container_id}" >/dev/null; then + err "could not start release-gate container ${container_id}" + return 1 + fi + + port_mapping="$(docker port "${container_id}" 5432/tcp)" + PGPORT="${port_mapping##*:}" + if [[ ! "${PGPORT}" =~ ^[0-9]+$ ]]; then + err "could not resolve ${container_name} port: ${port_mapping}" + return 1 + fi + + export PGPORT + export PGPASSWORD="${POSTGRES_PASSWORD}" + export PGUSER="${POSTGRES_USER}" + export PGDATABASE="${POSTGRES_DATABASE}" + export PAGER=cat + export PSQLRC=/dev/null + export CONTAINER_ID="${container_id}" + export CRON=on + wait_for_postgres +} + +create_extensions() { + local want_cron=$1 + local want_pgss=$2 + + if [[ "${want_cron}" == "t" ]]; then + psql_gate --command="create extension pg_cron;" + fi + if [[ "${want_pgss}" == "t" ]]; then + psql_gate --command="create extension pg_stat_statements;" + fi +} + +assert_extension_state() { + local expected_cron=$1 + local expected_pgss=$2 + local actual + local expected="${expected_cron}|${expected_pgss}" + + actual="$(psql_gate \ + --tuples-only \ + --no-align \ + --command=" + select + case when exists ( + select from pg_extension where extname = 'pg_cron' + ) then 't' else 'f' end + || '|' || + case when exists ( + select from pg_extension where extname = 'pg_stat_statements' + ) then 't' else 'f' end; + ")" + actual="${actual//$'\r'/}" + + if [[ "${actual}" != "${expected}" ]]; then + err "extension precondition failed: expected ${expected}; got ${actual}" + return 1 + fi + printf 'Extension state: pg_cron=%s pg_stat_statements=%s\n' \ + "${expected_cron}" "${expected_pgss}" +} + +install_fresh() { + local install_path + install_path="$(python3 "${CHAIN_HELPER}" fresh-install-path)" + + if [[ "${install_path}" == /* || ! -f "${REPO_ROOT}/${install_path}" ]]; then + err "fresh-install-path returned an invalid path: ${install_path}" + return 1 + fi + printf 'Fresh installer: %s\n' "${install_path}" + psql_gate --file="${REPO_ROOT}/${install_path}" +} + +uninstall_if_present() { + local schema_present + + schema_present="$(psql_gate \ + --tuples-only \ + --no-align \ + --command=" + select exists ( + select from pg_namespace where nspname = 'ash' + )::text; + ")" + if [[ "${schema_present//$'\r'/}" == "true" ]]; then + psql_gate --command="select ash.uninstall('yes');" + fi +} + +run_ci_selection() { + local selection_file="${surface_temp_dir}/ci-selection.bin" + local step_name + local step_body + local step_count=0 + local -a helper_args=("$@") + + PYTHONDONTWRITEBYTECODE=1 python3 "${CI_STEP_HELPER}" \ + "${helper_args[@]}" \ + --null >"${selection_file}" + + while IFS= read -r -d '' step_name \ + && IFS= read -r -d '' step_body; do + ((step_count += 1)) + printf '[ci-step %s] %s\n' "${step_count}" "${step_name}" + + # Parallel major workers must not share the workflow's historical /tmp + # filenames. This is the only transformation made to canonical step bodies. + step_body="${step_body//\/tmp\//${surface_temp_dir}/}" + if ! bash \ + --noprofile \ + --norc \ + -e \ + -o pipefail \ + -c "${step_body}" \ + ci-step; then + err "canonical CI step failed: ${step_name}" + return 1 + fi + done <"${selection_file}" + + if ((step_count == 0)); then + err "CI selection returned no executable steps" + return 1 + fi +} + +run_fresh_install() { + assert_extension_state t t + install_fresh + run_ci_selection \ + range \ + "Test schema and infrastructure" \ + "Test uninstall" \ + --exclude \ + "H-CI-3: end-to-end pg_cron fires ash.take_sample (#46)" +} + +run_upgrade_chain() { + local major=$1 + local reapply_chain + local reapply_count + + assert_extension_state t t + reapply_chain="$(python3 "${CHAIN_HELPER}" reapply-chain)" + reapply_count="$(printf '%s\n' "${reapply_chain}" \ + | awk 'NF { count++ } END { print count + 0 }')" + printf 'Discovered re-apply-safe migration scripts: %s\n' "${reapply_count}" + if ((reapply_count == 0)); then + printf '%s\n' \ + 'NOTE: reapply-chain is empty; installer re-apply regressions still run.' + fi + + if [[ "${major}" == "17" ]]; then + run_ci_selection \ + step \ + "Release upgrade path: actual v1.4 tag to v1.5" + fi + + run_ci_selection \ + range \ + "Upgrade path: discovered full chain" \ + "Schema equivalence: fresh dev install vs full upgrade path" +} + +run_degraded_no_cron() { + assert_extension_state f t + install_fresh + psql_gate --file="${REPO_ROOT}/devel/tests/degraded_no_cron.sql" +} + +run_degraded_no_pgss() { + assert_extension_state t f + install_fresh + psql_gate --file="${REPO_ROOT}/devel/tests/degraded_no_pgss.sql" +} + +run_degraded_neither() { + assert_extension_state f f + install_fresh + psql_gate --file="${REPO_ROOT}/devel/tests/degraded_no_pgss.sql" + + # Preserve the CI tests' fresh-install isolation between degraded bodies. + uninstall_if_present + install_fresh + assert_extension_state f f + psql_gate --file="${REPO_ROOT}/devel/tests/degraded_no_cron.sql" +} + +run_cron_path() { + assert_extension_state t t + install_fresh + run_ci_selection \ + step \ + "Verify pg_cron wiring" \ + "Test start/stop" \ + "H-CI-3: end-to-end pg_cron fires ash.take_sample (#46)" \ + "Test all interval formats (issue #2)" \ + "Test #61: status() works for non-superuser without cron.job access" +} + +surface_configuration() { + local surface=$1 + + case "${surface}" in + fresh-install | upgrade-chain | cron-path) + printf '%s\t%s\t%s\n' "pg_cron,pg_stat_statements" t t + ;; + degraded-neither) + printf '%s\t%s\t%s\n' "" f f + ;; + degraded-no-cron) + printf '%s\t%s\t%s\n' "pg_stat_statements" f t + ;; + degraded-no-pgss) + printf '%s\t%s\t%s\n' "pg_cron" t f + ;; + *) + err "no extension configuration for surface: ${surface}" + return 1 + ;; + esac +} + +execute_surface() { + local major=$1 + local surface=$2 + local expected_cron=$3 + local expected_pgss=$4 + + create_extensions "${expected_cron}" "${expected_pgss}" + + case "${surface}" in + fresh-install) + run_fresh_install + ;; + upgrade-chain) + run_upgrade_chain "${major}" + ;; + degraded-no-cron) + run_degraded_no_cron + ;; + degraded-no-pgss) + run_degraded_no_pgss + ;; + degraded-neither) + run_degraded_neither + ;; + cron-path) + run_cron_path + ;; + esac +} + +failure_detail() { + local log_file=$1 + local detail + + detail="$(awk ' + /::error::|ERROR:|FAILED|assertion failed|FATAL:/ { + sub(/^[[:space:]]+/, "") + print + exit + } + ' "${log_file}")" + if [[ -z "${detail}" ]]; then + detail="command failed; inspect ${log_file}" + fi + detail="${detail//$'\t'/ }" + detail="${detail//$'\r'/}" + printf '%.500s\n' "${detail}" +} + +append_result() { + local major=$1 + local surface=$2 + local result=$3 + local detail=$4 + local log_file=$5 + + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${major}" \ + "${surface}" \ + "${result}" \ + "${detail}" \ + "${log_file}" >>"${summary_file}" +} + +remove_owned_container() { + local id=$1 + local owned_id + local -a remaining=() + + if ! docker rm --force --volumes "${id}" >/dev/null 2>&1; then + err "could not tear down owned container ${id}" + return 1 + fi + for owned_id in "${owned_containers[@]}"; do + if [[ "${owned_id}" != "${id}" ]]; then + remaining+=("${owned_id}") + fi + done + owned_containers=("${remaining[@]}") + if [[ "${container_id}" == "${id}" ]]; then + container_id="" + fi +} + +run_surface() { + local major=$1 + local surface=$2 + local safe_major + local safe_surface + local log_file + local exit_code + local detail + local configuration + local preload + local expected_cron + local expected_pgss + + safe_major="$(safe_component "${major}")" + safe_surface="$(safe_component "${surface}")" + surface_temp_dir="${output_dir}/tmp_${safe_major}_${safe_surface}" + mkdir -p "${surface_temp_dir}" + log_file="${output_dir}/${safe_major}_${safe_surface}.log" + container_name="pgash_gate_${safe_major}_${safe_surface}_$$_${RANDOM}" + container_id="" + image_name="${IMAGE_REPOSITORY}:${major}" + configuration="$(surface_configuration "${surface}")" + IFS=$'\t' read -r preload expected_cron expected_pgss <<<"${configuration}" + if [[ "${preload}" == "" ]]; then + preload="" + fi + + printf 'RUN PostgreSQL %-7s %s\n' "${major}" "${surface}" + set +e + start_container \ + "${major}" "${surface}" "${preload}" >"${log_file}" 2>&1 + exit_code=$? + set -e + + if ((exit_code == 0)); then + set +e + ( + trap - EXIT INT TERM + set -Eeuo pipefail + execute_surface \ + "${major}" "${surface}" "${expected_cron}" "${expected_pgss}" + ) >>"${log_file}" 2>&1 + exit_code=$? + set -e + fi + + if ((exit_code != 0)) && [[ -n "${container_id}" ]]; then + docker logs "${container_id}" >>"${log_file}" 2>&1 || true + fi + if [[ -n "${container_id}" ]]; then + if ! remove_owned_container "${container_id}"; then + printf 'ERROR: container teardown failed for %s\n' \ + "${container_id}" >>"${log_file}" + exit_code=1 + fi + fi + + if ((exit_code == 0)); then + append_result \ + "${major}" "${surface}" PASS "all assertions passed" "${log_file}" + printf 'PASS PostgreSQL %-7s %s\n' "${major}" "${surface}" + else + detail="$(failure_detail "${log_file}")" + append_result "${major}" "${surface}" FAIL "${detail}" "${log_file}" + err "FAIL PostgreSQL ${major} ${surface}: ${detail} (log: ${log_file})" + any_failure=1 + fi +} + +prepare_output() { + local requested_dir=${RELEASE_GATE_OUTPUT_DIR:-} + local requested_summary=${RELEASE_GATE_SUMMARY:-} + + if [[ -n "${requested_dir}" ]]; then + output_dir="${requested_dir}" + mkdir -p "${output_dir}" + output_dir="$(cd "${output_dir}" && pwd -P)" + else + output_dir="$(mktemp -d /tmp/pg_ash-release-gate.XXXXXX)" + fi + if [[ ! "${output_dir}" =~ ^/[a-zA-Z0-9_./-]+$ ]]; then + err "release-gate output path contains unsupported shell characters: ${output_dir}" + return 2 + fi + + if [[ -n "${requested_summary}" ]]; then + summary_file="${requested_summary}" + mkdir -p "$(dirname "${summary_file}")" + else + summary_file="${output_dir}/results.tsv" + fi + printf 'major\tsurface\tresult\tdetail\tlog\n' >"${summary_file}" +} + +build_image() { + local major=$1 + local build_log + build_log="${output_dir}/build_$(safe_component "${major}").log" + + if [[ "${RELEASE_GATE_PULL:-1}" == "1" ]]; then + if ! docker pull "postgres:${major}" >"${build_log}" 2>&1; then + err "docker pull failed for postgres:${major}" + return 1 + fi + elif [[ "${RELEASE_GATE_PULL}" != "0" ]]; then + err "RELEASE_GATE_PULL must be 0 or 1" + return 1 + fi + + if ! docker build \ + --build-arg="POSTGRES_TAG=${major}" \ + --tag="${IMAGE_REPOSITORY}:${major}" \ + "${REPO_ROOT}/devel/docker" >>"${build_log}" 2>&1; then + err "docker build failed for ${IMAGE_REPOSITORY}:${major}" + return 1 + fi +} + +print_human_table() { + printf '\n%-10s %-21s %-6s %s\n' \ + "PG major" "surface" "result" "detail" + printf '%-10s %-21s %-6s %s\n' \ + "----------" "---------------------" "------" "------" + awk -F'\t' ' + NR > 1 { + printf "%-10s %-21s %-6s %s\n", $1, $2, $3, $4 + } + ' "${summary_file}" + printf '\nMachine-readable TSV: %s\n' "${summary_file}" + printf 'Logs: %s\n' "${output_dir}" +} + +mark_build_failures() { + local major=$1 + shift + local surface + local build_log + build_log="${output_dir}/build_$(safe_component "${major}").log" + + for surface in "$@"; do + append_result \ + "${major}" \ + "${surface}" \ + FAIL \ + "pg_cron image build failed" \ + "${build_log}" + done + any_failure=1 +} + +run_major() { + local major=$1 + local selector=$2 + local surface + local -a selected_surfaces=() + + prepare_output + if [[ "${selector}" == "all" ]]; then + selected_surfaces=("${SURFACES[@]}") + else + selected_surfaces=("${selector}") + fi + + if ! build_image "${major}"; then + err "could not build pg_cron image for PostgreSQL ${major}" + mark_build_failures "${major}" "${selected_surfaces[@]}" + print_human_table + return 1 + fi + + for surface in "${selected_surfaces[@]}"; do + run_surface "${major}" "${surface}" + done + + print_human_table + return "${any_failure}" +} + +wait_for_worker() { + local pid=$1 + local worker_pid + local exit_code=0 + local -a remaining=() + + if ! wait "${pid}"; then + exit_code=1 + fi + for worker_pid in "${owned_workers[@]}"; do + if [[ "${worker_pid}" != "${pid}" ]]; then + remaining+=("${worker_pid}") + fi + done + owned_workers=("${remaining[@]}") + if ((exit_code != 0)); then + return "${exit_code}" + fi +} + +run_all_majors() { + local selector=$1 + local majors_text=${PG_MAJORS:-${DEFAULT_MAJORS}} + local jobs=${RELEASE_GATE_JOBS:-2} + local parent_output + local parent_summary + local major + local safe_major + local child_output + local child_summary + local worker_log + local pid + local worker_failed=0 + local header + local result_row + local row_count + local surface + local worker_major + local major_problem + local expected_failure + local -a majors=() + local -a active_pids=() + local -a expected_surfaces=() + local -a result_fields=() + local -a validated_majors=() + local -A worker_major_by_pid=() + local -A worker_failed_by_major=() + local -A result_rows=() + local -A result_valid=() + + IFS=$' \t\n' read -r -a majors <<<"${majors_text}" + if ((${#majors[@]} == 0)); then + err "PG_MAJORS did not contain any majors" + return 2 + fi + if [[ ! "${jobs}" =~ ^[1-9][0-9]*$ ]]; then + err "RELEASE_GATE_JOBS must be a positive integer" + return 2 + fi + for major in "${majors[@]}"; do + validate_major "${major}" + if contains_value "${major}" "${validated_majors[@]}"; then + err "PG_MAJORS contains a duplicate major: ${major}" + return 2 + fi + validated_majors+=("${major}") + done + if [[ "${selector}" == "all" ]]; then + expected_surfaces=("${SURFACES[@]}") + else + expected_surfaces=("${selector}") + fi + + prepare_output + parent_output="${output_dir}" + parent_summary="${summary_file}" + + for major in "${majors[@]}"; do + while ((${#active_pids[@]} >= jobs)); do + pid="${active_pids[0]}" + worker_major="${worker_major_by_pid[${pid}]}" + if ! wait_for_worker "${pid}"; then + worker_failed=1 + worker_failed_by_major["${worker_major}"]=1 + fi + active_pids=("${active_pids[@]:1}") + done + + safe_major="$(safe_component "${major}")" + child_output="${parent_output}/pg_${safe_major}" + child_summary="${parent_output}/pg_${safe_major}.tsv" + RELEASE_GATE_OUTPUT_DIR="${child_output}" \ + RELEASE_GATE_SUMMARY="${child_summary}" \ + "${BASH_SOURCE[0]}" "${major}" "${selector}" \ + >"${parent_output}/worker_${safe_major}.log" 2>&1 & + pid=$! + active_pids+=("${pid}") + owned_workers+=("${pid}") + worker_major_by_pid["${pid}"]="${major}" + worker_failed_by_major["${major}"]=0 + printf 'LAUNCH PostgreSQL %-7s worker pid=%s\n' "${major}" "${pid}" + done + + for pid in "${active_pids[@]}"; do + worker_major="${worker_major_by_pid[${pid}]}" + if ! wait_for_worker "${pid}"; then + worker_failed=1 + worker_failed_by_major["${worker_major}"]=1 + fi + done + + for major in "${majors[@]}"; do + safe_major="$(safe_component "${major}")" + child_summary="${parent_output}/pg_${safe_major}.tsv" + worker_log="${parent_output}/worker_${safe_major}.log" + if [[ ! -f "${child_summary}" ]]; then + for surface in "${expected_surfaces[@]}"; do + append_result \ + "${major}" \ + "${surface}" \ + FAIL \ + "worker produced no summary" \ + "${worker_log}" + done + worker_failed=1 + continue + fi + + major_problem="${worker_failed_by_major[${major}]}" + header="" + IFS= read -r header <"${child_summary}" || true + if [[ "${header}" != $'major\tsurface\tresult\tdetail\tlog' ]]; then + err "worker ${major} produced an invalid summary header" + worker_failed=1 + major_problem=1 + fi + row_count="$(awk 'NR > 1 { count++ } END { print count + 0 }' \ + "${child_summary}")" + if [[ "${row_count}" != "${#expected_surfaces[@]}" ]]; then + err "worker ${major} produced ${row_count} result rows; expected ${#expected_surfaces[@]}" + worker_failed=1 + major_problem=1 + fi + + result_rows=() + result_valid=() + expected_failure=0 + for surface in "${expected_surfaces[@]}"; do + result_row="" + if [[ "${header}" == $'major\tsurface\tresult\tdetail\tlog' ]] \ + && result_row="$(awk -F'\t' \ + -v expected_major="${major}" \ + -v expected_surface="${surface}" ' + NR > 1 \ + && $1 == expected_major \ + && $2 == expected_surface \ + && ($3 == "PASS" || $3 == "FAIL") \ + && NF == 5 { + count++ + row = $0 + } + END { + if (count == 1) { + print row + } else { + exit 1 + } + } + ' "${child_summary}")"; then + result_rows["${surface}"]="${result_row}" + result_valid["${surface}"]=1 + IFS=$'\t' read -r -a result_fields <<<"${result_row}" + if [[ "${result_fields[2]}" == "FAIL" ]]; then + expected_failure=1 + fi + else + result_valid["${surface}"]=0 + expected_failure=1 + worker_failed=1 + major_problem=1 + fi + done + + for surface in "${expected_surfaces[@]}"; do + if ((major_problem != 0 && expected_failure == 0)) \ + && [[ "${surface}" == "${expected_surfaces[0]}" ]]; then + append_result \ + "${major}" \ + "${surface}" \ + FAIL \ + "worker exited non-zero or produced an invalid summary" \ + "${worker_log}" + elif [[ "${result_valid[${surface}]}" == "1" ]]; then + printf '%s\n' "${result_rows[${surface}]}" >>"${parent_summary}" + else + append_result \ + "${major}" \ + "${surface}" \ + FAIL \ + "worker summary missing or duplicated this surface" \ + "${worker_log}" + fi + done + done + + summary_file="${parent_summary}" + output_dir="${parent_output}" + print_human_table + if awk -F'\t' 'NR > 1 && $3 == "FAIL" { found=1 } END { exit !found }' \ + "${summary_file}"; then + worker_failed=1 + fi + return "${worker_failed}" +} + +main() { + local target=${1:-} + local selector=${2:-all} + + if [[ "${target}" == "--help" || "${target}" == "-h" ]]; then + usage + return 0 + fi + if [[ -z "${target}" || $# -gt 2 ]]; then + usage >&2 + return 2 + fi + + require_commands + validate_surface "${selector}" + [[ -f "${DOCKERFILE}" ]] || { + err "Dockerfile not found: ${DOCKERFILE}" + return 2 + } + [[ -f "${CHAIN_HELPER}" && -f "${CI_STEP_HELPER}" ]] || { + err "release-gate helper is missing" + return 2 + } + + cd "${REPO_ROOT}" + trap cleanup EXIT + trap 'signal_exit 130' INT + trap 'signal_exit 143' TERM + + if [[ "${target}" == "all" ]]; then + run_all_majors "${selector}" + else + validate_major "${target}" + run_major "${target}" "${selector}" + fi +} + +main "$@" diff --git a/devel/scripts/test_ci_step_script.py b/devel/scripts/test_ci_step_script.py new file mode 100755 index 0000000..409391e --- /dev/null +++ b/devel/scripts/test_ci_step_script.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Unit tests for ci_step_script.py (stdlib only).""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import ci_step_script + + +SAMPLE = """\ +name: sample +jobs: + docs: + steps: + - name: Not the test job + run: | + exit 99 + test: + steps: + - uses: actions/checkout@example + - name: First + run: | + printf '%s\\n' first + + echo done + - name: "Second: quoted" + run: |- + echo second + - name: 'Third''s step' + run: |+ + echo third + +""" + + +class WorkflowParserTests(unittest.TestCase): + def parse(self, text: str = SAMPLE) -> list[ci_step_script.Step]: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "workflow.yml" + path.write_text(text, encoding="utf-8") + return ci_step_script.parse_workflow(path) + + def test_extracts_only_named_test_steps_and_honors_chomping(self) -> None: + steps = self.parse() + self.assertEqual( + [step.name for step in steps], + ["First", "Second: quoted", "Third's step"], + ) + self.assertEqual( + steps[0].run, + "printf '%s\\n' first\n\n" + "echo done\n", + ) + self.assertEqual(steps[1].run, "echo second") + self.assertEqual(steps[2].run, "echo third\n\n") + + def test_duplicate_workflow_names_fail_with_locations(self) -> None: + text = SAMPLE.replace( + " - name: 'Third''s step'", + " - name: First", + ) + with self.assertRaisesRegex( + ci_step_script.WorkflowError, "duplicate step names.*First.*lines" + ): + self.parse(text) + + def test_unnamed_run_step_fails_instead_of_being_silently_omitted(self) -> None: + text = SAMPLE.replace( + " - uses: actions/checkout@example", + " - run: |\n" + " echo unnamed", + ) + with self.assertRaisesRegex( + ci_step_script.WorkflowError, "executable step.*has no name" + ): + self.parse(text) + + def test_named_and_range_selection_are_strict(self) -> None: + steps = self.parse() + selected = ci_step_script.select_range( + steps, + "First", + "Third's step", + ["Second: quoted"], + ) + self.assertEqual([step.name for step in selected], ["First", "Third's step"]) + + with self.assertRaisesRegex(ci_step_script.WorkflowError, "unknown step"): + ci_step_script.select_named(steps, ["missing"]) + with self.assertRaisesRegex(ci_step_script.WorkflowError, "occurs after"): + ci_step_script.select_range( + steps, "Third's step", "First", [] + ) + with self.assertRaisesRegex(ci_step_script.WorkflowError, "outside"): + ci_step_script.select_range( + steps, "Second: quoted", "Third's step", ["First"] + ) + + def test_run_mode_uses_a_fresh_bash_process_for_each_step(self) -> None: + steps = [ + ci_step_script.Step( + ordinal=1, + line=1, + name="set child environment", + run="export CI_STEP_MUST_NOT_LEAK=yes\n", + ), + ci_step_script.Step( + ordinal=2, + line=2, + name="check child environment", + run='test -z "${CI_STEP_MUST_NOT_LEAK:-}"\n', + ), + ] + args = argparse.Namespace(mode="run", env=[], cwd=None) + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(ci_step_script._emit_or_run(args, steps), 0) + + def test_null_mode_preserves_exact_record_boundaries(self) -> None: + steps = [ + ci_step_script.Step( + ordinal=1, + line=1, + name="first", + run="printf 'line one\\nline two\\n'\n", + ), + ci_step_script.Step( + ordinal=2, + line=2, + name="second", + run="echo done\n", + ), + ] + args = argparse.Namespace(mode="null", env=[], cwd=None) + + class BinaryCapture(io.StringIO): + def __init__(self) -> None: + super().__init__() + self.buffer = io.BytesIO() + + output = BinaryCapture() + with contextlib.redirect_stdout(output): + self.assertEqual(ci_step_script._emit_or_run(args, steps), 0) + self.assertEqual( + output.buffer.getvalue(), + b"first\0printf 'line one\\nline two\\n'\n\0" + b"second\0echo done\n\0", + ) + + def test_current_workflow_integration(self) -> None: + steps = ci_step_script.parse_workflow(ci_step_script.DEFAULT_WORKFLOW) + names = [step.name for step in steps] + self.assertGreater(len(steps), 40) + self.assertEqual(names[0], "Install pg_cron in container") + self.assertIn("Test schema and infrastructure", names) + self.assertIn("Degraded mode: without pg_stat_statements", names) + self.assertNotIn("Guard SQL release workflow", names) + selected = ci_step_script.select_named( + steps, ["Test schema and infrastructure"] + )[0] + self.assertIsNotNone(selected.run) + self.assertTrue((selected.run or "").startswith("psql -h localhost")) + self.assertIn("Schema and infrastructure tests PASSED", selected.run or "") + + +if __name__ == "__main__": + unittest.main() From b207943c14c2eb4c8ab5fb89aa49d719e3d63817 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:11:42 +0000 Subject: [PATCH 04/18] test: add behavioral feature coverage --- devel/tests/features.sql | 68 ++ devel/tests/features_fixture.sql | 239 +++++ devel/tests/features_lifecycle.sql | 895 ++++++++++++++++ devel/tests/features_privileges.sql | 321 ++++++ devel/tests/features_readers.sql | 1507 +++++++++++++++++++++++++++ 5 files changed, 3030 insertions(+) create mode 100644 devel/tests/features.sql create mode 100644 devel/tests/features_fixture.sql create mode 100644 devel/tests/features_lifecycle.sql create mode 100644 devel/tests/features_privileges.sql create mode 100644 devel/tests/features_readers.sql diff --git a/devel/tests/features.sql b/devel/tests/features.sql new file mode 100644 index 0000000..535ad66 --- /dev/null +++ b/devel/tests/features.sql @@ -0,0 +1,68 @@ +\set ON_ERROR_STOP on + +/* + * Behavioral coverage for every public pg_ash surface. The caller supplies + * the exact optional-extension state and starts one pg_sleep() client backend + * so take_sample() has a deterministic live session to capture. + */ +select pg_catalog.set_config( + 'ash.feature_mode', + :'feature_mode', + false +); +select pg_catalog.set_config( + 'ash.feature_expected_cron', + :'expected_cron', + false +); +select pg_catalog.set_config( + 'ash.feature_expected_pgss', + :'expected_pgss', + false +); + +do $feature_preconditions$ +declare + v_mode text := pg_catalog.current_setting('ash.feature_mode'); + v_expected_cron boolean := + pg_catalog.current_setting('ash.feature_expected_cron')::boolean; + v_expected_pgss boolean := + pg_catalog.current_setting('ash.feature_expected_pgss')::boolean; + v_actual_cron boolean; + v_actual_pgss boolean; +begin + select exists ( + select + from pg_catalog.pg_extension + where extname = 'pg_cron' + ) + into v_actual_cron; + + select exists ( + select + from pg_catalog.pg_extension + where extname = 'pg_stat_statements' + ) + into v_actual_pgss; + + assert v_actual_cron = v_expected_cron, + format( + '[%s] feature precondition: expected pg_cron=%s, got %s', + v_mode, + v_expected_cron, + v_actual_cron + ); + assert v_actual_pgss = v_expected_pgss, + format( + '[%s] feature precondition: expected pg_stat_statements=%s, got %s', + v_mode, + v_expected_pgss, + v_actual_pgss + ); +end +$feature_preconditions$; + +\ir features_fixture.sql +\ir features_readers.sql +\ir features_privileges.sql +\ir features_lifecycle.sql diff --git a/devel/tests/features_fixture.sql b/devel/tests/features_fixture.sql new file mode 100644 index 0000000..a85546e --- /dev/null +++ b/devel/tests/features_fixture.sql @@ -0,0 +1,239 @@ +/* + * Four complete one-minute samples, known by construction: + * + * minute total CPU* IO Lock query 10101 query 20202 query 30303 null + * 0 3 1 2 0 2 1 0 0 + * 1 4 1 3 0 1 3 0 0 + * 2 5 1 0 4 1 0 4 0 + * 3 4 1 2 1 0 2 1 1 + * + * sample_interval = 1 minute makes every encoded backend appearance exactly + * one backend-minute. Over the four-minute window: avg=4, peak=5, p99=4.97, + * backend_seconds=960. + */ +truncate table ash.sample; +truncate table + ash.query_map_0, + ash.query_map_1, + ash.query_map_2 +restart identity; +truncate table ash.rollup_1m, ash.rollup_1h; +truncate table ash.wait_event_map restart identity; + +update ash.config +set + current_slot = 0, + sampling_enabled = true, + skipped_samples = 0, + sample_interval = interval '1 minute', + rotation_period = interval '1 day', + rotated_at = pg_catalog.clock_timestamp(), + rollup_1m_retention_days = 30, + rollup_1h_retention_days = 1825, + rollup_min_backend_seconds = 1, + last_rollup_1m_ts = null, + last_rollup_1h_ts = null, + debug_logging = false +where singleton; + +insert into ash.wait_event_map ( + state, + type, + event +) +values + ('active', 'CPU*', 'CPU*'), + ('active', 'IO', 'DataFileRead'), + ('active', 'Lock', 'tuple'); + +insert into ash.query_map_0 (query_id) +values + (10101), + (20202), + (30303); + +create temporary table ash_feature_context ( + fixture_start timestamptz not null, + fixture_end timestamptz not null, + datid oid not null, + cpu_wait_id smallint not null, + io_wait_id smallint not null, + lock_wait_id smallint not null, + query_10101_id int4 not null, + query_20202_id int4 not null, + query_30303_id int4 not null +) +on commit preserve rows; + +insert into ash_feature_context ( + fixture_start, + fixture_end, + datid, + cpu_wait_id, + io_wait_id, + lock_wait_id, + query_10101_id, + query_20202_id, + query_30303_id +) +select + fixture_anchor.fixture_start, + fixture_anchor.fixture_start + interval '4 minutes', + database_row.oid, + cpu_wait.id, + io_wait.id, + lock_wait.id, + query_10101.id, + query_20202.id, + query_30303.id +from pg_catalog.pg_database as database_row +cross join lateral ( + select + pg_catalog.date_trunc( + 'hour', + pg_catalog.statement_timestamp() + ) - interval '50 minutes' as fixture_start +) as fixture_anchor +cross join lateral ( + select id + from ash.wait_event_map + where type = 'CPU*' +) as cpu_wait +cross join lateral ( + select id + from ash.wait_event_map + where type = 'IO' +) as io_wait +cross join lateral ( + select id + from ash.wait_event_map + where type = 'Lock' +) as lock_wait +cross join lateral ( + select id + from ash.query_map_0 + where query_id = 10101 +) as query_10101 +cross join lateral ( + select id + from ash.query_map_0 + where query_id = 20202 +) as query_20202 +cross join lateral ( + select id + from ash.query_map_0 + where query_id = 30303 +) as query_30303 +where database_row.datname = pg_catalog.current_database(); + +insert into ash.sample ( + sample_ts, + datid, + active_count, + data, + slot +) +select + ash.ts_from_timestamptz(fixture.fixture_start), + fixture.datid, + 3, + array[ + -fixture.cpu_wait_id, + 1, + fixture.query_10101_id, + -fixture.io_wait_id, + 2, + fixture.query_10101_id, + fixture.query_20202_id + ]::integer[], + 0 +from ash_feature_context as fixture +union all +select + ash.ts_from_timestamptz(fixture.fixture_start + interval '1 minute'), + fixture.datid, + 4, + array[ + -fixture.cpu_wait_id, + 1, + fixture.query_10101_id, + -fixture.io_wait_id, + 3, + fixture.query_20202_id, + fixture.query_20202_id, + fixture.query_20202_id + ]::integer[], + 0 +from ash_feature_context as fixture +union all +select + ash.ts_from_timestamptz(fixture.fixture_start + interval '2 minutes'), + fixture.datid, + 5, + array[ + -fixture.cpu_wait_id, + 1, + fixture.query_10101_id, + -fixture.lock_wait_id, + 4, + fixture.query_30303_id, + fixture.query_30303_id, + fixture.query_30303_id, + fixture.query_30303_id + ]::integer[], + 0 +from ash_feature_context as fixture +union all +select + ash.ts_from_timestamptz(fixture.fixture_start + interval '3 minutes'), + fixture.datid, + 4, + array[ + -fixture.cpu_wait_id, + 1, + 0, + -fixture.io_wait_id, + 2, + fixture.query_20202_id, + fixture.query_20202_id, + -fixture.lock_wait_id, + 1, + fixture.query_30303_id + ]::integer[], + 0 +from ash_feature_context as fixture; + +do $feature_fixture$ +declare + v_mode text := pg_catalog.current_setting( + 'ash.feature_mode', + true + ); + v_fixture ash_feature_context%rowtype; + v_rows bigint; + v_backends bigint; +begin + select * + into strict v_fixture + from ash_feature_context; + + select + count(*), + sum(active_count) + into + v_rows, + v_backends + from ash.sample + where + sample_ts >= ash.ts_from_timestamptz(v_fixture.fixture_start) + and sample_ts < ash.ts_from_timestamptz(v_fixture.fixture_end); + + assert v_rows = 4 and v_backends = 16, + format( + '[%s] fixture: expected 4 sample rows and 16 encoded backends, got rows=%s backends=%s', + pg_catalog.coalesce(v_mode, 'standalone'), + v_rows, + v_backends + ); +end +$feature_fixture$; diff --git a/devel/tests/features_lifecycle.sql b/devel/tests/features_lifecycle.sql new file mode 100644 index 0000000..55b447e --- /dev/null +++ b/devel/tests/features_lifecycle.sql @@ -0,0 +1,895 @@ +/* ------------------------------------------------------------------------- + * ash.set_debug_logging() + * ------------------------------------------------------------------------- */ +do $feature_debug_logging$ +declare + v_actual text; +begin + select ash.set_debug_logging() + into v_actual; + assert v_actual = 'debug_logging = false', + format( + '[%s] ash.set_debug_logging report: expected "debug_logging = false", got %L', + pg_catalog.current_setting('ash.feature_mode'), + v_actual + ); + + select ash.set_debug_logging(true) + into v_actual; + assert v_actual = + 'debug_logging enabled — each sampled session will emit RAISE LOG' + and ( + select debug_logging + from ash.config + where singleton + ), + format( + '[%s] ash.set_debug_logging enable: expected exact return and config=true, got return=%L config=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_actual, + ( + select debug_logging + from ash.config + where singleton + ) + ); + + select ash.set_debug_logging(false) + into v_actual; + assert v_actual = 'debug_logging disabled' + and not ( + select debug_logging + from ash.config + where singleton + ), + format( + '[%s] ash.set_debug_logging disable: expected exact return and config=false, got return=%L config=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_actual, + ( + select debug_logging + from ash.config + where singleton + ) + ); +end +$feature_debug_logging$; + +/* ------------------------------------------------------------------------- + * ash.take_sample(): disabled skip and one known pg_sleep() backend. + * ------------------------------------------------------------------------- */ +begin; + +do $feature_take_sample_disabled$ +declare + v_before_rows bigint; + v_before_skips int4; + v_result int; +begin + select + pg_catalog.count(*) + into v_before_rows + from ash.sample; + select skipped_samples + into strict v_before_skips + from ash.config + where singleton; + + update ash.config + set sampling_enabled = false + where singleton; + select ash.take_sample() + into v_result; + + assert v_result = 0 + and ( + select pg_catalog.count(*) + from ash.sample + ) = v_before_rows + and ( + select skipped_samples + from ash.config + where singleton + ) = v_before_skips + 1, + format( + '[%s] ash.take_sample disabled: expected return 0/no row/skipped+1, got return=%s rows=%s skips=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_result, + ( + select pg_catalog.count(*) + from ash.sample + ), + ( + select skipped_samples + from ash.config + where singleton + ) + ); +end +$feature_take_sample_disabled$; + +rollback; + +begin; + +do $feature_take_sample_live$ +declare + v_before_queries bigint; + v_before_rows bigint; + v_before_waits bigint; + v_decoded jsonb; + v_new_sample record; + v_result int; +begin + select pg_catalog.count(*) + into v_before_rows + from ash.sample; + select pg_catalog.count(*) + into v_before_waits + from ash.wait_event_map; + select pg_catalog.count(*) + into v_before_queries + from ash.query_map_all; + + update ash.config + set + sampling_enabled = true, + sample_interval = interval '1 second' + where singleton; + + select ash.take_sample() + into v_result; + + select * + into strict v_new_sample + from ash.sample + order by sample_ts desc + limit 1; + select pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_array( + decoded.wait_event, + decoded.query_id, + decoded.count + ) + ) + into v_decoded + from ash.decode_sample( + v_new_sample.data, + v_new_sample.slot + ) as decoded; + + assert v_result = 1 + and ( + select pg_catalog.count(*) + from ash.sample + ) = v_before_rows + 1 + and v_new_sample.active_count = 1 + and v_new_sample.slot = 0 + and pg_catalog.jsonb_array_length(v_decoded) = 1 + and v_decoded -> 0 ->> 0 = 'Timeout:PgSleep', + format( + '[%s] ash.take_sample live: expected one inserted row containing exactly one Timeout:PgSleep backend, got return=%s row=%s decoded=%s total_rows=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_result, + pg_catalog.row_to_json(v_new_sample), + v_decoded, + ( + select pg_catalog.count(*) + from ash.sample + ) + ); + assert ( + select pg_catalog.count(*) + from ash.wait_event_map + ) = v_before_waits + 1 + and ( + select pg_catalog.count(*) + from ash.query_map_all + ) between v_before_queries and v_before_queries + 1, + format( + '[%s] ash.take_sample dictionaries: expected one PgSleep wait and zero/one real query mapping, got waits %s->%s queries %s->%s', + pg_catalog.current_setting('ash.feature_mode'), + v_before_waits, + ( + select pg_catalog.count(*) + from ash.wait_event_map + ), + v_before_queries, + ( + select pg_catalog.count(*) + from ash.query_map_all + ) + ); +end +$feature_take_sample_live$; + +rollback; + +/* ------------------------------------------------------------------------- + * ash.start() / ash.stop(): exact cron and external-scheduler behavior. + * ------------------------------------------------------------------------- */ +do $feature_start_stop$ +declare + v_expected_cron boolean := + pg_catalog.current_setting('ash.feature_expected_cron')::boolean; + v_jobs jsonb; + v_start_rows jsonb; + v_stop_rows jsonb; +begin + if v_expected_cron then + select pg_catalog.jsonb_object_agg( + start_row.job_type, + pg_catalog.jsonb_build_array( + start_row.job_id is not null, + start_row.status + ) + ) + into v_start_rows + from ash.start(interval '2 seconds') as start_row; + + assert v_start_rows = '{ + "sampler": [true, "created"], + "rotation": [true, "created"], + "rollup_1m": [true, "created"], + "rollup_1h": [true, "created"], + "rollup_gc": [true, "created"] + }'::jsonb, + format( + '[%s] ash.start cron return: expected five exact created job rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_start_rows + ); + + select pg_catalog.jsonb_object_agg( + cron_job.jobname, + pg_catalog.jsonb_build_array( + cron_job.schedule, + cron_job.command, + cron_job.database, + cron_job.active + ) + ) + into v_jobs + from cron.job as cron_job + where cron_job.jobname like 'ash_%'; + assert v_jobs = pg_catalog.jsonb_build_object( + 'ash_sampler', + pg_catalog.jsonb_build_array( + '2 seconds', + 'set statement_timeout = ''500ms''; select ash.take_sample()', + pg_catalog.current_database(), + true + ), + 'ash_rotation', + pg_catalog.jsonb_build_array( + '0 0 * * *', + 'select ash.rotate()', + pg_catalog.current_database(), + true + ), + 'ash_rollup_1m', + pg_catalog.jsonb_build_array( + '* * * * *', + 'select ash.rollup_minute()', + pg_catalog.current_database(), + true + ), + 'ash_rollup_1h', + pg_catalog.jsonb_build_array( + '1 * * * *', + 'select ash.rollup_hour()', + pg_catalog.current_database(), + true + ), + 'ash_rollup_gc', + pg_catalog.jsonb_build_array( + '0 3 * * *', + 'select ash.rollup_cleanup()', + pg_catalog.current_database(), + true + ) + ), + format( + '[%s] ash.start cron side effect: expected exact five schedules/commands/database/active states, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_jobs + ); + + perform * from ash.start(interval '3 seconds'); + assert ( + select schedule = '3 seconds' + from cron.job + where jobname = 'ash_sampler' + ) + and ( + select sample_interval = interval '3 seconds' + and sampling_enabled + from ash.config + where singleton + ), + format( + '[%s] ash.start idempotent reschedule: expected cron/config 3 seconds and enabled=true', + pg_catalog.current_setting('ash.feature_mode') + ); + + select pg_catalog.jsonb_object_agg( + stop_row.job_type, + stop_row.status + ) + into v_stop_rows + from ash.stop() as stop_row; + assert v_stop_rows = '{ + "sampler": "removed", + "rotation": "removed", + "rollup_1m": "removed", + "rollup_1h": "removed", + "rollup_gc": "removed" + }'::jsonb + and not exists ( + select + from cron.job + where jobname like 'ash_%' + ) + and not ( + select sampling_enabled + from ash.config + where singleton + ), + format( + '[%s] ash.stop cron side effect: expected five removed rows/no ash jobs/config disabled, got rows=%s remaining=%s enabled=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_stop_rows, + ( + select pg_catalog.count(*) + from cron.job + where jobname like 'ash_%' + ), + ( + select sampling_enabled + from ash.config + where singleton + ) + ); + else + select pg_catalog.jsonb_object_agg( + start_row.job_type, + pg_catalog.jsonb_build_array(start_row.job_id, start_row.status) + ) + into v_start_rows + from ash.start(interval '2 seconds') as start_row; + assert v_start_rows = '{ + "sampler": [ + null, + "interval set to 00:00:02 — schedule externally (pg_cron not available)" + ], + "rotation": [ + null, + "rotation_period is 1 day — schedule ash.rotate() externally" + ], + "rollup": [ + null, + "schedule ash.rollup_minute() every minute, ash.rollup_hour() at minute 1 every hour, ash.rollup_cleanup() daily" + ] + }'::jsonb + and ( + select sample_interval = interval '2 seconds' + and sampling_enabled + from ash.config + where singleton + ), + format( + '[%s] ash.start no-cron: expected exact three external-scheduler rows and enabled 2-second config, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_start_rows + ); + + select pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_array( + stop_row.job_type, + stop_row.job_id, + stop_row.status + ) + ) + into v_stop_rows + from ash.stop() as stop_row; + assert v_stop_rows = '[[ + "info", + null, + "pg_cron not installed — remember to stop your external scheduler (cron, systemd timer, loop script, etc.)" + ]]'::jsonb + and not ( + select sampling_enabled + from ash.config + where singleton + ), + format( + '[%s] ash.stop no-cron: expected exact reminder row and config disabled, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_stop_rows + ); + end if; +end +$feature_start_stop$; + +/* ------------------------------------------------------------------------- + * ash.rotate(): exact slot advance and lockstep partition/query-map truncate. + * ------------------------------------------------------------------------- */ +begin; + +do $feature_rotate$ +declare + v_inserted_id int4; + v_result text; +begin + insert into ash.query_map_2 (query_id) + values (909090) + returning id + into v_inserted_id; + assert v_inserted_id = 1, + format( + '[%s] ash.rotate setup: expected fresh slot-2 identity 1, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_inserted_id + ); + + update ash.config + set + current_slot = 0, + rotated_at = pg_catalog.now() - interval '2 days', + rotation_period = interval '1 day' + where singleton; + + select ash.rotate() + into v_result; + assert v_result = + 'rotated: slot 0 -> 1, truncated slot 2 (sample + query_map)' + and ash.current_slot() = 1 + and ( + select pg_catalog.count(*) + from ash.query_map_2 + ) = 0 + and ( + select pg_catalog.count(*) + from ash.sample + ) = 4, + format( + '[%s] ash.rotate: expected exact slot 0->1/truncate slot2 while retaining four slot0 samples, got result=%L slot=%s qmap2=%s samples=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_result, + ash.current_slot(), + ( + select pg_catalog.count(*) + from ash.query_map_2 + ), + ( + select pg_catalog.count(*) + from ash.sample + ) + ); + + insert into ash.query_map_2 (query_id) + values (909091) + returning id + into v_inserted_id; + assert v_inserted_id = 1, + format( + '[%s] ash.rotate identity side effect: expected truncated slot identity restart at 1, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_inserted_id + ); +end +$feature_rotate$; + +rollback; + +/* ------------------------------------------------------------------------- + * ash.rollup_hour(): exact merged hour and minute-count preservation. + * ------------------------------------------------------------------------- */ +begin; + +do $feature_rollup_hour$ +declare + v_fixture ash_feature_context%rowtype; + v_hour record; + v_result int; +begin + select * + into strict v_fixture + from ash_feature_context; + + update ash.config + set last_rollup_1h_ts = + ash.ts_from_timestamptz( + pg_catalog.date_trunc('hour', v_fixture.fixture_start) + ) + where singleton; + select ash.rollup_hour() + into v_result; + select * + into strict v_hour + from ash.rollup_1h + where ts = ash.ts_from_timestamptz( + pg_catalog.date_trunc('hour', v_fixture.fixture_start) + ); + + assert v_result = 1 + and v_hour.datid = v_fixture.datid + and v_hour.samples = 4 + and v_hour.peak_backends = 5 + and v_hour.wait_counts = array[ + v_fixture.io_wait_id, + 7, + v_fixture.lock_wait_id, + 5, + v_fixture.cpu_wait_id, + 4 + ]::int4[] + and v_hour.query_counts = array[ + 20202, 6, 30303, 5, 10101, 4 + ]::int8[] + and pg_catalog.array_length(v_hour.minute_counts, 1) = 60 + and v_hour.minute_counts[11:14] = array[3, 4, 5, 4]::int4[] + and ( + select pg_catalog.count(*) + from pg_catalog.unnest(v_hour.minute_counts) as minute_count + where minute_count is not null + ) = 4, + format( + '[%s] ash.rollup_hour: expected one exact merged row with [3,4,5,4] minute counts, got return=%s row=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_result, + pg_catalog.row_to_json(v_hour) + ); +end +$feature_rollup_hour$; + +rollback; + +/* ------------------------------------------------------------------------- + * ash.rollup_cleanup(): exact retention deletions and survivor counts. + * ------------------------------------------------------------------------- */ +begin; + +do $feature_rollup_cleanup$ +declare + v_fixture ash_feature_context%rowtype; + v_recent_1h_ts int4; + v_recent_1m_ts int4; + v_result text; + v_stale_1h_ts int4; + v_stale_1m_ts int4; +begin + select * + into strict v_fixture + from ash_feature_context; + + v_stale_1m_ts := ash.ts_from_timestamptz( + pg_catalog.date_trunc( + 'minute', + pg_catalog.now() - interval '2 days' + ) + ); + v_recent_1m_ts := ash.ts_from_timestamptz( + pg_catalog.date_trunc('minute', pg_catalog.now()) + ); + v_stale_1h_ts := ash.ts_from_timestamptz( + pg_catalog.date_trunc( + 'hour', + pg_catalog.now() - interval '2 days' + ) + ); + v_recent_1h_ts := ash.ts_from_timestamptz( + pg_catalog.date_trunc('hour', pg_catalog.now()) + ); + + update ash.config + set + rollup_1m_retention_days = 1, + rollup_1h_retention_days = 1 + where singleton; + insert into ash.rollup_1m ( + ts, + datid, + samples, + peak_backends, + wait_counts, + query_counts + ) + values + ( + v_stale_1m_ts, + v_fixture.datid, + 1, + 1, + array[v_fixture.cpu_wait_id, 1]::int4[], + '{}'::int8[] + ), + ( + v_recent_1m_ts, + v_fixture.datid, + 1, + 1, + array[v_fixture.cpu_wait_id, 1]::int4[], + '{}'::int8[] + ); + insert into ash.rollup_1h ( + ts, + datid, + samples, + peak_backends, + wait_counts, + query_counts, + minute_counts + ) + values + ( + v_stale_1h_ts, + v_fixture.datid, + 1, + 1, + array[v_fixture.cpu_wait_id, 1]::int4[], + '{}'::int8[], + array[1]::int4[] + ), + ( + v_recent_1h_ts, + v_fixture.datid, + 1, + 1, + array[v_fixture.cpu_wait_id, 1]::int4[], + '{}'::int8[], + array[1]::int4[] + ); + + select ash.rollup_cleanup() + into v_result; + assert v_result = 'cleanup: deleted 1 minute rows, 1 hourly rows' + and not exists ( + select + from ash.rollup_1m + where ts = v_stale_1m_ts + ) + and exists ( + select + from ash.rollup_1m + where ts = v_recent_1m_ts + ) + and not exists ( + select + from ash.rollup_1h + where ts = v_stale_1h_ts + ) + and exists ( + select + from ash.rollup_1h + where ts = v_recent_1h_ts + ), + format( + '[%s] ash.rollup_cleanup: expected exact 1/1 deletion with recent survivors, got %L', + pg_catalog.current_setting('ash.feature_mode'), + v_result + ); +end +$feature_rollup_cleanup$; + +rollback; + +/* ------------------------------------------------------------------------- + * ash.rebuild_partitions(): destructive side effects and reader re-grant. + * ------------------------------------------------------------------------- */ +begin; + +do $feature_rebuild_confirmation$ +declare + v_before bigint; + v_refused boolean := false; +begin + select pg_catalog.count(*) + into v_before + from ash.sample; + begin + perform ash.rebuild_partitions(4); + exception + when others then + v_refused := sqlerrm = + 'rebuild_partitions is destructive — all raw sample data will be lost. To proceed, call: select ash.rebuild_partitions(4, ''yes'')'; + end; + assert v_refused + and ( + select pg_catalog.count(*) + from ash.sample + ) = v_before, + format( + '[%s] ash.rebuild_partitions confirmation: expected exact refusal before mutation and %s retained rows, got refused=%s rows=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_before, + v_refused, + ( + select pg_catalog.count(*) + from ash.sample + ) + ); +end +$feature_rebuild_confirmation$; + +do $feature_rebuild$ +declare + v_actual_read boolean := false; + v_result text; +begin + select ash.rebuild_partitions(4, 'yes') + into v_result; + assert v_result = + 'rebuilt: 3 -> 4 partitions. all raw data cleared. call ash.start() to resume sampling.' + and ash.current_slot() = 0 + and ( + select num_partitions = 4 + and not sampling_enabled + from ash.config + where singleton + ) + and ( + select pg_catalog.count(*) + from ash.sample + ) = 0 + and ( + select pg_catalog.count(*) + from ash.rollup_1m + ) = 4 + and ( + select pg_catalog.count(*) + from pg_catalog.pg_inherits as inheritance_row + where inheritance_row.inhparent = 'ash.sample'::regclass + ) = 4 + and ( + select pg_catalog.count(*) + from pg_catalog.pg_class as relation_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = relation_row.relnamespace + where + namespace_row.nspname = 'ash' + and relation_row.relname ~ '^query_map_[0-9]+$' + and relation_row.relkind = 'r' + ) = 4, + format( + '[%s] ash.rebuild_partitions: expected exact 3->4 result, disabled slot0 config, zero raw, four retained rollups, and four sample/query partitions; got result=%L config=%s raw=%s rollups=%s sample_children=%s query_maps=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_result, + ( + select pg_catalog.row_to_json(config_row) + from ash.config as config_row + ), + ( + select pg_catalog.count(*) + from ash.sample + ), + ( + select pg_catalog.count(*) + from ash.rollup_1m + ), + ( + select pg_catalog.count(*) + from pg_catalog.pg_inherits as inheritance_row + where inheritance_row.inhparent = 'ash.sample'::regclass + ), + ( + select pg_catalog.count(*) + from pg_catalog.pg_class as relation_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = relation_row.relnamespace + where + namespace_row.nspname = 'ash' + and relation_row.relname ~ '^query_map_[0-9]+$' + and relation_row.relkind = 'r' + ) + ); + + assert not pg_catalog.has_table_privilege( + 'ash_feature_reader', + 'ash.sample_3', + 'SELECT' + ), + format( + '[%s] ash.rebuild_partitions ACL contract: expected replacement partition to require re-running grant_reader()', + pg_catalog.current_setting('ash.feature_mode') + ); + perform ash.grant_reader('ash_feature_reader'); + execute 'set local role ash_feature_reader'; + begin + perform pg_catalog.count(*) from ash.query_map_all; + perform pg_catalog.count(*) from ash.sample_3; + v_actual_read := true; + exception + when insufficient_privilege then + v_actual_read := false; + end; + execute 'reset role'; + assert v_actual_read + and pg_catalog.has_table_privilege( + 'ash_feature_reader', + 'ash.sample_3', + 'SELECT' + ), + format( + '[%s] ash.rebuild_partitions re-grant: reader could not actually read replacement view/partition after grant_reader()', + pg_catalog.current_setting('ash.feature_mode') + ); +end +$feature_rebuild$; + +rollback; + +/* ------------------------------------------------------------------------- + * ash.uninstall(): verify the destructive effect, then rollback for cleanup. + * ------------------------------------------------------------------------- */ +begin; + +create temporary table ash_feature_uninstall_result +on commit drop +as +select ash.uninstall('yes') as result; + +do $feature_uninstall_rollback$ +declare + v_expected_cron boolean := + pg_catalog.current_setting('ash.feature_expected_cron')::boolean; + v_result text; +begin + select result + into strict v_result + from ash_feature_uninstall_result; + assert v_result = + 'uninstalled: removed 0 pg_cron jobs, dropped ash schema' + and pg_catalog.to_regnamespace('ash') is null, + format( + '[%s] ash.uninstall: expected exact zero-job result and absent schema, got result=%L schema=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_result, + pg_catalog.to_regnamespace('ash') + ); + if v_expected_cron then + assert not exists ( + select + from cron.job + where jobname like 'ash_%' + ), + format( + '[%s] ash.uninstall cron side effect: ash_* jobs remained', + pg_catalog.current_setting('ash.feature_mode') + ); + end if; +end +$feature_uninstall_rollback$; + +rollback; + +/* + * Final real uninstall leaves the caller's mode isolated and lets the shell + * assert the schema is absent before configuring the next extension mode. + */ +create temporary table ash_feature_final_uninstall +on commit preserve rows +as +select ash.uninstall('yes') as result; + +do $feature_final_cleanup$ +declare + v_result text; +begin + select result + into strict v_result + from ash_feature_final_uninstall; + assert v_result = + 'uninstalled: removed 0 pg_cron jobs, dropped ash schema' + and pg_catalog.to_regnamespace('ash') is null, + format( + '[%s] final ash.uninstall cleanup: expected absent schema and exact zero-job result, got result=%L schema=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_result, + pg_catalog.to_regnamespace('ash') + ); +end +$feature_final_cleanup$; + +drop owned by ash_feature_reader; +drop role ash_feature_reader; diff --git a/devel/tests/features_privileges.sql b/devel/tests/features_privileges.sql new file mode 100644 index 0000000..940d5b7 --- /dev/null +++ b/devel/tests/features_privileges.sql @@ -0,0 +1,321 @@ +/* ------------------------------------------------------------------------- + * ash.grant_reader(), ash.revoke_reader(), and the default pg_monitor bundle + * ------------------------------------------------------------------------- */ +do $feature_reader_role_setup$ +begin + if not exists ( + select + from pg_catalog.pg_roles + where rolname = 'ash_feature_reader' + ) then + create role ash_feature_reader nologin noinherit; + else + perform ash.revoke_reader('ash_feature_reader'); + end if; +end +$feature_reader_role_setup$; + +do $feature_privileges$ +declare + v_admin_denied boolean := false; + v_aas record; + v_direct_functions bigint; + v_direct_tables bigint; + v_fixture ash_feature_context%rowtype; + v_read_denied boolean := false; + v_status_version text; +begin + select * + into strict v_fixture + from ash_feature_context; + + begin + execute 'set local role ash_feature_reader'; + perform * from ash.status(); + execute 'reset role'; + exception + when insufficient_privilege then + execute 'reset role'; + v_read_denied := true; + end; + assert v_read_denied, + format( + '[%s] ash.grant_reader precondition: ungranted role unexpectedly read ash.status()', + pg_catalog.current_setting('ash.feature_mode') + ); + + perform ash.grant_reader('ash_feature_reader'); + + select pg_catalog.count(*) + into v_direct_functions + from pg_catalog.pg_proc as procedure_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = procedure_row.pronamespace + cross join lateral pg_catalog.aclexplode(procedure_row.proacl) as acl + where + namespace_row.nspname = 'ash' + and acl.grantee = ( + select role_row.oid + from pg_catalog.pg_roles as role_row + where role_row.rolname = 'ash_feature_reader' + ) + and acl.privilege_type = 'EXECUTE'; + + select pg_catalog.count(*) + into v_direct_tables + from pg_catalog.pg_class as relation_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = relation_row.relnamespace + cross join lateral pg_catalog.aclexplode(relation_row.relacl) as acl + where + namespace_row.nspname = 'ash' + and acl.grantee = ( + select role_row.oid + from pg_catalog.pg_roles as role_row + where role_row.rolname = 'ash_feature_reader' + ) + and acl.privilege_type = 'SELECT'; + + assert pg_catalog.has_schema_privilege( + 'ash_feature_reader', + 'ash', + 'USAGE' + ) + and v_direct_functions = 41 + and v_direct_tables = 12, + format( + '[%s] ash.grant_reader ACLs: expected schema USAGE, 41 direct function EXECUTEs, and 12 direct table SELECTs; got usage=%s functions=%s tables=%s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.has_schema_privilege( + 'ash_feature_reader', + 'ash', + 'USAGE' + ), + v_direct_functions, + v_direct_tables + ); + assert not pg_catalog.has_function_privilege( + 'ash_feature_reader', + 'ash.start(interval)', + 'EXECUTE' + ) + and not pg_catalog.has_function_privilege( + 'ash_feature_reader', + 'ash.take_sample()', + 'EXECUTE' + ) + and not pg_catalog.has_function_privilege( + 'ash_feature_reader', + 'ash.rebuild_partitions(integer,text)', + 'EXECUTE' + ), + format( + '[%s] ash.grant_reader least privilege: reader acquired an admin EXECUTE grant', + pg_catalog.current_setting('ash.feature_mode') + ); + + execute 'set local role ash_feature_reader'; + select * + into strict v_aas + from ash.aas(v_fixture.fixture_start, v_fixture.fixture_end); + select value + into strict v_status_version + from ash.status() + where metric = 'version'; + perform pg_catalog.count(*) from ash.sample; + perform pg_catalog.count(*) from ash.rollup_1m; + begin + perform * from ash.start(interval '2 seconds'); + exception + when insufficient_privilege then + v_admin_denied := true; + end; + execute 'reset role'; + + assert v_aas.avg_aas = 4.00 + and v_aas.peak_aas = 5.00 + and v_aas.p99_aas = 4.97 + and v_status_version is not null, + format( + '[%s] ash.grant_reader actual read: expected exact AAS 4.00/5.00/4.97 and readable status, got aas=%s version=%s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_aas), + v_status_version + ); + assert v_admin_denied, + format( + '[%s] ash.grant_reader actual least privilege: role unexpectedly called ash.start()', + pg_catalog.current_setting('ash.feature_mode') + ); + + perform ash.revoke_reader('ash_feature_reader'); + + select pg_catalog.count(*) + into v_direct_functions + from pg_catalog.pg_proc as procedure_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = procedure_row.pronamespace + cross join lateral pg_catalog.aclexplode(procedure_row.proacl) as acl + where + namespace_row.nspname = 'ash' + and acl.grantee = ( + select role_row.oid + from pg_catalog.pg_roles as role_row + where role_row.rolname = 'ash_feature_reader' + ) + and acl.privilege_type = 'EXECUTE'; + + select pg_catalog.count(*) + into v_direct_tables + from pg_catalog.pg_class as relation_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = relation_row.relnamespace + cross join lateral pg_catalog.aclexplode(relation_row.relacl) as acl + where + namespace_row.nspname = 'ash' + and acl.grantee = ( + select role_row.oid + from pg_catalog.pg_roles as role_row + where role_row.rolname = 'ash_feature_reader' + ) + and acl.privilege_type = 'SELECT'; + + assert not pg_catalog.has_schema_privilege( + 'ash_feature_reader', + 'ash', + 'USAGE' + ) + and v_direct_functions = 0 + and v_direct_tables = 0, + format( + '[%s] ash.revoke_reader ACLs: expected no schema USAGE/direct EXECUTE/direct SELECT, got usage=%s functions=%s tables=%s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.has_schema_privilege( + 'ash_feature_reader', + 'ash', + 'USAGE' + ), + v_direct_functions, + v_direct_tables + ); + + v_read_denied := false; + execute 'set local role ash_feature_reader'; + begin + perform * from ash.status(); + exception + when insufficient_privilege then + v_read_denied := true; + end; + execute 'reset role'; + assert v_read_denied, + format( + '[%s] ash.revoke_reader actual read: revoked role unexpectedly read ash.status()', + pg_catalog.current_setting('ash.feature_mode') + ); + + /* + * Leave this role granted for rebuild_partitions() to prove that the + * destructive rebuild preserves the real bundle on replacement children. + */ + perform ash.grant_reader('ash_feature_reader'); +end +$feature_privileges$; + +do $feature_default_pg_monitor$ +declare + v_admin_denied boolean := false; + v_aas record; + v_direct_functions bigint; + v_direct_tables bigint; + v_fixture ash_feature_context%rowtype; + v_version text; +begin + select * + into strict v_fixture + from ash_feature_context; + + select pg_catalog.count(*) + into v_direct_functions + from pg_catalog.pg_proc as procedure_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = procedure_row.pronamespace + cross join lateral pg_catalog.aclexplode(procedure_row.proacl) as acl + where + namespace_row.nspname = 'ash' + and acl.grantee = ( + select role_row.oid + from pg_catalog.pg_roles as role_row + where role_row.rolname = 'pg_monitor' + ) + and acl.privilege_type = 'EXECUTE'; + + select pg_catalog.count(*) + into v_direct_tables + from pg_catalog.pg_class as relation_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = relation_row.relnamespace + cross join lateral pg_catalog.aclexplode(relation_row.relacl) as acl + where + namespace_row.nspname = 'ash' + and acl.grantee = ( + select role_row.oid + from pg_catalog.pg_roles as role_row + where role_row.rolname = 'pg_monitor' + ) + and acl.privilege_type = 'SELECT'; + + assert pg_catalog.has_schema_privilege('pg_monitor', 'ash', 'USAGE') + and v_direct_functions = 41 + and v_direct_tables = 12 + and not pg_catalog.has_function_privilege( + 'pg_monitor', + 'ash.start(interval)', + 'EXECUTE' + ), + format( + '[%s] default pg_monitor ACLs: expected USAGE/41 reader functions/12 tables/no start(), got usage=%s functions=%s tables=%s start=%s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.has_schema_privilege('pg_monitor', 'ash', 'USAGE'), + v_direct_functions, + v_direct_tables, + pg_catalog.has_function_privilege( + 'pg_monitor', + 'ash.start(interval)', + 'EXECUTE' + ) + ); + + execute 'set local role pg_monitor'; + select * + into strict v_aas + from ash.aas(v_fixture.fixture_start, v_fixture.fixture_end); + select value + into strict v_version + from ash.status() + where metric = 'version'; + begin + perform * from ash.start(interval '2 seconds'); + exception + when insufficient_privilege then + v_admin_denied := true; + end; + execute 'reset role'; + + assert v_aas.avg_aas = 4.00 + and v_aas.peak_aas = 5.00 + and v_aas.p99_aas = 4.97 + and v_version is not null, + format( + '[%s] default pg_monitor actual read: expected exact AAS and status success, got aas=%s version=%s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_aas), + v_version + ); + assert v_admin_denied, + format( + '[%s] default pg_monitor actual least privilege: role unexpectedly called ash.start()', + pg_catalog.current_setting('ash.feature_mode') + ); +end +$feature_default_pg_monitor$; diff --git a/devel/tests/features_readers.sql b/devel/tests/features_readers.sql new file mode 100644 index 0000000..9543cf2 --- /dev/null +++ b/devel/tests/features_readers.sql @@ -0,0 +1,1507 @@ +/* ------------------------------------------------------------------------- + * Helper / codec surfaces + * ------------------------------------------------------------------------- */ +do $feature_helpers$ +declare + v_fixture ash_feature_context%rowtype; + v_actual jsonb; + v_decoded_at jsonb; + v_decoded_row jsonb; + v_decoded_ts jsonb; +begin + select * + into strict v_fixture + from ash_feature_context; + + assert ash.epoch() = '2026-01-01T00:00:00+00:00'::timestamptz, + format( + '[%s] ash.epoch: expected the immutable 2026-01-01 UTC epoch, got %s', + pg_catalog.current_setting('ash.feature_mode'), + ash.epoch() + ); + assert ash.ts_from_timestamptz( + '2026-01-02T10:17:36+00:00'::timestamptz + ) = 123456, + format( + '[%s] ash.ts_from_timestamptz: expected 123456, got %s', + pg_catalog.current_setting('ash.feature_mode'), + ash.ts_from_timestamptz( + '2026-01-02T10:17:36+00:00'::timestamptz + ) + ); + assert ash.ts_to_timestamptz(123456) = + '2026-01-02T10:17:36+00:00'::timestamptz, + format( + '[%s] ash.ts_to_timestamptz: expected 2026-01-02T10:17:36Z, got %s', + pg_catalog.current_setting('ash.feature_mode'), + ash.ts_to_timestamptz(123456) + ); + assert ash.current_slot() = 0, + format( + '[%s] ash.current_slot: expected fixture slot 0, got %s', + pg_catalog.current_setting('ash.feature_mode'), + ash.current_slot() + ); + + select pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_array( + decoded.wait_event, + decoded.query_id, + decoded.count + ) + order by decoded.wait_event, decoded.query_id + ) + into v_decoded_row + from ash.sample as sample_row + cross join lateral ash.decode_sample( + sample_row.data, + sample_row.slot + ) as decoded + where sample_row.sample_ts = + ash.ts_from_timestamptz(v_fixture.fixture_start); + + v_actual := pg_catalog.jsonb_build_array( + pg_catalog.jsonb_build_array('CPU*', 10101, 1), + pg_catalog.jsonb_build_array('IO:DataFileRead', 10101, 1), + pg_catalog.jsonb_build_array('IO:DataFileRead', 20202, 1) + ); + assert v_decoded_row = v_actual, + format( + '[%s] ash.decode_sample(data,slot): expected exact wait/query expansion %s, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_actual, + v_decoded_row + ); + + select pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_array( + decoded.datid, + decoded.wait_event, + decoded.query_id, + decoded.count + ) + order by decoded.wait_event, decoded.query_id + ) + into v_decoded_ts + from ash.decode_sample( + ash.ts_from_timestamptz(v_fixture.fixture_start) + ) as decoded; + + select pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_array( + decoded.datid, + decoded.wait_event, + decoded.query_id, + decoded.count + ) + order by decoded.wait_event, decoded.query_id + ) + into v_decoded_at + from ash.decode_sample_at(v_fixture.fixture_start) as decoded; + + assert v_decoded_ts = v_decoded_at + and pg_catalog.jsonb_array_length(v_decoded_ts) = 3, + format( + '[%s] ash.decode_sample(int4)/decode_sample_at: expected the same three exact rows, got int4=%s timestamptz=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_decoded_ts, + v_decoded_at + ); +end +$feature_helpers$; + +/* ------------------------------------------------------------------------- + * ash.aas() + * ------------------------------------------------------------------------- */ +do $feature_aas$ +declare + v_fixture ash_feature_context%rowtype; + v_actual record; +begin + select * + into strict v_fixture + from ash_feature_context; + + select * + into strict v_actual + from ash.aas(v_fixture.fixture_start, v_fixture.fixture_end); + + assert v_actual.period_start = v_fixture.fixture_start + and v_actual.period_end = v_fixture.fixture_end + and v_actual.source = 'raw' + and v_actual.buckets_expected = 4 + and v_actual.buckets_with_data = 4 + and v_actual.avg_aas = 4.00 + and v_actual.peak_aas = 5.00 + and v_actual.p99_aas = 4.97 + and v_actual.backend_seconds = 960.00, + format( + '[%s] ash.aas: expected raw/4 buckets/avg 4.00/peak 5.00/p99 4.97/960 backend-seconds, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select * + into strict v_actual + from ash.aas( + v_fixture.fixture_start, + v_fixture.fixture_end, + wait_event_type => 'IO' + ); + assert v_actual.source = 'raw' + and v_actual.buckets_expected = 4 + and v_actual.buckets_with_data = 4 + and v_actual.avg_aas = 1.75 + and v_actual.peak_aas = 3.00 + and v_actual.p99_aas = 2.97 + and v_actual.backend_seconds = 420.00, + format( + '[%s] ash.aas filter: expected exact IO metrics 1.75/3.00/2.97/420, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select * + into strict v_actual + from ash.aas( + v_fixture.fixture_start, + v_fixture.fixture_end, + wait_event_type => 'IPC' + ); + assert v_actual.source = 'raw' + and v_actual.buckets_expected = 4 + and v_actual.buckets_with_data = 4 + and v_actual.avg_aas = 0 + and v_actual.peak_aas = 0 + and v_actual.p99_aas = 0 + and v_actual.backend_seconds = 0, + format( + '[%s] ash.aas no-match filter: expected measured coverage with exact zero metrics, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select * + into strict v_actual + from ash.aas( + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_start + interval '3 minutes' + ); + assert v_actual.buckets_expected = 1 + and v_actual.buckets_with_data = 1 + and v_actual.avg_aas = 5.00 + and v_actual.peak_aas = 5.00 + and v_actual.p99_aas = 5.00 + and v_actual.backend_seconds = 300.00, + format( + '[%s] ash.aas single-sample window: expected exact 5.00 AAS and 300 backend-seconds, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select * + into strict v_actual + from ash.aas( + v_fixture.fixture_start + interval '1 minute', + v_fixture.fixture_start + interval '1 minute' + ); + assert v_actual.period_end = + v_fixture.fixture_start + interval '2 minutes' + and v_actual.buckets_expected = 1 + and v_actual.buckets_with_data = 1 + and v_actual.avg_aas = 4.00 + and v_actual.peak_aas = 4.00 + and v_actual.p99_aas = 4.00, + format( + '[%s] ash.aas empty/degenerate window: expected documented one-minute expansion with exact 4.00 AAS, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select * + into strict v_actual + from ash.aas( + v_fixture.fixture_start, + v_fixture.fixture_end, + bucket => interval '1 hour' + ); + assert v_actual.buckets_expected = 1 + and v_actual.buckets_with_data = 1 + and v_actual.avg_aas = 4.00 + and v_actual.peak_aas = 4.00 + and v_actual.p99_aas = 4.00 + and v_actual.backend_seconds = 960.00, + format( + '[%s] ash.aas bucket larger than window: expected one 4.00-AAS bucket, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); +end +$feature_aas$; + +/* ------------------------------------------------------------------------- + * ash.timeline() + * ------------------------------------------------------------------------- */ +do $feature_timeline$ +declare + v_fixture ash_feature_context%rowtype; + v_actual record; + v_avg numeric[]; + v_buckets timestamptz[]; + v_data_points bigint[]; + v_peak numeric[]; + v_p99 numeric[]; + v_sources text[]; +begin + select * + into strict v_fixture + from ash_feature_context; + + select + pg_catalog.array_agg(bucket_start order by bucket_start), + pg_catalog.array_agg(source order by bucket_start), + pg_catalog.array_agg(data_points order by bucket_start), + pg_catalog.array_agg(avg_aas order by bucket_start), + pg_catalog.array_agg(peak_aas order by bucket_start), + pg_catalog.array_agg(p99_aas order by bucket_start) + into + v_buckets, + v_sources, + v_data_points, + v_avg, + v_peak, + v_p99 + from ash.timeline( + v_fixture.fixture_start, + v_fixture.fixture_end, + interval '1 minute' + ); + + assert v_buckets = array[ + v_fixture.fixture_start, + v_fixture.fixture_start + interval '1 minute', + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_start + interval '3 minutes' + ]::timestamptz[] + and v_sources = array['raw', 'raw', 'raw', 'raw']::text[] + and v_data_points = array[1, 1, 1, 1]::bigint[] + and v_avg = array[3, 4, 5, 4]::numeric[] + and v_peak = array[3, 4, 5, 4]::numeric[] + and v_p99 = array[3, 4, 5, 4]::numeric[], + format( + '[%s] ash.timeline: expected four exact raw buckets [3,4,5,4], got buckets=%s sources=%s points=%s avg=%s peak=%s p99=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_buckets, + v_sources, + v_data_points, + v_avg, + v_peak, + v_p99 + ); + + select + pg_catalog.array_agg(data_points order by bucket_start), + pg_catalog.array_agg(avg_aas order by bucket_start) + into + v_data_points, + v_avg + from ash.timeline( + v_fixture.fixture_start, + v_fixture.fixture_end, + interval '1 minute', + wait_event_type => 'IPC' + ); + assert v_data_points = array[1, 1, 1, 1]::bigint[] + and v_avg = array[0, 0, 0, 0]::numeric[], + format( + '[%s] ash.timeline no-match filter: expected four measured-zero buckets, got points=%s avg=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_data_points, + v_avg + ); + + select * + into strict v_actual + from ash.timeline( + v_fixture.fixture_start, + v_fixture.fixture_end, + interval '1 hour' + ); + assert v_actual.bucket_start = + pg_catalog.date_trunc('hour', v_fixture.fixture_start) + and v_actual.source = 'raw' + and v_actual.data_points = 4 + and v_actual.avg_aas = 4.00 + and v_actual.peak_aas = 5.00 + and v_actual.p99_aas = 4.97, + format( + '[%s] ash.timeline bucket larger than window: expected one calendar bucket with avg/peak/p99 4.00/5.00/4.97, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); +end +$feature_timeline$; + +/* ------------------------------------------------------------------------- + * ash.top() + * ------------------------------------------------------------------------- */ +do $feature_top$ +declare + v_fixture ash_feature_context%rowtype; + v_actual record; + v_count bigint; + v_metrics jsonb; + v_pct numeric; +begin + select * + into strict v_fixture + from ash_feature_context; + + select + pg_catalog.count(*), + pg_catalog.sum(top_row.pct), + pg_catalog.jsonb_object_agg( + top_row.key, + pg_catalog.jsonb_build_array( + top_row.source, + top_row.avg_aas, + top_row.peak_aas, + top_row.p99_aas, + top_row.backend_seconds, + top_row.pct + ) + ) + into + v_count, + v_pct, + v_metrics + from ash.top( + 'wait_event_type', + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 10 + ) as top_row; + + assert v_count = 3 + and v_pct = 100.00 + and v_metrics = pg_catalog.jsonb_build_object( + 'CPU*', + pg_catalog.jsonb_build_array( + 'raw', + 1.00, + 1.00, + 1.00, + 240.00, + 25.00 + ), + 'IO', + pg_catalog.jsonb_build_array( + 'raw', + 1.75, + 3.00, + 2.97, + 420.00, + 43.75 + ), + 'Lock', + pg_catalog.jsonb_build_array( + 'raw', + 1.25, + 4.00, + 3.91, + 300.00, + 31.25 + ) + ), + format( + '[%s] ash.top(wait_event_type): expected exact metrics and pct sum 100.00, got count=%s pct=%s metrics=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_count, + v_pct, + v_metrics + ); + + select pg_catalog.jsonb_object_agg( + top_row.key, + pg_catalog.jsonb_build_array( + top_row.avg_aas, + top_row.peak_aas, + top_row.p99_aas, + top_row.backend_seconds, + top_row.pct + ) + ) + into v_metrics + from ash.top( + 'wait_event', + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 10 + ) as top_row; + assert v_metrics = pg_catalog.jsonb_build_object( + 'CPU*', + pg_catalog.jsonb_build_array(1.00, 1.00, 1.00, 240.00, 25.00), + 'IO:DataFileRead', + pg_catalog.jsonb_build_array(1.75, 3.00, 2.97, 420.00, 43.75), + 'Lock:tuple', + pg_catalog.jsonb_build_array(1.25, 4.00, 3.91, 300.00, 31.25) + ), + format( + '[%s] ash.top(wait_event): expected exact event breakdown, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_metrics + ); + + select * + into strict v_actual + from ash.top( + 'wait_event_type', + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 1 + ); + assert v_actual.key = 'IO' + and v_actual.avg_aas = 1.75 + and v_actual.pct = 43.75, + format( + '[%s] ash.top n=>1 avg rank: expected IO 1.75/43.75, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select * + into strict v_actual + from ash.top( + 'wait_event_type', + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 1, + order_by => 'peak' + ); + assert v_actual.key = 'Lock' + and v_actual.peak_aas = 4.00, + format( + '[%s] ash.top n=>1 peak rank: expected Lock peak 4.00, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select * + into strict v_actual + from ash.top( + 'database', + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 10 + ); + assert v_actual.key = pg_catalog.current_database() + and v_actual.source = 'raw' + and v_actual.avg_aas = 4.00 + and v_actual.peak_aas = 5.00 + and v_actual.p99_aas = 4.97 + and v_actual.backend_seconds = 960.00 + and v_actual.pct = 100.00, + format( + '[%s] ash.top(database): expected current database at exact 4.00/5.00/4.97/960/100, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select pg_catalog.count(*) + into v_count + from ash.top( + 'wait_event', + v_fixture.fixture_start, + v_fixture.fixture_end, + wait_event_type => 'IPC' + ); + assert v_count = 0, + format( + '[%s] ash.top no-match filter: expected zero rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_count + ); + + select * + into strict v_actual + from ash.top( + 'query_id', + v_fixture.fixture_start, + v_fixture.fixture_end, + wait_event => 'IO:DataFileRead', + n => 1 + ); + assert v_actual.key = '20202' + and v_actual.query_text is null + and v_actual.source = 'raw' + and v_actual.avg_aas = 1.50 + and v_actual.peak_aas = 3.00 + and v_actual.p99_aas = 2.97 + and v_actual.backend_seconds = 360.00 + and v_actual.pct = 85.71, + format( + '[%s] ash.top raw wait/query tie: expected qid 20202 exact metrics and null synthetic query text, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); +end +$feature_top$; + +/* ------------------------------------------------------------------------- + * ash.compare() + * ------------------------------------------------------------------------- */ +do $feature_compare$ +declare + v_fixture ash_feature_context%rowtype; + v_actual record; + v_count bigint; + v_metrics jsonb; +begin + select * + into strict v_fixture + from ash_feature_context; + + select * + into strict v_actual + from ash.compare( + v_fixture.fixture_start, + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_end + ); + assert v_actual.key = 'overall' + and v_actual.query_text is null + and v_actual.avg_aas_1 = 3.50 + and v_actual.avg_aas_2 = 4.50 + and v_actual.avg_delta = 1.00 + and v_actual.peak_aas_1 = 4.00 + and v_actual.peak_aas_2 = 5.00 + and v_actual.p99_aas_1 = 3.99 + and v_actual.p99_aas_2 = 4.99 + and v_actual.pct_1 is null + and v_actual.pct_2 is null, + format( + '[%s] ash.compare overall: expected exact before/after 3.50->4.50 delta 1.00, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select + pg_catalog.count(*), + pg_catalog.jsonb_object_agg( + compare_row.key, + pg_catalog.jsonb_build_array( + compare_row.avg_aas_1, + compare_row.avg_aas_2, + compare_row.avg_delta, + compare_row.peak_aas_1, + compare_row.peak_aas_2, + compare_row.p99_aas_1, + compare_row.p99_aas_2, + compare_row.pct_1, + compare_row.pct_2 + ) + ) + into + v_count, + v_metrics + from ash.compare( + v_fixture.fixture_start, + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_end, + dimension => 'wait_event_type', + n => 10 + ) as compare_row; + assert v_count = 3 + and v_metrics = pg_catalog.jsonb_build_object( + 'CPU*', + pg_catalog.jsonb_build_array( + 1.00, 1.00, 0.00, 1.00, 1.00, 1.00, 1.00, 28.57, 22.22 + ), + 'IO', + pg_catalog.jsonb_build_array( + 2.50, 1.00, -1.50, 3.00, 2.00, 2.99, 1.98, 71.43, 22.22 + ), + 'Lock', + pg_catalog.jsonb_build_array( + null, 2.50, 2.50, null, 4.00, null, 3.97, null, 55.56 + ) + ), + format( + '[%s] ash.compare dimension: expected exact full-outer wait deltas, got count=%s metrics=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_count, + v_metrics + ); + + select * + into strict v_actual + from ash.compare( + v_fixture.fixture_start, + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_end, + dimension => 'wait_event_type', + n => 1 + ); + assert v_actual.key = 'Lock' + and v_actual.avg_delta = 2.50, + format( + '[%s] ash.compare n=>1: expected largest absolute delta Lock=2.50, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); +end +$feature_compare$; + +/* ------------------------------------------------------------------------- + * ash.samples() + * ------------------------------------------------------------------------- */ +do $feature_samples$ +declare + v_fixture ash_feature_context%rowtype; + v_actual record; + v_count bigint; + v_query_text_count bigint; +begin + select * + into strict v_fixture + from ash_feature_context; + + select + pg_catalog.count(*), + pg_catalog.count(query_text) + into + v_count, + v_query_text_count + from ash.samples( + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 100 + ); + assert v_count = 16 + and v_query_text_count = 0, + format( + '[%s] ash.samples: expected 16 decoded rows and zero synthetic pgss texts, got rows=%s text_rows=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_count, + v_query_text_count + ); + + select * + into strict v_actual + from ash.samples( + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 1 + ); + assert v_actual.sample_time = + v_fixture.fixture_start + interval '3 minutes' + and v_actual.database_name = pg_catalog.current_database() + and v_actual.active_backends = 4 + and v_actual.wait_event = 'CPU*' + and v_actual.query_id is null + and v_actual.query_text is null, + format( + '[%s] ash.samples n=>1: expected newest CPU* unattributed row with active_backends=4, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_actual) + ); + + select pg_catalog.count(*) + into v_count + from ash.samples( + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 100, + query_id => 30303 + ); + assert v_count = 5, + format( + '[%s] ash.samples query filter: expected five qid=30303 rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_count + ); + + select pg_catalog.count(*) + into v_count + from ash.samples( + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 100, + wait_event_type => 'IPC' + ); + assert v_count = 0, + format( + '[%s] ash.samples no-match filter: expected zero rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_count + ); +end +$feature_samples$; + +/* ------------------------------------------------------------------------- + * ash.rollup_minute() and ash.periods() + * ------------------------------------------------------------------------- */ +do $feature_rollup_minute_and_periods$ +declare + v_fixture ash_feature_context%rowtype; + v_actual_rollup jsonb; + v_periods jsonb; + v_rollup record; + v_rolled int; +begin + select * + into strict v_fixture + from ash_feature_context; + + select ash.rollup_minute(120) + into v_rolled; + assert v_rolled = 4, + format( + '[%s] ash.rollup_minute: expected four exact (minute,database) rows written, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_rolled + ); + + select pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_array( + ash.ts_to_timestamptz(rollup_min.ts), + rollup_min.samples, + rollup_min.peak_backends, + rollup_min.wait_counts, + rollup_min.query_counts + ) + order by rollup_min.ts + ) + into v_actual_rollup + from ash.rollup_1m as rollup_min + where + rollup_min.ts >= ash.ts_from_timestamptz(v_fixture.fixture_start) + and rollup_min.ts < ash.ts_from_timestamptz(v_fixture.fixture_end); + + assert pg_catalog.jsonb_array_length(v_actual_rollup) = 4, + format( + '[%s] ash.rollup_minute rows: expected four persisted minute rollups, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_actual_rollup + ); + + select * + into strict v_rollup + from ash.rollup_1m + where ts = ash.ts_from_timestamptz(v_fixture.fixture_start); + assert v_rollup.datid = v_fixture.datid + and v_rollup.samples = 1 + and v_rollup.peak_backends = 3 + and v_rollup.wait_counts = array[ + v_fixture.io_wait_id, 2, v_fixture.cpu_wait_id, 1 + ]::int4[] + and v_rollup.query_counts = array[10101, 2, 20202, 1]::int8[], + format( + '[%s] ash.rollup_minute minute 0: expected exact samples/peak/wait/query arrays, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_rollup) + ); + + select * + into strict v_rollup + from ash.rollup_1m + where ts = ash.ts_from_timestamptz( + v_fixture.fixture_start + interval '1 minute' + ); + assert v_rollup.datid = v_fixture.datid + and v_rollup.samples = 1 + and v_rollup.peak_backends = 4 + and v_rollup.wait_counts = array[ + v_fixture.io_wait_id, 3, v_fixture.cpu_wait_id, 1 + ]::int4[] + and v_rollup.query_counts = array[20202, 3, 10101, 1]::int8[], + format( + '[%s] ash.rollup_minute minute 1: expected exact samples/peak/wait/query arrays, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_rollup) + ); + + select * + into strict v_rollup + from ash.rollup_1m + where ts = ash.ts_from_timestamptz( + v_fixture.fixture_start + interval '2 minutes' + ); + assert v_rollup.datid = v_fixture.datid + and v_rollup.samples = 1 + and v_rollup.peak_backends = 5 + and v_rollup.wait_counts = array[ + v_fixture.lock_wait_id, 4, v_fixture.cpu_wait_id, 1 + ]::int4[] + and v_rollup.query_counts = array[30303, 4, 10101, 1]::int8[], + format( + '[%s] ash.rollup_minute minute 2: expected exact samples/peak/wait/query arrays, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_rollup) + ); + + select * + into strict v_rollup + from ash.rollup_1m + where ts = ash.ts_from_timestamptz( + v_fixture.fixture_start + interval '3 minutes' + ); + assert v_rollup.datid = v_fixture.datid + and v_rollup.samples = 1 + and v_rollup.peak_backends = 4 + and v_rollup.wait_counts = array[ + v_fixture.io_wait_id, + 2, + v_fixture.cpu_wait_id, + 1, + v_fixture.lock_wait_id, + 1 + ]::int4[] + and v_rollup.query_counts = array[20202, 2, 30303, 1]::int8[], + format( + '[%s] ash.rollup_minute minute 3: expected exact samples/peak/wait/query arrays, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_rollup) + ); + + select pg_catalog.jsonb_object_agg( + period_row.period, + pg_catalog.jsonb_build_array( + period_row.source, + period_row.bucket, + period_row.buckets_with_data, + period_row.avg_aas, + period_row.peak_aas, + period_row.p99_aas + ) + ) + into v_periods + from ash.periods(v_fixture.fixture_end) as period_row; + + assert v_periods = pg_catalog.jsonb_build_object( + '1m', + pg_catalog.jsonb_build_array( + 'raw', interval '1 minute', 1, 4.00, 4.00, 4.00 + ), + '5m', + pg_catalog.jsonb_build_array( + 'rollup_1m', interval '1 minute', 4, 3.20, 5.00, 4.97 + ), + '1h', + pg_catalog.jsonb_build_array( + 'rollup_1m', interval '1 minute', 4, 0.27, 5.00, 4.97 + ), + '1d', + pg_catalog.jsonb_build_array( + 'rollup_1m', interval '1 minute', 4, 0.01, 5.00, 4.97 + ), + '1w', + pg_catalog.jsonb_build_array( + 'rollup_1m', interval '1 minute', 4, 0.00, 5.00, 4.97 + ), + '1mo', + pg_catalog.jsonb_build_array( + 'rollup_1m', interval '1 minute', 4, 0.00, 5.00, 4.97 + ) + ), + format( + '[%s] ash.periods: expected six exact window/source/bucket/AAS rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_periods + ); +end +$feature_rollup_minute_and_periods$; + +/* ------------------------------------------------------------------------- + * ash.report() + * ------------------------------------------------------------------------- */ +do $feature_report$ +declare + v_fixture ash_feature_context%rowtype; + v_expected_keys text[]; + v_keys text[]; + v_report jsonb; +begin + select * + into strict v_fixture + from ash_feature_context; + + select ash.report( + v_fixture.fixture_start, + v_fixture.fixture_end, + vcpus => 8, + n => 3 + ) + into strict v_report; + + select pg_catalog.array_agg(report_key order by report_key) + into v_keys + from pg_catalog.jsonb_object_keys( + v_report - 'cluster_name' + ) as report_key; + v_expected_keys := array[ + 'aas_avg', + 'aas_p99', + 'aas_p999', + 'aas_worst1m', + 'coverage', + 'top_events_p99', + 'top_events_p999', + 'top_events_worst1m', + 'top_queryids_available', + 'top_queryids_p99', + 'top_queryids_p999', + 'top_queryids_worst1m', + 'vcpus' + ]::text[]; + + assert v_keys = v_expected_keys, + format( + '[%s] ash.report keys: expected exact frozen payload keys %s, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_expected_keys, + v_keys + ); + assert v_report -> 'vcpus' = '8'::jsonb, + format( + '[%s] ash.report vcpus: expected pass-through 8, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_report -> 'vcpus' + ); + assert v_report -> 'aas_avg' = '{ + "total": 4.00, + "cpu": 1.00, + "io": 1.75, + "ipc": 0.00, + "lock": 1.25, + "lwlock": 0.00 + }'::jsonb + and v_report -> 'aas_worst1m' = '{ + "total": 5.00, + "cpu": 1.00, + "io": 3.00, + "ipc": 0.00, + "lock": 4.00, + "lwlock": 0.00 + }'::jsonb + and v_report -> 'aas_p99' = '{ + "total": 4.97, + "cpu": 1.00, + "io": 2.97, + "ipc": 0.00, + "lock": 3.91, + "lwlock": 0.00 + }'::jsonb + and v_report -> 'aas_p999' = '{ + "total": 5.00, + "cpu": 1.00, + "io": 3.00, + "ipc": 0.00, + "lock": 3.99, + "lwlock": 0.00 + }'::jsonb, + format( + '[%s] ash.report metrics: expected exact class total/avg/extreme values, got avg=%s worst=%s p99=%s p999=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_report -> 'aas_avg', + v_report -> 'aas_worst1m', + v_report -> 'aas_p99', + v_report -> 'aas_p999' + ); + assert v_report -> 'top_events_worst1m' = '{ + "io": ["DataFileRead(3.0)"], + "ipc": [], + "lock": ["tuple(4.0)"], + "lwlock": [] + }'::jsonb + and v_report -> 'top_events_p99' = '{ + "io": ["DataFileRead(3.0)"], + "ipc": [], + "lock": ["tuple(4.0)"], + "lwlock": [] + }'::jsonb + and v_report -> 'top_events_p999' = '{ + "io": ["DataFileRead(3.0)"], + "ipc": [], + "lock": ["tuple(4.0)"], + "lwlock": [] + }'::jsonb, + format( + '[%s] ash.report top events: expected exact extreme-minute event arrays, got worst=%s p99=%s p999=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_report -> 'top_events_worst1m', + v_report -> 'top_events_p99', + v_report -> 'top_events_p999' + ); + assert v_report -> 'top_queryids_worst1m' = '{ + "total": ["30303(4.0)", "10101(1.0)"], + "io": ["20202(3.0)"], + "ipc": [], + "lock": ["30303(4.0)"], + "lwlock": [] + }'::jsonb + and v_report -> 'top_queryids_p99' = '{ + "total": ["30303(4.0)", "10101(1.0)"], + "io": ["20202(3.0)"], + "lock": ["30303(4.0)"] + }'::jsonb + and v_report -> 'top_queryids_p999' = '{ + "total": ["30303(4.0)", "10101(1.0)"], + "io": ["20202(3.0)"], + "lock": ["30303(4.0)"] + }'::jsonb + and v_report -> 'top_queryids_available' = 'true'::jsonb, + format( + '[%s] ash.report top query IDs: expected exact attributed extreme-minute arrays, got worst=%s p99=%s p999=%s available=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_report -> 'top_queryids_worst1m', + v_report -> 'top_queryids_p99', + v_report -> 'top_queryids_p999', + v_report -> 'top_queryids_available' + ); + assert v_report -> 'coverage' = pg_catalog.jsonb_build_object( + 'from', + v_fixture.fixture_start, + 'to', + v_fixture.fixture_end, + 'source', + 'rollup_1m', + 'minutes_expected', + 4, + 'minutes_with_data', + 4, + 'raw_retention_start', + v_fixture.fixture_start + ), + format( + '[%s] ash.report coverage: expected exact four-minute rollup coverage, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_report -> 'coverage' + ); +end +$feature_report$; + +/* ------------------------------------------------------------------------- + * ash.chart() + * ------------------------------------------------------------------------- */ +do $feature_chart$ +declare + v_fixture ash_feature_context%rowtype; + v_aas numeric[]; + v_buckets timestamptz[]; + v_charts text[]; + v_details text[]; + v_legend text := + '█ IO:DataFileRead ▓ Lock:tuple · Other'; +begin + select * + into strict v_fixture + from ash_feature_context; + + select + pg_catalog.array_agg(bucket_start), + pg_catalog.array_agg(aas), + pg_catalog.array_agg(detail), + pg_catalog.array_agg(chart) + into + v_buckets, + v_aas, + v_details, + v_charts + from ash.chart( + v_fixture.fixture_start, + v_fixture.fixture_end, + bucket => interval '1 minute', + n => 1, + width => 10, + color => false + ); + + assert v_buckets = array[ + null, + v_fixture.fixture_start, + v_fixture.fixture_start + interval '1 minute', + v_fixture.fixture_start + interval '2 minutes', + v_fixture.fixture_start + interval '3 minutes' + ]::timestamptz[] + and v_aas = array[null, 3, 4, 5, 4]::numeric[] + and v_details = array[ + null, + 'IO:DataFileRead=2.00 Other=1.00', + 'IO:DataFileRead=3.00 Other=1.00', + 'Lock:tuple=4.00 Other=1.00', + 'IO:DataFileRead=2.00 Lock:tuple=1.00 Other=1.00' + ]::text[] + and v_charts = array[ + v_legend, + pg_catalog.rpad('████··', pg_catalog.length(v_legend)), + pg_catalog.rpad('██████··', pg_catalog.length(v_legend)), + pg_catalog.rpad('▓▓▓▓▓▓▓▓··', pg_catalog.length(v_legend)), + pg_catalog.rpad('████▓▓··', pg_catalog.length(v_legend)) + ]::text[], + format( + '[%s] ash.chart: expected exact legend plus four numeric/detail/bar rows, got buckets=%s aas=%s details=%s charts=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_buckets, + v_aas, + v_details, + v_charts + ); +end +$feature_chart$; + +/* ------------------------------------------------------------------------- + * ash.summary() + * ------------------------------------------------------------------------- */ +do $feature_summary$ +declare + v_fixture ash_feature_context%rowtype; + v_metrics text[]; + v_values text[]; +begin + select * + into strict v_fixture + from ash_feature_context; + + select + pg_catalog.array_agg(metric), + pg_catalog.array_agg(value) + into + v_metrics, + v_values + from ash.summary( + v_fixture.fixture_start, + v_fixture.fixture_end + ); + + assert v_metrics = array[ + 'period_start', + 'period_end', + 'source', + 'minutes_with_data', + 'avg_aas', + 'peak_aas', + 'p99_aas', + 'backend_seconds', + 'databases_active', + 'top_wait_1', + 'top_wait_2', + 'top_wait_3', + 'top_query_1', + 'top_query_2', + 'top_query_3' + ]::text[] + and v_values = array[ + v_fixture.fixture_start::text, + v_fixture.fixture_end::text, + 'raw', + '4', + '4.00', + '5.00', + '4.97', + '960.00', + '1', + 'IO:DataFileRead (avg_aas 1.75, 43.75%)', + 'Lock:tuple (avg_aas 1.25, 31.25%)', + 'CPU* (avg_aas 1.00, 25.00%)', + '20202 (avg_aas 1.50, 37.50%)', + '30303 (avg_aas 1.25, 31.25%)', + '10101 (avg_aas 1.00, 25.00%)' + ]::text[], + format( + '[%s] ash.summary: expected exact 15-row human summary, got metrics=%s values=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_metrics, + v_values + ); +end +$feature_summary$; + +/* ------------------------------------------------------------------------- + * ash.status() + * ------------------------------------------------------------------------- */ +do $feature_status$ +declare + v_expected_cron boolean := + pg_catalog.current_setting('ash.feature_expected_cron')::boolean; + v_fixture ash_feature_context%rowtype; + v_status jsonb; +begin + select * + into strict v_fixture + from ash_feature_context; + + select pg_catalog.jsonb_object_agg(status_row.metric, status_row.value) + into v_status + from ash.status() as status_row; + + assert v_status ->> 'current_slot' = '0' + and v_status ->> 'sampling_enabled' = 'true' + and v_status ->> 'sample_interval' = '00:01:00' + and v_status ->> 'samples_in_current_slot' = '4' + and v_status ->> 'samples_total' = '4' + and v_status ->> 'wait_event_map_count' = '3' + and v_status ->> 'query_map_count' = '3' + and v_status ->> 'rollup_1m_rows' = '4' + and (v_status ->> 'raw_retention_start')::timestamptz = + v_fixture.fixture_start + and (v_status ->> 'rollup_1m_retention_start')::timestamptz = + v_fixture.fixture_start, + format( + '[%s] ash.status: expected exact fixture/config/count/retention side effects, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_status + ); + assert (v_expected_cron and v_status ->> 'pg_cron_available' = 'yes') + or ( + not v_expected_cron + and v_status ->> 'pg_cron_available' = + 'no (use external scheduler)' + ), + format( + '[%s] ash.status degraded scheduler state: expected pg_cron_available=%s, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_expected_cron, + v_status ->> 'pg_cron_available' + ); +end +$feature_status$; + +/* ------------------------------------------------------------------------- + * pg_stat_statements query-text enrichment (present and absent paths). + * ------------------------------------------------------------------------- */ +\if :expected_pgss +select 424242::int as ash_feature_pgss_marker_424242; +\endif + +do $feature_pgss_text$ +declare + v_expected_pgss boolean := + pg_catalog.current_setting('ash.feature_expected_pgss')::boolean; + v_fixture ash_feature_context%rowtype; + v_marker_query text; + v_marker_query_id bigint; + v_pgss_schema text; + v_sample_text text; + v_top_text text; +begin + select * + into strict v_fixture + from ash_feature_context; + + v_pgss_schema := ash._pgss_schema(); + if not v_expected_pgss then + assert v_pgss_schema is null, + format( + '[%s] no-pgss enrichment: expected no catalog-resolved pgss schema, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_pgss_schema + ); + return; + end if; + + assert v_pgss_schema is not null, + format( + '[%s] pgss enrichment: expected the installed extension schema, got null', + pg_catalog.current_setting('ash.feature_mode') + ); + execute pg_catalog.format( + 'select queryid, query from %I.pg_stat_statements ' + 'where query like $1 order by calls desc, queryid limit 1', + v_pgss_schema + ) + into strict + v_marker_query_id, + v_marker_query + using '%ash_feature_pgss_marker_424242%'; + + update ash.query_map_0 + set query_id = v_marker_query_id + where query_id = 20202; + + select top_row.query_text + into strict v_top_text + from ash.top( + 'query_id', + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 10 + ) as top_row + where top_row.key = v_marker_query_id::text; + assert v_top_text = pg_catalog.left(v_marker_query, 100), + format( + '[%s] ash.top pgss enrichment: expected exact text %L, got %L', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.left(v_marker_query, 100), + v_top_text + ); + + select sample_row.query_text + into strict v_sample_text + from ash.samples( + v_fixture.fixture_start, + v_fixture.fixture_end, + n => 100, + query_id => v_marker_query_id + ) as sample_row + limit 1; + assert v_sample_text = pg_catalog.left(v_marker_query, 80), + format( + '[%s] ash.samples pgss enrichment: expected exact text %L, got %L', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.left(v_marker_query, 80), + v_sample_text + ); +end +$feature_pgss_text$; + +/* ------------------------------------------------------------------------- + * Documented no-data and future-window contracts across every reader. + * ------------------------------------------------------------------------- */ +do $feature_reader_edges$ +declare + v_fixture ash_feature_context%rowtype; + v_aas record; + v_chart_count bigint; + v_compare record; + v_future_end timestamptz; + v_future_start timestamptz; + v_report jsonb; + v_samples_count bigint; + v_summary jsonb; + v_timeline record; + v_top_count bigint; +begin + select * + into strict v_fixture + from ash_feature_context; + v_future_start := + pg_catalog.date_trunc('hour', v_fixture.fixture_end) + + interval '2 hours'; + v_future_end := v_future_start + interval '1 hour'; + + select * + into strict v_aas + from ash.aas( + v_fixture.fixture_start - interval '2 hours', + v_fixture.fixture_start - interval '1 hour' + ); + assert v_aas.buckets_with_data = 0 + and v_aas.avg_aas = 0 + and v_aas.peak_aas = 0 + and v_aas.p99_aas = 0 + and v_aas.backend_seconds = 0, + format( + '[%s] ash.aas before-oldest window: expected zero coverage and exact zero metrics, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_aas) + ); + + select * + into strict v_timeline + from ash.timeline( + v_future_start, + v_future_end, + interval '1 hour' + ); + assert v_timeline.data_points = 0 + and v_timeline.avg_aas is null + and v_timeline.peak_aas is null + and v_timeline.p99_aas is null, + format( + '[%s] ash.timeline future window: expected one explicit no-data bucket with null metrics, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_timeline) + ); + + select pg_catalog.count(*) + into v_top_count + from ash.top( + 'wait_event', + v_future_start, + v_future_end + ); + assert v_top_count = 0, + format( + '[%s] ash.top future window: expected zero rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_top_count + ); + + select pg_catalog.count(*) + into v_samples_count + from ash.samples( + v_fixture.fixture_start - interval '2 hours', + v_fixture.fixture_start - interval '1 hour' + ); + assert v_samples_count = 0, + format( + '[%s] ash.samples before-oldest window: expected zero raw rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_samples_count + ); + + select ash.report( + v_future_start, + v_future_end + ) + into v_report; + assert v_report is null, + format( + '[%s] ash.report future window: expected SQL null, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_report + ); + + select pg_catalog.count(*) + into v_chart_count + from ash.chart( + v_future_start, + v_future_end + ); + assert v_chart_count = 0, + format( + '[%s] ash.chart future window: expected zero rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_chart_count + ); + + select pg_catalog.jsonb_object_agg(summary_row.metric, summary_row.value) + into v_summary + from ash.summary( + v_future_start, + v_future_end + ) as summary_row; + assert v_summary = '{"status": "no data in this time range"}'::jsonb, + format( + '[%s] ash.summary future window: expected exact no-data status, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_summary + ); + + select * + into strict v_compare + from ash.compare( + v_fixture.fixture_start, + v_fixture.fixture_end, + v_future_start, + v_future_end + ); + assert v_compare.key = 'overall' + and v_compare.avg_aas_1 = 4.00 + and v_compare.avg_aas_2 is null + and v_compare.avg_delta is null + and v_compare.peak_aas_1 = 5.00 + and v_compare.peak_aas_2 is null + and v_compare.p99_aas_1 = 4.97 + and v_compare.p99_aas_2 is null, + format( + '[%s] ash.compare uncovered side: expected covered window 1 and null window 2/delta, got %s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.row_to_json(v_compare) + ); +end +$feature_reader_edges$; From 8d7a5e9f0f2e86e0244108497090689b9b6d2ca6 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:11:46 +0000 Subject: [PATCH 05/18] test: add features release-gate surface --- .github/workflows/test.yml | 253 ++++++++++++++++++++++++++++++++++ devel/scripts/release_gate.sh | 30 +++- 2 files changed, 282 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe4a811..efdeb68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11171,6 +11171,259 @@ jobs: echo "FAIL: ash.start emitted the old cluster-wide pg_cron claim" >&2 exit 1 fi + - name: "Behavioral feature coverage across extension modes" + env: + PGPASSWORD: postgres + CRON: ${{ matrix.cron }} + run: | + set -Eeuo pipefail + IFS=$'\n\t' + export PAGER=cat + + psql_base=( + psql + --no-psqlrc + --host=localhost + --username=postgres + --dbname=postgres + --set=ON_ERROR_STOP=1 + ) + sleeper_pid="" + sleeper_backend_pid="" + sleeper_application="" + + stop_feature_sleeper() { + local pid="${sleeper_pid}" + local backend_pid="${sleeper_backend_pid}" + local application="${sleeper_application}" + + sleeper_backend_pid="" + sleeper_application="" + if [[ "${backend_pid}" =~ ^[0-9]+$ ]]; then + "${psql_base[@]}" \ + --command=" + select pg_terminate_backend(pid) + from pg_stat_activity + where pid = ${backend_pid} + and application_name = '${application}'; + " >/dev/null 2>&1 || true + fi + sleeper_pid="" + if [[ -n "${pid}" ]]; then + kill -TERM "${pid}" >/dev/null 2>&1 || true + wait "${pid}" >/dev/null 2>&1 || true + fi + } + + cleanup_feature_step() { + local exit_code=$? + + trap - EXIT INT TERM + stop_feature_sleeper + exit "${exit_code}" + } + + signal_feature_step() { + local exit_code=$1 + + trap - INT TERM + exit "${exit_code}" + } + + uninstall_ash_if_present() { + local ash_present + + ash_present="$("${psql_base[@]}" \ + --tuples-only \ + --no-align \ + --command=" + select ( + pg_catalog.to_regnamespace('ash') is not null + )::text; + ")" + if [[ "${ash_present//$'\r'/}" == "true" ]]; then + "${psql_base[@]}" \ + --command="select ash.uninstall('yes');" + fi + } + + assert_ash_absent() { + local ash_absent + + ash_absent="$("${psql_base[@]}" \ + --tuples-only \ + --no-align \ + --command=" + select ( + pg_catalog.to_regnamespace('ash') is null + )::text; + ")" + if [[ "${ash_absent//$'\r'/}" != "true" ]]; then + printf '%s\n' \ + "feature driver left the ash schema installed" >&2 + return 1 + fi + } + + configure_extensions() { + local expected_cron=$1 + local expected_pgss=$2 + local actual + + "${psql_base[@]}" \ + --command=" + drop extension if exists pg_cron cascade; + drop extension if exists pg_stat_statements cascade; + " + if [[ "${expected_cron}" == "true" ]]; then + "${psql_base[@]}" \ + --command="create extension pg_cron;" + fi + if [[ "${expected_pgss}" == "true" ]]; then + "${psql_base[@]}" \ + --command="create extension pg_stat_statements;" + fi + + actual="$("${psql_base[@]}" \ + --tuples-only \ + --no-align \ + --command=" + select + ( + select count(*) = 1 + from pg_extension + where extname = 'pg_cron' + )::text + || '|' + || ( + select count(*) = 1 + from pg_extension + where extname = 'pg_stat_statements' + )::text; + ")" + actual="${actual//$'\r'/}" + if [[ "${actual}" != "${expected_cron}|${expected_pgss}" ]]; then + printf '%s\n' \ + "extension mode mismatch: expected ${expected_cron}|${expected_pgss}, got ${actual}" >&2 + return 1 + fi + } + + wait_for_feature_sleeper() { + local attempt + local backend_pids + + for ((attempt = 1; attempt <= 50; attempt++)); do + backend_pids="$("${psql_base[@]}" \ + --tuples-only \ + --no-align \ + --command=" + select string_agg(pid::text, ',' order by pid) + from pg_stat_activity + where application_name = '${sleeper_application}' + and state = 'active' + and query = 'select pg_sleep(300);'; + ")" + backend_pids="${backend_pids//$'\r'/}" + if [[ "${backend_pids}" =~ ^[0-9]+$ ]]; then + sleeper_backend_pid="${backend_pids}" + return 0 + fi + if [[ -n "${backend_pids}" ]]; then + printf '%s\n' \ + "expected exactly one feature sleeper, found backend PIDs ${backend_pids}" >&2 + return 1 + fi + sleep 0.1 + done + + printf '%s\n' \ + "feature sleeper did not become active" >&2 + return 1 + } + + run_feature_mode() { + local feature_mode=$1 + local expected_cron=$2 + local expected_pgss=$3 + local install_path + + printf 'Feature mode: %s (pg_cron=%s pg_stat_statements=%s)\n' \ + "${feature_mode}" "${expected_cron}" "${expected_pgss}" + uninstall_ash_if_present + assert_ash_absent + configure_extensions "${expected_cron}" "${expected_pgss}" + + install_path="$( + python3 devel/scripts/ash_sql_chain.py fresh-install-path + )" + if [[ "${install_path}" == /* || ! -f "${install_path}" ]]; then + printf 'Invalid fresh-install path: %s\n' \ + "${install_path}" >&2 + return 1 + fi + "${psql_base[@]}" --file="${install_path}" >/dev/null + + sleeper_application="pg_ash_features_${feature_mode}" + PGAPPNAME="${sleeper_application}" \ + "${psql_base[@]}" \ + --command="select pg_sleep(300);" >/dev/null 2>&1 & + sleeper_pid=$! + wait_for_feature_sleeper + + "${psql_base[@]}" \ + -v "expected_cron=${expected_cron}" \ + -v "expected_pgss=${expected_pgss}" \ + -v "feature_mode=${feature_mode}" \ + --file=devel/tests/features.sql + + stop_feature_sleeper + assert_ash_absent + } + + trap cleanup_feature_step EXIT + trap 'signal_feature_step 130' INT + trap 'signal_feature_step 143' TERM + + cron_available="$("${psql_base[@]}" \ + --tuples-only \ + --no-align \ + --command=" + select exists ( + select + from pg_available_extensions + where name = 'pg_cron' + )::text; + ")" + cron_available="${cron_available//$'\r'/}" + + if [[ -n "${PG_ASH_FEATURE_MODE_ROWS:-}" ]]; then + mapfile -t feature_modes \ + <<<"${PG_ASH_FEATURE_MODE_ROWS}" + elif [[ "${CRON}" == "on" && "${cron_available}" == "true" ]]; then + feature_modes=( + $'both\ttrue\ttrue' + $'no-cron\tfalse\ttrue' + $'no-pgss\ttrue\tfalse' + $'neither\tfalse\tfalse' + ) + else + feature_modes=( + $'no-cron\tfalse\ttrue' + $'neither\tfalse\tfalse' + ) + fi + + for feature_mode_row in "${feature_modes[@]}"; do + IFS=$'\t' read -r \ + feature_mode expected_cron expected_pgss \ + <<<"${feature_mode_row}" + run_feature_mode \ + "${feature_mode}" "${expected_cron}" "${expected_pgss}" + done + + assert_ash_absent + - name: Final summary env: PGPASSWORD: postgres diff --git a/devel/scripts/release_gate.sh b/devel/scripts/release_gate.sh index 389691a..8741b8d 100755 --- a/devel/scripts/release_gate.sh +++ b/devel/scripts/release_gate.sh @@ -30,6 +30,7 @@ readonly -a SUPPORTED_MAJORS=(14 15 16 17 18 19beta2) readonly -a SURFACES=( fresh-install upgrade-chain + features degraded-no-cron degraded-no-pgss degraded-neither @@ -62,6 +63,7 @@ Supported majors: Supported surfaces: fresh-install upgrade-chain + features degraded-no-cron degraded-no-pgss degraded-neither @@ -421,16 +423,38 @@ run_upgrade_chain() { "Schema equivalence: fresh dev install vs full upgrade path" } +run_features() { + assert_extension_state t t + run_ci_selection \ + step \ + "Behavioral feature coverage across extension modes" +} + +run_feature_mode() { + local feature_mode=$1 + local expected_cron=$2 + local expected_pgss=$3 + + PG_ASH_FEATURE_MODE_ROWS="$( + printf '%s\t%s\t%s\n' \ + "${feature_mode}" "${expected_cron}" "${expected_pgss}" + )" run_ci_selection \ + step \ + "Behavioral feature coverage across extension modes" +} + run_degraded_no_cron() { assert_extension_state f t install_fresh psql_gate --file="${REPO_ROOT}/devel/tests/degraded_no_cron.sql" + run_feature_mode no-cron false true } run_degraded_no_pgss() { assert_extension_state t f install_fresh psql_gate --file="${REPO_ROOT}/devel/tests/degraded_no_pgss.sql" + run_feature_mode no-pgss true false } run_degraded_neither() { @@ -443,6 +467,7 @@ run_degraded_neither() { install_fresh assert_extension_state f f psql_gate --file="${REPO_ROOT}/devel/tests/degraded_no_cron.sql" + run_feature_mode neither false false } run_cron_path() { @@ -461,7 +486,7 @@ surface_configuration() { local surface=$1 case "${surface}" in - fresh-install | upgrade-chain | cron-path) + fresh-install | upgrade-chain | features | cron-path) printf '%s\t%s\t%s\n' "pg_cron,pg_stat_statements" t t ;; degraded-neither) @@ -495,6 +520,9 @@ execute_surface() { upgrade-chain) run_upgrade_chain "${major}" ;; + features) + run_features + ;; degraded-no-cron) run_degraded_no_cron ;; From af1ef76a92eba4dc6fa9ea47397eb3ab7aa3a23e Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:18:56 +0000 Subject: [PATCH 06/18] test: align feature contracts with privilege fixes --- devel/tests/features_fixture.sql | 2 +- devel/tests/features_lifecycle.sql | 182 +++++++++++++++++++++++----- devel/tests/features_privileges.sql | 118 +++++++++++++++++- devel/tests/features_readers.sql | 65 ++++++++-- 4 files changed, 324 insertions(+), 43 deletions(-) diff --git a/devel/tests/features_fixture.sql b/devel/tests/features_fixture.sql index a85546e..63b146d 100644 --- a/devel/tests/features_fixture.sql +++ b/devel/tests/features_fixture.sql @@ -231,7 +231,7 @@ begin assert v_rows = 4 and v_backends = 16, format( '[%s] fixture: expected 4 sample rows and 16 encoded backends, got rows=%s backends=%s', - pg_catalog.coalesce(v_mode, 'standalone'), + coalesce(v_mode, 'standalone'), v_rows, v_backends ); diff --git a/devel/tests/features_lifecycle.sql b/devel/tests/features_lifecycle.sql index 55b447e..3c455d6 100644 --- a/devel/tests/features_lifecycle.sql +++ b/devel/tests/features_lifecycle.sql @@ -118,7 +118,19 @@ declare v_before_rows bigint; v_before_waits bigint; v_decoded jsonb; + v_expected_query_id bigint; v_new_sample record; + v_pgss_preloaded boolean := + 'pg_stat_statements' = any ( + pg_catalog.string_to_array( + pg_catalog.replace( + pg_catalog.current_setting('shared_preload_libraries'), + ' ', + '' + ), + ',' + ) + ); v_result int; begin select pg_catalog.count(*) @@ -130,6 +142,24 @@ begin select pg_catalog.count(*) into v_before_queries from ash.query_map_all; + select activity.query_id + into strict v_expected_query_id + from pg_catalog.pg_stat_activity as activity + where + activity.application_name = pg_catalog.format( + 'pg_ash_features_%s', + pg_catalog.current_setting('ash.feature_mode') + ) + and activity.state = 'active' + and activity.query = 'select pg_sleep(300);'; + + assert v_pgss_preloaded = (v_expected_query_id is not null), + format( + '[%s] ash.take_sample preload precondition: expected pg_stat_statements preload=%s to match sleeper query_id presence, got query_id=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_pgss_preloaded, + v_expected_query_id + ); update ash.config set @@ -166,10 +196,13 @@ begin and v_new_sample.active_count = 1 and v_new_sample.slot = 0 and pg_catalog.jsonb_array_length(v_decoded) = 1 - and v_decoded -> 0 ->> 0 = 'Timeout:PgSleep', + and v_decoded -> 0 ->> 0 = 'Timeout:PgSleep' + and (v_decoded -> 0 ->> 1)::bigint + is not distinct from v_expected_query_id, format( - '[%s] ash.take_sample live: expected one inserted row containing exactly one Timeout:PgSleep backend, got return=%s row=%s decoded=%s total_rows=%s', + '[%s] ash.take_sample live: expected one inserted Timeout:PgSleep backend with exact live query_id %s, got return=%s row=%s decoded=%s total_rows=%s', pg_catalog.current_setting('ash.feature_mode'), + v_expected_query_id, v_result, pg_catalog.row_to_json(v_new_sample), v_decoded, @@ -185,10 +218,12 @@ begin and ( select pg_catalog.count(*) from ash.query_map_all - ) between v_before_queries and v_before_queries + 1, + ) = v_before_queries + + case when v_expected_query_id is null then 0 else 1 end, format( - '[%s] ash.take_sample dictionaries: expected one PgSleep wait and zero/one real query mapping, got waits %s->%s queries %s->%s', + '[%s] ash.take_sample dictionaries: expected one PgSleep wait and exactly %s real query mapping(s), got waits %s->%s queries %s->%s', pg_catalog.current_setting('ash.feature_mode'), + case when v_expected_query_id is null then 0 else 1 end, v_before_waits, ( select pg_catalog.count(*) @@ -418,9 +453,14 @@ begin; do $feature_rotate$ declare + v_fixture ash_feature_context%rowtype; v_inserted_id int4; v_result text; begin + select * + into strict v_fixture + from ash_feature_context; + insert into ash.query_map_2 (query_id) values (909090) returning id @@ -432,6 +472,29 @@ begin v_inserted_id ); + insert into ash.sample_2 ( + sample_ts, + datid, + active_count, + data, + slot + ) + values ( + ash.ts_from_timestamptz(v_fixture.fixture_start), + v_fixture.datid, + 1, + array[-v_fixture.cpu_wait_id, 1, 0]::int4[], + 2 + ); + assert ( + select pg_catalog.count(*) + from ash.sample_2 + ) = 1, + format( + '[%s] ash.rotate setup: expected one doomed slot-2 sample sentinel', + pg_catalog.current_setting('ash.feature_mode') + ); + update ash.config set current_slot = 0, @@ -448,12 +511,16 @@ begin select pg_catalog.count(*) from ash.query_map_2 ) = 0 + and ( + select pg_catalog.count(*) + from ash.sample_2 + ) = 0 and ( select pg_catalog.count(*) from ash.sample ) = 4, format( - '[%s] ash.rotate: expected exact slot 0->1/truncate slot2 while retaining four slot0 samples, got result=%L slot=%s qmap2=%s samples=%s', + '[%s] ash.rotate: expected exact slot 0->1, empty slot-2 sample/query partitions, and four retained slot-0 samples; got result=%L slot=%s qmap2=%s sample2=%s samples=%s', pg_catalog.current_setting('ash.feature_mode'), v_result, ash.current_slot(), @@ -461,6 +528,10 @@ begin select pg_catalog.count(*) from ash.query_map_2 ), + ( + select pg_catalog.count(*) + from ash.sample_2 + ), ( select pg_catalog.count(*) from ash.sample @@ -675,7 +746,7 @@ $feature_rollup_cleanup$; rollback; /* ------------------------------------------------------------------------- - * ash.rebuild_partitions(): destructive side effects and reader re-grant. + * ash.rebuild_partitions(): destructive side effects and preserved readers. * ------------------------------------------------------------------------- */ begin; @@ -714,7 +785,8 @@ $feature_rebuild_confirmation$; do $feature_rebuild$ declare - v_actual_read boolean := false; + v_monitor_read boolean := false; + v_reader_read boolean := false; v_result text; begin select ash.rebuild_partitions(4, 'yes') @@ -784,35 +856,60 @@ begin ) ); - assert not pg_catalog.has_table_privilege( - 'ash_feature_reader', - 'ash.sample_3', - 'SELECT' - ), + assert pg_catalog.has_table_privilege( + 'ash_feature_reader', + 'ash.sample_3', + 'SELECT' + ) + and pg_catalog.has_table_privilege( + 'pg_monitor', + 'ash.sample_3', + 'SELECT' + ), format( - '[%s] ash.rebuild_partitions ACL contract: expected replacement partition to require re-running grant_reader()', - pg_catalog.current_setting('ash.feature_mode') + '[%s] ash.rebuild_partitions preserved ACLs: expected both reader bundles on replacement sample_3, got custom=%s pg_monitor=%s', + pg_catalog.current_setting('ash.feature_mode'), + pg_catalog.has_table_privilege( + 'ash_feature_reader', + 'ash.sample_3', + 'SELECT' + ), + pg_catalog.has_table_privilege( + 'pg_monitor', + 'ash.sample_3', + 'SELECT' + ) ); - perform ash.grant_reader('ash_feature_reader'); + execute 'set local role ash_feature_reader'; begin perform pg_catalog.count(*) from ash.query_map_all; perform pg_catalog.count(*) from ash.sample_3; - v_actual_read := true; + v_reader_read := true; exception when insufficient_privilege then - v_actual_read := false; + v_reader_read := false; end; execute 'reset role'; - assert v_actual_read - and pg_catalog.has_table_privilege( - 'ash_feature_reader', - 'ash.sample_3', - 'SELECT' - ), + + execute 'set local role pg_monitor'; + begin + perform pg_catalog.count(*) from ash.query_map_all; + perform pg_catalog.count(*) from ash.sample_3; + v_monitor_read := true; + exception + when insufficient_privilege then + v_monitor_read := false; + end; + execute 'reset role'; + + assert v_reader_read + and v_monitor_read, format( - '[%s] ash.rebuild_partitions re-grant: reader could not actually read replacement view/partition after grant_reader()', - pg_catalog.current_setting('ash.feature_mode') + '[%s] ash.rebuild_partitions preserved ACL behavior: expected immediate custom/pg_monitor reads without re-grant, got custom=%s pg_monitor=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_reader_read, + v_monitor_read ); end $feature_rebuild$; @@ -824,6 +921,32 @@ rollback; * ------------------------------------------------------------------------- */ begin; +do $feature_uninstall_setup$ +declare + v_expected_cron boolean := + pg_catalog.current_setting('ash.feature_expected_cron')::boolean; +begin + if v_expected_cron then + perform * + from ash.start(interval '5 seconds'); + assert ( + select pg_catalog.count(*) + from cron.job + where jobname like 'ash_%' + ) = 5, + format( + '[%s] ash.uninstall setup: expected exactly five ash_* jobs for uninstall to remove, got %s', + pg_catalog.current_setting('ash.feature_mode'), + ( + select pg_catalog.count(*) + from cron.job + where jobname like 'ash_%' + ) + ); + end if; +end +$feature_uninstall_setup$; + create temporary table ash_feature_uninstall_result on commit drop as @@ -838,12 +961,15 @@ begin select result into strict v_result from ash_feature_uninstall_result; - assert v_result = - 'uninstalled: removed 0 pg_cron jobs, dropped ash schema' + assert v_result = pg_catalog.format( + 'uninstalled: removed %s pg_cron jobs, dropped ash schema', + case when v_expected_cron then 5 else 0 end + ) and pg_catalog.to_regnamespace('ash') is null, format( - '[%s] ash.uninstall: expected exact zero-job result and absent schema, got result=%L schema=%s', + '[%s] ash.uninstall: expected exact %s-job removal result and absent schema, got result=%L schema=%s', pg_catalog.current_setting('ash.feature_mode'), + case when v_expected_cron then 5 else 0 end, v_result, pg_catalog.to_regnamespace('ash') ); diff --git a/devel/tests/features_privileges.sql b/devel/tests/features_privileges.sql index 940d5b7..4c42aa2 100644 --- a/devel/tests/features_privileges.sql +++ b/devel/tests/features_privileges.sql @@ -81,10 +81,10 @@ begin 'ash', 'USAGE' ) - and v_direct_functions = 41 + and v_direct_functions = 40 and v_direct_tables = 12, format( - '[%s] ash.grant_reader ACLs: expected schema USAGE, 41 direct function EXECUTEs, and 12 direct table SELECTs; got usage=%s functions=%s tables=%s', + '[%s] ash.grant_reader ACLs: expected schema USAGE, 40 direct function EXECUTEs, and 12 direct table SELECTs; got usage=%s functions=%s tables=%s', pg_catalog.current_setting('ash.feature_mode'), pg_catalog.has_schema_privilege( 'ash_feature_reader', @@ -108,6 +108,11 @@ begin 'ash_feature_reader', 'ash.rebuild_partitions(integer,text)', 'EXECUTE' + ) + and not pg_catalog.has_function_privilege( + 'ash_feature_reader', + 'ash._admin_funcs()', + 'EXECUTE' ), format( '[%s] ash.grant_reader least privilege: reader acquired an admin EXECUTE grant', @@ -222,6 +227,101 @@ begin end $feature_privileges$; +do $feature_revoke_protected$ +declare + v_acl_after jsonb; + v_acl_before jsonb; + v_refused boolean := false; +begin + select pg_catalog.jsonb_build_object( + 'schema', + ( + select pg_catalog.to_jsonb(namespace_row.nspacl) + from pg_catalog.pg_namespace as namespace_row + where namespace_row.nspname = 'ash' + ), + 'functions', + ( + select pg_catalog.jsonb_object_agg( + procedure_row.oid::text, + pg_catalog.to_jsonb(procedure_row.proacl) + order by procedure_row.oid + ) + from pg_catalog.pg_proc as procedure_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = procedure_row.pronamespace + where namespace_row.nspname = 'ash' + ), + 'relations', + ( + select pg_catalog.jsonb_object_agg( + relation_row.oid::text, + pg_catalog.to_jsonb(relation_row.relacl) + order by relation_row.oid + ) + from pg_catalog.pg_class as relation_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = relation_row.relnamespace + where namespace_row.nspname = 'ash' + ) + ) + into v_acl_before; + + begin + perform ash.revoke_reader(current_user::name); + exception + when others then + v_refused := sqlerrm = pg_catalog.format( + 'ash.revoke_reader: refusing protected role %s (schema owner, current user, or superuser)', + current_user + ); + end; + + select pg_catalog.jsonb_build_object( + 'schema', + ( + select pg_catalog.to_jsonb(namespace_row.nspacl) + from pg_catalog.pg_namespace as namespace_row + where namespace_row.nspname = 'ash' + ), + 'functions', + ( + select pg_catalog.jsonb_object_agg( + procedure_row.oid::text, + pg_catalog.to_jsonb(procedure_row.proacl) + order by procedure_row.oid + ) + from pg_catalog.pg_proc as procedure_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = procedure_row.pronamespace + where namespace_row.nspname = 'ash' + ), + 'relations', + ( + select pg_catalog.jsonb_object_agg( + relation_row.oid::text, + pg_catalog.to_jsonb(relation_row.relacl) + order by relation_row.oid + ) + from pg_catalog.pg_class as relation_row + inner join pg_catalog.pg_namespace as namespace_row + on namespace_row.oid = relation_row.relnamespace + where namespace_row.nspname = 'ash' + ) + ) + into v_acl_after; + + assert v_refused + and v_acl_after = v_acl_before, + format( + '[%s] ash.revoke_reader protected target: expected exact refusal before any ACL mutation, got refused=%s acl_unchanged=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_refused, + v_acl_after = v_acl_before + ); +end +$feature_revoke_protected$; + do $feature_default_pg_monitor$ declare v_admin_denied boolean := false; @@ -266,15 +366,20 @@ begin and acl.privilege_type = 'SELECT'; assert pg_catalog.has_schema_privilege('pg_monitor', 'ash', 'USAGE') - and v_direct_functions = 41 + and v_direct_functions = 40 and v_direct_tables = 12 and not pg_catalog.has_function_privilege( 'pg_monitor', 'ash.start(interval)', 'EXECUTE' + ) + and not pg_catalog.has_function_privilege( + 'pg_monitor', + 'ash._admin_funcs()', + 'EXECUTE' ), format( - '[%s] default pg_monitor ACLs: expected USAGE/41 reader functions/12 tables/no start(), got usage=%s functions=%s tables=%s start=%s', + '[%s] default pg_monitor ACLs: expected USAGE/40 reader functions/12 tables/no start() or _admin_funcs(), got usage=%s functions=%s tables=%s start=%s admin_list=%s', pg_catalog.current_setting('ash.feature_mode'), pg_catalog.has_schema_privilege('pg_monitor', 'ash', 'USAGE'), v_direct_functions, @@ -283,6 +388,11 @@ begin 'pg_monitor', 'ash.start(interval)', 'EXECUTE' + ), + pg_catalog.has_function_privilege( + 'pg_monitor', + 'ash._admin_funcs()', + 'EXECUTE' ) ); diff --git a/devel/tests/features_readers.sql b/devel/tests/features_readers.sql index 9543cf2..b77067f 100644 --- a/devel/tests/features_readers.sql +++ b/devel/tests/features_readers.sql @@ -8,6 +8,7 @@ declare v_decoded_at jsonb; v_decoded_row jsonb; v_decoded_ts jsonb; + v_expected_decoded_ts jsonb; begin select * into strict v_fixture @@ -99,11 +100,32 @@ begin into v_decoded_at from ash.decode_sample_at(v_fixture.fixture_start) as decoded; - assert v_decoded_ts = v_decoded_at - and pg_catalog.jsonb_array_length(v_decoded_ts) = 3, + v_expected_decoded_ts := pg_catalog.jsonb_build_array( + pg_catalog.jsonb_build_array( + v_fixture.datid, + 'CPU*', + 10101, + 1 + ), + pg_catalog.jsonb_build_array( + v_fixture.datid, + 'IO:DataFileRead', + 10101, + 1 + ), + pg_catalog.jsonb_build_array( + v_fixture.datid, + 'IO:DataFileRead', + 20202, + 1 + ) + ); + assert v_decoded_ts = v_expected_decoded_ts + and v_decoded_at = v_expected_decoded_ts, format( - '[%s] ash.decode_sample(int4)/decode_sample_at: expected the same three exact rows, got int4=%s timestamptz=%s', + '[%s] ash.decode_sample(int4)/decode_sample_at: expected exact rows %s, got int4=%s timestamptz=%s', pg_catalog.current_setting('ash.feature_mode'), + v_expected_decoded_ts, v_decoded_ts, v_decoded_at ); @@ -920,6 +942,8 @@ $feature_rollup_minute_and_periods$; * ------------------------------------------------------------------------- */ do $feature_report$ declare + v_cluster_name text := + pg_catalog.current_setting('cluster_name', true); v_fixture ash_feature_context%rowtype; v_expected_keys text[]; v_keys text[]; @@ -965,6 +989,21 @@ begin v_expected_keys, v_keys ); + assert ( + coalesce(v_cluster_name, '') = '' + and not (v_report ? 'cluster_name') + ) + or ( + coalesce(v_cluster_name, '') <> '' + and v_report ->> 'cluster_name' = v_cluster_name + ), + format( + '[%s] ash.report cluster_name: expected exact optional pass-through %L, got key=%s value=%L', + pg_catalog.current_setting('ash.feature_mode'), + v_cluster_name, + v_report ? 'cluster_name', + v_report ->> 'cluster_name' + ); assert v_report -> 'vcpus' = '8'::jsonb, format( '[%s] ash.report vcpus: expected pass-through 8, got %s', @@ -1102,10 +1141,10 @@ begin from ash_feature_context; select - pg_catalog.array_agg(bucket_start), - pg_catalog.array_agg(aas), - pg_catalog.array_agg(detail), - pg_catalog.array_agg(chart) + pg_catalog.array_agg(bucket_start order by ordinal), + pg_catalog.array_agg(aas order by ordinal), + pg_catalog.array_agg(detail order by ordinal), + pg_catalog.array_agg(chart order by ordinal) into v_buckets, v_aas, @@ -1118,6 +1157,12 @@ begin n => 1, width => 10, color => false + ) with ordinality as chart_row( + bucket_start, + aas, + detail, + chart, + ordinal ); assert v_buckets = array[ @@ -1167,15 +1212,15 @@ begin from ash_feature_context; select - pg_catalog.array_agg(metric), - pg_catalog.array_agg(value) + pg_catalog.array_agg(metric order by ordinal), + pg_catalog.array_agg(value order by ordinal) into v_metrics, v_values from ash.summary( v_fixture.fixture_start, v_fixture.fixture_end - ); + ) with ordinality as summary_row(metric, value, ordinal); assert v_metrics = array[ 'period_start', From 8632cb262211ee752c4aadbf03215bac03fac037 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:27:34 +0000 Subject: [PATCH 07/18] test: expose rollup return contract mismatch --- devel/tests/features_lifecycle.sql | 133 +++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/devel/tests/features_lifecycle.sql b/devel/tests/features_lifecycle.sql index 3c455d6..7208414 100644 --- a/devel/tests/features_lifecycle.sql +++ b/devel/tests/features_lifecycle.sql @@ -916,6 +916,114 @@ $feature_rebuild$; rollback; +/* + * rollup return contract: one time grain containing two databases must count + * as one processed minute/hour, not two physical rollup rows (#191). Record + * both calls now, then raise only after uninstall and role cleanup below. + */ +create temporary table ash_feature_rollup_return_contract ( + datids bigint not null, + minute_result int not null, + minute_rows bigint not null, + hour_result int not null, + hour_rows bigint not null +) +on commit preserve rows; + +do $feature_rollup_return_probe$ +declare + v_anchor timestamptz := + pg_catalog.date_trunc( + 'hour', + pg_catalog.statement_timestamp() + ) - interval '1 hour'; + v_datids bigint; + v_hour_result int; + v_minute_result int; +begin + truncate table ash.sample; + truncate table ash.rollup_1m, ash.rollup_1h; + truncate table ash.wait_event_map restart identity; + + insert into ash.wait_event_map (state, type, event) + values ('active', 'CPU*', 'CPU*'); + + update ash.config + set + current_slot = 0, + sample_interval = interval '1 second', + last_rollup_1m_ts = ash.ts_from_timestamptz(v_anchor), + last_rollup_1h_ts = null + where singleton; + + with database_ids as ( + select database_row.oid as datid + from pg_catalog.pg_database as database_row + order by + (database_row.datname = pg_catalog.current_database()) desc, + database_row.oid + limit 2 + ), + wait_id as ( + select id + from ash.wait_event_map + where + state = 'active' + and type = 'CPU*' + and event = 'CPU*' + ) + insert into ash.sample ( + sample_ts, + datid, + active_count, + data, + slot + ) + select + ash.ts_from_timestamptz(v_anchor), + database_ids.datid, + 1, + array[-wait_id.id::int, 1, 0]::int4[], + 0 + from database_ids + cross join wait_id; + + select pg_catalog.count(distinct sample_row.datid) + into v_datids + from ash.sample as sample_row; + select ash.rollup_minute(1) + into v_minute_result; + + update ash.config + set + last_rollup_1m_ts = ash.ts_from_timestamptz(v_anchor + interval '1 hour'), + last_rollup_1h_ts = ash.ts_from_timestamptz(v_anchor) + where singleton; + select ash.rollup_hour() + into v_hour_result; + + insert into ash_feature_rollup_return_contract ( + datids, + minute_result, + minute_rows, + hour_result, + hour_rows + ) + select + v_datids, + v_minute_result, + ( + select pg_catalog.count(*) + from ash.rollup_1m + ), + v_hour_result, + ( + select pg_catalog.count(*) + from ash.rollup_1h + ); +end +$feature_rollup_return_probe$; + /* ------------------------------------------------------------------------- * ash.uninstall(): verify the destructive effect, then rollback for cleanup. * ------------------------------------------------------------------------- */ @@ -1019,3 +1127,28 @@ $feature_final_cleanup$; drop owned by ash_feature_reader; drop role ash_feature_reader; + +do $feature_rollup_return_contract$ +declare + v_probe ash_feature_rollup_return_contract%rowtype; +begin + select * + into strict v_probe + from ash_feature_rollup_return_contract; + + assert v_probe.datids = 2 + and v_probe.minute_rows = 2 + and v_probe.hour_rows = 2 + and v_probe.minute_result = 1 + and v_probe.hour_result = 1, + format( + '[%s] ash.rollup return contract (#191): expected one processed minute/hour for two datids while writing two rows each, got datids=%s minute_result=%s minute_rows=%s hour_result=%s hour_rows=%s', + pg_catalog.current_setting('ash.feature_mode'), + v_probe.datids, + v_probe.minute_result, + v_probe.minute_rows, + v_probe.hour_result, + v_probe.hour_rows + ); +end +$feature_rollup_return_contract$; From 0246d33ff3a7432d2dd45d67f0add6b8a2bee032 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:31:32 +0000 Subject: [PATCH 08/18] fix: normalize ash.config upgrade ordinals Rebuild the v1.5 upgrade table in canonical fresh-install order while preserving the singleton row, defaults, constraints, indexes, ownership, ACLs, and comments. Reject unsupported custom dependencies and catalog properties transactionally instead of dropping them. Add an upgrade regression that seeds non-default values and custom catalog objects, verifies preservation, and proves migration reapplication keeps the replacement table OID. --- .github/workflows/test.yml | 42 ++ devel/tests/config_ordinal_assert.sql | 170 ++++++ devel/tests/config_ordinal_setup.sql | 78 +++ sql/migrations/ash-1.5-to-2.0.sql | 846 +++++++++++++++++++++++++- 4 files changed, 1133 insertions(+), 3 deletions(-) create mode 100644 devel/tests/config_ordinal_assert.sql create mode 100644 devel/tests/config_ordinal_setup.sql diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index efdeb68..655e841 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10132,6 +10132,48 @@ jobs: select ash.uninstall('yes'); EOF + - name: "Upgrade path: ash.config canonical column order and preservation" + env: + PGPASSWORD: postgres + run: | + python3 devel/scripts/ash_sql_chain.py full-upgrade-chain > /tmp/ash_full_upgrade_chain.sql + sed '$d' /tmp/ash_full_upgrade_chain.sql > /tmp/ash_chain_before_latest.sql + tail -n 1 /tmp/ash_full_upgrade_chain.sql > /tmp/ash_latest_upgrade.sql + test -s /tmp/ash_chain_before_latest.sql + test -s /tmp/ash_latest_upgrade.sql + + cat > /tmp/ash_config_ordinal_driver.sql << 'EOF' + \i /tmp/ash_chain_before_latest.sql + \i devel/tests/config_ordinal_setup.sql + \i /tmp/ash_latest_upgrade.sql + \i devel/tests/config_ordinal_assert.sql + + create temporary table config_ordinal_oid_after + on commit preserve rows + as + select 'ash.config'::regclass::oid as relation_oid; + + -- The released migration remains safe to re-apply after normalization. + \i /tmp/ash_latest_upgrade.sql + \i devel/tests/config_ordinal_assert.sql + + do $$ + begin + assert 'ash.config'::regclass::oid = ( + select relation_oid from config_ordinal_oid_after + ), 'canonical ash.config was rebuilt during migration re-apply'; + end $$; + + select ash.uninstall('yes'); + drop owned by config_ordinal_owner, config_ordinal_reader; + drop role config_ordinal_owner; + drop role config_ordinal_reader; + EOF + + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -v expected_version="$(python3 devel/scripts/ash_sql_chain.py fresh-install-version)" \ + -f /tmp/ash_config_ordinal_driver.sql + - name: "Issue #49: sample_data_check tightened by discovered upgrade chain" env: PGPASSWORD: postgres diff --git a/devel/tests/config_ordinal_assert.sql b/devel/tests/config_ordinal_assert.sql new file mode 100644 index 0000000..5864274 --- /dev/null +++ b/devel/tests/config_ordinal_assert.sql @@ -0,0 +1,170 @@ +\set ON_ERROR_STOP on + +select set_config('pg_ash.expected_version', :'expected_version', false); + +do $$ +declare + v_columns text[]; + v_config ash.config%rowtype; + v_default_diff text; + v_owner text; +begin + select array_agg(attribute.attname::text order by attribute.attnum) + into v_columns + from pg_attribute as attribute + where + attribute.attrelid = 'ash.config'::regclass + and attribute.attnum > 0 + and not attribute.attisdropped; + + assert v_columns = array[ + 'singleton', + 'current_slot', + 'num_partitions', + 'sampling_enabled', + 'skipped_samples', + 'missed_samples', + 'sample_interval', + 'rotation_period', + 'include_bg_workers', + 'debug_logging', + 'encoding_version', + 'version', + 'rotated_at', + 'installed_at', + 'rollup_1m_retention_days', + 'rollup_1h_retention_days', + 'rollup_min_backend_seconds', + 'last_rollup_1m_ts', + 'last_rollup_1h_ts', + 'insert_errors', + 'register_wait_cap_hits' + ]::text[], + format('ash.config column order differs: %s', v_columns); + + select * + into strict v_config + from ash.config + where singleton; + + assert v_config.current_slot = 2, 'current_slot was not preserved'; + assert not v_config.sampling_enabled, 'sampling_enabled was not preserved'; + assert v_config.skipped_samples = 42, 'skipped_samples was not preserved'; + assert v_config.missed_samples = 43, 'missed_samples was not preserved'; + assert v_config.sample_interval = interval '7 seconds', + 'sample_interval was not preserved'; + assert v_config.rotation_period = interval '2 days', + 'rotation_period was not preserved'; + assert v_config.include_bg_workers, 'include_bg_workers was not preserved'; + assert v_config.debug_logging, 'debug_logging was not preserved'; + assert v_config.encoding_version = 1, 'encoding_version was not preserved'; + assert v_config.version = current_setting('pg_ash.expected_version'), + 'version was not upgraded'; + assert v_config.rotated_at = '2026-01-02T03:04:05+00'::timestamptz, + 'rotated_at was not preserved'; + assert v_config.installed_at = '2025-06-07T08:09:10+00'::timestamptz, + 'installed_at was not preserved'; + assert v_config.rollup_1m_retention_days = 31, + 'rollup_1m_retention_days was not preserved'; + assert v_config.rollup_1h_retention_days = 1830, + 'rollup_1h_retention_days was not preserved'; + assert v_config.rollup_min_backend_seconds = 4, + 'rollup_min_backend_seconds was not preserved'; + assert v_config.last_rollup_1m_ts = 111, + 'last_rollup_1m_ts was not preserved'; + assert v_config.last_rollup_1h_ts = 222, + 'last_rollup_1h_ts was not preserved'; + assert v_config.insert_errors = 0, 'insert_errors default was not applied'; + assert v_config.register_wait_cap_hits = 0, + 'register_wait_cap_hits default was not applied'; + + select pg_get_userbyid(relation.relowner) + into v_owner + from pg_class as relation + where relation.oid = 'ash.config'::regclass; + + assert v_owner = 'config_ordinal_owner', + format('ash.config owner changed to %s', v_owner); + assert has_table_privilege( + 'config_ordinal_reader', + 'ash.config', + 'select with grant option' + ), 'table SELECT WITH GRANT OPTION was not preserved'; + assert not has_table_privilege('public', 'ash.config', 'select'), + 'PUBLIC SELECT on ash.config was widened'; + assert has_column_privilege( + 'config_ordinal_reader', + 'ash.config', + 'sampling_enabled', + 'update' + ), 'column UPDATE was not preserved'; + assert not has_column_privilege( + 'config_ordinal_reader', + 'ash.config', + 'debug_logging', + 'update' + ), 'column UPDATE widened to debug_logging'; + + assert exists ( + select + from pg_constraint as constraint_row + where + constraint_row.conrelid = 'ash.config'::regclass + and constraint_row.conname = 'config_ordinal_skipped_nonnegative' + ), 'custom config constraint was not preserved'; + + assert to_regclass('ash.config_ordinal_debug_idx') is not null, + 'custom config index was not preserved'; + + assert to_regclass('ash.config_ordinal_slot_uidx') is not null, + 'standalone unique config index was not preserved'; + + assert exists ( + select + from pg_constraint as constraint_row + where + constraint_row.conrelid = 'ash.config'::regclass + and constraint_row.conname = 'config_ordinal_slot_self_fk' + and constraint_row.contype = 'f' + ), 'self-referencing config FK was not preserved'; + + select string_agg( + format( + '%s: before=%s after=%s', + defaults_before.column_name, + defaults_before.default_expression, + defaults_after.default_expression + ), + '; ' + order by defaults_before.column_name + ) + into v_default_diff + from pg_temp.config_ordinal_defaults_before as defaults_before + left join ( + select + attribute.attname::text as column_name, + pg_get_expr( + default_value.adbin, + default_value.adrelid + ) as default_expression + from pg_attribute as attribute + left join pg_attrdef as default_value + on default_value.adrelid = attribute.attrelid + and default_value.adnum = attribute.attnum + where + attribute.attrelid = 'ash.config'::regclass + and attribute.attnum > 0 + and not attribute.attisdropped + ) as defaults_after + using (column_name) + where + defaults_before.column_name <> 'version' + and defaults_before.default_expression + is distinct from defaults_after.default_expression; + + assert v_default_diff is null, + format('ash.config defaults changed: %s', v_default_diff); + + raise notice + 'ash.config canonical ordinals, row, defaults, constraints, indexes, and ACLs PASSED'; +end $$; diff --git a/devel/tests/config_ordinal_setup.sql b/devel/tests/config_ordinal_setup.sql new file mode 100644 index 0000000..2da5217 --- /dev/null +++ b/devel/tests/config_ordinal_setup.sql @@ -0,0 +1,78 @@ +\set ON_ERROR_STOP on + +create role config_ordinal_owner nologin; +create role config_ordinal_reader nologin; + +do $$ +declare + v_num_partitions_attnum smallint; +begin + select attribute.attnum + into v_num_partitions_attnum + from pg_attribute as attribute + where + attribute.attrelid = 'ash.config'::regclass + and attribute.attname = 'num_partitions' + and not attribute.attisdropped; + + assert v_num_partitions_attnum <> 3, + 'upgrade fixture unexpectedly already has canonical config ordinals'; +end $$; + +update ash.config +set + current_slot = 2, + sampling_enabled = false, + skipped_samples = 42, + missed_samples = 43, + sample_interval = interval '7 seconds', + rotation_period = interval '2 days', + include_bg_workers = true, + debug_logging = true, + rotated_at = '2026-01-02T03:04:05+00'::timestamptz, + installed_at = '2025-06-07T08:09:10+00'::timestamptz, + rollup_1m_retention_days = 31, + rollup_1h_retention_days = 1830, + rollup_min_backend_seconds = 4, + last_rollup_1m_ts = 111, + last_rollup_1h_ts = 222 +where singleton; + +alter table ash.config owner to config_ordinal_owner; +grant select on table ash.config to config_ordinal_reader with grant option; +grant update (sampling_enabled) on table ash.config to config_ordinal_reader; + +alter table ash.config + add constraint config_ordinal_skipped_nonnegative + check (skipped_samples >= 0); + +create index config_ordinal_debug_idx +on ash.config (debug_logging) +where debug_logging; + +/* + * PostgreSQL permits a foreign key to target a standalone non-partial unique + * index. This exercises the replay order: the index must precede the FK. + */ +create unique index config_ordinal_slot_uidx +on ash.config (current_slot); + +alter table ash.config + add constraint config_ordinal_slot_self_fk + foreign key (current_slot) + references ash.config (current_slot); + +create temporary table config_ordinal_defaults_before +on commit preserve rows +as +select + attribute.attname::text as column_name, + pg_get_expr(default_value.adbin, default_value.adrelid) as default_expression +from pg_attribute as attribute +left join pg_attrdef as default_value + on default_value.adrelid = attribute.attrelid + and default_value.adnum = attribute.attnum +where + attribute.attrelid = 'ash.config'::regclass + and attribute.attnum > 0 + and not attribute.attisdropped; diff --git a/sql/migrations/ash-1.5-to-2.0.sql b/sql/migrations/ash-1.5-to-2.0.sql index c98933b..2c182d9 100644 --- a/sql/migrations/ash-1.5-to-2.0.sql +++ b/sql/migrations/ash-1.5-to-2.0.sql @@ -22,15 +22,855 @@ * * grants the default reader bundle to pg_monitor (best-effort, new in * 2.0 — see the block at the end of the installer; opt out afterwards * with `select ash.revoke_reader('pg_monitor')`), and - * * stamps ash.config.version = '2.0-beta1' (and the column default). + * * stamps ash.config.version = '2.0-beta1' (and the column default), and + * * normalizes ash.config's physical column order to match a fresh 2.0 + * install while preserving its singleton row and catalog properties. * * Sampling and storage are unchanged. Other admin/lifecycle behavior and * rollup scheduling remain compatible; rollup_minute()/rollup_hour() correct * only their return values to count time grains instead of per-database rows. * Re-apply-safe: the installer is idempotent (CREATE OR REPLACE / IF NOT - * EXISTS plus the deterministic drop block), so running this script again is - * a no-op. + * EXISTS plus the deterministic drop block), and the config normalization + * exits without replacing the table once the canonical order is present. */ \set ON_ERROR_STOP on \ir ../ash-install.sql + +begin; +set local search_path = pg_catalog, pg_temp; +set local default_tablespace = ''; + +/* + * v1.1 added fields to ash.config after the original columns, and subsequent + * upgrades kept appending fields. The values and definitions converged by + * 2.0, but the physical attnum order did not. Normalize the upgraded table to + * the fresh-install order so row types, SELECT * consumers, and schema + * snapshots agree. + * + * The swap is atomic inside this migration transaction. ACCESS EXCLUSIVE + * serializes it with samplers and readers. DROP RESTRICT is deliberate: + * an unexpected external dependency aborts and rolls back the migration + * instead of being destroyed. Shipped pg_ash functions use string-bodied SQL + * or PL/pgSQL and do not depend on ash.config's relation OID. + * + * Effective grants and grant options are preserved. Historical grantor OIDs + * are not replayed because doing so would require assuming arbitrary roles; + * grants are replayed by the migration owner instead. + */ +do $config_ordinal_normalization$ +declare + v_canonical_columns constant text[] := array[ + 'singleton', + 'current_slot', + 'num_partitions', + 'sampling_enabled', + 'skipped_samples', + 'missed_samples', + 'sample_interval', + 'rotation_period', + 'include_bg_workers', + 'debug_logging', + 'encoding_version', + 'version', + 'rotated_at', + 'installed_at', + 'rollup_1m_retention_days', + 'rollup_1h_retention_days', + 'rollup_min_backend_seconds', + 'last_rollup_1m_ts', + 'last_rollup_1h_ts', + 'insert_errors', + 'register_wait_cap_hits' + ]::text[]; + v_canonical_types constant text[] := array[ + 'boolean', + 'smallint', + 'smallint', + 'boolean', + 'integer', + 'bigint', + 'interval', + 'interval', + 'boolean', + 'boolean', + 'smallint', + 'text', + 'timestamp with time zone', + 'timestamp with time zone', + 'smallint', + 'smallint', + 'smallint', + 'integer', + 'integer', + 'bigint', + 'bigint' + ]::text[]; + v_canonical_not_null constant bool[] := array[ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true + ]::bool[]; + v_actual_columns text[]; + v_actual_columns_sorted text[]; + v_actual_types text[]; + v_actual_not_null bool[]; + v_canonical_columns_sorted text[]; + v_live_column_count int; + v_min_attnum int; + v_max_attnum int; + v_has_dropped_columns bool; + v_relation_oid oid; + v_heap_am_oid oid; + v_owner_oid oid; + v_owner_name text; + v_has_not_null_constraints bool; + v_row_count bigint; + v_singleton_count bigint; + v_relation record; + v_default record; + v_constraint record; + v_index record; + v_acl record; + v_comment record; + v_grantee_sql text; + v_grant_option_sql text; +begin + lock table ash.config in access exclusive mode; + v_relation_oid := 'ash.config'::regclass; + + select + array_agg( + attribute.attname::text + order by attribute.attnum + ), + array_agg( + attribute.attname::text + order by attribute.attname + ), + count(*)::int, + min(attribute.attnum)::int, + max(attribute.attnum)::int + into + v_actual_columns, + v_actual_columns_sorted, + v_live_column_count, + v_min_attnum, + v_max_attnum + from pg_catalog.pg_attribute as attribute + where + attribute.attrelid = v_relation_oid + and attribute.attnum > 0 + and not attribute.attisdropped; + + select array_agg(column_name order by column_name) + into v_canonical_columns_sorted + from unnest(v_canonical_columns) as canonical(column_name); + + select exists ( + select + from pg_catalog.pg_attribute as attribute + where + attribute.attrelid = v_relation_oid + and attribute.attnum > 0 + and attribute.attisdropped + ) + into v_has_dropped_columns; + + if v_actual_columns_sorted is distinct from v_canonical_columns_sorted then + raise exception + 'cannot normalize ash.config: expected columns %, found %', + v_canonical_columns, + v_actual_columns; + end if; + + if v_actual_columns = v_canonical_columns + and v_live_column_count = cardinality(v_canonical_columns) + and v_min_attnum = 1 + and v_max_attnum = cardinality(v_canonical_columns) + and not v_has_dropped_columns then + raise notice 'ash.config column order is already canonical'; + return; + end if; + + select + array_agg( + pg_catalog.format_type( + attribute.atttypid, + attribute.atttypmod + ) + order by canonical.ordinality + ), + array_agg( + attribute.attnotnull + order by canonical.ordinality + ) + into + v_actual_types, + v_actual_not_null + from unnest(v_canonical_columns) + with ordinality as canonical(column_name, ordinality) + inner join pg_catalog.pg_attribute as attribute + on attribute.attrelid = v_relation_oid + and attribute.attname = canonical.column_name + and attribute.attnum > 0 + and not attribute.attisdropped; + + if v_actual_types is distinct from v_canonical_types + or v_actual_not_null is distinct from v_canonical_not_null then + raise exception + 'cannot normalize ash.config: unsupported column definitions (types %, not-null %)', + v_actual_types, + v_actual_not_null; + end if; + + select + count(*), + count(*) filter (where singleton) + into + v_row_count, + v_singleton_count + from ash.config; + + if v_row_count <> 1 or v_singleton_count <> 1 then + raise exception + 'cannot normalize ash.config: expected one singleton row, found % row(s), % singleton', + v_row_count, + v_singleton_count; + end if; + + if pg_catalog.to_regclass('ash.config_ordinal_legacy') is not null then + raise exception + 'cannot normalize ash.config: ash.config_ordinal_legacy already exists'; + end if; + + select + relation.relkind, + relation.relpersistence, + relation.relispartition, + relation.relrowsecurity, + relation.relforcerowsecurity, + relation.relreplident, + relation.reltablespace, + relation.reloptions, + relation.reloftype, + relation.relam, + relation.relowner + into strict v_relation + from pg_catalog.pg_class as relation + where relation.oid = v_relation_oid; + + select access_method.oid + into strict v_heap_am_oid + from pg_catalog.pg_am as access_method + where access_method.amname = 'heap'; + + if v_relation.relkind <> 'r' + or v_relation.relpersistence <> 'p' + or v_relation.relispartition + or v_relation.relrowsecurity + or v_relation.relforcerowsecurity + or v_relation.relreplident <> 'd' + or v_relation.reltablespace <> 0 + or v_relation.reloptions is not null + or v_relation.reloftype <> 0 + or v_relation.relam <> v_heap_am_oid then + raise exception + 'cannot normalize ash.config: unsupported table storage, security, or replication properties'; + end if; + + if exists ( + select + from pg_catalog.pg_attribute as attribute + inner join pg_catalog.pg_type as data_type + on data_type.oid = attribute.atttypid + where + attribute.attrelid = v_relation_oid + and attribute.attnum > 0 + and not attribute.attisdropped + and ( + attribute.attidentity <> '' + or attribute.attgenerated <> '' + or attribute.attstattarget <> -1 + or attribute.attstorage <> data_type.typstorage + or attribute.attcompression <> '' + or attribute.attoptions is not null + or ( + data_type.typcollation <> 0 + and attribute.attcollation <> data_type.typcollation + ) + ) + ) then + raise exception + 'cannot normalize ash.config: unsupported custom column storage properties'; + end if; + + if exists ( + select + from pg_catalog.pg_trigger as trigger_row + where + trigger_row.tgrelid = v_relation_oid + and not trigger_row.tgisinternal + ) then + raise exception 'cannot normalize ash.config: custom triggers are present'; + end if; + + if exists ( + select + from pg_catalog.pg_policy as policy + where policy.polrelid = v_relation_oid + ) then + raise exception 'cannot normalize ash.config: row security policies are present'; + end if; + + if exists ( + select + from pg_catalog.pg_rewrite as rule + where rule.ev_class = v_relation_oid + ) then + raise exception 'cannot normalize ash.config: custom rules are present'; + end if; + + if exists ( + select + from pg_catalog.pg_publication_rel as publication_relation + where publication_relation.prrelid = v_relation_oid + ) then + raise exception 'cannot normalize ash.config: publication membership is present'; + end if; + + if exists ( + select + from pg_catalog.pg_statistic_ext as statistic + where statistic.stxrelid = v_relation_oid + ) then + raise exception 'cannot normalize ash.config: extended statistics are present'; + end if; + + if exists ( + select + from pg_catalog.pg_seclabel as security_label + where + security_label.classoid = 'pg_class'::regclass + and security_label.objoid = v_relation_oid + ) then + raise exception 'cannot normalize ash.config: security labels are present'; + end if; + + if exists ( + select + from pg_catalog.pg_inherits as inheritance + where + inheritance.inhrelid = v_relation_oid + or inheritance.inhparent = v_relation_oid + ) then + raise exception 'cannot normalize ash.config: table inheritance is present'; + end if; + + if exists ( + select + from pg_catalog.pg_constraint as constraint_row + where + constraint_row.confrelid = v_relation_oid + and constraint_row.conrelid <> v_relation_oid + ) then + raise exception + 'cannot normalize ash.config: another table has a foreign key to it'; + end if; + + if exists ( + select + from pg_catalog.pg_index as index_row + where + index_row.indrelid = v_relation_oid + and ( + not index_row.indisvalid + or not index_row.indisready + or not index_row.indislive + ) + ) then + raise exception 'cannot normalize ash.config: an invalid index is present'; + end if; + + v_owner_oid := v_relation.relowner; + + create temporary table ash_config_migration_defaults + on commit drop + as + select + attribute.attname::text as column_name, + attribute.attnotnull as is_not_null, + pg_catalog.pg_get_expr( + default_value.adbin, + default_value.adrelid + ) as default_expression + from pg_catalog.pg_attribute as attribute + left join pg_catalog.pg_attrdef as default_value + on default_value.adrelid = attribute.attrelid + and default_value.adnum = attribute.attnum + where + attribute.attrelid = v_relation_oid + and attribute.attnum > 0 + and not attribute.attisdropped; + + create temporary table ash_config_migration_constraints + on commit drop + as + select + constraint_row.conname::text as constraint_name, + constraint_row.contype as constraint_type, + pg_catalog.pg_get_constraintdef( + constraint_row.oid, + false + ) as constraint_definition, + pg_catalog.obj_description( + constraint_row.oid, + 'pg_constraint' + ) as constraint_comment + from pg_catalog.pg_constraint as constraint_row + where + constraint_row.conrelid = v_relation_oid + order by constraint_row.conname; + + select exists ( + select + from ash_config_migration_constraints as constraint_row + where constraint_row.constraint_type = 'n' + ) + into v_has_not_null_constraints; + + create temporary table ash_config_migration_indexes + on commit drop + as + select + index_relation.relname::text as index_name, + exists ( + select + from pg_catalog.pg_constraint as constraint_row + where + constraint_row.conindid = index_row.indexrelid + and constraint_row.contype in ('p', 'u', 'x') + ) as is_constraint_index, + index_row.indisclustered as is_clustered, + pg_catalog.pg_get_indexdef(index_row.indexrelid) as index_definition, + pg_catalog.obj_description( + index_row.indexrelid, + 'pg_class' + ) as index_comment + from pg_catalog.pg_index as index_row + inner join pg_catalog.pg_class as index_relation + on index_relation.oid = index_row.indexrelid + where + index_row.indrelid = v_relation_oid + order by index_relation.relname; + + create temporary table ash_config_migration_table_acl + on commit drop + as + select + acl.grantee, + acl.privilege_type, + acl.is_grantable + from pg_catalog.pg_class as relation + cross join lateral pg_catalog.aclexplode(relation.relacl) as acl + where relation.oid = v_relation_oid; + + create temporary table ash_config_migration_column_acl + on commit drop + as + select + attribute.attname::text as column_name, + acl.grantee, + acl.privilege_type, + acl.is_grantable + from pg_catalog.pg_attribute as attribute + cross join lateral pg_catalog.aclexplode(attribute.attacl) as acl + where + attribute.attrelid = v_relation_oid + and attribute.attnum > 0 + and not attribute.attisdropped; + + create temporary table ash_config_migration_comments + on commit drop + as + select + null::text as column_name, + pg_catalog.obj_description( + v_relation_oid, + 'pg_class' + ) as object_comment + union all + select + attribute.attname::text as column_name, + pg_catalog.col_description( + v_relation_oid, + attribute.attnum + ) as object_comment + from pg_catalog.pg_attribute as attribute + where + attribute.attrelid = v_relation_oid + and attribute.attnum > 0 + and not attribute.attisdropped; + + alter table ash.config rename to config_ordinal_legacy; + + create table ash.config ( + singleton bool, + current_slot smallint, + num_partitions smallint, + sampling_enabled bool, + skipped_samples int4, + missed_samples bigint, + sample_interval interval, + rotation_period interval, + include_bg_workers bool, + debug_logging bool, + encoding_version smallint, + version text, + rotated_at timestamptz, + installed_at timestamptz, + rollup_1m_retention_days smallint, + rollup_1h_retention_days smallint, + rollup_min_backend_seconds smallint, + last_rollup_1m_ts int4, + last_rollup_1h_ts int4, + insert_errors bigint, + register_wait_cap_hits bigint + ) using heap; + + insert into ash.config ( + singleton, + current_slot, + num_partitions, + sampling_enabled, + skipped_samples, + missed_samples, + sample_interval, + rotation_period, + include_bg_workers, + debug_logging, + encoding_version, + version, + rotated_at, + installed_at, + rollup_1m_retention_days, + rollup_1h_retention_days, + rollup_min_backend_seconds, + last_rollup_1m_ts, + last_rollup_1h_ts, + insert_errors, + register_wait_cap_hits + ) + select + singleton, + current_slot, + num_partitions, + sampling_enabled, + skipped_samples, + missed_samples, + sample_interval, + rotation_period, + include_bg_workers, + debug_logging, + encoding_version, + version, + rotated_at, + installed_at, + rollup_1m_retention_days, + rollup_1h_retention_days, + rollup_min_backend_seconds, + last_rollup_1m_ts, + last_rollup_1h_ts, + insert_errors, + register_wait_cap_hits + from ash.config_ordinal_legacy; + + drop table ash.config_ordinal_legacy restrict; + + v_owner_name := pg_catalog.pg_get_userbyid(v_owner_oid); + execute format( + 'alter table ash.config owner to %I', + v_owner_name + ); + + for v_default in + select + column_name, + is_not_null, + default_expression + from ash_config_migration_defaults + order by column_name + loop + if v_default.default_expression is not null then + execute format( + 'alter table ash.config alter column %I set default %s', + v_default.column_name, + v_default.default_expression + ); + end if; + end loop; + + /* + * PostgreSQL 18 exposes NOT NULL declarations as named pg_constraint rows, + * so the general constraint replay below preserves their exact names. + * Earlier majors represent them only in pg_attribute. + */ + if not v_has_not_null_constraints then + for v_default in + select column_name + from ash_config_migration_defaults + where is_not_null + order by column_name + loop + execute format( + 'alter table ash.config alter column %I set not null', + v_default.column_name + ); + end loop; + end if; + + /* + * Standalone unique indexes can be valid foreign-key targets. Recreate + * them before constraints so a custom self-referencing FK can bind to one. + */ + for v_index in + select + index_name, + index_definition + from ash_config_migration_indexes + where not is_constraint_index + order by index_name + loop + execute v_index.index_definition; + end loop; + + for v_constraint in + select + constraint_name, + constraint_type, + constraint_definition, + constraint_comment + from ash_config_migration_constraints + order by + case + when constraint_type in ('p', 'u', 'x') then 1 + when constraint_type = 'f' then 3 + else 2 + end, + constraint_name + loop + execute format( + 'alter table ash.config add constraint %I %s', + v_constraint.constraint_name, + v_constraint.constraint_definition + ); + + if v_constraint.constraint_comment is not null then + execute format( + 'comment on constraint %I on ash.config is %L', + v_constraint.constraint_name, + v_constraint.constraint_comment + ); + end if; + end loop; + + for v_index in + select + index_name, + index_comment + from ash_config_migration_indexes + where index_comment is not null + order by index_name + loop + execute format( + 'comment on index ash.%I is %L', + v_index.index_name, + v_index.index_comment + ); + end loop; + + for v_index in + select index_name + from ash_config_migration_indexes + where is_clustered + order by index_name + loop + execute format( + 'alter table ash.config cluster on %I', + v_index.index_name + ); + end loop; + + /* + * CREATE TABLE applies the migration role's current default privileges. + * Remove every generated non-owner grant before replaying the old ACL. + */ + execute 'revoke all privileges on table ash.config from public'; + for v_acl in + select distinct + acl.grantee, + case + when acl.grantee = 0 then null + else pg_catalog.pg_get_userbyid(acl.grantee) + end as grantee_name + from pg_catalog.pg_class as relation + cross join lateral pg_catalog.aclexplode(relation.relacl) as acl + where + relation.oid = 'ash.config'::regclass + and acl.grantee <> v_owner_oid + loop + if v_acl.grantee = 0 then + v_grantee_sql := 'public'; + else + v_grantee_sql := format( + '%I', + pg_catalog.pg_get_userbyid(v_acl.grantee) + ); + end if; + + execute format( + 'revoke all privileges on table ash.config from %s', + v_grantee_sql + ); + end loop; + + for v_acl in + select + grantee, + privilege_type, + is_grantable + from ash_config_migration_table_acl + order by grantee, privilege_type + loop + if v_acl.grantee = 0 then + v_grantee_sql := 'public'; + else + v_grantee_sql := format( + '%I', + pg_catalog.pg_get_userbyid(v_acl.grantee) + ); + end if; + + if v_acl.is_grantable then + v_grant_option_sql := ' with grant option'; + else + v_grant_option_sql := ''; + end if; + + execute format( + 'grant %s on table ash.config to %s%s', + v_acl.privilege_type, + v_grantee_sql, + v_grant_option_sql + ); + end loop; + + for v_acl in + select + column_name, + grantee, + privilege_type, + is_grantable + from ash_config_migration_column_acl + order by column_name, grantee, privilege_type + loop + if v_acl.grantee = 0 then + v_grantee_sql := 'public'; + else + v_grantee_sql := format( + '%I', + pg_catalog.pg_get_userbyid(v_acl.grantee) + ); + end if; + + if v_acl.is_grantable then + v_grant_option_sql := ' with grant option'; + else + v_grant_option_sql := ''; + end if; + + execute format( + 'grant %s (%I) on table ash.config to %s%s', + v_acl.privilege_type, + v_acl.column_name, + v_grantee_sql, + v_grant_option_sql + ); + end loop; + + for v_comment in + select + column_name, + object_comment + from ash_config_migration_comments + where object_comment is not null + order by column_name nulls first + loop + if v_comment.column_name is null then + execute format( + 'comment on table ash.config is %L', + v_comment.object_comment + ); + else + execute format( + 'comment on column ash.config.%I is %L', + v_comment.column_name, + v_comment.object_comment + ); + end if; + end loop; + + select + array_agg( + attribute.attname::text + order by attribute.attnum + ) + into v_actual_columns + from pg_catalog.pg_attribute as attribute + where + attribute.attrelid = 'ash.config'::regclass + and attribute.attnum > 0 + and not attribute.attisdropped; + + if v_actual_columns is distinct from v_canonical_columns then + raise exception + 'ash.config normalization produced unexpected columns: %', + v_actual_columns; + end if; + + select + count(*), + count(*) filter (where singleton) + into + v_row_count, + v_singleton_count + from ash.config; + + if v_row_count <> 1 or v_singleton_count <> 1 then + raise exception + 'ash.config normalization lost the singleton row'; + end if; + + raise notice + 'ash.config normalized to canonical column order with row and catalog properties preserved'; +end +$config_ordinal_normalization$; +commit; From d9b60410dff8bd5de21e9e4f549a63fa315a7234 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:39:02 +0000 Subject: [PATCH 09/18] docs: clarify config normalization transaction --- sql/migrations/ash-1.5-to-2.0.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/migrations/ash-1.5-to-2.0.sql b/sql/migrations/ash-1.5-to-2.0.sql index 2c182d9..ae9bbc7 100644 --- a/sql/migrations/ash-1.5-to-2.0.sql +++ b/sql/migrations/ash-1.5-to-2.0.sql @@ -48,7 +48,7 @@ set local default_tablespace = ''; * the fresh-install order so row types, SELECT * consumers, and schema * snapshots agree. * - * The swap is atomic inside this migration transaction. ACCESS EXCLUSIVE + * The swap is atomic inside this normalization transaction. ACCESS EXCLUSIVE * serializes it with samplers and readers. DROP RESTRICT is deliberate: * an unexpected external dependency aborts and rolls back the migration * instead of being destroyed. Shipped pg_ash functions use string-bodied SQL From 82274bc62e7b9b5492b9f2f29bc7e1de0a1295da Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:35:40 +0000 Subject: [PATCH 10/18] test: adapt release gate to current main --- devel/scripts/release_gate.sh | 3 +- devel/tests/config_ordinal_assert.sql | 17 +++++++++- devel/tests/features_lifecycle.sql | 6 ++-- sql/migrations/ash-1.5-to-2.0.sql | 45 +++++++++++++++++++++------ 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/devel/scripts/release_gate.sh b/devel/scripts/release_gate.sh index 8741b8d..40657ac 100755 --- a/devel/scripts/release_gate.sh +++ b/devel/scripts/release_gate.sh @@ -138,6 +138,7 @@ require_commands() { pg_isready psql python3 + script sed sort ) @@ -390,7 +391,7 @@ run_fresh_install() { install_fresh run_ci_selection \ range \ - "Test schema and infrastructure" \ + "Test SQL entrypoints fail atomically" \ "Test uninstall" \ --exclude \ "H-CI-3: end-to-end pg_cron fires ash.take_sample (#46)" diff --git a/devel/tests/config_ordinal_assert.sql b/devel/tests/config_ordinal_assert.sql index 5864274..257ddc9 100644 --- a/devel/tests/config_ordinal_assert.sql +++ b/devel/tests/config_ordinal_assert.sql @@ -38,7 +38,8 @@ begin 'last_rollup_1m_ts', 'last_rollup_1h_ts', 'insert_errors', - 'register_wait_cap_hits' + 'register_wait_cap_hits', + 'consecutive_rotate_failures' ]::text[], format('ash.config column order differs: %s', v_columns); @@ -77,6 +78,8 @@ begin assert v_config.insert_errors = 0, 'insert_errors default was not applied'; assert v_config.register_wait_cap_hits = 0, 'register_wait_cap_hits default was not applied'; + assert v_config.consecutive_rotate_failures = 0, + 'consecutive_rotate_failures default was not applied'; select pg_get_userbyid(relation.relowner) into v_owner @@ -113,6 +116,18 @@ begin and constraint_row.conname = 'config_ordinal_skipped_nonnegative' ), 'custom config constraint was not preserved'; + assert exists ( + select + from pg_trigger as trigger_row + where + trigger_row.tgrelid = 'ash.config'::regclass + and not trigger_row.tgisinternal + and trigger_row.tgname = 'config_validate_rotation' + and trigger_row.tgfoid = + 'ash._validate_config_update()'::regprocedure + and trigger_row.tgenabled = 'O' + ), 'config rotation validation trigger was not preserved'; + assert to_regclass('ash.config_ordinal_debug_idx') is not null, 'custom config index was not preserved'; diff --git a/devel/tests/features_lifecycle.sql b/devel/tests/features_lifecycle.sql index 7208414..55867a5 100644 --- a/devel/tests/features_lifecycle.sql +++ b/devel/tests/features_lifecycle.sql @@ -637,7 +637,7 @@ begin v_stale_1m_ts := ash.ts_from_timestamptz( pg_catalog.date_trunc( 'minute', - pg_catalog.now() - interval '2 days' + pg_catalog.now() - interval '3 days' ) ); v_recent_1m_ts := ash.ts_from_timestamptz( @@ -646,7 +646,7 @@ begin v_stale_1h_ts := ash.ts_from_timestamptz( pg_catalog.date_trunc( 'hour', - pg_catalog.now() - interval '2 days' + pg_catalog.now() - interval '3 days' ) ); v_recent_1h_ts := ash.ts_from_timestamptz( @@ -655,7 +655,7 @@ begin update ash.config set - rollup_1m_retention_days = 1, + rollup_1m_retention_days = 2, rollup_1h_retention_days = 1 where singleton; insert into ash.rollup_1m ( diff --git a/sql/migrations/ash-1.5-to-2.0.sql b/sql/migrations/ash-1.5-to-2.0.sql index ae9bbc7..2404909 100644 --- a/sql/migrations/ash-1.5-to-2.0.sql +++ b/sql/migrations/ash-1.5-to-2.0.sql @@ -81,7 +81,8 @@ declare 'last_rollup_1m_ts', 'last_rollup_1h_ts', 'insert_errors', - 'register_wait_cap_hits' + 'register_wait_cap_hits', + 'consecutive_rotate_failures' ]::text[]; v_canonical_types constant text[] := array[ 'boolean', @@ -104,6 +105,7 @@ declare 'integer', 'integer', 'bigint', + 'bigint', 'bigint' ]::text[]; v_canonical_not_null constant bool[] := array[ @@ -127,6 +129,7 @@ declare false, false, true, + true, true ]::bool[]; v_actual_columns text[]; @@ -323,14 +326,26 @@ begin 'cannot normalize ash.config: unsupported custom column storage properties'; end if; - if exists ( - select + if ( + select count(*) from pg_catalog.pg_trigger as trigger_row where trigger_row.tgrelid = v_relation_oid and not trigger_row.tgisinternal - ) then - raise exception 'cannot normalize ash.config: custom triggers are present'; + ) <> 1 + or not exists ( + select + from pg_catalog.pg_trigger as trigger_row + where + trigger_row.tgrelid = v_relation_oid + and not trigger_row.tgisinternal + and trigger_row.tgname = 'config_validate_rotation' + and trigger_row.tgfoid = + 'ash._validate_config_update()'::regprocedure + and trigger_row.tgenabled = 'O' + ) then + raise exception + 'cannot normalize ash.config: unsupported custom triggers are present'; end if; if exists ( @@ -552,8 +567,9 @@ begin rollup_min_backend_seconds smallint, last_rollup_1m_ts int4, last_rollup_1h_ts int4, - insert_errors bigint, - register_wait_cap_hits bigint + insert_errors bigint, + register_wait_cap_hits bigint, + consecutive_rotate_failures bigint ) using heap; insert into ash.config ( @@ -577,7 +593,8 @@ begin last_rollup_1m_ts, last_rollup_1h_ts, insert_errors, - register_wait_cap_hits + register_wait_cap_hits, + consecutive_rotate_failures ) select singleton, @@ -600,11 +617,21 @@ begin last_rollup_1m_ts, last_rollup_1h_ts, insert_errors, - register_wait_cap_hits + register_wait_cap_hits, + consecutive_rotate_failures from ash.config_ordinal_legacy; drop table ash.config_ordinal_legacy restrict; + create trigger config_validate_rotation + before insert or update of + num_partitions, + rotation_period, + rollup_1m_retention_days + on ash.config + for each row + execute function ash._validate_config_update(); + v_owner_name := pg_catalog.pg_get_userbyid(v_owner_oid); execute format( 'alter table ash.config owner to %I', From 4d2e242b9b9739a3afcf4b4cbee77c94ec32ad84 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:27:17 +0000 Subject: [PATCH 11/18] test: include current PG17 upgrade coverage --- devel/scripts/release_gate.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/devel/scripts/release_gate.sh b/devel/scripts/release_gate.sh index 40657ac..41ee88f 100755 --- a/devel/scripts/release_gate.sh +++ b/devel/scripts/release_gate.sh @@ -414,8 +414,9 @@ run_upgrade_chain() { if [[ "${major}" == "17" ]]; then run_ci_selection \ - step \ - "Release upgrade path: actual v1.4 tag to v1.5" + range \ + "Release upgrade path: actual v1.4 tag to v1.5" \ + "Release upgrade path: v1.5 rollup_1h backfill honesty (#161, #168)" fi run_ci_selection \ From 4f43665bb1bcd337b122505fae0eb56879cb9f30 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:56:39 +0000 Subject: [PATCH 12/18] test: distinguish rollup progress from rows --- devel/tests/features_fixture.sql | 30 ++++++++++++++++-- devel/tests/features_lifecycle.sql | 18 ++++++++--- devel/tests/features_privileges.sql | 8 ++--- devel/tests/features_readers.sql | 48 +++++++++++++---------------- sql/ash-install.sql | 2 +- 5 files changed, 69 insertions(+), 37 deletions(-) diff --git a/devel/tests/features_fixture.sql b/devel/tests/features_fixture.sql index 63b146d..f73f7c7 100644 --- a/devel/tests/features_fixture.sql +++ b/devel/tests/features_fixture.sql @@ -31,7 +31,6 @@ set rollup_1m_retention_days = 30, rollup_1h_retention_days = 1825, rollup_min_backend_seconds = 1, - last_rollup_1m_ts = null, last_rollup_1h_ts = null, debug_logging = false where singleton; @@ -55,6 +54,7 @@ values create temporary table ash_feature_context ( fixture_start timestamptz not null, fixture_end timestamptz not null, + raw_retention_start timestamptz not null, datid oid not null, cpu_wait_id smallint not null, io_wait_id smallint not null, @@ -68,6 +68,7 @@ on commit preserve rows; insert into ash_feature_context ( fixture_start, fixture_end, + raw_retention_start, datid, cpu_wait_id, io_wait_id, @@ -79,6 +80,15 @@ insert into ash_feature_context ( select fixture_anchor.fixture_start, fixture_anchor.fixture_start + interval '4 minutes', + ash.ts_to_timestamptz( + ash.ts_from_timestamptz( + pg_catalog.date_trunc( + 'minute', + config_row.rotated_at + - (config_row.num_partitions - 2) * config_row.rotation_period + ) + ) + ), database_row.oid, cpu_wait.id, io_wait.id, @@ -87,6 +97,7 @@ select query_20202.id, query_30303.id from pg_catalog.pg_database as database_row +cross join ash.config as config_row cross join lateral ( select pg_catalog.date_trunc( @@ -124,7 +135,22 @@ cross join lateral ( from ash.query_map_0 where query_id = 30303 ) as query_30303 -where database_row.datname = pg_catalog.current_database(); +where + database_row.datname = pg_catalog.current_database() + and config_row.singleton; + +/* + * Pin the minute watermark to the first data-bearing grain. The reader test + * processes exactly five completed grains: these four fixture minutes plus + * one trailing empty minute. This keeps processed-grain and persisted-row + * assertions deterministic and deliberately different. + */ +update ash.config as config +set last_rollup_1m_ts = ash.ts_from_timestamptz( + fixture.fixture_start +) +from ash_feature_context as fixture +where config.singleton; insert into ash.sample ( sample_ts, diff --git a/devel/tests/features_lifecycle.sql b/devel/tests/features_lifecycle.sql index 55867a5..d35c078 100644 --- a/devel/tests/features_lifecycle.sql +++ b/devel/tests/features_lifecycle.sql @@ -568,11 +568,21 @@ begin into strict v_fixture from ash_feature_context; + /* + * ash.rollup_hour() may seal an hour only after minute processing has + * reached its end. Pin both watermarks so this fixture states that + * precondition explicitly. + */ update ash.config - set last_rollup_1h_ts = - ash.ts_from_timestamptz( - pg_catalog.date_trunc('hour', v_fixture.fixture_start) - ) + set last_rollup_1m_ts = + ash.ts_from_timestamptz( + pg_catalog.date_trunc('hour', v_fixture.fixture_start) + + interval '1 hour' + ), + last_rollup_1h_ts = + ash.ts_from_timestamptz( + pg_catalog.date_trunc('hour', v_fixture.fixture_start) + ) where singleton; select ash.rollup_hour() into v_result; diff --git a/devel/tests/features_privileges.sql b/devel/tests/features_privileges.sql index 4c42aa2..6ad54da 100644 --- a/devel/tests/features_privileges.sql +++ b/devel/tests/features_privileges.sql @@ -81,10 +81,10 @@ begin 'ash', 'USAGE' ) - and v_direct_functions = 40 + and v_direct_functions = 43 and v_direct_tables = 12, format( - '[%s] ash.grant_reader ACLs: expected schema USAGE, 40 direct function EXECUTEs, and 12 direct table SELECTs; got usage=%s functions=%s tables=%s', + '[%s] ash.grant_reader ACLs: expected schema USAGE, 43 direct function EXECUTEs, and 12 direct table SELECTs; got usage=%s functions=%s tables=%s', pg_catalog.current_setting('ash.feature_mode'), pg_catalog.has_schema_privilege( 'ash_feature_reader', @@ -366,7 +366,7 @@ begin and acl.privilege_type = 'SELECT'; assert pg_catalog.has_schema_privilege('pg_monitor', 'ash', 'USAGE') - and v_direct_functions = 40 + and v_direct_functions = 43 and v_direct_tables = 12 and not pg_catalog.has_function_privilege( 'pg_monitor', @@ -379,7 +379,7 @@ begin 'EXECUTE' ), format( - '[%s] default pg_monitor ACLs: expected USAGE/40 reader functions/12 tables/no start() or _admin_funcs(), got usage=%s functions=%s tables=%s start=%s admin_list=%s', + '[%s] default pg_monitor ACLs: expected USAGE/43 reader functions/12 tables/no start() or _admin_funcs(), got usage=%s functions=%s tables=%s start=%s admin_list=%s', pg_catalog.current_setting('ash.feature_mode'), pg_catalog.has_schema_privilege('pg_monitor', 'ash', 'USAGE'), v_direct_functions, diff --git a/devel/tests/features_readers.sql b/devel/tests/features_readers.sql index b77067f..67808f3 100644 --- a/devel/tests/features_readers.sql +++ b/devel/tests/features_readers.sql @@ -769,45 +769,33 @@ $feature_samples$; do $feature_rollup_minute_and_periods$ declare v_fixture ash_feature_context%rowtype; - v_actual_rollup jsonb; v_periods jsonb; v_rollup record; + v_rollup_rows bigint; v_rolled int; begin select * into strict v_fixture from ash_feature_context; - select ash.rollup_minute(120) + select ash.rollup_minute(5) into v_rolled; - assert v_rolled = 4, + assert v_rolled = 5, format( - '[%s] ash.rollup_minute: expected four exact (minute,database) rows written, got %s', + '[%s] ash.rollup_minute return: expected five processed grains (four data-bearing plus one empty), got %s', pg_catalog.current_setting('ash.feature_mode'), v_rolled ); - select pg_catalog.jsonb_agg( - pg_catalog.jsonb_build_array( - ash.ts_to_timestamptz(rollup_min.ts), - rollup_min.samples, - rollup_min.peak_backends, - rollup_min.wait_counts, - rollup_min.query_counts - ) - order by rollup_min.ts - ) - into v_actual_rollup - from ash.rollup_1m as rollup_min - where - rollup_min.ts >= ash.ts_from_timestamptz(v_fixture.fixture_start) - and rollup_min.ts < ash.ts_from_timestamptz(v_fixture.fixture_end); + select pg_catalog.count(*) + into v_rollup_rows + from ash.rollup_1m; - assert pg_catalog.jsonb_array_length(v_actual_rollup) = 4, + assert v_rollup_rows = 4, format( - '[%s] ash.rollup_minute rows: expected four persisted minute rollups, got %s', + '[%s] ash.rollup_minute rows: expected exactly four data-bearing rollup_1m rows, got %s', pg_catalog.current_setting('ash.feature_mode'), - v_actual_rollup + v_rollup_rows ); select * @@ -1113,7 +1101,7 @@ begin 'minutes_with_data', 4, 'raw_retention_start', - v_fixture.fixture_start + v_fixture.raw_retention_start ), format( '[%s] ash.report coverage: expected exact four-minute rollup coverage, got %s', @@ -1226,11 +1214,15 @@ begin 'period_start', 'period_end', 'source', - 'minutes_with_data', + 'buckets_with_data', 'avg_aas', 'peak_aas', 'p99_aas', 'backend_seconds', + 'drill_source', + 'drill_period_start', + 'drill_period_end', + 'drill_effective_bucket', 'databases_active', 'top_wait_1', 'top_wait_2', @@ -1248,6 +1240,10 @@ begin '5.00', '4.97', '960.00', + 'raw', + v_fixture.fixture_start::text, + v_fixture.fixture_end::text, + interval '1 minute'::text, '1', 'IO:DataFileRead (avg_aas 1.75, 43.75%)', 'Lock:tuple (avg_aas 1.25, 31.25%)', @@ -1257,7 +1253,7 @@ begin '10101 (avg_aas 1.00, 25.00%)' ]::text[], format( - '[%s] ash.summary: expected exact 15-row human summary, got metrics=%s values=%s', + '[%s] ash.summary: expected exact 19-row human summary with headline and drill provenance, got metrics=%s values=%s', pg_catalog.current_setting('ash.feature_mode'), v_metrics, v_values @@ -1292,7 +1288,7 @@ begin and v_status ->> 'query_map_count' = '3' and v_status ->> 'rollup_1m_rows' = '4' and (v_status ->> 'raw_retention_start')::timestamptz = - v_fixture.fixture_start + v_fixture.raw_retention_start and (v_status ->> 'rollup_1m_retention_start')::timestamptz = v_fixture.fixture_start, format( diff --git a/sql/ash-install.sql b/sql/ash-install.sql index 84da148..8df1a24 100644 --- a/sql/ash-install.sql +++ b/sql/ash-install.sql @@ -7140,7 +7140,7 @@ comment on function ash.rotate() is $$Admin: rotate to the next partition slot, rolling up and truncating the oldest (checked daily by ash.start()). Readable raw retention is approximately (num_partitions - 2) * rotation_period. The current partial period may add more. The pre-truncation guard requires minute rollups only while they remain inside rollup_1m_retention_days. Failed attempts increment consecutive_rotate_failures in ash.status(); successful rotation resets it. Safe to call manually to force a due rotation.$$; comment on function ash.rollup_minute(int) is -$$Admin: fold completed minutes of raw samples into ash.rollup_1m (the every-minute job scheduled by ash.start()); batch_limit caps catch-up minutes per call. Returns minutes processed. Readers pick rollups automatically — this only needs manual calls when pg_cron is absent.$$; +$$Admin: fold completed minutes of raw samples into ash.rollup_1m (the every-minute job scheduled by ash.start()); batch_limit caps catch-up minutes per call. Returns completed minute grains processed and watermark-advanced; empty minutes count even when they write no ash.rollup_1m row, while an already-caught-up call returns 0. Readers pick rollups automatically — this only needs manual calls when pg_cron is absent.$$; comment on function ash.rollup_hour() is $$Admin: fold completed hours of ash.rollup_1m into ash.rollup_1h. minute_counts preserves available per-minute totals and must sum to the hourly wait total; missing stored rows remain NULL slots because idle and uncovered minutes are indistinguishable (#137). Returns hours processed.$$; From dc05e02260042c0a4310e99aa0871f4ad0e8f0e5 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:37:08 +0000 Subject: [PATCH 13/18] test: isolate feature pgss state The shared feature step runs after the full workflow has accumulated hundreds of pg_stat_statements entries. Dropping and recreating the extension does not clear the preloaded shared state, so the exact marker query can be missing even though the reader code is correct. Reset pg_stat_statements immediately after recreating it. This preserves the exact ash.top()/ash.samples() enrichment assertions while making their fixture independent of prior workflow activity. --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 655e841..6797e8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11324,6 +11324,12 @@ jobs: if [[ "${expected_pgss}" == "true" ]]; then "${psql_base[@]}" \ --command="create extension pg_stat_statements;" + # The full workflow has already exercised hundreds of distinct + # statements before this shared feature step. DROP/CREATE does + # not clear pg_stat_statements' preloaded shared state, so reset + # it explicitly before asserting exact marker-query enrichment. + "${psql_base[@]}" \ + --command="select pg_stat_statements_reset();" fi actual="$("${psql_base[@]}" \ From 9f280f5370de0c0e2d8f6253023f9d23ac12bf4d Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:56:16 +0000 Subject: [PATCH 14/18] test: reap cron-path workload Tag H-CI-3's three synthetic pg_sleep clients, terminate only those tagged backends on every exit, and wait for their client processes. The prior step left all three sessions live for later workflow steps, so features_lifecycle.sql correctly observed four active sleepers instead of its single owned fixture. Keep the exact active_count = 1 and decoded-row assertions unchanged; restore the quiescent precondition they test. --- .github/workflows/test.yml | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6797e8b..b92d7b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2619,11 +2619,35 @@ jobs: # Background workload so take_sample() has active sessions to capture. # In a quiescent container, ash.take_sample() returns 0 (no active - # backends to sample). Start a couple of long-running pg_sleep + # backends to sample). Start a few long-running pg_sleep # connections in the background so each cron tick sees activity. - (psql -h localhost -U postgres -d postgres -c "select pg_sleep(120)" >/dev/null 2>&1 & - psql -h localhost -U postgres -d postgres -c "select pg_sleep(120)" >/dev/null 2>&1 & - psql -h localhost -U postgres -d postgres -c "select pg_sleep(120)" >/dev/null 2>&1 &) || true + hci3_workload_pids=() + cleanup_hci3_workload() { + psql -h localhost -U postgres -d postgres -tAc \ + "select pg_terminate_backend(pid) + from pg_stat_activity + where pid <> pg_backend_pid() + and datname = current_database() + and application_name = 'pg_ash_h_ci_3_workload'" \ + >/dev/null 2>&1 || true + for hci3_pid in "${hci3_workload_pids[@]}"; do + kill "$hci3_pid" >/dev/null 2>&1 || true + done + for hci3_pid in "${hci3_workload_pids[@]}"; do + wait "$hci3_pid" >/dev/null 2>&1 || true + done + } + trap cleanup_hci3_workload EXIT + + PGAPPNAME=pg_ash_h_ci_3_workload \ + psql -h localhost -U postgres -d postgres -c "select pg_sleep(120)" >/dev/null 2>&1 & + hci3_workload_pids+=("$!") + PGAPPNAME=pg_ash_h_ci_3_workload \ + psql -h localhost -U postgres -d postgres -c "select pg_sleep(120)" >/dev/null 2>&1 & + hci3_workload_pids+=("$!") + PGAPPNAME=pg_ash_h_ci_3_workload \ + psql -h localhost -U postgres -d postgres -c "select pg_sleep(120)" >/dev/null 2>&1 & + hci3_workload_pids+=("$!") # Give the sleepers a moment to register in pg_stat_activity. sleep 2 @@ -2669,6 +2693,8 @@ jobs: # Tear down: stop the sampler, leave state consistent for later steps. psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -c "select ash.stop();" + cleanup_hci3_workload + trap - EXIT - name: Test set_debug_logging env: From dccb0eb188329930e8b72d2759a427ad6bd82537 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:02:14 +0000 Subject: [PATCH 15/18] test: make report diagnostics PG19-safe PostgreSQL 19 beta 2 resolves text || jsonb as JSON concatenation, so both B5 ASSERT message expressions raise on the JSON token before reporting the contract. Cast the coverage object explicitly to text. The asserted bounds and minutes-with-data predicates are unchanged; this is a diagnostic typing fix, not a behavior change. The old exact head fails on PG19beta2, while the canonical fresh-install surface passes with these casts. --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b92d7b3..69a394b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3667,7 +3667,7 @@ jobs: and (j->'coverage'->>'minutes_expected')::int = 60 and (j->'coverage'->>'minutes_with_data')::int = 1, 'report until-only coverage must be exactly the preceding hour: ' - || j->'coverage'; + || (j->'coverage')::text; j := ash.report(); assert (j->'coverage'->>'from')::timestamptz = date_trunc('minute', now() - interval '1 day') @@ -3676,7 +3676,7 @@ jobs: and (j->'coverage'->>'minutes_expected')::int = 1440 and (j->'coverage'->>'minutes_with_data')::int = 2, 'report() no-argument default must remain the preceding day: ' - || j->'coverage'; + || (j->'coverage')::text; select value into v_value from ash.summary(until => v_until) where metric = 'avg_aas'; From 0bc0760a39433282395a93f72779674dd4f893c2 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:47:50 +0000 Subject: [PATCH 16/18] test: share stale-rollup selector gate Combining #192 with #198 and #199 makes test.yml 513,005 bytes, above GitHub Actions' 512,000-byte workflow limit. Move #199's exact 110-line transaction fixture to devel/tests and invoke it with the same psql connection and ON_ERROR_STOP behavior. The payload is byte-identical after removing YAML indentation; all nine assertions remain. The combined inventory is 848 inline plus 184 shared, 1,032 total. --- .github/workflows/test.yml | 113 +--------------------------- devel/tests/stale_rollup_source.sql | 110 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 112 deletions(-) create mode 100644 devel/tests/stale_rollup_source.sql diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69a394b..c7670c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7083,118 +7083,7 @@ jobs: env: PGPASSWORD: postgres run: | - psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 << 'EOF' - begin; - - -- When raw covers a wide aggregate window, rollup_1m may replace it - -- only if that rollup covers the requested end. Otherwise a lagging - -- worker would silently drop recent raw load. Completeness for a - -- stalled rollup older than physical raw coverage remains #122. - -- Conversely, a healthy rollup must keep the fast path when a - -- dashboard rounds until up to the next minute boundary. - do $$ - declare - v_wait smallint; - v_start timestamptz := date_trunc('minute', now()) - interval '2 hours'; - v_end timestamptz := date_trunc('minute', now()); - v_historical_end timestamptz := v_end - interval '30 minutes'; - v_start_ts int4; - v_recent_ts int4; - a record; - begin - truncate ash.sample_0, ash.sample_1, ash.sample_2; - truncate ash.rollup_1m, ash.rollup_1h; - update ash.config - set current_slot = 0, - num_partitions = 3, - sample_interval = interval '1 second', - rotation_period = interval '1 day', - rollup_1m_retention_days = 30, - rotated_at = v_end, - last_rollup_1m_ts = ash.ts_from_timestamptz( - v_start + interval '1 minute' - ), - last_rollup_1h_ts = null - where singleton; - - select ash._register_wait('active', 'IO', 'StaleRollupProof') - into v_wait; - v_start_ts := ash.ts_from_timestamptz(v_start); - v_recent_ts := ash.ts_from_timestamptz(v_end - interval '1 minute'); - - insert into ash.rollup_1m ( - ts, datid, samples, peak_backends, wait_counts, query_counts - ) values ( - v_start_ts, 0::oid, 1, 1, - array[v_wait, 1]::int4[], '{}'::int8[] - ); - insert into ash.sample ( - sample_ts, datid, active_count, data, slot - ) values - (v_start_ts, 0::oid, 1, array[-v_wait, 1, 0]::int4[], 0), - (v_recent_ts, 0::oid, 1, array[-v_wait, 1, 0]::int4[], 0); - - select * into a from ash.aas(v_start, v_end); - assert a.source = 'raw', - 'stale rollup must fall back to raw, got ' || a.source; - assert a.buckets_with_data = 2, - 'raw fallback should retain both covered minutes, got ' - || a.buckets_with_data; - assert a.backend_seconds = 2.00, - 'raw fallback should retain both backend-seconds, got ' - || a.backend_seconds; - - truncate ash.rollup_1m; - insert into ash.rollup_1m ( - ts, datid, samples, peak_backends, wait_counts, query_counts - ) - select - ash.ts_from_timestamptz(minute_ts), - 0::oid, - 1, - 1, - array[v_wait, 1]::int4[], - '{}'::int8[] - from generate_series( - v_start, - v_end - interval '1 minute', - interval '1 minute' - ) as minute_ts; - update ash.config - set last_rollup_1m_ts = ash.ts_from_timestamptz(v_historical_end) - where singleton; - - select * into a from ash.aas(v_start, v_historical_end); - assert a.source = 'rollup_1m', - 'healthy historical rollup must use its covered watermark, got ' - || a.source; - assert a.buckets_with_data = 90, - 'historical rollup should cover 90 complete minutes, got ' - || a.buckets_with_data; - assert a.backend_seconds = 90.00, - 'historical rollup should retain 90 backend-seconds, got ' - || a.backend_seconds; - - update ash.config - set last_rollup_1m_ts = ash.ts_from_timestamptz(v_end) - where singleton; - select * into a - from ash.aas(v_start, v_end + interval '1 minute'); - assert a.source = 'rollup_1m', - 'healthy rollup must keep the fast path for a rounded-up until, ' - 'got ' || a.source; - assert a.buckets_with_data = 120, - 'healthy rollup should cover 120 complete minutes, got ' - || a.buckets_with_data; - assert a.backend_seconds = 120.00, - 'healthy rollup should retain 120 backend-seconds, got ' - || a.backend_seconds; - - raise notice 'stale/rounded-up rollup source selection PASSED'; - end $$; - - rollback; - EOF + psql -h localhost -U postgres -d postgres -v ON_ERROR_STOP=1 -f devel/tests/stale_rollup_source.sql - name: "Test 2.0: minute-grain peak/p99 survive the rollup_1h seam" env: diff --git a/devel/tests/stale_rollup_source.sql b/devel/tests/stale_rollup_source.sql new file mode 100644 index 0000000..36bdfb7 --- /dev/null +++ b/devel/tests/stale_rollup_source.sql @@ -0,0 +1,110 @@ +begin; + +-- When raw covers a wide aggregate window, rollup_1m may replace it +-- only if that rollup covers the requested end. Otherwise a lagging +-- worker would silently drop recent raw load. Completeness for a +-- stalled rollup older than physical raw coverage remains #122. +-- Conversely, a healthy rollup must keep the fast path when a +-- dashboard rounds until up to the next minute boundary. +do $$ +declare + v_wait smallint; + v_start timestamptz := date_trunc('minute', now()) - interval '2 hours'; + v_end timestamptz := date_trunc('minute', now()); + v_historical_end timestamptz := v_end - interval '30 minutes'; + v_start_ts int4; + v_recent_ts int4; + a record; +begin + truncate ash.sample_0, ash.sample_1, ash.sample_2; + truncate ash.rollup_1m, ash.rollup_1h; + update ash.config + set current_slot = 0, + num_partitions = 3, + sample_interval = interval '1 second', + rotation_period = interval '1 day', + rollup_1m_retention_days = 30, + rotated_at = v_end, + last_rollup_1m_ts = ash.ts_from_timestamptz( + v_start + interval '1 minute' + ), + last_rollup_1h_ts = null + where singleton; + + select ash._register_wait('active', 'IO', 'StaleRollupProof') + into v_wait; + v_start_ts := ash.ts_from_timestamptz(v_start); + v_recent_ts := ash.ts_from_timestamptz(v_end - interval '1 minute'); + + insert into ash.rollup_1m ( + ts, datid, samples, peak_backends, wait_counts, query_counts + ) values ( + v_start_ts, 0::oid, 1, 1, + array[v_wait, 1]::int4[], '{}'::int8[] + ); + insert into ash.sample ( + sample_ts, datid, active_count, data, slot + ) values + (v_start_ts, 0::oid, 1, array[-v_wait, 1, 0]::int4[], 0), + (v_recent_ts, 0::oid, 1, array[-v_wait, 1, 0]::int4[], 0); + + select * into a from ash.aas(v_start, v_end); + assert a.source = 'raw', + 'stale rollup must fall back to raw, got ' || a.source; + assert a.buckets_with_data = 2, + 'raw fallback should retain both covered minutes, got ' + || a.buckets_with_data; + assert a.backend_seconds = 2.00, + 'raw fallback should retain both backend-seconds, got ' + || a.backend_seconds; + + truncate ash.rollup_1m; + insert into ash.rollup_1m ( + ts, datid, samples, peak_backends, wait_counts, query_counts + ) + select + ash.ts_from_timestamptz(minute_ts), + 0::oid, + 1, + 1, + array[v_wait, 1]::int4[], + '{}'::int8[] + from generate_series( + v_start, + v_end - interval '1 minute', + interval '1 minute' + ) as minute_ts; + update ash.config + set last_rollup_1m_ts = ash.ts_from_timestamptz(v_historical_end) + where singleton; + + select * into a from ash.aas(v_start, v_historical_end); + assert a.source = 'rollup_1m', + 'healthy historical rollup must use its covered watermark, got ' + || a.source; + assert a.buckets_with_data = 90, + 'historical rollup should cover 90 complete minutes, got ' + || a.buckets_with_data; + assert a.backend_seconds = 90.00, + 'historical rollup should retain 90 backend-seconds, got ' + || a.backend_seconds; + + update ash.config + set last_rollup_1m_ts = ash.ts_from_timestamptz(v_end) + where singleton; + select * into a + from ash.aas(v_start, v_end + interval '1 minute'); + assert a.source = 'rollup_1m', + 'healthy rollup must keep the fast path for a rounded-up until, ' + 'got ' || a.source; + assert a.buckets_with_data = 120, + 'healthy rollup should cover 120 complete minutes, got ' + || a.buckets_with_data; + assert a.backend_seconds = 120.00, + 'healthy rollup should retain 120 backend-seconds, got ' + || a.backend_seconds; + + raise notice 'stale/rounded-up rollup source selection PASSED'; +end $$; + +rollback; From 54862894dc53beab565ea083e6804b9322f089a7 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:52:01 +0000 Subject: [PATCH 17/18] fix: floor report bounds before epoch conversion ash.ts_from_timestamptz() rounds fractional seconds while casting to int4. report() previously converted first and divided by 60 afterward, so an endpoint at :59.5 or later crossed into the next minute before the documented minute floor. Floor v_from/v_to in timestamp space first. Add a deterministic +59.9-second feature assertion: the old code shifts both bounds and reports three of four data-bearing minutes; the corrected code preserves the exact four-minute coverage. Record the public behavior change in RELEASE_NOTES.md. No assertion is weakened. --- RELEASE_NOTES.md | 5 +++++ devel/tests/features_readers.sql | 26 ++++++++++++++++++++++++++ sql/ash-install.sql | 9 +++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fbcfa1b..56756cf 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -77,6 +77,11 @@ AAS-oriented 2.0 API: minute no longer makes a healthy rollup fall back to raw. Completeness when a window starts before physical raw coverage remains tracked by issue #122. ([PR #199](https://github.com/NikolayS/pg_ash/pull/199)) +- **Report windows now floor subsecond endpoints before epoch conversion.** + `ash.report()` no longer shifts a request made in the final half-second of a + minute into the next minute, so its coverage bounds and data-bearing minute + count remain aligned with the documented minute floor. + ([PR #192](https://github.com/NikolayS/pg_ash/pull/192)) ## Retired tooling diff --git a/devel/tests/features_readers.sql b/devel/tests/features_readers.sql index 67808f3..e7ec2dd 100644 --- a/devel/tests/features_readers.sql +++ b/devel/tests/features_readers.sql @@ -936,6 +936,7 @@ declare v_expected_keys text[]; v_keys text[]; v_report jsonb; + v_boundary_report jsonb; begin select * into strict v_fixture @@ -1108,6 +1109,31 @@ begin pg_catalog.current_setting('ash.feature_mode'), v_report -> 'coverage' ); + + select ash.report( + v_fixture.fixture_start + interval '59.9 seconds', + v_fixture.fixture_end + interval '59.9 seconds', + vcpus => 8, + n => 3 + ) + into strict v_boundary_report; + assert ( + v_boundary_report -> 'coverage' ->> 'from' + )::timestamptz = v_fixture.fixture_start + and ( + v_boundary_report -> 'coverage' ->> 'to' + )::timestamptz = v_fixture.fixture_end + and ( + v_boundary_report -> 'coverage' ->> 'minutes_expected' + )::int = 4 + and ( + v_boundary_report -> 'coverage' ->> 'minutes_with_data' + )::int = 4, + format( + '[%s] ash.report minute-floor boundary: expected exact four-minute coverage, got %s', + pg_catalog.current_setting('ash.feature_mode'), + (v_boundary_report -> 'coverage')::text + ); end $feature_report$; diff --git a/sql/ash-install.sql b/sql/ash-install.sql index 8df1a24..cc0f5c0 100644 --- a/sql/ash-install.sql +++ b/sql/ash-install.sql @@ -6337,8 +6337,13 @@ begin 'ash.report: since must be less than or equal to until'; end if; - v_start_ts := (ash.ts_from_timestamptz(v_from) / 60) * 60; - v_end_ts := (ash.ts_from_timestamptz(v_to) / 60) * 60; + /* + * Floor in timestamp space before converting to int4 seconds. + * ts_from_timestamptz() rounds fractional seconds while casting; converting + * first could therefore move a :59.5+ endpoint into the next minute. + */ + v_start_ts := ash.ts_from_timestamptz(date_trunc('minute', v_from)); + v_end_ts := ash.ts_from_timestamptz(date_trunc('minute', v_to)); -- overflow-safe empty/degenerate-window guard (#63). if v_end_ts <= v_start_ts then v_end_ts := least(v_start_ts::bigint + 60, 2147483647)::int4; From fcd37d71efbe5c2b86b2374b52a07d42194db4b8 Mon Sep 17 00:00:00 2001 From: samo-agent <280144521+samo-agent@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:54:31 +0000 Subject: [PATCH 18/18] test: include query-history helper in reader bundle After #198, grant_reader() correctly discovers 44 non-admin reader functions rather than the pre-#198 count of 43. Update both the explicit-role and default pg_monitor feature contracts, and name _exact_query_uses_coarser(integer,integer,name) in a direct EXECUTE assertion so an unrelated 44th grant cannot mask a missing helper. The initial exact PG14/PG17 feature run failed 44-versus-43; all four feature-bearing surfaces now pass on both majors. --- devel/tests/features_privileges.sql | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/devel/tests/features_privileges.sql b/devel/tests/features_privileges.sql index 6ad54da..37a7053 100644 --- a/devel/tests/features_privileges.sql +++ b/devel/tests/features_privileges.sql @@ -81,10 +81,10 @@ begin 'ash', 'USAGE' ) - and v_direct_functions = 43 + and v_direct_functions = 44 and v_direct_tables = 12, format( - '[%s] ash.grant_reader ACLs: expected schema USAGE, 43 direct function EXECUTEs, and 12 direct table SELECTs; got usage=%s functions=%s tables=%s', + '[%s] ash.grant_reader ACLs: expected schema USAGE, 44 direct function EXECUTEs, and 12 direct table SELECTs; got usage=%s functions=%s tables=%s', pg_catalog.current_setting('ash.feature_mode'), pg_catalog.has_schema_privilege( 'ash_feature_reader', @@ -94,6 +94,15 @@ begin v_direct_functions, v_direct_tables ); + assert pg_catalog.has_function_privilege( + 'ash_feature_reader', + 'ash._exact_query_uses_coarser(integer,integer,name)', + 'EXECUTE' + ), + format( + '[%s] ash.grant_reader helper bundle: _exact_query_uses_coarser() is not executable', + pg_catalog.current_setting('ash.feature_mode') + ); assert not pg_catalog.has_function_privilege( 'ash_feature_reader', 'ash.start(interval)', @@ -366,7 +375,7 @@ begin and acl.privilege_type = 'SELECT'; assert pg_catalog.has_schema_privilege('pg_monitor', 'ash', 'USAGE') - and v_direct_functions = 43 + and v_direct_functions = 44 and v_direct_tables = 12 and not pg_catalog.has_function_privilege( 'pg_monitor', @@ -379,7 +388,7 @@ begin 'EXECUTE' ), format( - '[%s] default pg_monitor ACLs: expected USAGE/43 reader functions/12 tables/no start() or _admin_funcs(), got usage=%s functions=%s tables=%s start=%s admin_list=%s', + '[%s] default pg_monitor ACLs: expected USAGE/44 reader functions/12 tables/no start() or _admin_funcs(), got usage=%s functions=%s tables=%s start=%s admin_list=%s', pg_catalog.current_setting('ash.feature_mode'), pg_catalog.has_schema_privilege('pg_monitor', 'ash', 'USAGE'), v_direct_functions, @@ -395,6 +404,15 @@ begin 'EXECUTE' ) ); + assert pg_catalog.has_function_privilege( + 'pg_monitor', + 'ash._exact_query_uses_coarser(integer,integer,name)', + 'EXECUTE' + ), + format( + '[%s] default pg_monitor helper bundle: _exact_query_uses_coarser() is not executable', + pg_catalog.current_setting('ash.feature_mode') + ); execute 'set local role pg_monitor'; select *