diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a57147c..c7670c3 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"] @@ -2619,44 +2619,40 @@ 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 - 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 @@ -2697,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: @@ -3669,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') @@ -3678,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'; @@ -7085,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: @@ -10160,6 +10047,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 @@ -11020,67 +10949,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 +10959,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 +10972,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 +10992,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 +11069,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 +11092,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,6 +11128,264 @@ 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;" + # 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[@]}" \ + --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: 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/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 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..41ee88f --- /dev/null +++ b/devel/scripts/release_gate.sh @@ -0,0 +1,1027 @@ +#!/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 + features + 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 + features + 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 + script + 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 SQL entrypoints fail atomically" \ + "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 \ + 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 \ + range \ + "Upgrade path: discovered full 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() { + 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_feature_mode neither false false +} + +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 | features | 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}" + ;; + features) + run_features + ;; + 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() diff --git a/devel/tests/config_ordinal_assert.sql b/devel/tests/config_ordinal_assert.sql new file mode 100644 index 0000000..257ddc9 --- /dev/null +++ b/devel/tests/config_ordinal_assert.sql @@ -0,0 +1,185 @@ +\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', + 'consecutive_rotate_failures' + ]::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'; + assert v_config.consecutive_rotate_failures = 0, + 'consecutive_rotate_failures 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 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'; + + 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/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/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..f73f7c7 --- /dev/null +++ b/devel/tests/features_fixture.sql @@ -0,0 +1,265 @@ +/* + * 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_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, + raw_retention_start 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, + raw_retention_start, + 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', + 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, + lock_wait.id, + query_10101.id, + 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( + '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() + 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, + 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', + 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..d35c078 --- /dev/null +++ b/devel/tests/features_lifecycle.sql @@ -0,0 +1,1164 @@ +/* ------------------------------------------------------------------------- + * 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_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(*) + 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; + 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 + 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' + and (v_decoded -> 0 ->> 1)::bigint + is not distinct from v_expected_query_id, + format( + '[%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, + ( + 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 + ) = 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 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(*) + 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_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 + 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 + ); + + 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, + 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_2 + ) = 0 + and ( + select pg_catalog.count(*) + from ash.sample + ) = 4, + format( + '[%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(), + ( + select pg_catalog.count(*) + from ash.query_map_2 + ), + ( + select pg_catalog.count(*) + from ash.sample_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; + + /* + * 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_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; + 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 '3 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 '3 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 = 2, + 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 preserved readers. + * ------------------------------------------------------------------------- */ +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_monitor_read boolean := false; + v_reader_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 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 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' + ) + ); + + 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_reader_read := true; + exception + when insufficient_privilege then + v_reader_read := false; + end; + execute 'reset role'; + + 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 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$; + +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. + * ------------------------------------------------------------------------- */ +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 +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 = 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 %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') + ); + 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; + +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$; diff --git a/devel/tests/features_privileges.sql b/devel/tests/features_privileges.sql new file mode 100644 index 0000000..37a7053 --- /dev/null +++ b/devel/tests/features_privileges.sql @@ -0,0 +1,449 @@ +/* ------------------------------------------------------------------------- + * 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 = 44 + and v_direct_tables = 12, + format( + '[%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', + 'ash', + 'USAGE' + ), + 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)', + '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' + ) + 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', + 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_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; + 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 = 44 + 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/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, + v_direct_tables, + pg_catalog.has_function_privilege( + 'pg_monitor', + 'ash.start(interval)', + 'EXECUTE' + ), + pg_catalog.has_function_privilege( + 'pg_monitor', + 'ash._admin_funcs()', + '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 * + 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..e7ec2dd --- /dev/null +++ b/devel/tests/features_readers.sql @@ -0,0 +1,1574 @@ +/* ------------------------------------------------------------------------- + * 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; + v_expected_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; + + 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 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 + ); +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_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(5) + into v_rolled; + assert v_rolled = 5, + format( + '[%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.count(*) + into v_rollup_rows + from ash.rollup_1m; + + assert v_rollup_rows = 4, + format( + '[%s] ash.rollup_minute rows: expected exactly four data-bearing rollup_1m rows, got %s', + pg_catalog.current_setting('ash.feature_mode'), + v_rollup_rows + ); + + 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_cluster_name text := + pg_catalog.current_setting('cluster_name', true); + v_fixture ash_feature_context%rowtype; + v_expected_keys text[]; + v_keys text[]; + v_report jsonb; + v_boundary_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 ( + 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', + 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.raw_retention_start + ), + format( + '[%s] ash.report coverage: expected exact four-minute rollup coverage, got %s', + 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$; + +/* ------------------------------------------------------------------------- + * 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 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, + 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 + ) with ordinality as chart_row( + bucket_start, + aas, + detail, + chart, + ordinal + ); + + 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 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', + 'period_end', + 'source', + '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', + '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', + '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%)', + '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 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 + ); +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.raw_retention_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$; 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); 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; diff --git a/sql/ash-install.sql b/sql/ash-install.sql index 84da148..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; @@ -7140,7 +7145,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.$$; diff --git a/sql/migrations/ash-1.5-to-2.0.sql b/sql/migrations/ash-1.5-to-2.0.sql index c98933b..2404909 100644 --- a/sql/migrations/ash-1.5-to-2.0.sql +++ b/sql/migrations/ash-1.5-to-2.0.sql @@ -22,15 +22,882 @@ * * 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 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 + * 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', + 'consecutive_rotate_failures' + ]::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', + '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, + 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 ( + select count(*) + from pg_catalog.pg_trigger as trigger_row + where + trigger_row.tgrelid = v_relation_oid + and not trigger_row.tgisinternal + ) <> 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 ( + 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, + consecutive_rotate_failures 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, + consecutive_rotate_failures + ) + 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, + 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', + 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;