From 4407cbc0166783362cfa132819af97d143fe12dc Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Sun, 6 Sep 2026 20:50:14 +0000 Subject: [PATCH v4 3/3] Add unlogged materialized views Allow CREATE and ALTER UNLOGGED MATERIALIZED VIEW. Storage is unlogged, so REFRESH populates the data without WAL-logging it. Stamp the populated epoch on refresh and treat stale epochs (after a crash, promotion, or PITR) as unpopulated. Support ALTER MATERIALIZED VIEW SET LOGGED/UNLOGGED, teach psql to report an unlogged materialized view as such and to tab-complete the new syntax. Add recovery tests for the crash, standby and promotion cases, plus docs. --- doc/src/sgml/func/func-info.sgml | 4 +- doc/src/sgml/ref/alter_materialized_view.sgml | 24 ++ .../sgml/ref/create_materialized_view.sgml | 44 +++- doc/src/sgml/storage.sgml | 10 + src/backend/access/heap/heapam_handler.c | 3 +- src/backend/commands/matview.c | 41 ++- src/backend/commands/repack.c | 23 ++ src/backend/commands/tablecmds.c | 3 +- src/backend/optimizer/util/plancat.c | 19 ++ src/backend/parser/analyze.c | 12 - src/bin/pg_dump/pg_dump.c | 9 +- src/bin/pg_dump/t/002_pg_dump.pl | 36 +++ src/bin/psql/describe.c | 8 +- src/bin/psql/tab-complete.in.c | 23 +- src/test/recovery/meson.build | 2 + src/test/recovery/t/058_unlogged_matview.pl | 150 +++++++++++ .../t/059_unlogged_matview_standby.pl | 205 +++++++++++++++ src/test/regress/expected/matview.out | 238 ++++++++++++++++++ src/test/regress/sql/matview.sql | 74 ++++++ 19 files changed, 886 insertions(+), 42 deletions(-) create mode 100644 src/test/recovery/t/058_unlogged_matview.pl create mode 100644 src/test/recovery/t/059_unlogged_matview_standby.pl diff --git a/doc/src/sgml/func/func-info.sgml b/doc/src/sgml/func/func-info.sgml index 7ac5ec409aa..1242d12ade0 100644 --- a/doc/src/sgml/func/func-info.sgml +++ b/doc/src/sgml/func/func-info.sgml @@ -1872,7 +1872,9 @@ SELECT currval(pg_get_serial_sequence('sometable', 'id')); Returns true if the materialized view currently holds valid data, false if it must be refreshed before use. Returns NULL for arguments that are not materialized - views. + views. Unlike reading pg_class directly, + this accounts for unlogged materialized views whose contents were + removed by a crash or are unavailable during recovery. diff --git a/doc/src/sgml/ref/alter_materialized_view.sgml b/doc/src/sgml/ref/alter_materialized_view.sgml index f81a7393f5d..9fc6e4a4b34 100644 --- a/doc/src/sgml/ref/alter_materialized_view.sgml +++ b/doc/src/sgml/ref/alter_materialized_view.sgml @@ -45,6 +45,7 @@ ALTER MATERIALIZED VIEW ALL IN TABLESPACE namenew_access_method SET TABLESPACE new_tablespace + SET { LOGGED | UNLOGGED } SET ( storage_parameter [= value] [, ... ] ) RESET ( storage_parameter [, ... ] ) OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER } @@ -152,6 +153,29 @@ ALTER MATERIALIZED VIEW ALL IN TABLESPACE name + + + SET { LOGGED | UNLOGGED } + + + This form changes the materialized view from unlogged to logged or + vice-versa (see UNLOGGED in + CREATE + MATERIALIZED VIEW), the same as + SET + { LOGGED | UNLOGGED } does for + ALTER TABLE. The change takes effect immediately: + whatever the materialized view currently holds is preserved and remains + queryable, no crash or refresh is involved. Once set to + UNLOGGED, however, the contents will be lost after + a future crash or unclean shutdown, at which point the materialized + view reverts to the unpopulated state. Conversely, setting an unlogged + materialized view that has already reverted to the unpopulated state + back to LOGGED leaves it unpopulated; there is no + data left to make crash-safe. + + + diff --git a/doc/src/sgml/ref/create_materialized_view.sgml b/doc/src/sgml/ref/create_materialized_view.sgml index 62d897931c3..74f6485d4d9 100644 --- a/doc/src/sgml/ref/create_materialized_view.sgml +++ b/doc/src/sgml/ref/create_materialized_view.sgml @@ -21,7 +21,7 @@ PostgreSQL documentation -CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name +CREATE [ UNLOGGED ] MATERIALIZED VIEW [ IF NOT EXISTS ] table_name [ (column_name [, ...] ) ] [ USING method ] [ WITH ( storage_parameter [= value] [, ... ] ) ] @@ -60,7 +60,33 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name Parameters - + + UNLOGGED + + + If specified, the materialized view is created as an unlogged + materialized view. Data written to an unlogged materialized view is + not written to the write-ahead log (see ), which + makes populating it, whether by CREATE MATERIALIZED + VIEW or by a later REFRESH MATERIALIZED + VIEW, considerably faster. However, an unlogged materialized + view is not crash-safe: its contents are discarded after a crash or + unclean shutdown, at which point the materialized view reverts to the + unpopulated state and must be rebuilt with REFRESH + MATERIALIZED VIEW before it can be queried again, the same as + a materialized view created with WITH NO DATA (see + below). Unlike an unlogged table, which reads as empty after a crash, + an unlogged materialized view instead reports that it has not been + populated. Contents are preserved across a normal shutdown and + restart. The contents of an unlogged materialized view are also not + replicated to standby servers, so on a standby such a materialized view + always reports itself as unpopulated, even immediately after the + primary refreshes it. + + + + + IF NOT EXISTS @@ -72,7 +98,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name - + table_name @@ -84,7 +110,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name - + column_name @@ -94,7 +120,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name - + USING method @@ -109,7 +135,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name - + WITH ( storage_parameter [= value] [, ... ] ) @@ -125,7 +151,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name - + TABLESPACE tablespace_name @@ -136,7 +162,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name - + query @@ -150,7 +176,7 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name - + WITH [ NO ] DATA diff --git a/doc/src/sgml/storage.sgml b/doc/src/sgml/storage.sgml index 83de016eaa5..c9735372881 100644 --- a/doc/src/sgml/storage.sgml +++ b/doc/src/sgml/storage.sgml @@ -702,6 +702,16 @@ initialization fork is copied over the main fork, and any other forks are erased (they will be recreated automatically as needed). + +An unlogged materialized view has an initialization fork too, and its +storage is reset the same way after a crash. Because a materialized view +additionally tracks whether it is populated, resetting its storage also +makes it read as unpopulated rather than as empty; a subsequent +REFRESH MATERIALIZED VIEW repopulates it. This check +happens whenever the materialized view is accessed, so no explicit repair +step or new session is required after the crash. + + diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c index 6adb760b54f..60c37731fd5 100644 --- a/src/backend/access/heap/heapam_handler.c +++ b/src/backend/access/heap/heapam_handler.c @@ -517,7 +517,8 @@ heapam_relation_set_new_filelocator(Relation rel, if (persistence == RELPERSISTENCE_UNLOGGED) { Assert(rel->rd_rel->relkind == RELKIND_RELATION || - rel->rd_rel->relkind == RELKIND_TOASTVALUE); + rel->rd_rel->relkind == RELKIND_TOASTVALUE || + rel->rd_rel->relkind == RELKIND_MATVIEW); smgrcreate(srel, INIT_FORKNUM, false); log_smgrcreate(newrlocator, INIT_FORKNUM); } diff --git a/src/backend/commands/matview.c b/src/backend/commands/matview.c index f8c18720e99..8573876e4be 100644 --- a/src/backend/commands/matview.c +++ b/src/backend/commands/matview.c @@ -120,14 +120,25 @@ SetMatViewPopulatedState(Relation relation, bool newstate) /* * MatViewPopulatedValueIsValid * Does this pg_class.relpopulated value denote currently valid data? - * - * This only distinguishes RELPOPULATED_NONE from everything else; any other - * value, whether RELPOPULATED_ETERNAL or an epoch stamp, counts as valid. */ bool MatViewPopulatedValueIsValid(int64 value) { - return value != RELPOPULATED_NONE; + if (value == RELPOPULATED_NONE) + return false; + if (value == RELPOPULATED_ETERNAL) + return true; + + /* + * Epoch stamp: valid only if it matches the current epoch. During + * recovery always treat it as invalid -- a standby never has the unlogged + * data, and its epoch is still the one its base backup came with, which + * may well be the primary's current one. + */ + if (RecoveryInProgress()) + return false; + + return (uint64) value == GetUnloggedPopulatedEpoch(); } /* @@ -138,6 +149,12 @@ MatViewPopulatedValueIsValid(int64 value) bool RelationIsPopulated(Relation relation) { + /* Only unlogged matviews may carry an epoch stamp. */ + Assert(relation->rd_rel->relpopulated == RELPOPULATED_NONE || + relation->rd_rel->relpopulated == RELPOPULATED_ETERNAL || + (relation->rd_rel->relkind == RELKIND_MATVIEW && + relation->rd_rel->relpersistence == RELPERSISTENCE_UNLOGGED)); + return MatViewPopulatedValueIsValid(relation->rd_rel->relpopulated); } @@ -356,10 +373,20 @@ RefreshMatViewByOid(Oid matviewOid, bool is_create, bool skipData, /* * Tentatively mark the matview as populated or not, if its state is - * changing (this will roll back if we fail later). + * changing (this will roll back if we fail later). WITH NO DATA must + * also clear a stale epoch stamp, which reads as not populated but still + * records that the matview is meant to hold data. */ - if (RelationIsPopulated(matviewRel) != !skipData) - SetMatViewPopulatedState(matviewRel, !skipData); + if (skipData) + { + if (matviewRel->rd_rel->relpopulated != RELPOPULATED_NONE) + SetMatViewPopulatedState(matviewRel, false); + } + else + { + if (!RelationIsPopulated(matviewRel)) + SetMatViewPopulatedState(matviewRel, true); + } /* Concurrent refresh builds new data in temp tablespace, and does diff. */ if (concurrent) diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c index db7cf65d674..be833531032 100644 --- a/src/backend/commands/repack.c +++ b/src/backend/commands/repack.c @@ -1686,6 +1686,29 @@ swap_relation_files(Oid r1, Oid r2, bool target_is_pg_class, relform1->relpersistence = relform2->relpersistence; relform2->relpersistence = swptmpchr; + /* + * A matview's relpopulated value encodes its persistence class: + * permanent matviews use RELPOPULATED_ETERNAL, unlogged matviews + * carry an epoch stamp. Convert the value alongside the persistence + * change (relform1 is the surviving relation's own pg_class row; the + * relkind check skips it in the recursive call for TOAST). A stale + * stamp (populated before the last crash or promotion) means the + * storage is empty, so converting it to LOGGED must yield "not + * populated" rather than eternally-populated garbage. + */ + if (relform1->relkind == RELKIND_MATVIEW && + relform1->relpopulated != RELPOPULATED_NONE) + { + if (relform1->relpersistence == RELPERSISTENCE_UNLOGGED && + relform1->relpopulated == RELPOPULATED_ETERNAL) + relform1->relpopulated = (int64) GetUnloggedPopulatedEpoch(); + else if (relform1->relpersistence == RELPERSISTENCE_PERMANENT && + relform1->relpopulated != RELPOPULATED_ETERNAL) + relform1->relpopulated = + MatViewPopulatedValueIsValid(relform1->relpopulated) + ? RELPOPULATED_ETERNAL : RELPOPULATED_NONE; + } + /* Also swap toast links, if we're swapping by links */ if (!swap_toast_by_content) { diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index 0274d892f2e..6631ca026d1 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -5205,7 +5205,8 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd, break; case AT_SetLogged: /* SET LOGGED */ case AT_SetUnLogged: /* SET UNLOGGED */ - ATSimplePermissions(cmd->subtype, rel, ATT_TABLE | ATT_SEQUENCE); + ATSimplePermissions(cmd->subtype, rel, + ATT_TABLE | ATT_SEQUENCE | ATT_MATVIEW); if (tab->chgPersistence) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c index d067368cfc1..eb5ea760cd7 100644 --- a/src/backend/optimizer/util/plancat.c +++ b/src/backend/optimizer/util/plancat.c @@ -32,6 +32,7 @@ #include "catalog/pg_proc.h" #include "catalog/pg_statistic_ext.h" #include "catalog/pg_statistic_ext_data.h" +#include "commands/matview.h" #include "foreign/fdwapi.h" #include "miscadmin.h" #include "nodes/makefuncs.h" @@ -152,6 +153,24 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent, errdetail_relkind_not_supported(relation->rd_rel->relkind))); } + /* + * An unlogged matview has no storage on a standby, though its + * relpopulated may still carry an epoch stamp from the primary. The + * generic recovery guard just below would reject it too, but reporting it + * as unpopulated matches what the same matview does on the primary once a + * crash has invalidated its stamp. Permanent matviews are excluded so + * plan-time behavior is unchanged. No REFRESH hint, as that cannot run + * during recovery. + */ + if (relation->rd_rel->relkind == RELKIND_MATVIEW && + !RelationIsPermanent(relation) && + RecoveryInProgress() && + !RelationIsPopulated(relation)) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("materialized view \"%s\" has not been populated", + RelationGetRelationName(relation)))); + /* Temporary and unlogged relations are inaccessible during recovery. */ if (!RelationIsPermanent(relation) && RecoveryInProgress()) ereport(ERROR, diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c index 08f99dff711..cde1ad7b7df 100644 --- a/src/backend/parser/analyze.c +++ b/src/backend/parser/analyze.c @@ -3209,18 +3209,6 @@ transformCreateTableAsStmt(ParseState *pstate, CreateTableAsStmt *stmt) (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("materialized views may not be defined using bound parameters"))); - /* - * For now, we disallow unlogged materialized views, because it seems - * like a bad idea for them to just go to empty after a crash. (If we - * could mark them as unpopulated, that would be better, but that - * requires catalog changes which crash recovery can't presently - * handle.) - */ - if (stmt->into->rel->relpersistence == RELPERSISTENCE_UNLOGGED) - ereport(ERROR, - (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), - errmsg("materialized views cannot be unlogged"))); - /* * At runtime, we'll need a copy of the parsed-but-not-rewritten Query * for purposes of creating the view's ON SELECT rule. We stash that diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c index d14e8f9a5c1..4a9d0f3c09f 100644 --- a/src/bin/pg_dump/pg_dump.c +++ b/src/bin/pg_dump/pg_dump.c @@ -3022,8 +3022,15 @@ makeTableDataInfo(DumpOptions *dopt, TableInfo *tbinfo) if (tbinfo->relkind == RELKIND_PARTITIONED_TABLE) return; - /* Don't dump data in unlogged tables, if so requested */ + /* + * Don't dump data in unlogged tables, if so requested. This does not + * apply to unlogged materialized views: their REFRESH carries no data, + * and skipping it would break the REFRESH of any matview built on top. + * XXX Whether the option should also cover them (and mark dependent + * matviews unpopulated instead) is an open question. + */ if (tbinfo->relpersistence == RELPERSISTENCE_UNLOGGED && + tbinfo->relkind != RELKIND_MATVIEW && dopt->no_unlogged_table_data) return; diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl index 1299c837063..d91e6a92d25 100644 --- a/src/bin/pg_dump/t/002_pg_dump.pl +++ b/src/bin/pg_dump/t/002_pg_dump.pl @@ -2976,6 +2976,25 @@ my %tests = ( }, }, + 'CREATE UNLOGGED MATERIALIZED VIEW matview_unlogged' => { + create_order => 21, + create_sql => 'CREATE UNLOGGED MATERIALIZED VIEW + dump_test.matview_unlogged (col1) AS + SELECT * FROM dump_test.matview;', + regexp => qr/^ + \QCREATE UNLOGGED MATERIALIZED VIEW dump_test.matview_unlogged AS\E + \n\s+\QSELECT col1\E + \n\s+\QFROM dump_test.matview\E + \n\s+\QWITH NO DATA;\E + /xm, + like => + { %full_runs, %dump_test_schema_runs, section_pre_data => 1, }, + unlike => { + exclude_dump_test_schema => 1, + only_dump_measurement => 1, + }, + }, + 'CREATE MATERIALIZED VIEW matview_third' => { create_order => 58, create_sql => 'CREATE MATERIALIZED VIEW @@ -4649,6 +4668,23 @@ my %tests = ( }, }, + # not affected by --no-unlogged-table-data + 'REFRESH MATERIALIZED VIEW matview_unlogged' => { + regexp => qr/^ + \QREFRESH MATERIALIZED VIEW dump_test.matview;\E + \n.* + \QREFRESH MATERIALIZED VIEW dump_test.matview_unlogged;\E + /xms, + like => + { %full_runs, %dump_test_schema_runs, section_post_data => 1, }, + unlike => { + binary_upgrade => 1, + exclude_dump_test_schema => 1, + schema_only => 1, + only_dump_measurement => 1, + }, + }, + 'REFRESH MATERIALIZED VIEW matview_second' => { regexp => qr/^ \QREFRESH MATERIALIZED VIEW dump_test.matview;\E diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c index dc9b3841592..25cda865b96 100644 --- a/src/bin/psql/describe.c +++ b/src/bin/psql/describe.c @@ -1935,8 +1935,12 @@ describeOneTableDetails(const char *schemaname, schemaname, relationname); break; case RELKIND_MATVIEW: - printfPQExpBuffer(&title, _("Materialized view \"%s.%s\""), - schemaname, relationname); + if (tableinfo.relpersistence == RELPERSISTENCE_UNLOGGED) + printfPQExpBuffer(&title, _("Unlogged materialized view \"%s.%s\""), + schemaname, relationname); + else + printfPQExpBuffer(&title, _("Materialized view \"%s.%s\""), + schemaname, relationname); break; case RELKIND_INDEX: if (tableinfo.relpersistence == RELPERSISTENCE_UNLOGGED) diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c index b3bfe050b18..446fbdd5e89 100644 --- a/src/bin/psql/tab-complete.in.c +++ b/src/bin/psql/tab-complete.in.c @@ -2718,7 +2718,8 @@ match_previous_words(int pattern_id, COMPLETE_WITH("TO"); /* ALTER MATERIALIZED VIEW xxx SET */ else if (Matches("ALTER", "MATERIALIZED", "VIEW", MatchAny, "SET")) - COMPLETE_WITH("(", "ACCESS METHOD", "SCHEMA", "TABLESPACE", "WITHOUT CLUSTER"); + COMPLETE_WITH("(", "ACCESS METHOD", "LOGGED", "SCHEMA", "TABLESPACE", + "UNLOGGED", "WITHOUT CLUSTER"); /* ALTER MATERIALIZED VIEW xxx SET ACCESS METHOD */ else if (Matches("ALTER", "MATERIALIZED", "VIEW", MatchAny, "SET", "ACCESS", "METHOD")) COMPLETE_WITH_QUERY(Query_for_list_of_table_access_methods); @@ -3790,9 +3791,9 @@ match_previous_words(int pattern_id, /* Complete "CREATE TEMP/TEMPORARY" with the possible temp objects */ else if (TailMatches("CREATE", "TEMP|TEMPORARY")) COMPLETE_WITH("SEQUENCE", "TABLE", "VIEW"); - /* Complete "CREATE UNLOGGED" with TABLE or SEQUENCE */ + /* Complete "CREATE UNLOGGED" with the possible unlogged objects */ else if (TailMatches("CREATE", "UNLOGGED")) - COMPLETE_WITH("TABLE", "SEQUENCE"); + COMPLETE_WITH("MATERIALIZED VIEW", "SEQUENCE", "TABLE"); /* Complete PARTITION BY with RANGE ( or LIST ( or ... */ else if (TailMatches("PARTITION", "BY")) COMPLETE_WITH("RANGE (", "LIST (", "HASH ("); @@ -4184,20 +4185,24 @@ match_previous_words(int pattern_id, COMPLETE_WITH("SELECT"); /* CREATE MATERIALIZED VIEW */ - else if (Matches("CREATE", "MATERIALIZED")) + else if (Matches("CREATE", "MATERIALIZED") || + Matches("CREATE", "UNLOGGED", "MATERIALIZED")) COMPLETE_WITH("VIEW"); /* Complete CREATE MATERIALIZED VIEW with AS or USING */ - else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny)) + else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny) || + Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny)) COMPLETE_WITH("AS", "USING"); /* * Complete CREATE MATERIALIZED VIEW USING with list of access * methods */ - else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING")) + else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING") || + Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny, "USING")) COMPLETE_WITH_QUERY(Query_for_list_of_table_access_methods); /* Complete CREATE MATERIALIZED VIEW USING with AS */ - else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny)) + else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny) || + Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny)) COMPLETE_WITH("AS"); /* @@ -4205,7 +4210,9 @@ match_previous_words(int pattern_id, * with "SELECT" */ else if (Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "AS") || - Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny, "AS")) + Matches("CREATE", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny, "AS") || + Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny, "AS") || + Matches("CREATE", "UNLOGGED", "MATERIALIZED", "VIEW", MatchAny, "USING", MatchAny, "AS")) COMPLETE_WITH("SELECT"); /* CREATE EVENT TRIGGER */ diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index ebb12dd8766..5420ef5659b 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -66,6 +66,8 @@ tests += { 't/055_cascade_reconnect.pl', 't/056_standby_snapshot_export.pl', 't/057_snapshot_commit_race.pl', + 't/058_unlogged_matview.pl', + 't/059_unlogged_matview_standby.pl', ], }, } diff --git a/src/test/recovery/t/058_unlogged_matview.pl b/src/test/recovery/t/058_unlogged_matview.pl new file mode 100644 index 00000000000..7714e709aeb --- /dev/null +++ b/src/test/recovery/t/058_unlogged_matview.pl @@ -0,0 +1,150 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# Tests the crash-recovery contract for UNLOGGED MATERIALIZED VIEWs. +# +# An unlogged matview's pg_class.relpopulated carries an epoch stamp: the end +# of WAL at the last reset of unlogged relations before it was populated. +# Crash recovery starts a new epoch at its own end of WAL, so after a crash +# the stale stamp reads as unpopulated at scan time, with no catalog repair +# needed. A clean restart leaves the epoch unchanged, so contents survive. +# A logged matview is unaffected by a crash. REFRESH stamps the current +# epoch and restores the contents. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('umv'); +$node->init; +$node->start; + +# mv_conv is converted to LOGGED after the crash below. +$node->safe_psql('postgres', <<'SQL'); +CREATE UNLOGGED MATERIALIZED VIEW mv_u AS SELECT 42 AS x; +CREATE UNLOGGED MATERIALIZED VIEW mv_conv AS SELECT 7 AS x; +CREATE MATERIALIZED VIEW mv_p AS SELECT 43 AS x; +SQL + +is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'), + '1', 'unlogged matview is scannable after creation'); +is( $node->safe_psql( + 'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}), + 't', + 'unlogged matview reports populated after creation'); + +# --- Clean restart preserves contents ----------------------------------------- +# +# A clean shutdown does not reset unlogged relations or start a new epoch, so +# the epoch stamp is still current and the data survives. + +$node->restart; + +is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'), + '1', 'unlogged matview contents preserved across a clean restart'); + +# --- Crash makes the epoch stamp stale ---------------------------------------- +# +# Crash recovery starts a new epoch, so the stamp written at population time +# no longer matches the current epoch. Reads treat the matview as +# unpopulated without any catalog write. + +$node->stop('immediate'); +$node->start; + +my ($rc, $out, $err) = + $node->psql('postgres', 'SELECT count(*) FROM mv_u'); +isnt($rc, 0, 'SELECT on crash-stale unlogged matview fails'); +like( + $err, + qr/has not been populated/, + 'crash-stale unlogged matview reports "has not been populated"'); +is( $node->safe_psql( + 'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}), + 'f', + 'pg_matview_is_populated is false for crash-stale unlogged matview'); +is( $node->safe_psql( + 'postgres', + q{SELECT ispopulated FROM pg_matviews WHERE matviewname = 'mv_u'}), + 'f', + 'pg_matviews.ispopulated is false for crash-stale unlogged matview'); + +# The logged matview is unaffected by the crash. +is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_p'), + '1', 'logged matview still returns rows after crash'); + +# ALTER MATERIALIZED VIEW ... SET LOGGED must not launder a stale epoch stamp +# into an eternally-populated state: the unlogged storage was reset by the +# crash, so the converted matview must read as unpopulated until REFRESH. +$node->safe_psql('postgres', 'ALTER MATERIALIZED VIEW mv_conv SET LOGGED'); +is( $node->safe_psql( + 'postgres', + q{SELECT relpersistence FROM pg_class WHERE oid = 'mv_conv'::regclass} + ), + 'p', + 'crash-stale unlogged matview converted to LOGGED'); +($rc, $out, $err) = $node->psql('postgres', 'SELECT count(*) FROM mv_conv'); +like( + $err, + qr/has not been populated/, + 'stale stamp converted to "not populated", not to eternally-populated'); + +# WITH NO DATA clears the stale stamp, so pg_dump no longer sees it as +# meant to be populated. +$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u WITH NO DATA'); +is( $node->safe_psql( + 'postgres', + q{SELECT relpopulated FROM pg_class WHERE oid = 'mv_u'::regclass}), + '0', + 'REFRESH WITH NO DATA clears a stale stamp'); + +# REFRESH stamps the current epoch and restores the contents. +$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u'); +$node->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_conv'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'), + '1', 'REFRESH restores the unlogged matview after crash'); +is( $node->safe_psql( + 'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}), + 't', + 'unlogged matview reports populated again after REFRESH'); +is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_conv'), + '1', 'REFRESH restores the converted matview'); + +# --- pg_resetwal after a clean shutdown keeps the contents -------------------- +# +# Resetting the WAL of a cleanly shut down cluster does not touch unlogged +# storage, and the next startup does not run crash recovery, so the epoch +# stamp must stay current. pg_upgrade relies on this: it resets the WAL of +# the new cluster after restoring the schema, and the transferred unlogged +# matview heaps have to remain usable. + +$node->stop; + +$node->command_ok([ 'pg_resetwal', '-D', $node->data_dir ], + 'pg_resetwal on a cleanly shut down cluster'); + +$node->start; + +is($node->safe_psql('postgres', 'SELECT count(*) FROM mv_u'), + '1', 'unlogged matview survives pg_resetwal after a clean shutdown'); + +# --- pg_resetwal after an unclean shutdown discards the contents -------------- +# +# Here the unlogged storage is torn and nothing will reset it later, so the +# epoch has to move and the matview must read as unpopulated. + +$node->stop('immediate'); + +$node->command_ok([ 'pg_resetwal', '-f', '-D', $node->data_dir ], + 'pg_resetwal -f on an uncleanly shut down cluster'); + +$node->start; + +($rc, $out, $err) = $node->psql('postgres', 'SELECT count(*) FROM mv_u'); +like( + $err, + qr/has not been populated/, + 'unlogged matview unpopulated after pg_resetwal -f on a dirty cluster'); + +done_testing(); diff --git a/src/test/recovery/t/059_unlogged_matview_standby.pl b/src/test/recovery/t/059_unlogged_matview_standby.pl new file mode 100644 index 00000000000..9746ba9a3ea --- /dev/null +++ b/src/test/recovery/t/059_unlogged_matview_standby.pl @@ -0,0 +1,205 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +# Tests UNLOGGED MATERIALIZED VIEWs on a standby, and when it leaves +# recovery. +# +# A standby never has unlogged storage, so an unlogged matview must read as +# unpopulated there, whatever epoch stamp the primary replicated. Once the +# standby leaves recovery, its own epoch is past every replicated stamp, so +# the matview stays unpopulated without any catalog write. Two standbys +# from one backup leave recovery differently: promotion (new timeline) and +# restart without standby.signal (same timeline). A session kept open +# across the promotion must see the change at its next scan. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Extract "Unlogged relations reset at" from pg_controldata output. +sub unlogged_reset_lsn +{ + my ($node) = @_; + my ($stdout, $stderr) = run_command([ 'pg_controldata', $node->data_dir ]); + $stdout =~ /^Unlogged relations reset at:\s+(\S+)\r?$/m + or die "no unlogged reset LSN in pg_controldata output"; + return $1; +} + +# relpopulated of mv_u compared with an LSN: returns "<", "=" or ">". +sub stamp_vs_lsn +{ + my ($node, $lsn) = @_; + return $node->safe_psql( + 'postgres', qq{ + SELECT CASE sign(relpopulated - ('$lsn'::pg_lsn - '0/0')) + WHEN -1 THEN '<' WHEN 0 THEN '=' ELSE '>' END + FROM pg_class WHERE relname = 'mv_u'}); +} + +my $node_primary = PostgreSQL::Test::Cluster->new('primary'); +$node_primary->init(allows_streaming => 1); +$node_primary->start; + +$node_primary->safe_psql('postgres', <<'SQL'); +CREATE UNLOGGED MATERIALIZED VIEW mv_u AS SELECT 42 AS x; +CREATE MATERIALIZED VIEW mv_p AS SELECT 42 AS x; +SQL + +# --- Two standbys from the same backup -------------------------------------- + +$node_primary->backup('bkp'); + +my $node_standby = PostgreSQL::Test::Cluster->new('standby'); +$node_standby->init_from_backup($node_primary, 'bkp', has_streaming => 1); +$node_standby->start; + +my $node_standby2 = PostgreSQL::Test::Cluster->new('standby2'); +$node_standby2->init_from_backup($node_primary, 'bkp', has_streaming => 1); +$node_standby2->start; + +# --- Primary crash and a fresh stamp ------------------------------------------ + +$node_primary->stop('immediate'); +$node_primary->start; + +my $reset_primary = unlogged_reset_lsn($node_primary); + +# Repopulate, and let the new stamp replicate to both standbys, whose own +# pg_control still carries the epoch from the backup. +$node_primary->safe_psql('postgres', 'REFRESH MATERIALIZED VIEW mv_u'); +$node_primary->wait_for_catchup($node_standby); +$node_primary->wait_for_catchup($node_standby2); + +is(stamp_vs_lsn($node_standby, $reset_primary), + '=', 'standby replicated the primary\'s post-crash epoch stamp'); + +# --- In recovery, the unlogged matview is unpopulated ------------------------- + +is($node_standby->safe_psql('postgres', 'SELECT count(*) FROM mv_p'), + '1', 'logged matview is scannable on the standby'); + +my ($rc, $out, $err) = + $node_standby->psql('postgres', 'SELECT count(*) FROM mv_u'); +isnt($rc, 0, 'SELECT on unlogged matview fails on standby'); +like( + $err, + qr/has not been populated/, + 'unlogged matview reports "has not been populated" on standby'); +unlike( + $err, + qr/cannot access temporary or unlogged relations during recovery/, + 'unlogged matview does not report the generic unlogged-relation error'); + +# The plan-time guard in plancat.c must reject it too, before execution. +($rc, $out, $err) = + $node_standby->psql('postgres', 'EXPLAIN SELECT * FROM mv_u'); +like( + $err, + qr/has not been populated/, + 'EXPLAIN on unlogged matview reports "has not been populated" on standby' +); + +# COPY TO must honor the same scannability contract. +($rc, $out, $err) = $node_standby->psql('postgres', 'COPY mv_u TO stdout'); +like( + $err, + qr/unpopulated materialized view/, + 'COPY from unlogged matview reports unpopulated error on standby'); + +# pg_matview_is_populated() is per node: false on the standby while the very +# same stamp reads true on the primary. +is( $node_standby->safe_psql( + 'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}), + 'f', + 'pg_matview_is_populated is false for unlogged matview on standby'); +is( $node_primary->safe_psql( + 'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}), + 't', + 'pg_matview_is_populated is true for unlogged matview on primary'); +is( $node_standby->safe_psql( + 'postgres', + q{SELECT ispopulated FROM pg_matviews WHERE matviewname = 'mv_u'}), + 'f', + 'pg_matviews.ispopulated is false for unlogged matview on standby'); + +# --- A session that will survive the promotion -------------------------------- + +my $bg = $node_standby->background_psql('postgres', on_error_stop => 0); + +my ($bg_out, $bg_err) = $bg->query('SELECT count(*) FROM mv_u'); +is($bg_err, 1, 'surviving session: unlogged matview errors on the standby'); +$bg->{stderr} = ''; + +# --- Promote ------------------------------------------------------------------ + +$node_standby->promote; +$node_standby->poll_query_until('postgres', 'SELECT NOT pg_is_in_recovery()') + or die "standby never left recovery after promotion"; + +# The session predates the promotion, so no connect-time repair could have +# run for it. If the epoch check were not recomputed at scan time, the +# SELECT would silently return a zero count over the empty, never-replicated +# storage. +is($bg->query_safe('SELECT pg_is_in_recovery()'), + 'f', 'surviving session survived the promotion'); + +($bg_out, $bg_err) = $bg->query('SELECT count(*) FROM mv_u'); +is($bg_err, 1, + 'surviving session: SELECT on unlogged matview errors after promotion'); +is($bg_out, '', 'surviving session: SELECT returned no rows at all'); +like( + $bg->{stderr}, + qr/has not been populated/, + 'surviving session: promoted node reports "has not been populated"'); +$bg->{stderr} = ''; + +# A new connection agrees, and the promoted node reset unlogged relations +# past the replicated stamp. +is( $node_standby->safe_psql( + 'postgres', q{SELECT pg_matview_is_populated('mv_u'::regclass)}), + 'f', + 'pg_matview_is_populated is false on the promoted node'); +my $reset_promoted = unlogged_reset_lsn($node_standby); +is(stamp_vs_lsn($node_standby, $reset_promoted), + '<', 'replicated stamp is below the promoted node\'s epoch'); + +# REFRESH on the promoted node restores the matview. +$bg->query_safe('REFRESH MATERIALIZED VIEW mv_u'); +is($bg->query_safe('SELECT count(*) FROM mv_u'), + '1', 'surviving session: REFRESH restored the matview'); +is(stamp_vs_lsn($node_standby, $reset_promoted), + '=', 'post-promotion REFRESH stamped the promoted node\'s epoch'); + +$bg->quit; + +# --- Leaving recovery without promotion --------------------------------------- +# +# Restart the second standby without standby.signal. That runs plain crash +# recovery, which keeps timeline 1, yet the stamp it replicated from the +# primary must still read as unpopulated: the standby has no data for it. + +$node_standby2->stop; +unlink($node_standby2->data_dir . '/standby.signal') + or die "could not remove standby.signal: $!"; +$node_standby2->start; + +is( $node_standby2->safe_psql( + 'postgres', 'SELECT timeline_id FROM pg_control_checkpoint()'), + '1', 'second standby left recovery on timeline 1'); +is(stamp_vs_lsn($node_standby2, unlogged_reset_lsn($node_standby2)), + '<', 'replicated stamp is below the second standby\'s epoch'); + +($rc, $out, $err) = + $node_standby2->psql('postgres', 'SELECT count(*) FROM mv_u'); +like( + $err, + qr/has not been populated/, + 'second standby reports "has not been populated"'); + +$node_standby2->stop; +$node_standby->stop; +$node_primary->stop; + +done_testing(); diff --git a/src/test/regress/expected/matview.out b/src/test/regress/expected/matview.out index 7500bf027da..f9c6c8ea56d 100644 --- a/src/test/regress/expected/matview.out +++ b/src/test/regress/expected/matview.out @@ -325,6 +325,244 @@ SELECT type, m.totamt AS mtot, v.totamt AS vtot FROM mvtest_tm m LEFT JOIN mvtes z | 24 | 24 (3 rows) +-- unlogged materialized view: storage is unlogged, REFRESH/SELECT work. +-- The data is not WAL-logged (relpersistence 'u'); crash semantics that mark +-- the matview unpopulated after a crash are handled separately. +CREATE UNLOGGED MATERIALIZED VIEW mvtest_unlogged AS + SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_unlogged'::regclass; + relpersistence +---------------- + u +(1 row) + +SELECT pg_matview_is_populated('mvtest_unlogged'::regclass); + pg_matview_is_populated +------------------------- + t +(1 row) + +\d mvtest_unlogged +Unlogged materialized view "public.mvtest_unlogged" + Column | Type | Collation | Nullable | Default +--------+---------+-----------+----------+--------- + type | text | | | + totamt | numeric | | | + +-- the matview's toast table is unlogged too +SELECT t.relpersistence FROM pg_class c JOIN pg_class t ON c.reltoastrelid = t.oid + WHERE c.oid = 'mvtest_unlogged'::regclass; + relpersistence +---------------- + u +(1 row) + +SELECT * FROM mvtest_unlogged ORDER BY type; + type | totamt +------+-------- + x | 5 + y | 12 + z | 24 +(3 rows) + +-- an index on an unlogged matview is unlogged as well +CREATE UNIQUE INDEX mvtest_unlogged_type ON mvtest_unlogged (type); +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_unlogged_type'::regclass; + relpersistence +---------------- + u +(1 row) + +-- REFRESH still works and leaves the data intact +REFRESH MATERIALIZED VIEW mvtest_unlogged; +SELECT * FROM mvtest_unlogged ORDER BY type; + type | totamt +------+-------- + x | 5 + y | 12 + z | 24 +(3 rows) + +-- REFRESH CONCURRENTLY works too, and keeps the existing epoch stamp +SELECT relpopulated AS mvtest_unlogged_stamp FROM pg_class + WHERE oid = 'mvtest_unlogged'::regclass \gset +REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_unlogged; +SELECT relpopulated = :mvtest_unlogged_stamp AS stamp_kept FROM pg_class + WHERE oid = 'mvtest_unlogged'::regclass; + stamp_kept +------------ + t +(1 row) + +SELECT pg_matview_is_populated('mvtest_unlogged'::regclass); + pg_matview_is_populated +------------------------- + t +(1 row) + +SELECT * FROM mvtest_unlogged ORDER BY type; + type | totamt +------+-------- + x | 5 + y | 12 + z | 24 +(3 rows) + +DROP MATERIALIZED VIEW mvtest_unlogged; +-- ALTER MATERIALIZED VIEW ... SET {LOGGED|UNLOGGED} rewrites persistence +-- of the matview, its toast table and its indexes, preserving data. +CREATE MATERIALIZED VIEW mvtest_setlog AS + SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type; +CREATE UNIQUE INDEX mvtest_setlog_type ON mvtest_setlog (type); +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass; -- p + relpersistence +---------------- + p +(1 row) + +SELECT count(*) FROM mvtest_setlog; + count +------- + 3 +(1 row) + +ALTER MATERIALIZED VIEW mvtest_setlog SET UNLOGGED; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass; -- u + relpersistence +---------------- + u +(1 row) + +SELECT relpersistence FROM pg_class + WHERE oid = (SELECT reltoastrelid FROM pg_class WHERE oid = 'mvtest_setlog'::regclass); -- u + relpersistence +---------------- + u +(1 row) + +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog_type'::regclass; -- u + relpersistence +---------------- + u +(1 row) + +SELECT pg_matview_is_populated('mvtest_setlog'::regclass); -- t + pg_matview_is_populated +------------------------- + t +(1 row) + +SELECT count(*) FROM mvtest_setlog; + count +------- + 3 +(1 row) + +ALTER MATERIALIZED VIEW mvtest_setlog SET LOGGED; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass; -- p + relpersistence +---------------- + p +(1 row) + +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog_type'::regclass; -- p + relpersistence +---------------- + p +(1 row) + +SELECT pg_matview_is_populated('mvtest_setlog'::regclass); -- t + pg_matview_is_populated +------------------------- + t +(1 row) + +SELECT count(*) FROM mvtest_setlog; + count +------- + 3 +(1 row) + +-- cannot change persistence setting twice in one ALTER +ALTER MATERIALIZED VIEW mvtest_setlog SET UNLOGGED, SET LOGGED; +ERROR: cannot change persistence setting twice +DROP MATERIALIZED VIEW mvtest_setlog; +-- ALTER MATERIALIZED VIEW SET LOGGED / SET UNLOGGED converts the populated state +CREATE UNLOGGED MATERIALIZED VIEW mvtest_persist AS SELECT 1 AS a; +SELECT pg_matview_is_populated('mvtest_persist'::regclass); + pg_matview_is_populated +------------------------- + t +(1 row) + +ALTER MATERIALIZED VIEW mvtest_persist SET LOGGED; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_persist'::regclass; + relpersistence +---------------- + p +(1 row) + +SELECT relpopulated FROM pg_class WHERE oid = 'mvtest_persist'::regclass; -- 1: eternal + relpopulated +-------------- + 1 +(1 row) + +SELECT * FROM mvtest_persist; + a +--- + 1 +(1 row) + +ALTER MATERIALIZED VIEW mvtest_persist SET UNLOGGED; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_persist'::regclass; + relpersistence +---------------- + u +(1 row) + +SELECT relpopulated > 1 OR relpopulated < 0 AS is_epoch_stamp FROM pg_class WHERE oid = 'mvtest_persist'::regclass; + is_epoch_stamp +---------------- + t +(1 row) + +SELECT pg_matview_is_populated('mvtest_persist'::regclass); + pg_matview_is_populated +------------------------- + t +(1 row) + +SELECT * FROM mvtest_persist; + a +--- + 1 +(1 row) + +DROP MATERIALIZED VIEW mvtest_persist; +-- SET UNLOGGED on an unpopulated matview keeps it unpopulated +CREATE MATERIALIZED VIEW mvtest_nodata AS SELECT 1 AS a WITH NO DATA; +ALTER MATERIALIZED VIEW mvtest_nodata SET UNLOGGED; +SELECT relpopulated FROM pg_class WHERE oid = 'mvtest_nodata'::regclass; -- 0: none + relpopulated +-------------- + 0 +(1 row) + +SELECT pg_matview_is_populated('mvtest_nodata'::regclass); + pg_matview_is_populated +------------------------- + f +(1 row) + +REFRESH MATERIALIZED VIEW mvtest_nodata; +SELECT pg_matview_is_populated('mvtest_nodata'::regclass); + pg_matview_is_populated +------------------------- + t +(1 row) + +DROP MATERIALIZED VIEW mvtest_nodata; -- make sure that dependencies are reported properly when they block the drop DROP TABLE mvtest_t; ERROR: cannot drop table mvtest_t because other objects depend on it diff --git a/src/test/regress/sql/matview.sql b/src/test/regress/sql/matview.sql index 8890a2f7932..11b1cd52a1b 100644 --- a/src/test/regress/sql/matview.sql +++ b/src/test/regress/sql/matview.sql @@ -108,6 +108,80 @@ CREATE MATERIALIZED VIEW mvtest_temp_tm AS SELECT * FROM mvtest_temp_t; -- test join of mv and view SELECT type, m.totamt AS mtot, v.totamt AS vtot FROM mvtest_tm m LEFT JOIN mvtest_tv v USING (type) ORDER BY type; +-- unlogged materialized view: storage is unlogged, REFRESH/SELECT work. +-- The data is not WAL-logged (relpersistence 'u'); crash semantics that mark +-- the matview unpopulated after a crash are handled separately. +CREATE UNLOGGED MATERIALIZED VIEW mvtest_unlogged AS + SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_unlogged'::regclass; +SELECT pg_matview_is_populated('mvtest_unlogged'::regclass); +\d mvtest_unlogged +-- the matview's toast table is unlogged too +SELECT t.relpersistence FROM pg_class c JOIN pg_class t ON c.reltoastrelid = t.oid + WHERE c.oid = 'mvtest_unlogged'::regclass; +SELECT * FROM mvtest_unlogged ORDER BY type; +-- an index on an unlogged matview is unlogged as well +CREATE UNIQUE INDEX mvtest_unlogged_type ON mvtest_unlogged (type); +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_unlogged_type'::regclass; +-- REFRESH still works and leaves the data intact +REFRESH MATERIALIZED VIEW mvtest_unlogged; +SELECT * FROM mvtest_unlogged ORDER BY type; +-- REFRESH CONCURRENTLY works too, and keeps the existing epoch stamp +SELECT relpopulated AS mvtest_unlogged_stamp FROM pg_class + WHERE oid = 'mvtest_unlogged'::regclass \gset +REFRESH MATERIALIZED VIEW CONCURRENTLY mvtest_unlogged; +SELECT relpopulated = :mvtest_unlogged_stamp AS stamp_kept FROM pg_class + WHERE oid = 'mvtest_unlogged'::regclass; +SELECT pg_matview_is_populated('mvtest_unlogged'::regclass); +SELECT * FROM mvtest_unlogged ORDER BY type; +DROP MATERIALIZED VIEW mvtest_unlogged; + +-- ALTER MATERIALIZED VIEW ... SET {LOGGED|UNLOGGED} rewrites persistence +-- of the matview, its toast table and its indexes, preserving data. +CREATE MATERIALIZED VIEW mvtest_setlog AS + SELECT type, sum(amt) AS totamt FROM mvtest_t GROUP BY type; +CREATE UNIQUE INDEX mvtest_setlog_type ON mvtest_setlog (type); +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass; -- p +SELECT count(*) FROM mvtest_setlog; +ALTER MATERIALIZED VIEW mvtest_setlog SET UNLOGGED; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass; -- u +SELECT relpersistence FROM pg_class + WHERE oid = (SELECT reltoastrelid FROM pg_class WHERE oid = 'mvtest_setlog'::regclass); -- u +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog_type'::regclass; -- u +SELECT pg_matview_is_populated('mvtest_setlog'::regclass); -- t +SELECT count(*) FROM mvtest_setlog; +ALTER MATERIALIZED VIEW mvtest_setlog SET LOGGED; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog'::regclass; -- p +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_setlog_type'::regclass; -- p +SELECT pg_matview_is_populated('mvtest_setlog'::regclass); -- t +SELECT count(*) FROM mvtest_setlog; +-- cannot change persistence setting twice in one ALTER +ALTER MATERIALIZED VIEW mvtest_setlog SET UNLOGGED, SET LOGGED; +DROP MATERIALIZED VIEW mvtest_setlog; + +-- ALTER MATERIALIZED VIEW SET LOGGED / SET UNLOGGED converts the populated state +CREATE UNLOGGED MATERIALIZED VIEW mvtest_persist AS SELECT 1 AS a; +SELECT pg_matview_is_populated('mvtest_persist'::regclass); +ALTER MATERIALIZED VIEW mvtest_persist SET LOGGED; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_persist'::regclass; +SELECT relpopulated FROM pg_class WHERE oid = 'mvtest_persist'::regclass; -- 1: eternal +SELECT * FROM mvtest_persist; +ALTER MATERIALIZED VIEW mvtest_persist SET UNLOGGED; +SELECT relpersistence FROM pg_class WHERE oid = 'mvtest_persist'::regclass; +SELECT relpopulated > 1 OR relpopulated < 0 AS is_epoch_stamp FROM pg_class WHERE oid = 'mvtest_persist'::regclass; +SELECT pg_matview_is_populated('mvtest_persist'::regclass); +SELECT * FROM mvtest_persist; +DROP MATERIALIZED VIEW mvtest_persist; + +-- SET UNLOGGED on an unpopulated matview keeps it unpopulated +CREATE MATERIALIZED VIEW mvtest_nodata AS SELECT 1 AS a WITH NO DATA; +ALTER MATERIALIZED VIEW mvtest_nodata SET UNLOGGED; +SELECT relpopulated FROM pg_class WHERE oid = 'mvtest_nodata'::regclass; -- 0: none +SELECT pg_matview_is_populated('mvtest_nodata'::regclass); +REFRESH MATERIALIZED VIEW mvtest_nodata; +SELECT pg_matview_is_populated('mvtest_nodata'::regclass); +DROP MATERIALIZED VIEW mvtest_nodata; + -- make sure that dependencies are reported properly when they block the drop DROP TABLE mvtest_t; -- 2.55.0