diff --git a/contrib/Makefile b/contrib/Makefile index dd04c20acd2..7d91fe77db3 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -36,6 +36,7 @@ SUBDIRS = \ pg_overexplain \ pg_plan_advice \ pg_prewarm \ + pg_stash_advice \ pg_stat_statements \ pg_surgery \ pg_trgm \ diff --git a/contrib/meson.build b/contrib/meson.build index 5a752eac347..ebb7f83d8c5 100644 --- a/contrib/meson.build +++ b/contrib/meson.build @@ -51,6 +51,7 @@ subdir('pg_overexplain') subdir('pg_plan_advice') subdir('pg_prewarm') subdir('pgrowlocks') +subdir('pg_stash_advice') subdir('pg_stat_statements') subdir('pgstattuple') subdir('pg_surgery') diff --git a/contrib/pg_stash_advice/Makefile b/contrib/pg_stash_advice/Makefile new file mode 100644 index 00000000000..f7670c2d4b6 --- /dev/null +++ b/contrib/pg_stash_advice/Makefile @@ -0,0 +1,27 @@ +# contrib/pg_stash_advice/Makefile + +MODULE_big = pg_stash_advice +OBJS = \ + $(WIN32RES) \ + pg_stash_advice.o \ + stashfuncs.o + +EXTENSION = pg_stash_advice +DATA = pg_stash_advice--1.0.sql +PGFILEDESC = "pg_stash_advice - store and automatically apply plan advice" + +REGRESS = pg_stash_advice pg_stash_advice_utf8 +EXTRA_INSTALL = contrib/pg_plan_advice + +ifdef USE_PGXS +PG_CPPFLAGS = -I$(includedir_server)/extension +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +PG_CPPFLAGS = -I$(top_srcdir)/contrib/pg_plan_advice +subdir = contrib/pg_stash_advice +top_builddir = ../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice.out b/contrib/pg_stash_advice/expected/pg_stash_advice.out new file mode 100644 index 00000000000..788da854aa7 --- /dev/null +++ b/contrib/pg_stash_advice/expected/pg_stash_advice.out @@ -0,0 +1,331 @@ +CREATE EXTENSION pg_stash_advice; +SET compute_query_id = on; +SET max_parallel_workers_per_gather = 0; +-- Helper: extract query identifier from EXPLAIN VERBOSE output. +CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + line text; + qid bigint; +BEGIN + FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text + LOOP + IF line ~ 'Query Identifier:' THEN + qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint; + RETURN qid; + END IF; + END LOOP; + RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output'; +END; +$$; +CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int) + WITH (autovacuum_enabled = false); +INSERT INTO aa_dim1 (id, dim1, val1) + SELECT g, 'some filler text ' || g, (g % 3) + 1 + FROM generate_series(1,100) g; +VACUUM ANALYZE aa_dim1; +CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int) + WITH (autovacuum_enabled = false); +INSERT INTO aa_dim2 (id, dim2, val2) + SELECT g, 'some filler text ' || g, (g % 7) + 1 + FROM generate_series(1,1000) g; +VACUUM ANALYZE aa_dim2; +CREATE TABLE aa_fact ( + id int primary key, + dim1_id integer not null references aa_dim1 (id), + dim2_id integer not null references aa_dim2 (id) +) WITH (autovacuum_enabled = false); +INSERT INTO aa_fact + SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g; +VACUUM ANALYZE aa_fact; +-- Get the query identifier. +SELECT get_query_id($$ +SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; +$$) AS qid \gset +-- Create an advice stash and point pg_stash_advice at it. +SELECT pg_create_advice_stash('regress_stash'); + pg_create_advice_stash +------------------------ + +(1 row) + +SET pg_stash_advice.stash_name = 'regress_stash'; +-- Run our test query for the first time with no stashed advice. +EXPLAIN (COSTS OFF) +SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + QUERY PLAN +------------------------------------------ + Hash Join + Hash Cond: (f.dim1_id = d1.id) + -> Hash Join + Hash Cond: (f.dim2_id = d2.id) + -> Seq Scan on aa_fact f + -> Hash + -> Seq Scan on aa_dim2 d2 + Filter: (val2 = 1) + -> Hash + -> Seq Scan on aa_dim1 d1 + Filter: (val1 = 1) +(11 rows) + +-- Force an index scan on dim1 +SELECT pg_set_stashed_advice('regress_stash', :'qid', + 'INDEX_SCAN(d1 aa_dim1_pkey)'); + pg_set_stashed_advice +----------------------- + +(1 row) + +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + QUERY PLAN +--------------------------------------------------------- + Hash Join + Hash Cond: (f.dim1_id = d1.id) + -> Hash Join + Hash Cond: (f.dim2_id = d2.id) + -> Seq Scan on aa_fact f + -> Hash + -> Seq Scan on aa_dim2 d2 + Filter: (val2 = 1) + -> Hash + -> Index Scan using aa_dim1_pkey on aa_dim1 d1 + Filter: (val1 = 1) + Supplied Plan Advice: + INDEX_SCAN(d1 aa_dim1_pkey) /* matched */ +(13 rows) + +-- Force an alternative join order +SELECT pg_set_stashed_advice('regress_stash', :'qid', + 'join_order(f d1 d2)'); + pg_set_stashed_advice +----------------------- + +(1 row) + +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + QUERY PLAN +------------------------------------------ + Hash Join + Hash Cond: (f.dim2_id = d2.id) + -> Hash Join + Hash Cond: (f.dim1_id = d1.id) + -> Seq Scan on aa_fact f + -> Hash + -> Seq Scan on aa_dim1 d1 + Filter: (val1 = 1) + -> Hash + -> Seq Scan on aa_dim2 d2 + Filter: (val2 = 1) + Supplied Plan Advice: + JOIN_ORDER(f d1 d2) /* matched */ +(13 rows) + +-- Force an alternative join strategy +SELECT pg_set_stashed_advice('regress_stash', :'qid', + 'NESTED_LOOP_PLAIN(d1)'); + pg_set_stashed_advice +----------------------- + +(1 row) + +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + QUERY PLAN +--------------------------------------------------- + Nested Loop + -> Hash Join + Hash Cond: (f.dim2_id = d2.id) + -> Seq Scan on aa_fact f + -> Hash + -> Seq Scan on aa_dim2 d2 + Filter: (val2 = 1) + -> Index Scan using aa_dim1_pkey on aa_dim1 d1 + Index Cond: (id = f.dim1_id) + Filter: (val1 = 1) + Supplied Plan Advice: + NESTED_LOOP_PLAIN(d1) /* matched */ +(12 rows) + +-- Add a useless extra entry to our test stash. Shouldn't change the result +-- from the previous test. +-- (If we're unlucky enough that this ever fails due to query ID actually +-- being 1, then just put some other constant here. Seems unlikely.) +SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)'); + pg_set_stashed_advice +----------------------- + +(1 row) + +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + QUERY PLAN +--------------------------------------------------- + Nested Loop + -> Hash Join + Hash Cond: (f.dim2_id = d2.id) + -> Seq Scan on aa_fact f + -> Hash + -> Seq Scan on aa_dim2 d2 + Filter: (val2 = 1) + -> Index Scan using aa_dim1_pkey on aa_dim1 d1 + Index Cond: (id = f.dim1_id) + Filter: (val1 = 1) + Supplied Plan Advice: + NESTED_LOOP_PLAIN(d1) /* matched */ +(12 rows) + +-- Try an empty stash to be sure it does nothing +SELECT pg_create_advice_stash('regress_empty_stash'); + pg_create_advice_stash +------------------------ + +(1 row) + +SET pg_stash_advice.stash_name = 'regress_empty_stash'; +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + QUERY PLAN +------------------------------------------ + Hash Join + Hash Cond: (f.dim1_id = d1.id) + -> Hash Join + Hash Cond: (f.dim2_id = d2.id) + -> Seq Scan on aa_fact f + -> Hash + -> Seq Scan on aa_dim2 d2 + Filter: (val2 = 1) + -> Hash + -> Seq Scan on aa_dim1 d1 + Filter: (val1 = 1) +(11 rows) + +-- Test that we can list each stash individually and all of them together, +-- but not a nonexistent stash. +SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name; + stash_name | num_entries +---------------------+------------- + regress_empty_stash | 0 + regress_stash | 2 +(2 rows) + +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents('regress_stash') ORDER BY advice_string; + stash_name | advice_string +---------------+----------------------- + regress_stash | NESTED_LOOP_PLAIN(d1) + regress_stash | SEQ_SCAN(d1) +(2 rows) + +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents('regress_empty_stash') + ORDER BY advice_string; + stash_name | advice_string +------------+--------------- +(0 rows) + +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents(NULL) ORDER BY advice_string; + stash_name | advice_string +---------------+----------------------- + regress_stash | NESTED_LOOP_PLAIN(d1) + regress_stash | SEQ_SCAN(d1) +(2 rows) + +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents('no_such_stash') + ORDER BY advice_string; +ERROR: advice stash "no_such_stash" does not exist +-- Test that we can remove advice. +SELECT pg_set_stashed_advice('regress_stash', :'qid', null); + pg_set_stashed_advice +----------------------- + +(1 row) + +SET pg_stash_advice.stash_name = 'regress_stash'; +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + QUERY PLAN +------------------------------------------ + Hash Join + Hash Cond: (f.dim1_id = d1.id) + -> Hash Join + Hash Cond: (f.dim2_id = d2.id) + -> Seq Scan on aa_fact f + -> Hash + -> Seq Scan on aa_dim2 d2 + Filter: (val2 = 1) + -> Hash + -> Seq Scan on aa_dim1 d1 + Filter: (val1 = 1) +(11 rows) + +SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name; + stash_name | num_entries +---------------------+------------- + regress_empty_stash | 0 + regress_stash | 1 +(2 rows) + +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents('regress_stash') ORDER BY advice_string; + stash_name | advice_string +---------------+--------------- + regress_stash | SEQ_SCAN(d1) +(1 row) + +-- Can't create a stash that already exists, or drop one that doesn't. +SELECT pg_create_advice_stash('regress_stash'); +ERROR: advice stash "regress_stash" already exists +SELECT pg_drop_advice_stash('no_such_stash'); +ERROR: advice stash "no_such_stash" does not exist +-- Can't add to or remove from a stash that does not exist. +SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)'); +ERROR: advice stash "no_such_stash" does not exist +SELECT pg_set_stashed_advice('no_such_stash', 1, null); +ERROR: advice stash "no_such_stash" does not exist +-- Can't use query ID 0. +SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)'); +ERROR: cannot set advice string for query ID 0 +-- Stash names must be non-empty, ASCII, and not too long, and must look +-- like identifiers. +SELECT pg_create_advice_stash(''); +ERROR: advice stash name may not be zero length +SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); +ERROR: advice stash names may not be longer than 63 bytes +SELECT pg_create_advice_stash(' '); +ERROR: advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores +SET pg_stash_advice.stash_name = '99bottles'; +ERROR: invalid value for parameter "pg_stash_advice.stash_name": "99bottles" +DETAIL: advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores +-- Clean up state in dynamic shared memory. +SELECT pg_drop_advice_stash('regress_stash'); + pg_drop_advice_stash +---------------------- + +(1 row) + +SELECT pg_drop_advice_stash('regress_empty_stash'); + pg_drop_advice_stash +---------------------- + +(1 row) + diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice_utf8.out b/contrib/pg_stash_advice/expected/pg_stash_advice_utf8.out new file mode 100644 index 00000000000..7c532571ed5 --- /dev/null +++ b/contrib/pg_stash_advice/expected/pg_stash_advice_utf8.out @@ -0,0 +1,16 @@ +/* + * This test must be run in a database with UTF-8 encoding, + * because other encodings don't support all the characters used. + */ +SELECT getdatabaseencoding() <> 'UTF8' + AS skip_test \gset +\if :skip_test +\quit +\endif +SET client_encoding = utf8; +-- Non-ASCII stash names should be rejected. +SELECT pg_create_advice_stash('café'); +ERROR: advice stash name must not contain non-ASCII characters +SET pg_stash_advice.stash_name = 'café'; +ERROR: invalid value for parameter "pg_stash_advice.stash_name": "café" +DETAIL: advice stash name must not contain non-ASCII characters diff --git a/contrib/pg_stash_advice/expected/pg_stash_advice_utf8_1.out b/contrib/pg_stash_advice/expected/pg_stash_advice_utf8_1.out new file mode 100644 index 00000000000..37aead89c0c --- /dev/null +++ b/contrib/pg_stash_advice/expected/pg_stash_advice_utf8_1.out @@ -0,0 +1,8 @@ +/* + * This test must be run in a database with UTF-8 encoding, + * because other encodings don't support all the characters used. + */ +SELECT getdatabaseencoding() <> 'UTF8' + AS skip_test \gset +\if :skip_test +\quit diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build new file mode 100644 index 00000000000..8fbcfcf8693 --- /dev/null +++ b/contrib/pg_stash_advice/meson.build @@ -0,0 +1,37 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +pg_stash_advice_sources = files( + 'pg_stash_advice.c', + 'stashfuncs.c' +) + +if host_system == 'windows' + pg_stash_advice_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'pg_stash_advice', + '--FILEDESC', 'pg_stash_advice - store and automatically apply plan advice',]) +endif + +pg_stash_advice = shared_module('pg_stash_advice', + pg_stash_advice_sources, + include_directories: [pg_plan_advice_inc, include_directories('.')], + kwargs: contrib_mod_args, +) +contrib_targets += pg_stash_advice + +install_data( + 'pg_stash_advice--1.0.sql', + 'pg_stash_advice.control', + kwargs: contrib_data_args, +) + +tests += { + 'name': 'pg_stash_advice', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'regress': { + 'sql': [ + 'pg_stash_advice', + 'pg_stash_advice_utf8', + ], + }, +} diff --git a/contrib/pg_stash_advice/pg_stash_advice--1.0.sql b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql new file mode 100644 index 00000000000..88dedd8ef1b --- /dev/null +++ b/contrib/pg_stash_advice/pg_stash_advice--1.0.sql @@ -0,0 +1,43 @@ +/* contrib/pg_stash_advice/pg_stash_advice--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION pg_stash_advice" to load this file. \quit + +CREATE FUNCTION pg_create_advice_stash(stash_name text) +RETURNS void +AS 'MODULE_PATHNAME', 'pg_create_advice_stash' +LANGUAGE C STRICT; + +CREATE FUNCTION pg_drop_advice_stash(stash_name text) +RETURNS void +AS 'MODULE_PATHNAME', 'pg_drop_advice_stash' +LANGUAGE C STRICT; + +CREATE FUNCTION pg_set_stashed_advice(stash_name text, query_id bigint, + advice_string text) +RETURNS void +AS 'MODULE_PATHNAME', 'pg_set_stashed_advice' +LANGUAGE C; + +CREATE FUNCTION pg_get_advice_stashes( + OUT stash_name text, + OUT num_entries bigint +) +RETURNS SETOF record +AS 'MODULE_PATHNAME', 'pg_get_advice_stashes' +LANGUAGE C STRICT; + +CREATE FUNCTION pg_get_advice_stash_contents( + INOUT stash_name text, + OUT query_id bigint, + OUT advice_string text +) +RETURNS SETOF record +AS 'MODULE_PATHNAME', 'pg_get_advice_stash_contents' +LANGUAGE C; + +REVOKE ALL ON FUNCTION pg_create_advice_stash(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION pg_drop_advice_stash(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION pg_get_advice_stash_contents(text) FROM PUBLIC; +REVOKE ALL ON FUNCTION pg_get_advice_stashes() FROM PUBLIC; +REVOKE ALL ON FUNCTION pg_set_stashed_advice(text, bigint, text) FROM PUBLIC; diff --git a/contrib/pg_stash_advice/pg_stash_advice.c b/contrib/pg_stash_advice/pg_stash_advice.c new file mode 100644 index 00000000000..715e6a2d19e --- /dev/null +++ b/contrib/pg_stash_advice/pg_stash_advice.c @@ -0,0 +1,605 @@ +/*------------------------------------------------------------------------- + * + * pg_stash_advice.c + * core infrastructure for pg_stash_advice contrib module + * + * Copyright (c) 2016-2026, PostgreSQL Global Development Group + * + * contrib/pg_stash_advice/pg_stash_advice.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "common/hashfn.h" +#include "common/string.h" +#include "nodes/queryjumble.h" +#include "pg_plan_advice.h" +#include "pg_stash_advice.h" +#include "storage/dsm_registry.h" +#include "utils/guc.h" +#include "utils/memutils.h" + +PG_MODULE_MAGIC; + +/* Shared memory hash table parameters */ +static dshash_parameters pgsa_stash_dshash_parameters = { + NAMEDATALEN, + sizeof(pgsa_stash), + dshash_strcmp, + dshash_strhash, + dshash_strcpy, + LWTRANCHE_INVALID /* gets set at runtime */ +}; + +static dshash_parameters pgsa_entry_dshash_parameters = { + sizeof(pgsa_entry_key), + sizeof(pgsa_entry), + dshash_memcmp, + dshash_memhash, + dshash_memcpy, + LWTRANCHE_INVALID /* gets set at runtime */ +}; + +/* GUC variable */ +static char *pg_stash_advice_stash_name = ""; + +/* Shared memory pointers */ +pgsa_shared_state *pgsa_state; +dsa_area *pgsa_dsa_area; +dshash_table *pgsa_stash_dshash; +dshash_table *pgsa_entry_dshash; + +/* Other global variables */ +static MemoryContext pg_stash_advice_mcxt; + +/* Function prototypes */ +static char *pgsa_advisor(PlannerGlobal *glob, + Query *parse, + const char *query_string, + int cursorOptions, + ExplainState *es); +static bool pgsa_check_stash_name_guc(char **newval, void **extra, + GucSource source); +static void pgsa_init_shared_state(void *ptr, void *arg); +static bool pgsa_is_identifier(char *str); + +/* Stash name -> stash ID hash table */ +#define SH_PREFIX pgsa_stash_name_table +#define SH_ELEMENT_TYPE pgsa_stash_name +#define SH_KEY_TYPE uint64 +#define SH_KEY pgsa_stash_id +#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64)) +#define SH_EQUAL(tb, a, b) (a == b) +#define SH_SCOPE extern +#define SH_DEFINE +#include "lib/simplehash.h" + +/* + * Initialize this module. + */ +void +_PG_init(void) +{ + void (*add_advisor_fn) (pg_plan_advice_advisor_hook hook); + + /* If compute_query_id = 'auto', we would like query IDs. */ + EnableQueryId(); + + /* Define our GUCs. */ + DefineCustomStringVariable("pg_stash_advice.stash_name", + "Name of the advice stash to be used in this session.", + NULL, + &pg_stash_advice_stash_name, + "", + PGC_USERSET, + 0, + pgsa_check_stash_name_guc, + NULL, + NULL); + + MarkGUCPrefixReserved("pg_stash_advice"); + + /* Tell pg_plan_advice that we want to provide advice strings. */ + add_advisor_fn = + load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor", + true, NULL); + (*add_advisor_fn) (pgsa_advisor); +} + +/* + * Get the advice string that has been configured for this query, if any, + * and return it. Otherwise, return NULL. + */ +static char * +pgsa_advisor(PlannerGlobal *glob, Query *parse, + const char *query_string, int cursorOptions, + ExplainState *es) +{ + pgsa_entry_key key; + pgsa_entry *entry; + char *advice_string; + uint64 stash_id; + + /* + * Exit quickly if the stash name is empty or there's no query ID. + */ + if (pg_stash_advice_stash_name[0] == '\0' || parse->queryId == 0) + return NULL; + + /* Attach to dynamic shared memory if not already done. */ + if (unlikely(pgsa_entry_dshash == NULL)) + pgsa_attach(); + + /* + * Translate pg_stash_advice.stash_name to an integer ID. + * + * pgsa_check_stash_name_guc() has already validated the advice stash + * name, so we don't need to call pgsa_check_stash_name() here. + */ + stash_id = pgsa_lookup_stash_id(pg_stash_advice_stash_name); + if (stash_id == 0) + return NULL; + + /* + * Look up the advice string for the given stash ID + query ID. + * + * If we find an advice string, we copy it into the current memory + * context, presumably short-lived, so that we can release the lock on the + * dshash entry. pg_plan_advice only needs the value to remain allocated + * long enough for it to be parsed, so this should be good enough. + */ + memset(&key, 0, sizeof(pgsa_entry_key)); + key.pgsa_stash_id = stash_id; + key.queryId = parse->queryId; + entry = dshash_find(pgsa_entry_dshash, &key, false); + if (entry == NULL) + return NULL; + if (entry->advice_string == InvalidDsaPointer) + advice_string = NULL; + else + advice_string = pstrdup(dsa_get_address(pgsa_dsa_area, + entry->advice_string)); + dshash_release_lock(pgsa_entry_dshash, entry); + + /* If we found an advice string, emit a debug message. */ + if (advice_string != NULL) + elog(DEBUG2, "supplying automatic advice for stash \"%s\", query ID %" PRId64 ": %s", + pg_stash_advice_stash_name, key.queryId, advice_string); + + return advice_string; +} + +/* + * Attach to various structures in dynamic shared memory. + * + * This function is designed to be resilient against errors. That is, if it + * fails partway through, it should be possible to call it again, repeat no + * work already completed, and potentially succeed or at least get further if + * whatever caused the previous failure has been corrected. + */ +void +pgsa_attach(void) +{ + bool found; + MemoryContext oldcontext; + + /* + * Create a memory context to make sure that any control structures + * allocated in local memory are sufficiently persistent. + */ + if (pg_stash_advice_mcxt == NULL) + pg_stash_advice_mcxt = AllocSetContextCreate(TopMemoryContext, + "pg_stash_advice", + ALLOCSET_DEFAULT_SIZES); + oldcontext = MemoryContextSwitchTo(pg_stash_advice_mcxt); + + /* Attach to the fixed-size state object if not already done. */ + if (pgsa_state == NULL) + pgsa_state = GetNamedDSMSegment("pg_stash_advice", + sizeof(pgsa_shared_state), + pgsa_init_shared_state, + &found, NULL); + + /* Attach to the DSA area if not already done. */ + if (pgsa_dsa_area == NULL) + { + dsa_handle area_handle; + + LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE); + area_handle = pgsa_state->area; + if (area_handle == DSA_HANDLE_INVALID) + { + pgsa_dsa_area = dsa_create(pgsa_state->dsa_tranche); + dsa_pin(pgsa_dsa_area); + pgsa_state->area = dsa_get_handle(pgsa_dsa_area); + LWLockRelease(&pgsa_state->lock); + } + else + { + LWLockRelease(&pgsa_state->lock); + pgsa_dsa_area = dsa_attach(area_handle); + } + dsa_pin_mapping(pgsa_dsa_area); + } + + /* Attach to the stash_name->stash_id hash table if not already done. */ + if (pgsa_stash_dshash == NULL) + { + dshash_table_handle stash_handle; + + LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE); + pgsa_stash_dshash_parameters.tranche_id = pgsa_state->stash_tranche; + stash_handle = pgsa_state->stash_hash; + if (stash_handle == DSHASH_HANDLE_INVALID) + { + pgsa_stash_dshash = dshash_create(pgsa_dsa_area, + &pgsa_stash_dshash_parameters, + NULL); + pgsa_state->stash_hash = + dshash_get_hash_table_handle(pgsa_stash_dshash); + LWLockRelease(&pgsa_state->lock); + } + else + { + LWLockRelease(&pgsa_state->lock); + pgsa_stash_dshash = dshash_attach(pgsa_dsa_area, + &pgsa_stash_dshash_parameters, + stash_handle, NULL); + } + } + + /* Attach to the entry hash table if not already done. */ + if (pgsa_entry_dshash == NULL) + { + dshash_table_handle entry_handle; + + LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE); + pgsa_entry_dshash_parameters.tranche_id = pgsa_state->entry_tranche; + entry_handle = pgsa_state->entry_hash; + if (entry_handle == DSHASH_HANDLE_INVALID) + { + pgsa_entry_dshash = dshash_create(pgsa_dsa_area, + &pgsa_entry_dshash_parameters, + NULL); + pgsa_state->entry_hash = + dshash_get_hash_table_handle(pgsa_entry_dshash); + LWLockRelease(&pgsa_state->lock); + } + else + { + LWLockRelease(&pgsa_state->lock); + pgsa_entry_dshash = dshash_attach(pgsa_dsa_area, + &pgsa_entry_dshash_parameters, + entry_handle, NULL); + } + } + + /* Restore previous memory context. */ + MemoryContextSwitchTo(oldcontext); +} + +/* + * Check whether an advice stash name is legal, and signal an error if not. + * + * Keep this in sync with pgsa_check_stash_name_guc, below. + */ +void +pgsa_check_stash_name(char *stash_name) +{ + /* Reject empty advice stash name. */ + if (stash_name[0] == '\0') + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash name may not be zero length")); + + /* Reject overlong advice stash names. */ + if (strlen(stash_name) + 1 > NAMEDATALEN) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash names may not be longer than %d bytes", + NAMEDATALEN - 1)); + + /* + * Reject non-ASCII advice stash names, since advice stashes are visible + * across all databases and the encodings of those databases might differ. + */ + if (!pg_is_ascii(stash_name)) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash name must not contain non-ASCII characters")); + + /* + * Reject things that do not look like identifiers, since the ability to + * create an advice stash with non-printable characters or weird symbols + * in the name is not likely to be useful to anyone. + */ + if (!pgsa_is_identifier(stash_name)) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores")); +} + +/* + * As above, but for the GUC check_hook. We allow the empty string here, + * though, as equivalent to disabling the feature. + */ +static bool +pgsa_check_stash_name_guc(char **newval, void **extra, GucSource source) +{ + char *stash_name = *newval; + + /* Reject overlong advice stash names. */ + if (strlen(stash_name) + 1 > NAMEDATALEN) + { + GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE); + GUC_check_errdetail("advice stash names may not be longer than %d bytes", + NAMEDATALEN - 1); + return false; + } + + /* + * Reject non-ASCII advice stash names, since advice stashes are visible + * across all databases and the encodings of those databases might differ. + */ + if (!pg_is_ascii(stash_name)) + { + GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE); + GUC_check_errdetail("advice stash name must not contain non-ASCII characters"); + return false; + } + + /* + * Reject things that do not look like identifiers, since the ability to + * create an advice stash with non-printable characters or weird symbols + * in the name is not likely to be useful to anyone. + */ + if (!pgsa_is_identifier(stash_name)) + { + GUC_check_errcode(ERRCODE_INVALID_PARAMETER_VALUE); + GUC_check_errdetail("advice stash name must begin with a letter or underscore and contain only letters, digits, and underscores"); + return false; + } + + return true; +} + +/* + * Create an advice stash. + */ +void +pgsa_create_stash(char *stash_name) +{ + pgsa_stash *stash; + bool found; + + Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE)); + + /* Create a stash with this name, unless one already exists. */ + stash = dshash_find_or_insert(pgsa_stash_dshash, stash_name, &found); + if (found) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash \"%s\" already exists", stash_name)); + stash->pgsa_stash_id = pgsa_state->next_stash_id++; + dshash_release_lock(pgsa_stash_dshash, stash); +} + +/* + * Remove any stored advice string for the given advice stash and query ID. + */ +void +pgsa_clear_advice_string(char *stash_name, int64 queryId) +{ + pgsa_entry *entry; + pgsa_entry_key key; + uint64 stash_id; + dsa_pointer old_dp; + + Assert(LWLockHeldByMe(&pgsa_state->lock)); + + /* Translate the stash name to an integer ID. */ + if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash \"%s\" does not exist", stash_name)); + + /* + * Look for an existing entry, and free it. But, be sure to save the + * pointer to the associated advice string, if any. + */ + memset(&key, 0, sizeof(pgsa_entry_key)); + key.pgsa_stash_id = stash_id; + key.queryId = queryId; + entry = dshash_find(pgsa_entry_dshash, &key, true); + if (entry == NULL) + old_dp = InvalidDsaPointer; + else + { + old_dp = entry->advice_string; + dshash_delete_entry(pgsa_entry_dshash, entry); + } + + /* Now we free the advice string as well, if there was one. */ + if (old_dp != InvalidDsaPointer) + dsa_free(pgsa_dsa_area, old_dp); +} + +/* + * Drop an advice stash. + */ +void +pgsa_drop_stash(char *stash_name) +{ + pgsa_entry *entry; + pgsa_stash *stash; + dshash_seq_status iterator; + uint64 stash_id; + + Assert(LWLockHeldByMeInMode(&pgsa_state->lock, LW_EXCLUSIVE)); + + /* Remove the entry for this advice stash. */ + stash = dshash_find(pgsa_stash_dshash, stash_name, true); + if (stash == NULL) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash \"%s\" does not exist", stash_name)); + stash_id = stash->pgsa_stash_id; + dshash_delete_entry(pgsa_stash_dshash, stash); + + /* + * Now remove all the entries. Since pgsa_state->lock must be held at + * least in shared mode to insert entries into pgsa_entry_dshash, it + * doesn't matter whether we do this before or after deleting the entry + * from pgsa_stash_dshash. + */ + dshash_seq_init(&iterator, pgsa_entry_dshash, true); + while ((entry = dshash_seq_next(&iterator)) != NULL) + { + if (stash_id == entry->key.pgsa_stash_id) + { + if (entry->advice_string != InvalidDsaPointer) + dsa_free(pgsa_dsa_area, entry->advice_string); + dshash_delete_current(&iterator); + } + } + dshash_seq_term(&iterator); +} + +/* + * Initialize shared state when first created. + */ +static void +pgsa_init_shared_state(void *ptr, void *arg) +{ + pgsa_shared_state *state = (pgsa_shared_state *) ptr; + + LWLockInitialize(&state->lock, + LWLockNewTrancheId("pg_stash_advice_lock")); + state->dsa_tranche = LWLockNewTrancheId("pg_stash_advice_dsa"); + state->stash_tranche = LWLockNewTrancheId("pg_stash_advice_stash"); + state->entry_tranche = LWLockNewTrancheId("pg_stash_advice_entry"); + state->next_stash_id = UINT64CONST(1); + state->area = DSA_HANDLE_INVALID; + state->stash_hash = DSHASH_HANDLE_INVALID; + state->entry_hash = DSHASH_HANDLE_INVALID; +} + +/* + * Check whether a string looks like a valid identifier. It must contain only + * ASCII identifier characters, and must not begin with a digit. + */ +static bool +pgsa_is_identifier(char *str) +{ + if (*str >= '0' && *str <= '9') + return false; + + while (*str != '\0') + { + char c = *str++; + + if ((c < '0' || c > '9') && (c < 'a' || c > 'z') && + (c < 'A' || c > 'Z') && c != '_') + return false; + } + + return true; +} + +/* + * Look up the integer ID that corresponds to the given stash name. + * + * Returns 0 if no such stash exists. + */ +uint64 +pgsa_lookup_stash_id(char *stash_name) +{ + pgsa_stash *stash; + uint64 stash_id; + + /* Search the shared hash table. */ + stash = dshash_find(pgsa_stash_dshash, stash_name, false); + if (stash == NULL) + return 0; + stash_id = stash->pgsa_stash_id; + dshash_release_lock(pgsa_stash_dshash, stash); + + return stash_id; +} + +/* + * Store a new or updated advice string for the given advice stash and query ID. + */ +void +pgsa_set_advice_string(char *stash_name, int64 queryId, char *advice_string) +{ + pgsa_entry *entry; + bool found; + pgsa_entry_key key; + uint64 stash_id; + dsa_pointer new_dp; + dsa_pointer old_dp; + + /* + * The caller must hold our lock, at least in shared mode. This is + * important for two reasons. + * + * First, it holds off interrupts, so that we can't bail out of this code + * after allocating DSA memory for the advice string and before storing + * the resulting pointer somewhere that others can find it. + * + * Second, we need to avoid a race against pgsa_drop_stash(). That + * function removes a stash_name->stash_id mapping and all the entries for + * that stash_id. Without the lock, there's a race condition no matter + * which of those things it does first, because as soon as we've looked up + * the stash ID, that whole function can execute before we do the rest of + * our work, which would result in us adding an entry for a stash that no + * longer exists. + */ + Assert(LWLockHeldByMe(&pgsa_state->lock)); + + /* Look up the stash ID. */ + if ((stash_id = pgsa_lookup_stash_id(stash_name)) == 0) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash \"%s\" does not exist", stash_name)); + + /* Allocate space for the advice string. */ + new_dp = dsa_allocate(pgsa_dsa_area, strlen(advice_string) + 1); + strcpy(dsa_get_address(pgsa_dsa_area, new_dp), advice_string); + + /* Attempt to insert an entry into the hash table. */ + memset(&key, 0, sizeof(pgsa_entry_key)); + key.pgsa_stash_id = stash_id; + key.queryId = queryId; + entry = dshash_find_or_insert_extended(pgsa_entry_dshash, &key, &found, + DSHASH_INSERT_NO_OOM); + + /* + * If it didn't work, bail out, being careful to free the shared memory + * we've already allocated before, since error cleanup will not do so. + */ + if (entry == NULL) + { + dsa_free(pgsa_dsa_area, new_dp); + ereport(ERROR, + errcode(ERRCODE_OUT_OF_MEMORY), + errmsg("out of memory"), + errdetail("could not insert advice string into shared hash table")); + } + + /* Update the entry and release the lock. */ + old_dp = found ? entry->advice_string : InvalidDsaPointer; + entry->advice_string = new_dp; + dshash_release_lock(pgsa_entry_dshash, entry); + + /* + * We're not safe from leaks yet! + * + * There's now a pointer to new_dp in the entry that we just updated, but + * that means that there's no longer anything pointing to old_dp. + */ + if (DsaPointerIsValid(old_dp)) + dsa_free(pgsa_dsa_area, old_dp); +} diff --git a/contrib/pg_stash_advice/pg_stash_advice.control b/contrib/pg_stash_advice/pg_stash_advice.control new file mode 100644 index 00000000000..4a0fff5c866 --- /dev/null +++ b/contrib/pg_stash_advice/pg_stash_advice.control @@ -0,0 +1,5 @@ +# pg_stash_advice extension +comment = 'store and automatically apply plan advice' +default_version = '1.0' +module_pathname = '$libdir/pg_stash_advice' +relocatable = true diff --git a/contrib/pg_stash_advice/pg_stash_advice.h b/contrib/pg_stash_advice/pg_stash_advice.h new file mode 100644 index 00000000000..eeaa61e0f37 --- /dev/null +++ b/contrib/pg_stash_advice/pg_stash_advice.h @@ -0,0 +1,99 @@ +/*------------------------------------------------------------------------- + * + * pg_stash_advice.h + * main header for pg_stash_advice contrib module + * + * This module allows plan advice strings (as used and generated by + * pg_plan_advice) to be "stashed" in dynamic shared memory and, from + * there, automatically be applied to queries as they are planned. + * You can create any number of advice stashes, each of which is + * identified by a human-readable, ASCII identifier, and each of them is + * essentially a query ID -> advice_string mapping. + * + * Copyright (c) 2016-2026, PostgreSQL Global Development Group + * + * contrib/pg_stash_advice/pg_stash_advice.h + * + *------------------------------------------------------------------------- + */ +#ifndef PG_STASH_ADVICE_H +#define PG_STASH_ADVICE_H + +#include "lib/dshash.h" +#include "storage/lwlock.h" + +/* + * The key that we use to find a particular stash entry. + */ +typedef struct pgsa_entry_key +{ + uint64 pgsa_stash_id; + int64 queryId; +} pgsa_entry_key; + +/* + * A single stash entry. + */ +typedef struct pgsa_entry +{ + pgsa_entry_key key; + dsa_pointer advice_string; +} pgsa_entry; + +/* + * The stash itself is just a mapping from a name to a stash ID. + */ +typedef struct pgsa_stash +{ + char name[NAMEDATALEN]; + uint64 pgsa_stash_id; +} pgsa_stash; + +/* + * Top-level shared state object for pg_stash_advice. + */ +typedef struct pgsa_shared_state +{ + LWLock lock; + int dsa_tranche; + int stash_tranche; + int entry_tranche; + uint64 next_stash_id; + dsa_handle area; + dshash_table_handle stash_hash; + dshash_table_handle entry_hash; +} pgsa_shared_state; + +/* For stash ID -> stash name hash table */ +typedef struct pgsa_stash_name +{ + uint32 status; + uint64 pgsa_stash_id; + char *name; +} pgsa_stash_name; + +/* Declare stash ID -> stash name hash table */ +#define SH_PREFIX pgsa_stash_name_table +#define SH_ELEMENT_TYPE pgsa_stash_name +#define SH_KEY_TYPE uint64 +#define SH_SCOPE extern +#define SH_DECLARE +#include "lib/simplehash.h" + +/* Shared memory pointers */ +extern pgsa_shared_state *pgsa_state; +extern dsa_area *pgsa_dsa_area; +extern dshash_table *pgsa_stash_dshash; +extern dshash_table *pgsa_entry_dshash; + +/* Function prototypes */ +extern void pgsa_attach(void); +extern void pgsa_check_stash_name(char *stash_name); +extern void pgsa_clear_advice_string(char *stash_name, int64 queryId); +extern void pgsa_create_stash(char *stash_name); +extern void pgsa_drop_stash(char *stash_name); +extern uint64 pgsa_lookup_stash_id(char *stash_name); +extern void pgsa_set_advice_string(char *stash_name, int64 queryId, + char *advice_string); + +#endif diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice.sql b/contrib/pg_stash_advice/sql/pg_stash_advice.sql new file mode 100644 index 00000000000..f047a2d1a09 --- /dev/null +++ b/contrib/pg_stash_advice/sql/pg_stash_advice.sql @@ -0,0 +1,150 @@ +CREATE EXTENSION pg_stash_advice; +SET compute_query_id = on; +SET max_parallel_workers_per_gather = 0; + +-- Helper: extract query identifier from EXPLAIN VERBOSE output. +CREATE OR REPLACE FUNCTION get_query_id(query_text text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + line text; + qid bigint; +BEGIN + FOR line IN EXECUTE 'EXPLAIN (VERBOSE, FORMAT TEXT) ' || query_text + LOOP + IF line ~ 'Query Identifier:' THEN + qid := regexp_replace(line, '.*Query Identifier:\s*(-?\d+).*', '\1')::bigint; + RETURN qid; + END IF; + END LOOP; + RAISE EXCEPTION 'Query Identifier not found in EXPLAIN output'; +END; +$$; + +CREATE TABLE aa_dim1 (id integer primary key, dim1 text, val1 int) + WITH (autovacuum_enabled = false); +INSERT INTO aa_dim1 (id, dim1, val1) + SELECT g, 'some filler text ' || g, (g % 3) + 1 + FROM generate_series(1,100) g; +VACUUM ANALYZE aa_dim1; + +CREATE TABLE aa_dim2 (id integer primary key, dim2 text, val2 int) + WITH (autovacuum_enabled = false); +INSERT INTO aa_dim2 (id, dim2, val2) + SELECT g, 'some filler text ' || g, (g % 7) + 1 + FROM generate_series(1,1000) g; +VACUUM ANALYZE aa_dim2; + +CREATE TABLE aa_fact ( + id int primary key, + dim1_id integer not null references aa_dim1 (id), + dim2_id integer not null references aa_dim2 (id) +) WITH (autovacuum_enabled = false); +INSERT INTO aa_fact + SELECT g, (g%100)+1, (g%100)+1 FROM generate_series(1,100000) g; +VACUUM ANALYZE aa_fact; + +-- Get the query identifier. +SELECT get_query_id($$ +SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; +$$) AS qid \gset + +-- Create an advice stash and point pg_stash_advice at it. +SELECT pg_create_advice_stash('regress_stash'); +SET pg_stash_advice.stash_name = 'regress_stash'; + +-- Run our test query for the first time with no stashed advice. +EXPLAIN (COSTS OFF) +SELECT * FROM aa_fact f LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + +-- Force an index scan on dim1 +SELECT pg_set_stashed_advice('regress_stash', :'qid', + 'INDEX_SCAN(d1 aa_dim1_pkey)'); +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + +-- Force an alternative join order +SELECT pg_set_stashed_advice('regress_stash', :'qid', + 'join_order(f d1 d2)'); +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + +-- Force an alternative join strategy +SELECT pg_set_stashed_advice('regress_stash', :'qid', + 'NESTED_LOOP_PLAIN(d1)'); +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + +-- Add a useless extra entry to our test stash. Shouldn't change the result +-- from the previous test. +-- (If we're unlucky enough that this ever fails due to query ID actually +-- being 1, then just put some other constant here. Seems unlikely.) +SELECT pg_set_stashed_advice('regress_stash', 1, 'SEQ_SCAN(d1)'); +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + +-- Try an empty stash to be sure it does nothing +SELECT pg_create_advice_stash('regress_empty_stash'); +SET pg_stash_advice.stash_name = 'regress_empty_stash'; +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; + +-- Test that we can list each stash individually and all of them together, +-- but not a nonexistent stash. +SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name; +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents('regress_stash') ORDER BY advice_string; +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents('regress_empty_stash') + ORDER BY advice_string; +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents(NULL) ORDER BY advice_string; +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents('no_such_stash') + ORDER BY advice_string; + +-- Test that we can remove advice. +SELECT pg_set_stashed_advice('regress_stash', :'qid', null); +SET pg_stash_advice.stash_name = 'regress_stash'; +EXPLAIN (COSTS OFF) SELECT * FROM aa_fact f + LEFT JOIN aa_dim1 d1 ON f.dim1_id = d1.id + LEFT JOIN aa_dim2 d2 ON f.dim2_id = d2.id + WHERE val1 = 1 AND val2 = 1; +SELECT * FROM pg_get_advice_stashes() ORDER BY stash_name; +SELECT stash_name, advice_string + FROM pg_get_advice_stash_contents('regress_stash') ORDER BY advice_string; + +-- Can't create a stash that already exists, or drop one that doesn't. +SELECT pg_create_advice_stash('regress_stash'); +SELECT pg_drop_advice_stash('no_such_stash'); + +-- Can't add to or remove from a stash that does not exist. +SELECT pg_set_stashed_advice('no_such_stash', 1, 'SEQ_SCAN(t)'); +SELECT pg_set_stashed_advice('no_such_stash', 1, null); + +-- Can't use query ID 0. +SELECT pg_set_stashed_advice('regress_stash', 0, 'SEQ_SCAN(t)'); + +-- Stash names must be non-empty, ASCII, and not too long, and must look +-- like identifiers. +SELECT pg_create_advice_stash(''); +SELECT pg_create_advice_stash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); +SELECT pg_create_advice_stash(' '); +SET pg_stash_advice.stash_name = '99bottles'; + +-- Clean up state in dynamic shared memory. +SELECT pg_drop_advice_stash('regress_stash'); +SELECT pg_drop_advice_stash('regress_empty_stash'); diff --git a/contrib/pg_stash_advice/sql/pg_stash_advice_utf8.sql b/contrib/pg_stash_advice/sql/pg_stash_advice_utf8.sql new file mode 100644 index 00000000000..13ba635267f --- /dev/null +++ b/contrib/pg_stash_advice/sql/pg_stash_advice_utf8.sql @@ -0,0 +1,16 @@ +/* + * This test must be run in a database with UTF-8 encoding, + * because other encodings don't support all the characters used. + */ + +SELECT getdatabaseencoding() <> 'UTF8' + AS skip_test \gset +\if :skip_test +\quit +\endif + +SET client_encoding = utf8; + +-- Non-ASCII stash names should be rejected. +SELECT pg_create_advice_stash('café'); +SET pg_stash_advice.stash_name = 'café'; diff --git a/contrib/pg_stash_advice/stashfuncs.c b/contrib/pg_stash_advice/stashfuncs.c new file mode 100644 index 00000000000..33e86abd9d4 --- /dev/null +++ b/contrib/pg_stash_advice/stashfuncs.c @@ -0,0 +1,306 @@ +/*------------------------------------------------------------------------- + * + * stashfuncs.c + * SQL interface to pg_stash_advice + * + * Copyright (c) 2016-2026, PostgreSQL Global Development Group + * + * contrib/pg_stash_advice/stashfuncs.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "common/hashfn.h" +#include "fmgr.h" +#include "funcapi.h" +#include "pg_stash_advice.h" +#include "utils/builtins.h" +#include "utils/tuplestore.h" + +PG_FUNCTION_INFO_V1(pg_create_advice_stash); +PG_FUNCTION_INFO_V1(pg_drop_advice_stash); +PG_FUNCTION_INFO_V1(pg_get_advice_stash_contents); +PG_FUNCTION_INFO_V1(pg_get_advice_stashes); +PG_FUNCTION_INFO_V1(pg_set_stashed_advice); + +typedef struct pgsa_stash_count +{ + uint32 status; + uint64 pgsa_stash_id; + int64 num_entries; +} pgsa_stash_count; + +#define SH_PREFIX pgsa_stash_count_table +#define SH_ELEMENT_TYPE pgsa_stash_count +#define SH_KEY_TYPE uint64 +#define SH_KEY pgsa_stash_id +#define SH_HASH_KEY(tb, key) hash_bytes((const unsigned char *) &(key), sizeof(uint64)) +#define SH_EQUAL(tb, a, b) (a == b) +#define SH_SCOPE static inline +#define SH_DEFINE +#define SH_DECLARE +#include "lib/simplehash.h" + +/* + * SQL-callable function to create an advice stash + */ +Datum +pg_create_advice_stash(PG_FUNCTION_ARGS) +{ + char *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0)); + + pgsa_check_stash_name(stash_name); + if (unlikely(pgsa_entry_dshash == NULL)) + pgsa_attach(); + LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE); + pgsa_create_stash(stash_name); + LWLockRelease(&pgsa_state->lock); + PG_RETURN_VOID(); +} + +/* + * SQL-callable function to drop an advice stash + */ +Datum +pg_drop_advice_stash(PG_FUNCTION_ARGS) +{ + char *stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0)); + + pgsa_check_stash_name(stash_name); + if (unlikely(pgsa_entry_dshash == NULL)) + pgsa_attach(); + LWLockAcquire(&pgsa_state->lock, LW_EXCLUSIVE); + pgsa_drop_stash(stash_name); + LWLockRelease(&pgsa_state->lock); + PG_RETURN_VOID(); +} + +/* + * SQL-callable function to provide a list of advice stashes + */ +Datum +pg_get_advice_stashes(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + dshash_seq_status iterator; + pgsa_entry *entry; + pgsa_stash *stash; + pgsa_stash_count_table_hash *chash; + + InitMaterializedSRF(fcinfo, 0); + + /* Attach to dynamic shared memory if not already done. */ + if (unlikely(pgsa_entry_dshash == NULL)) + pgsa_attach(); + + /* Tally up the number of entries per stash. */ + chash = pgsa_stash_count_table_create(CurrentMemoryContext, 64, NULL); + dshash_seq_init(&iterator, pgsa_entry_dshash, true); + while ((entry = dshash_seq_next(&iterator)) != NULL) + { + pgsa_stash_count *c; + bool found; + + c = pgsa_stash_count_table_insert(chash, + entry->key.pgsa_stash_id, + &found); + if (!found) + c->num_entries = 1; + else + c->num_entries++; + } + dshash_seq_term(&iterator); + + /* Emit results. */ + dshash_seq_init(&iterator, pgsa_stash_dshash, true); + while ((stash = dshash_seq_next(&iterator)) != NULL) + { + Datum values[2]; + bool nulls[2]; + pgsa_stash_count *c; + + values[0] = CStringGetTextDatum(stash->name); + nulls[0] = false; + + c = pgsa_stash_count_table_lookup(chash, stash->pgsa_stash_id); + values[1] = Int64GetDatum(c == NULL ? 0 : c->num_entries); + nulls[1] = false; + + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, + nulls); + } + dshash_seq_term(&iterator); + + return (Datum) 0; +} + +/* + * SQL-callable function to provide advice stash contents + */ +Datum +pg_get_advice_stash_contents(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; + dshash_seq_status iterator; + char *stash_name = NULL; + pgsa_stash_name_table_hash *nhash = NULL; + uint64 stash_id = 0; + pgsa_entry *entry; + + InitMaterializedSRF(fcinfo, 0); + + /* Attach to dynamic shared memory if not already done. */ + if (unlikely(pgsa_entry_dshash == NULL)) + pgsa_attach(); + + /* User can pass NULL for all stashes, or the name of a specific stash. */ + if (!PG_ARGISNULL(0)) + { + stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0)); + pgsa_check_stash_name(stash_name); + stash_id = pgsa_lookup_stash_id(stash_name); + + /* If the user specified a stash name, it should exist. */ + if (stash_id == 0) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("advice stash \"%s\" does not exist", stash_name)); + } + else + { + pgsa_stash *stash; + + /* + * If we're dumping data about all stashes, we need an ID->name lookup + * table. + */ + nhash = pgsa_stash_name_table_create(CurrentMemoryContext, 64, NULL); + dshash_seq_init(&iterator, pgsa_stash_dshash, true); + while ((stash = dshash_seq_next(&iterator)) != NULL) + { + pgsa_stash_name *n; + bool found; + + n = pgsa_stash_name_table_insert(nhash, + stash->pgsa_stash_id, + &found); + Assert(!found); + n->name = pstrdup(stash->name); + } + dshash_seq_term(&iterator); + } + + /* Now iterate over all the entries. */ + dshash_seq_init(&iterator, pgsa_entry_dshash, false); + while ((entry = dshash_seq_next(&iterator)) != NULL) + { + Datum values[3]; + bool nulls[3]; + char *this_stash_name; + char *advice_string; + + /* Skip incomplete entries where the advice string was never set. */ + if (entry->advice_string == InvalidDsaPointer) + continue; + + if (stash_id != 0) + { + /* + * We're only dumping data for one particular stash, so skip + * entries for any other stash and use the stash name specified by + * the user. + */ + if (stash_id != entry->key.pgsa_stash_id) + continue; + this_stash_name = stash_name; + } + else + { + pgsa_stash_name *n; + + /* + * We're dumping data for all stashes, so look up the correct name + * to use in the hash table. If nothing is found, which is + * possible due to race conditions, make up a string to use. + */ + n = pgsa_stash_name_table_lookup(nhash, entry->key.pgsa_stash_id); + if (n != NULL) + this_stash_name = n->name; + else + this_stash_name = psprintf("", + entry->key.pgsa_stash_id); + } + + /* Work out tuple values. */ + values[0] = CStringGetTextDatum(this_stash_name); + nulls[0] = false; + values[1] = Int64GetDatum(entry->key.queryId); + nulls[1] = false; + advice_string = dsa_get_address(pgsa_dsa_area, entry->advice_string); + values[2] = CStringGetTextDatum(advice_string); + nulls[2] = false; + + /* Emit the tuple. */ + tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, + nulls); + } + dshash_seq_term(&iterator); + + return (Datum) 0; +} + +/* + * SQL-callable function to update an advice stash entry for a particular + * query ID + * + * If the second argument is NULL, we delete any existing advice stash + * entry; otherwise, we either create an entry or update it with the new + * advice string. + */ +Datum +pg_set_stashed_advice(PG_FUNCTION_ARGS) +{ + char *stash_name; + int64 queryId; + + if (PG_ARGISNULL(0) || PG_ARGISNULL(1)) + PG_RETURN_NULL(); + + /* Get and check advice stash name. */ + stash_name = text_to_cstring(PG_GETARG_TEXT_PP(0)); + pgsa_check_stash_name(stash_name); + + /* + * Get and check query ID. + * + * queryID 0 means no query ID was computed, so reject that. + */ + queryId = PG_GETARG_INT64(1); + if (queryId == 0) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("cannot set advice string for query ID 0")); + + /* Attach to dynamic shared memory if not already done. */ + if (unlikely(pgsa_entry_dshash == NULL)) + pgsa_attach(); + + /* Now call the appropriate function to do the real work. */ + if (PG_ARGISNULL(2)) + { + LWLockAcquire(&pgsa_state->lock, LW_SHARED); + pgsa_clear_advice_string(stash_name, queryId); + LWLockRelease(&pgsa_state->lock); + } + else + { + char *advice_string = text_to_cstring(PG_GETARG_TEXT_PP(2)); + + LWLockAcquire(&pgsa_state->lock, LW_SHARED); + pgsa_set_advice_string(stash_name, queryId, advice_string); + LWLockRelease(&pgsa_state->lock); + } + + PG_RETURN_VOID(); +} diff --git a/doc/src/sgml/contrib.sgml b/doc/src/sgml/contrib.sgml index bdd4865f53f..b9b03654aad 100644 --- a/doc/src/sgml/contrib.sgml +++ b/doc/src/sgml/contrib.sgml @@ -159,6 +159,7 @@ CREATE EXTENSION extension_name; &pgplanadvice; &pgprewarm; &pgrowlocks; + &pgstashadvice; &pgstatstatements; &pgstattuple; &pgsurgery; diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml index d90b4338d2a..e8f758fc24b 100644 --- a/doc/src/sgml/filelist.sgml +++ b/doc/src/sgml/filelist.sgml @@ -144,6 +144,7 @@ + diff --git a/doc/src/sgml/pgstashadvice.sgml b/doc/src/sgml/pgstashadvice.sgml new file mode 100644 index 00000000000..ec60552a447 --- /dev/null +++ b/doc/src/sgml/pgstashadvice.sgml @@ -0,0 +1,216 @@ + + + + pg_stash_advice — store and automatically apply plan advice + + + pg_stash_advice + + + + The pg_stash_advice extension allows you to stash + plan advice strings in dynamic + shared memory where they can be automatically applied. An + advice stash is a mapping from + query identifiers to plan advice + strings. Whenever a session is asked to plan a query whose query ID appears + in the relevant advice stash, the plan advice string is automatically applied + to guide planning. Note that advice stashes exist purely in memory. This + means both that it is important to be mindful of memory consumption when + deciding how much plan advice to stash, and also that advice stashes must + be recreated and repopulated whenever the server is restarted. + + + + In order to use this module, you will need to execute + CREATE EXTENSION pg_stash_advice in at least + one database, so that you have access to the SQL functions to manage + advice stashes. You will also need the pg_stash_advice + module to be loaded in all sessions where you want this module to + automatically apply advice. It will usually be best to do this by adding + pg_stash_advice to + and restarting the server. + + + + Once you have met the above criteria, you can create advice stashes + using the pg_create_advice_stash function described + below and set the plan advice for a given query ID in a given stash using + the pg_set_stashed_advice function. Then, you need + only configure pg_stash_advice.stash_name to point + to the chosen advice stash name. For some use cases, rather than setting + this on a system-wide basis, you may find it helpful to use + ALTER DATABASE ... SET or + ALTER ROLE ... SET to configure values that will apply + only to a database or only to a certain role. Likewise, it may sometimes + be better to set the stash name in a particular session using + SET. + + + + Because pg_stash_advice works on the basis of query + identifiers, you will need to determine the query identifier for each query + whose plan you wish to control. You will also need to determine the advice + string that you wish to store for each query. One way to do this is to use + EXPLAIN: the VERBOSE option will + show the query ID, and the PLAN_ADVICE option will + show plan advice. Query identifiers can also be obtained through tools + such as or + , but these tools + will not provide plan advice strings. Note that + must be enabled for query + identifiers to be computed; if set to auto, loading + pg_stash_advice will enable it automatically. + + + + Generally, the fact that the planner is able to change query plans as + the underlying distribution of data changes is a feature, not a bug. + Moreover, applying plan advice can have a noticeable performance cost even + when it does not result in a change to the query plan. Therefore, it is + a good idea to use this feature only when and to the extent needed. + Plan advice strings can be trimmed down to mention only those aspects + of the plan that need to be controlled, and used only for queries where + there is believed to be a significant risk of planner error. + + + + Note that pg_stash_advice currently lacks a sophisticated + security model. Only the superuser, or a user to whom the superuser has + granted EXECUTE permission on the relevant functions, + may create advice stashes or alter their contents, but any user may set + pg_stash_advice.stash_name for their session, and this + may reveal the contents of any advice stash with that name. Users should + assume that information embedded in stashed advice strings may become visible + to nonprivileged users. + + + + Functions + + + + + + pg_create_advice_stash(stash_name text) returns void + + pg_create_advice_stash + + + + + + Creates a new, empty advice stash with the given name. + + + + + + + pg_drop_advice_stash(stash_name text) returns void + + pg_drop_advice_stash + + + + + + Drops the named advice stash and all of its entries. + + + + + + + pg_set_stashed_advice(stash_name text, query_id bigint, + advice_string text) returns void + + pg_set_stashed_advice + + + + + + Stores an advice string in the named advice stash, associated with + the given query identifier. If an entry for that query identifier + already exists in the stash, it is replaced. If + advice_string is NULL, + any existing entry for that query identifier is removed. + + + + + + + pg_get_advice_stashes() returns setof (stash_name text, + num_entries bigint) + + pg_get_advice_stashes + + + + + + Returns one row for each advice stash, showing the stash name and + the number of entries it contains. + + + + + + + pg_get_advice_stash_contents(stash_name text) returns setof + (stash_name text, query_id bigint, advice_string text) + + pg_get_advice_stash_contents + + + + + + Returns one row for each entry in the named advice stash. If + stash_name is NULL, returns + entries from all stashes. + + + + + + + + + + Configuration Parameters + + + + + + pg_stash_advice.stash_name (string) + + pg_stash_advice.stash_name configuration parameter + + + + + + Specifies the name of the advice stash to consult during query + planning. The default value is the empty string, which disables + this module. + + + + + + + + + + Author + + + Robert Haas rhaas@postgresql.org + + + + diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 35acda59851..e9430e07b36 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -4077,6 +4077,12 @@ pgpa_trove_lookup_type pgpa_trove_result pgpa_trove_slice pgpa_unrolled_join +pgsa_entry +pgsa_entry_key +pgsa_shared_state +pgsa_stash +pgsa_stash_count +pgsa_stash_name pgsocket pgsql_thing_t pgssEntry