diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a125a2..404386d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,26 @@ which was true until that script existed. directly. It is `SECURITY DEFINER` and checks that the caller may `SELECT` the table, so the owner can run it without superuser rights. +- `pgcolumnar.autovacuum`, a maintenance daemon for the online upkeep that core + autovacuum cannot reach (#415). pgColumnar's `compact_rewrite` and `recluster` + live in extension functions, not table access method callbacks, so core + autovacuum never runs them. A table's dead rows and clustering decay then + accumulate until someone runs the verbs by hand. This daemon runs them for you. + + It is off by default. When on, a launcher wakes every + `pgcolumnar.autovacuum_naptime` seconds (default 60) and starts one worker per + database. Each worker asks `pgcolumnar.maintenance_due()` which tables crossed a + threshold, then runs the recommended verb over SPI in its own transaction. + + Two properties make it safe unattended. It calls only the + `ShareUpdateExclusiveLock` verbs, never `vacuum`, `vacuum_sorted`, or `cluster`, + so it cannot block a reader or a writer. And it yields the way autovacuum does: + the worker sets `PROC_IS_AUTOVACUUM`, so the lock manager cancels its + maintenance the moment a backend queues for a stronger lock. New settings: + `pgcolumnar.autovacuum`, `autovacuum_naptime`, `autovacuum_compact_threshold` + (0.2), and `autovacuum_recluster_threshold` (0.05). See the administration + guide for the operator's view. + - `pgcolumnar.parallel_flush` dispatches a stripe flush across background workers (#445). Default off. When on, a flush of two or more columns fans the per-column encode and compress work out to a worker pool. Any column a worker does not diff --git a/Makefile b/Makefile index 33521540..b2aa0733 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,8 @@ OBJS = \ src/columnar_parallel_copy.o \ src/columnar_parallel_export.o \ src/columnar_objstore.o \ - src/columnar_sink.o + src/columnar_sink.o \ + src/columnar_autovacuum.o EXTENSION = pgcolumnar DATA = pgcolumnar--1.0-alpha.sql pgcolumnar--1.0-dev--1.0-alpha.sql diff --git a/docs/administration.md b/docs/administration.md index 18ef102a..99fc999f 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -156,6 +156,36 @@ others work in place and run beside your queries. Schedule the exclusive three in a maintenance window. Anything that runs unattended, such as a cron entry, should call the online ones. +### The maintenance daemon (pgcolumnar.autovacuum) + +pgColumnar's online maintenance verbs, `compact_rewrite` and `recluster`, live +in extension functions. PostgreSQL's autovacuum never calls them. Without a +schedule, a table's dead rows and clustering decay accumulate unattended. The +`pgcolumnar.autovacuum` daemon runs those verbs for you. + +It is off by default. When it is on, a launcher wakes every +`pgcolumnar.autovacuum_naptime` seconds (default 60) and starts one worker per +database. Each worker asks `pgcolumnar.maintenance_due()` which columnar tables +have crossed a threshold, then runs the verb it recommends. + +The daemon calls only the online `ShareUpdateExclusiveLock` verbs. It never +calls `vacuum`, `vacuum_sorted`, or `cluster`. So it does not block readers or +writers. It also yields the way autovacuum does: it cancels its own maintenance +the moment a statement needs a stronger lock on the table. + +``` +-- turn it on (SIGHUP, no restart) +ALTER SYSTEM SET pgcolumnar.autovacuum = on; +SELECT pg_reload_conf(); +``` + +The thresholds are reloadable. `pgcolumnar.autovacuum_compact_threshold` is the +deleted fraction (default 0.2). `pgcolumnar.autovacuum_recluster_threshold` is +the appended fraction (default 0.05). A table is reclustered only when it has a +recorded clustering key, from a prior `recluster` or from +`set_options(..., sort_by => ...)`. The launcher needs `pgcolumnar` in +`shared_preload_libraries`, which the extension already requires. + ### Online maintenance and disk reclaim The online maintenance functions run under ShareUpdateExclusiveLock, so reads and diff --git a/docs/configuration.md b/docs/configuration.md index 9096c1a7..49073da7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,6 +79,10 @@ disk. It never changes the values that a table returns. | --- | --- | --- | --- | | `pgcolumnar.reclaim_coalesce` | boolean | `on` | During online compaction, split an oversized freed range on reuse and coalesce adjacent freed ranges, so space is reclaimed under fragmentation. Off reverts to whole-range reuse. | | `pgcolumnar.enable_end_truncation` | boolean | `off` | Allow `pgcolumnar.truncate()` to return trailing reclaimed blocks to the operating system. Off makes `pgcolumnar.truncate()` a no-op. Requires superuser to set. | +| `pgcolumnar.autovacuum` | boolean | `off` | Run the maintenance daemon. When on, it runs `compact_rewrite` and `recluster` on columnar tables that cross a threshold. It uses only `ShareUpdateExclusiveLock` and yields to any stronger lock. It never blocks a reader or a writer. See the [administration guide](administration.md#the-maintenance-daemon-pgcolumnarautovacuum). Reloadable, not a per-session setting. | +| `pgcolumnar.autovacuum_naptime` | integer | `60` | Seconds between daemon sweeps. Each sweep starts one worker per database. Range 1 to 86400. Reloadable. | +| `pgcolumnar.autovacuum_compact_threshold` | float | `0.2` | Deleted fraction at which the daemon rewrites a table with `compact_rewrite`. Range 0.0 to 1.0. Reloadable. | +| `pgcolumnar.autovacuum_recluster_threshold` | float | `0.05` | Appended fraction at which the daemon reclusters a table that has a recorded clustering key. Range 0.0 to 1.0. Reloadable. | ### Object storage @@ -105,6 +109,7 @@ appears in `pg_settings` and a reader who finds it there deserves an answer. | Setting | Type | Default | Description | | --- | --- | --- | --- | | `pgcolumnar.bulk_parallel_writer` | boolean | `off` | Internal. Set by `pgcolumnar.parallel_copy` loader workers so they skip the storage-row creation lock when the row already exists committed, which is what lets several atomic writers load one table at once. Marked `GUC_NOT_IN_SAMPLE`; leave it alone. Setting it by hand is safe but pointless: the skip only fires when the storage row is already committed, which is exactly when the lock guards nothing. | +| `pgcolumnar.maintenance_hold_ms` | integer | `0` | Internal, for tests. A maintenance verb holds `ShareUpdateExclusiveLock` this many milliseconds, interruptibly, so a test can observe the daemon yield to a stronger lock. `0` disables it. Range 0 to 600000. Leave it at `0`. | ## Per-table storage options diff --git a/src/columnar.h b/src/columnar.h index 074d1130..e40305d4 100644 --- a/src/columnar.h +++ b/src/columnar.h @@ -193,6 +193,13 @@ extern bool pgcolumnar_enable_bloom_filter; /* bloom equality skipping (I7) */ extern bool pgcolumnar_objstore_buffered; /* chunk-granular remote reads (#393) */ extern char *pgcolumnar_objstore_allowed_endpoints; /* #393 allow-list, SUSET */ extern int pgcolumnar_objstore_part_size; /* #394 remote multipart part size */ +/* #415 autovacuum daemon */ +extern bool pgcolumnar_autovacuum; +extern int pgcolumnar_autovacuum_naptime; +extern double pgcolumnar_autovacuum_compact_threshold; +extern double pgcolumnar_autovacuum_recluster_threshold; +extern void PgColumnarAutovacuumRegister(void); +extern int pgcolumnar_maintenance_hold_ms; /* dev/test: hold SUEL this long in a maintenance verb */ /* Phase 6 GUCs (spec 8.3) */ extern bool pgcolumnar_enable_vectorization; /* vectorized aggregate path */ diff --git a/src/columnar_autovacuum.c b/src/columnar_autovacuum.c new file mode 100644 index 00000000..19471d58 --- /dev/null +++ b/src/columnar_autovacuum.c @@ -0,0 +1,384 @@ +/*------------------------------------------------------------------------- + * columnar_autovacuum.c + * A background worker that runs the maintenance autovacuum cannot reach + * (#415): the columnar space-reclaim and re-clustering that live in + * extension functions rather than table-AM callbacks. + * + * Shape mirrors core autovacuum: a launcher (registered from _PG_init, so it + * needs shared_preload_libraries -- which pgColumnar already requires) wakes on + * a naptime and starts one short-lived worker per database; each worker asks + * pgcolumnar.maintenance_due(rel) which columnar tables want attention and runs + * the recommended verb. + * + * TWO INVARIANTS make this safe to run unattended: + * + * 1. Only the ShareUpdateExclusiveLock verbs -- pgcolumnar.compact_rewrite and + * pgcolumnar.recluster (now self-gating, #415 part A) -- are ever called. + * Never vacuum()/vacuum_sorted()/cluster(), which take AccessExclusiveLock. + * SUEL does not block readers or ordinary writers, so the daemon cannot block + * production by construction. + * + * 2. Autovacuum's yield: the worker sets PROC_IS_AUTOVACUUM, so when a backend + * queues for a lock that conflicts with the worker's SUEL, core's lock + * manager cancels the worker -- the maintenance op takes a query-cancel, + * releases its lock, the user's statement proceeds, and the worker moves to + * the next table. Each op runs in its own transaction inside PG_TRY, so a + * cancel aborts only that op. + * + * Off by default (pgcolumnar.autovacuum = off): a new autonomous-maintenance + * daemon earns its keep opt-in, and even on it only runs the non-blocking verbs. + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "access/genam.h" +#include "access/heapam.h" +#include "access/table.h" +#include "access/xact.h" +#include "catalog/pg_database.h" +#include "executor/spi.h" +#include "miscadmin.h" +#include "pgstat.h" +#include "postmaster/bgworker.h" +#include "postmaster/interrupt.h" +#include "storage/ipc.h" +#include "storage/latch.h" +#include "storage/lmgr.h" +#include "storage/proc.h" +#include "storage/procarray.h" +#include "tcop/tcopprot.h" +#include "utils/builtins.h" +#include "utils/guc.h" +#include "utils/memutils.h" +#include "utils/snapmgr.h" + +#include "columnar.h" + +/* GUCs (defined in columnar_tableam.c _PG_init, declared in columnar.h) */ +extern bool pgcolumnar_autovacuum; +extern int pgcolumnar_autovacuum_naptime; +extern double pgcolumnar_autovacuum_compact_threshold; +extern double pgcolumnar_autovacuum_recluster_threshold; + +PGDLLEXPORT void pgcolumnar_av_launcher_main(Datum arg); +PGDLLEXPORT void pgcolumnar_av_worker_main(Datum arg); + +/* + * Register the launcher. Called from _PG_init only when + * process_shared_preload_libraries_in_progress -- a server run without preload + * loses the daemon and nothing else. + */ +void +PgColumnarAutovacuumRegister(void) +{ + BackgroundWorker bw; + + memset(&bw, 0, sizeof(bw)); + bw.bgw_flags = BGWORKER_SHMEM_ACCESS | BGWORKER_BACKEND_DATABASE_CONNECTION; + bw.bgw_start_time = BgWorkerStart_RecoveryFinished; + /* a crashed launcher restarts after a minute, never in a tight loop */ + bw.bgw_restart_time = 60; + strlcpy(bw.bgw_library_name, "pgcolumnar", BGW_MAXLEN); + strlcpy(bw.bgw_function_name, "pgcolumnar_av_launcher_main", BGW_MAXLEN); + strlcpy(bw.bgw_name, "pgcolumnar autovacuum launcher", BGW_MAXLEN); + strlcpy(bw.bgw_type, "pgcolumnar autovacuum launcher", BGW_MAXLEN); + bw.bgw_main_arg = (Datum) 0; + bw.bgw_notify_pid = 0; + RegisterBackgroundWorker(&bw); +} + +/* Collect the OIDs of databases that accept connections and are not templates. */ +static List * +av_database_list(void) +{ + List *dbs = NIL; + Relation rel; + TableScanDesc scan; + HeapTuple tup; + + StartTransactionCommand(); + rel = table_open(DatabaseRelationId, AccessShareLock); + scan = table_beginscan_catalog(rel, 0, NULL); + while ((tup = heap_getnext(scan, ForwardScanDirection)) != NULL) + { + Form_pg_database db = (Form_pg_database) GETSTRUCT(tup); + + if (db->datallowconn && !db->datistemplate) + { + MemoryContext old = MemoryContextSwitchTo(TopMemoryContext); + +#if PG_VERSION_NUM >= 120000 + dbs = lappend_oid(dbs, db->oid); +#else + dbs = lappend_oid(dbs, HeapTupleGetOid(tup)); +#endif + MemoryContextSwitchTo(old); + } + } + table_endscan(scan); + table_close(rel, AccessShareLock); + CommitTransactionCommand(); + return dbs; +} + +/* Start one worker for `dbid` and wait for it to finish. */ +static void +av_run_worker_for_db(Oid dbid) +{ + BackgroundWorker bw; + BackgroundWorkerHandle *handle; + BgwHandleStatus status; + pid_t pid; + + memset(&bw, 0, sizeof(bw)); + bw.bgw_flags = BGWORKER_SHMEM_ACCESS | BGWORKER_BACKEND_DATABASE_CONNECTION; + bw.bgw_start_time = BgWorkerStart_RecoveryFinished; + bw.bgw_restart_time = BGW_NEVER_RESTART; /* one-shot per naptime */ + strlcpy(bw.bgw_library_name, "pgcolumnar", BGW_MAXLEN); + strlcpy(bw.bgw_function_name, "pgcolumnar_av_worker_main", BGW_MAXLEN); + strlcpy(bw.bgw_name, "pgcolumnar autovacuum worker", BGW_MAXLEN); + strlcpy(bw.bgw_type, "pgcolumnar autovacuum worker", BGW_MAXLEN); + bw.bgw_main_arg = (Datum) 0; + memcpy(bw.bgw_extra, &dbid, sizeof(Oid)); + bw.bgw_notify_pid = MyProcPid; + + if (!RegisterDynamicBackgroundWorker(&bw, &handle)) + { + /* worker slots exhausted this cycle; try again next naptime */ + elog(DEBUG1, "pgcolumnar autovacuum: no worker slot for database %u", dbid); + return; + } + status = WaitForBackgroundWorkerStartup(handle, &pid); + if (status != BGWH_STARTED) + return; + (void) WaitForBackgroundWorkerShutdown(handle); +} + +/* + * The launcher. Connects to no database (shared-catalog access only), and on + * each naptime -- when enabled -- runs one worker per database in turn. + */ +void +pgcolumnar_av_launcher_main(Datum arg) +{ + pqsignal(SIGHUP, SignalHandlerForConfigReload); + pqsignal(SIGTERM, die); + BackgroundWorkerUnblockSignals(); + BackgroundWorkerInitializeConnection(NULL, NULL, 0); + + for (;;) + { + int rc; + + rc = WaitLatch(MyLatch, + WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + (long) pgcolumnar_autovacuum_naptime * 1000L, + PG_WAIT_EXTENSION); + if (rc & WL_LATCH_SET) + ResetLatch(MyLatch); + CHECK_FOR_INTERRUPTS(); /* SIGTERM via die() exits here */ + + if (ConfigReloadPending) + { + ConfigReloadPending = false; + ProcessConfigFile(PGC_SIGHUP); + } + + if (!pgcolumnar_autovacuum) + continue; + + { + List *dbs = av_database_list(); + ListCell *lc; + + foreach(lc, dbs) + { + CHECK_FOR_INTERRUPTS(); + if (!pgcolumnar_autovacuum) + break; + av_run_worker_for_db(lfirst_oid(lc)); + } + list_free(dbs); + } + } +} + +/* Mark this worker as autovacuum, so a lock waiter can cancel it (the yield). */ +static void +av_mark_as_autovacuum(void) +{ +#ifdef PROC_IS_AUTOVACUUM + LWLockAcquire(ProcArrayLock, LW_EXCLUSIVE); + MyProc->statusFlags |= PROC_IS_AUTOVACUUM; + ProcGlobal->statusFlags[MyProc->pgxactoff] = MyProc->statusFlags; + LWLockRelease(ProcArrayLock); +#endif +} + +/* + * Run one maintenance verb on one table, each in its own transaction inside + * PG_TRY: a query-cancel (the lock yield) or any error aborts just this op and + * the caller continues to the next table. + */ +static void +av_maintain_one(const char *qualname) +{ + StartTransactionCommand(); + PushActiveSnapshot(GetTransactionSnapshot()); + SetCurrentStatementStartTimestamp(); + + PG_TRY(); + { + bool compactDue = false; + bool reclusterDue = false; + char *sortKey = NULL; + Oid argtypes[3] = {REGCLASSOID, FLOAT8OID, FLOAT8OID}; + Datum argvals[3]; + char q[512]; + + argvals[0] = DirectFunctionCall1(regclassin, CStringGetDatum(qualname)); + argvals[1] = Float8GetDatum(pgcolumnar_autovacuum_compact_threshold); + argvals[2] = Float8GetDatum(pgcolumnar_autovacuum_recluster_threshold); + + if (SPI_connect() != SPI_OK_CONNECT) + elog(ERROR, "pgcolumnar autovacuum: SPI_connect failed"); + + if (SPI_execute_with_args( + "SELECT compact_rewrite_due, recluster_due, " + " array_to_string(sort_key, ',') " + "FROM pgcolumnar.maintenance_due($1, $2, $3)", + 3, argtypes, argvals, NULL, true, 1) == SPI_OK_SELECT && + SPI_processed == 1) + { + bool isnull; + Datum d; + + d = SPI_getbinval(SPI_tuptable->vals[0], SPI_tuptable->tupdesc, 1, &isnull); + compactDue = !isnull && DatumGetBool(d); + d = SPI_getbinval(SPI_tuptable->vals[0], SPI_tuptable->tupdesc, 2, &isnull); + reclusterDue = !isnull && DatumGetBool(d); + d = SPI_getbinval(SPI_tuptable->vals[0], SPI_tuptable->tupdesc, 3, &isnull); + if (!isnull) + sortKey = pstrdup(TextDatumGetCString(d)); + } + + /* compact_rewrite: online space reclaim (SUEL) */ + if (compactDue) + { + snprintf(q, sizeof(q), + "SELECT pgcolumnar.compact_rewrite(%s, %g)", + quote_literal_cstr(qualname), + pgcolumnar_autovacuum_compact_threshold); + (void) SPI_execute(q, false, 0); + elog(LOG, "pgcolumnar autovacuum: compact_rewrite %s", qualname); + } + + /* + * recluster: only when the table has a recorded/declared key (else the + * daemon cannot know the columns). The self-gating recluster (#415) is + * a no-op when nothing decayed, so calling it here is cheap when the + * gate was optimistic. + */ + if (reclusterDue && sortKey != NULL && sortKey[0] != '\0') + { + snprintf(q, sizeof(q), + "SELECT pgcolumnar.recluster(%s, VARIADIC string_to_array(%s, ',')::name[])", + quote_literal_cstr(qualname), + quote_literal_cstr(sortKey)); + (void) SPI_execute(q, false, 0); + elog(LOG, "pgcolumnar autovacuum: recluster %s by (%s)", qualname, sortKey); + } + + SPI_finish(); + PopActiveSnapshot(); + CommitTransactionCommand(); + } + PG_CATCH(); + { + /* + * A cancel (the lock yield) or any error: roll this op back and keep + * going. The where-it-failed line is what an administrator acts on. + */ + MemoryContext ecxt = MemoryContextSwitchTo(TopMemoryContext); + ErrorData *ed = CopyErrorData(); + + elog(LOG, "pgcolumnar autovacuum: skipped %s: %s", qualname, ed->message); + FreeErrorData(ed); + MemoryContextSwitchTo(ecxt); + + FlushErrorState(); + AbortCurrentTransaction(); + } + PG_END_TRY(); +} + +/* + * The per-database worker. Connects, marks itself autovacuum, enumerates + * columnar tables, and maintains each one. + */ +void +pgcolumnar_av_worker_main(Datum arg) +{ + Oid dbid; + uint32 conn_flags = 0; + List *tables = NIL; + ListCell *lc; + + memcpy(&dbid, MyBgworkerEntry->bgw_extra, sizeof(Oid)); + + pqsignal(SIGTERM, die); + BackgroundWorkerUnblockSignals(); + +#if PG_VERSION_NUM >= 170000 + conn_flags |= BGWORKER_BYPASS_ROLELOGINCHECK; +#endif + /* InvalidOid role: the bootstrap superuser, like a real autovacuum worker */ + BackgroundWorkerInitializeConnectionByOid(dbid, InvalidOid, conn_flags); + + av_mark_as_autovacuum(); + + /* Snapshot the columnar-table list once, in its own transaction. */ + StartTransactionCommand(); + PushActiveSnapshot(GetTransactionSnapshot()); + if (SPI_connect() == SPI_OK_CONNECT) + { + int ret = SPI_execute( + "SELECT quote_ident(n.nspname) || '.' || quote_ident(c.relname) " + "FROM pg_class c " + "JOIN pg_am a ON a.oid = c.relam " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE a.amname = 'pgcolumnar' AND c.relkind = 'r'", + true, 0); + if (ret == SPI_OK_SELECT) + { + uint64 i; + + for (i = 0; i < SPI_processed; i++) + { + bool isnull; + Datum d = SPI_getbinval(SPI_tuptable->vals[i], + SPI_tuptable->tupdesc, 1, &isnull); + + if (!isnull) + { + MemoryContext old = MemoryContextSwitchTo(TopMemoryContext); + + tables = lappend(tables, pstrdup(TextDatumGetCString(d))); + MemoryContextSwitchTo(old); + } + } + } + SPI_finish(); + } + PopActiveSnapshot(); + CommitTransactionCommand(); + + foreach(lc, tables) + { + CHECK_FOR_INTERRUPTS(); + av_maintain_one((const char *) lfirst(lc)); + } + /* worker exits; the launcher starts a fresh one next naptime */ +} diff --git a/src/columnar_tableam.c b/src/columnar_tableam.c index f77f2dc1..12c6148b 100644 --- a/src/columnar_tableam.c +++ b/src/columnar_tableam.c @@ -81,6 +81,11 @@ bool pgcolumnar_enable_bloom_filter = true; bool pgcolumnar_objstore_buffered = true; /* #393 */ int pgcolumnar_objstore_part_size = 0; /* #394 remote part size */ char *pgcolumnar_objstore_allowed_endpoints = NULL; /* #393 allow-list */ +bool pgcolumnar_autovacuum = false; /* #415 daemon master switch */ +int pgcolumnar_autovacuum_naptime = 60; +double pgcolumnar_autovacuum_compact_threshold = 0.2; +double pgcolumnar_autovacuum_recluster_threshold = 0.05; +int pgcolumnar_maintenance_hold_ms = 0; /* dev/test fault injection */ /* value set for columnar.compression (spec 5, 8.3) */ static const struct config_enum_entry pgcolumnar_compression_options[] = { @@ -2830,8 +2835,65 @@ _PG_init(void) 0, NULL, NULL, NULL); + DefineCustomBoolVariable("pgcolumnar.autovacuum", + "Run the pgColumnar maintenance daemon (compact_rewrite / recluster).", + "Off by default. When on it runs ONLY the online ShareUpdateExclusiveLock " + "verbs, and yields (like autovacuum) to any statement needing a stronger lock.", + &pgcolumnar_autovacuum, + false, + PGC_SIGHUP, + 0, + NULL, NULL, NULL); + + DefineCustomIntVariable("pgcolumnar.autovacuum_naptime", + "Seconds the maintenance launcher sleeps between sweeps.", + NULL, + &pgcolumnar_autovacuum_naptime, + 60, 1, 86400, + PGC_SIGHUP, + GUC_UNIT_S, + NULL, NULL, NULL); + + DefineCustomRealVariable("pgcolumnar.autovacuum_compact_threshold", + "Deleted fraction at or above which the daemon compact_rewrites a table.", + NULL, + &pgcolumnar_autovacuum_compact_threshold, + 0.2, 0.0, 1.0, + PGC_SIGHUP, + 0, + NULL, NULL, NULL); + + DefineCustomRealVariable("pgcolumnar.autovacuum_recluster_threshold", + "Appended fraction at or above which the daemon reclusters a table.", + NULL, + &pgcolumnar_autovacuum_recluster_threshold, + 0.05, 0.0, 1.0, + PGC_SIGHUP, + 0, + NULL, NULL, NULL); + + DefineCustomIntVariable("pgcolumnar.maintenance_hold_ms", + "Dev/test: hold ShareUpdateExclusiveLock this many ms inside a " + "maintenance verb, interruptibly.", + "0 disables. Exists so the autovacuum-yield behaviour is testable " + "deterministically: the verb waits with the lock held, so a session " + "needing a stronger lock can observe the daemon cancel.", + &pgcolumnar_maintenance_hold_ms, + 0, 0, 600000, + PGC_USERSET, + GUC_UNIT_MS, + NULL, NULL, NULL); + MarkGUCPrefixReserved("pgcolumnar"); + /* + * The maintenance daemon launcher (#415). Registered only under + * shared_preload_libraries, which pgColumnar already requires; a server + * without preload simply loses the daemon. + */ + if (process_shared_preload_libraries_in_progress) + PgColumnarAutovacuumRegister(); + RegisterXactCallback(pgcolumnar_xact_callback, NULL); RegisterSubXactCallback(pgcolumnar_subxact_callback, NULL); diff --git a/src/columnar_vacuum.c b/src/columnar_vacuum.c index febcf1b5..fbc07882 100644 --- a/src/columnar_vacuum.c +++ b/src/columnar_vacuum.c @@ -36,6 +36,8 @@ #include "executor/executor.h" #include "executor/tuptable.h" #include "miscadmin.h" +#include "pgstat.h" +#include "storage/latch.h" #include "storage/lmgr.h" #include "storage/lockdefs.h" #include "storage/procarray.h" @@ -835,6 +837,33 @@ pgcolumnar_recluster(PG_FUNCTION_ARGS) PG_RETURN_INT64(reclustered); } +/* + * Dev/test fault injection (#415): hold the caller's lock for + * pgcolumnar.maintenance_hold_ms, interruptibly. A query-cancel -- including the + * one the lock manager sends when this process holds a lock blocking a stronger + * request while carrying PROC_IS_AUTOVACUUM (the maintenance daemon's yield) -- + * fires at CHECK_FOR_INTERRUPTS and aborts the verb, which is what the yield test + * asserts. Zero (default) is a no-op. + */ +static void +pgcolumnar_maintenance_hold(void) +{ + long remaining = (long) pgcolumnar_maintenance_hold_ms; + + while (remaining > 0) + { + long slice = Min(remaining, 1000L); + int rc; + + rc = WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + slice, PG_WAIT_EXTENSION); + if (rc & WL_LATCH_SET) + ResetLatch(MyLatch); + CHECK_FOR_INTERRUPTS(); + remaining -= slice; + } +} + Datum pgcolumnar_compact_rewrite(PG_FUNCTION_ARGS) { @@ -866,6 +895,9 @@ pgcolumnar_compact_rewrite(PG_FUNCTION_ARGS) PgColumnarRequireTableOwner(rel); + /* dev/test: hold SUEL so the daemon's yield is observable (#415) */ + pgcolumnar_maintenance_hold(); + rewritten = pgcolumnar_rewrite_partial_groups(rel, minFrac, maxGroups); table_close(rel, NoLock); diff --git a/test/autovacuum.sh b/test/autovacuum.sh new file mode 100755 index 00000000..8992080c --- /dev/null +++ b/test/autovacuum.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# pgColumnar #415: the maintenance daemon (pgcolumnar_autovacuum). Off by +# default; when on, a launcher + per-database workers run ONLY the online +# ShareUpdateExclusiveLock verbs (compact_rewrite, recluster) that autovacuum +# cannot reach, gated by pgcolumnar.maintenance_due(). +# +# The arms that matter: +# off with the daemon off, a deleted-heavy table is NOT touched -- the +# control that keeps "it compacted" from being vacuously true. +# on enabling it (SIGHUP) makes the daemon compact that table within a +# few naptimes: the dead rows the DELETE left are physically gone. +# present the launcher is actually running (pg_stat_activity), so the on-arm +# cannot pass because of something else. +# +# Timing: naptime is set to 2s, so a poll of ~30s covers several sweeps. +# +# Usage: test/autovacuum.sh [PG_CONFIG] +# Written fresh for pgColumnar. + +set -uo pipefail +export PGC_EXTRA_CONF="pgcolumnar.autovacuum_naptime=2 +pgcolumnar.autovacuum_compact_threshold=0.1 +pgcolumnar.autovacuum_recluster_threshold=0.05" +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +pgc_setup "${1:-/usr/local/pg17/bin/pg_config}" + +CG=1000 +deleted_rows() { q "SELECT COALESCE(sum(deletedrows),0) FROM pgcolumnar.stats('$1');"; } + +mk_deleted() { # mk_deleted -- 20k rows, 40% scattered-deleted (> threshold) + psql_run "CREATE TABLE $1 (id int, v int, pad text) USING pgcolumnar;" + psql_run "SELECT pgcolumnar.set_options('$1', chunk_group_row_limit => $CG);" + psql_run "INSERT INTO $1 SELECT g, g%100, md5(g::text) FROM generate_series(1,20000) g;" + psql_run "DELETE FROM $1 WHERE (id * 2654435761)::bigint % 100 < 40;" + psql_run "SELECT pgcolumnar.compact('$1');" # retire fully-dead (none: scattered) +} + +# ---- premise: the launcher is registered and running ------------------------ +check "premise: the maintenance launcher is running" \ + "$(q "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'pgcolumnar autovacuum launcher';")" "1" +check "premise: the daemon is OFF by default" "$(q "SHOW pgcolumnar.autovacuum;")" "off" + +# ---- control: OFF -> a deleted-heavy table is left alone --------------------- +mk_deleted av_off +OFF_BEFORE="$(deleted_rows av_off)" +check "premise: av_off starts with dead rows above the threshold" \ + "$([ "${OFF_BEFORE:-0}" -gt 4000 ] && echo yes || echo no)" "yes" +sleep 8 # several naptimes; the daemon is off, so nothing should change +OFF_AFTER="$(deleted_rows av_off)" +check "OFF: the daemon did not touch the table (dead rows unchanged)" \ + "$OFF_AFTER" "$OFF_BEFORE" + +# ---- enable: ON -> the deleted-heavy table gets compacted ------------------- +mk_deleted av_on +ON_BEFORE="$(deleted_rows av_on)" +check "premise: av_on starts with dead rows above the threshold" \ + "$([ "${ON_BEFORE:-0}" -gt 4000 ] && echo yes || echo no)" "yes" + +psql_run "ALTER SYSTEM SET pgcolumnar.autovacuum = on;" +q "SELECT pg_reload_conf();" >/dev/null +check "the daemon is now ON" "$(q "SHOW pgcolumnar.autovacuum;")" "on" + +# poll up to ~30s for the daemon to compact av_on (dead rows -> gone) +ON_AFTER="$ON_BEFORE" +for _ in $(seq 1 15); do + sleep 2 + ON_AFTER="$(deleted_rows av_on)" + [ "${ON_AFTER:-1}" = "0" ] && break +done +check "ON: the daemon compacted the deleted-heavy table (dead rows now 0)" "$ON_AFTER" "0" +# and the live data is intact (compact_rewrite drops only dead rows) +check "ON: the surviving rows are unchanged" \ + "$(q "SELECT count(*) FROM av_on;")" \ + "$(q "SELECT count(*) FROM generate_series(1,20000) g WHERE NOT ((g * 2654435761)::bigint % 100 < 40);")" + +# ---- the recluster verb: the daemon folds appended decay back into the run --- +# (re-enable for this arm) +psql_run "ALTER SYSTEM SET pgcolumnar.autovacuum = on;" +q "SELECT pg_reload_conf();" >/dev/null +psql_run "CREATE TABLE av_rc (ts int, v int) USING pgcolumnar;" +psql_run "SELECT pgcolumnar.set_options('av_rc', chunk_group_row_limit => $CG);" +psql_run "INSERT INTO av_rc SELECT g, g%100 FROM generate_series(1,20000) g;" +psql_run "SELECT pgcolumnar.recluster('av_rc','ts');" # establishes the run + records the key +psql_run "INSERT INTO av_rc SELECT g, g%100 FROM generate_series(1,5000) g;" # 20% appended decay +check "premise: av_rc has a recorded key and appended decay" \ + "$(q "SELECT (sort_key IS NOT NULL AND appended_groups > 0) FROM pgcolumnar.sort_status('av_rc');")" "t" +RC_AFTER="$(q "SELECT appended_groups FROM pgcolumnar.sort_status('av_rc');")" +for _ in $(seq 1 15); do + sleep 2 + RC_AFTER="$(q "SELECT appended_groups FROM pgcolumnar.sort_status('av_rc');")" + [ "${RC_AFTER:-1}" = "0" ] && break +done +check "ON: the daemon reclustered the decayed table (appended groups folded to 0)" "$RC_AFTER" "0" +psql_run "ALTER SYSTEM SET pgcolumnar.autovacuum = off;" +q "SELECT pg_reload_conf();" >/dev/null +sleep 1 + +# ---- and OFF again stops it: a fresh deleted table is left alone ------------- +psql_run "ALTER SYSTEM SET pgcolumnar.autovacuum = off;" +q "SELECT pg_reload_conf();" >/dev/null +sleep 1 +mk_deleted av_off2 +OFF2_BEFORE="$(deleted_rows av_off2)" +sleep 8 +check "OFF-again: a new deleted table is left alone after disabling" \ + "$(deleted_rows av_off2)" "$OFF2_BEFORE" + +pgc_summary diff --git a/test/autovacuum_yield.sh b/test/autovacuum_yield.sh new file mode 100755 index 00000000..d4d34612 --- /dev/null +++ b/test/autovacuum_yield.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# pgColumnar #415: the maintenance daemon YIELDS like autovacuum. When a +# statement needs a lock that conflicts with the daemon's ShareUpdateExclusiveLock +# (an ALTER/DROP/TRUNCATE takes AccessExclusiveLock, the strongest case), core's +# lock manager -- seeing the holder flagged PROC_IS_AUTOVACUUM -- cancels the +# daemon's maintenance op after deadlock_timeout, so the op releases its lock and +# the user's statement proceeds. It is a bounded hiccup, not an indefinite block. +# +# Made deterministic by pgcolumnar.maintenance_hold_ms: the daemon's +# compact_rewrite holds SUEL for that long (interruptibly). The suite catches the +# daemon mid-hold, requests AccessExclusive, and asserts the DDL returns in far +# less than the hold (the yield fired) rather than waiting it out. +# +# yield AccessExclusive on a table the daemon holds SUEL on returns FAST +# (< a fraction of the hold) and succeeds -> the daemon was cancelled. +# +# The driver's removal proof deletes PROC_IS_AUTOVACUUM: then the daemon is not +# cancellable, the lock waits the full hold, statement_timeout fires, this arm +# reds (57014 instead of success). +# +# Usage: test/autovacuum_yield.sh [PG_CONFIG] +# Written fresh for pgColumnar. + +set -uo pipefail +export PGC_EXTRA_CONF="pgcolumnar.autovacuum=on +pgcolumnar.autovacuum_naptime=2 +pgcolumnar.autovacuum_compact_threshold=0.1 +pgcolumnar.maintenance_hold_ms=30000 +deadlock_timeout=1s" +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +pgc_setup "${1:-/usr/local/pg17/bin/pg_config}" + +CG=1000 + +check "premise: the launcher is running" \ + "$(q "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'pgcolumnar autovacuum launcher';")" "1" +check "premise: the daemon is on with a 30s maintenance hold" \ + "$(q "SELECT current_setting('pgcolumnar.autovacuum') || '/' || current_setting('pgcolumnar.maintenance_hold_ms');")" "on/30s" + +# a deleted-heavy table the daemon will compact_rewrite (and hold SUEL on) +psql_run "CREATE TABLE yt (id int, v int) USING pgcolumnar;" +psql_run "SELECT pgcolumnar.set_options('yt', chunk_group_row_limit => $CG);" +psql_run "INSERT INTO yt SELECT g, g%100 FROM generate_series(1,20000) g;" +psql_run "DELETE FROM yt WHERE (id * 2654435761)::bigint % 100 < 40;" +psql_run "SELECT pgcolumnar.compact('yt');" + +# poll (up to ~20s) for the daemon worker to be holding SUEL on yt +holds() { + q "SELECT count(*) FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid + WHERE l.relation = 'yt'::regclass AND l.mode = 'ShareUpdateExclusiveLock' + AND l.granted AND a.backend_type = 'pgcolumnar autovacuum worker';" +} +held=no +for _ in $(seq 1 40); do + [ "$(holds)" -ge 1 ] 2>/dev/null && { held=yes; break; } + sleep 0.5 +done +check "premise: the daemon is holding SUEL on the table (the hold window is open)" "$held" "yes" + +# now request AccessExclusive; time it. statement_timeout 15s < the 30s hold, so +# a daemon that did NOT yield would make this time out (57014); a yielding daemon +# releases within ~deadlock_timeout and this returns fast and clean. +t0=$(date +%s) +STATE="$(env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \ + -d "$PGC_DB" -qtA 2>&1 < daemon yielded)" \ + "${STATE:-granted}" "granted" +check "yield: and it was granted FAST (<= 8s, far under the 30s hold)" \ + "$([ "$elapsed" -le 8 ] 2>/dev/null && echo fast || echo "slow(${elapsed}s)")" "fast" + +# the daemon logged the cancel of its own maintenance op +sleep 1 +check "the daemon logged cancelling its maintenance op" \ + "$([ "$(grep -c 'pgcolumnar autovacuum: skipped .* canceling statement' "$PGC_LOGFILE" 2>/dev/null)" -ge 1 ] && echo yes || echo no)" "yes" + +pgc_summary diff --git a/test/run_all_versions.sh b/test/run_all_versions.sh index 85376b83..058da3b8 100755 --- a/test/run_all_versions.sh +++ b/test/run_all_versions.sh @@ -50,6 +50,8 @@ SUITES=( arrow_nested arrow_nested_import audit + autovacuum + autovacuum_yield batch_fold_explain bench_guards bloom_lazy