From d7f775dff850b8ac7998cbf681c2a7f8f35c6f54 Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Mon, 8 Jun 2026 14:02:29 -0400 Subject: [PATCH 01/18] Make pg_mkdir_p tolerant of a concurrent directory creation pg_mkdir_p creates each missing path component with a stat() followed by mkdir(). If the stat() reports the component as absent but another process creates it in the window before this process's mkdir(), mkdir() fails with EEXIST and pg_mkdir_p treated that as a hard error -- unlike "mkdir -p", which is meant to be idempotent and race-tolerant. This shows up when several processes concurrently create paths that share an ancestor directory: for example, parallel initdb runs whose data directories live under a common temporary directory. One process wins the race to create the shared ancestor and the others fail with could not create directory "...": File exists It is more easily hit on Windows, where stat() of a directory undergoing concurrent creation can transiently fail, but the race exists everywhere. After a failing mkdir(), accept the result when errno is EEXIST and the path now exists as a directory; only then is the failure genuine. --- src/port/pgmkdirp.c | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/port/pgmkdirp.c b/src/port/pgmkdirp.c index 7d7cea4dd0e..0094dd4087c 100644 --- a/src/port/pgmkdirp.c +++ b/src/port/pgmkdirp.c @@ -134,8 +134,17 @@ pg_mkdir_p(char *path, int omode) } else if (mkdir(path, last ? omode : S_IRWXU | S_IRWXG | S_IRWXO) < 0) { - retval = -1; - break; + /* + * Tolerate a concurrent creation of this directory: another + * process may have created it in the window between the stat() + * above and this mkdir(), in which case mkdir() fails with EEXIST. + * Only treat it as an error if the path still is not a directory. + */ + if (errno != EEXIST || stat(path, &sb) != 0 || !S_ISDIR(sb.st_mode)) + { + retval = -1; + break; + } } if (!last) *p = '/'; From d3444504517f79d67becdefe34396338ac533d34 Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:12:47 -0400 Subject: [PATCH 02/18] python tests: add the libpq ctypes layer and in-process Session A ctypes binding of libpq (bindings, constants, OIDs, library discovery, notification and result handling) plus a Session class providing synchronous, asynchronous, pipeline, COPY-free NOTIFY/notice and non-blocking query execution. This lets the Python test suite run SQL in-process without forking psql. --- src/test/pytest/libpq/__init__.py | 37 ++ src/test/pytest/libpq/bindings.py | 206 ++++++++++ src/test/pytest/libpq/constants.py | 130 +++++++ src/test/pytest/libpq/errors.py | 15 + src/test/pytest/libpq/findlib.py | 142 +++++++ src/test/pytest/libpq/oids.py | 151 ++++++++ src/test/pytest/libpq/pgnotify.py | 44 +++ src/test/pytest/libpq/result.py | 73 ++++ src/test/pytest/libpq/session.py | 597 +++++++++++++++++++++++++++++ 9 files changed, 1395 insertions(+) create mode 100644 src/test/pytest/libpq/__init__.py create mode 100644 src/test/pytest/libpq/bindings.py create mode 100644 src/test/pytest/libpq/constants.py create mode 100644 src/test/pytest/libpq/errors.py create mode 100644 src/test/pytest/libpq/findlib.py create mode 100644 src/test/pytest/libpq/oids.py create mode 100644 src/test/pytest/libpq/pgnotify.py create mode 100644 src/test/pytest/libpq/result.py create mode 100644 src/test/pytest/libpq/session.py diff --git a/src/test/pytest/libpq/__init__.py b/src/test/pytest/libpq/__init__.py new file mode 100644 index 00000000000..904ac7e2e77 --- /dev/null +++ b/src/test/pytest/libpq/__init__.py @@ -0,0 +1,37 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""ctypes-based libpq bindings for PostgreSQL tests. + +This is a complete in-process client: :class:`~libpq.session.Session` supports +synchronous, asynchronous and pipeline execution, LISTEN/NOTIFY, and notice +capture, so tests need neither psql subprocesses nor a third-party driver. +""" + +from . import constants, errors, oids +from .constants import ( + ConnStatusType, + ExecStatusType, + PGPing, + PGTransactionStatusType, + PostgresPollingStatusType, +) +from .errors import LibpqError, QueryError +from .result import ResultData +from .session import Session, connect, conninfo_quote + +__all__ = [ + "constants", + "errors", + "oids", + "ConnStatusType", + "ExecStatusType", + "PGPing", + "PGTransactionStatusType", + "PostgresPollingStatusType", + "LibpqError", + "QueryError", + "ResultData", + "Session", + "connect", + "conninfo_quote", +] diff --git a/src/test/pytest/libpq/bindings.py b/src/test/pytest/libpq/bindings.py new file mode 100644 index 00000000000..f2ada400f9b --- /dev/null +++ b/src/test/pytest/libpq/bindings.py @@ -0,0 +1,206 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Central ctypes prototype table for libpq. + +This is the ONLY module that assigns ``restype``/``argtypes`` to libpq +functions. Concentrating every prototype here contains the classic ctypes +footgun: a function whose ``restype`` is left at the default ``c_int`` would +sign-truncate a 64-bit pointer return on LP64 platforms and silently corrupt +it. Every entry in :data:`PROTOTYPES` sets restype and argtypes together, and +:func:`load` asserts each one was applied, so a forgotten or mistyped entry +fails loudly at load time instead of crashing mid-test. + +Opaque handles (``PGconn *``, ``PGresult *`` and the ``PGnotify *`` returned by +PQnotifies) are bound as ``c_void_p`` so the full 64-bit pointer survives and a +NULL return becomes Python ``None`` (falsy). ``Oid`` is unsigned 32-bit; the +microsecond clock and socket poll deadline are explicitly ``c_int64``. +""" + +import ctypes +import sys +from ctypes import ( + CFUNCTYPE, + POINTER, + Structure, + c_char_p, + c_int, + c_int64, + c_uint, + c_void_p, +) + +# Opaque handles and scalar aliases. The names mirror libpq's PGconn */ +# PGresult * typedefs, hence the non-PascalCase spelling. +PGconn_p = c_void_p # pylint: disable=invalid-name +PGresult_p = c_void_p # pylint: disable=invalid-name +Oid = c_uint + +_Oid_p = POINTER(c_uint) +_int_p = POINTER(c_int) +_charpp = POINTER(c_char_p) + + +class PQconninfoOption(Structure): + """A single resolved connection option, as returned by PQconninfo().""" + + _fields_ = [ + ("keyword", c_char_p), + ("envvar", c_char_p), + ("compiled", c_char_p), + ("val", c_char_p), + ("label", c_char_p), + ("dispchar", c_char_p), + ("dispsize", c_int), + ] + + +_PQconninfoOption_p = POINTER(PQconninfoOption) + +# void (*PQnoticeProcessor)(void *arg, const char *message) +NOTICE_PROCESSOR = CFUNCTYPE(None, c_void_p, c_char_p) + +# name -> (restype, [argtypes]). One line per libpq function. +PROTOTYPES = { + # --- Connection establishment / teardown ------------------------------- + "PQconnectdb": (PGconn_p, [c_char_p]), + "PQconnectdbParams": (PGconn_p, [_charpp, _charpp, c_int]), + "PQsetdbLogin": ( + PGconn_p, + [c_char_p, c_char_p, c_char_p, c_char_p, c_char_p, c_char_p, c_char_p], + ), + "PQconnectStart": (PGconn_p, [c_char_p]), + "PQconnectStartParams": (PGconn_p, [_charpp, _charpp, c_int]), + "PQconnectPoll": (c_int, [PGconn_p]), + "PQresetStart": (c_int, [PGconn_p]), + "PQresetPoll": (c_int, [PGconn_p]), + "PQfinish": (None, [PGconn_p]), + "PQreset": (None, [PGconn_p]), + # --- Connection introspection ------------------------------------------ + "PQdb": (c_char_p, [PGconn_p]), + "PQuser": (c_char_p, [PGconn_p]), + "PQpass": (c_char_p, [PGconn_p]), + "PQhost": (c_char_p, [PGconn_p]), + "PQhostaddr": (c_char_p, [PGconn_p]), + "PQport": (c_char_p, [PGconn_p]), + "PQtty": (c_char_p, [PGconn_p]), + "PQoptions": (c_char_p, [PGconn_p]), + "PQstatus": (c_int, [PGconn_p]), + "PQtransactionStatus": (c_int, [PGconn_p]), + "PQparameterStatus": (c_char_p, [PGconn_p, c_char_p]), + "PQping": (c_int, [c_char_p]), + "PQpingParams": (c_int, [_charpp, _charpp, c_int]), + "PQprotocolVersion": (c_int, [PGconn_p]), + "PQserverVersion": (c_int, [PGconn_p]), + "PQerrorMessage": (c_char_p, [PGconn_p]), + "PQsocket": (c_int, [PGconn_p]), + "PQsocketPoll": (c_int, [c_int, c_int, c_int, c_int64]), + "PQgetCurrentTimeUSec": (c_int64, []), + "PQbackendPID": (c_int, [PGconn_p]), + "PQconnectionNeedsPassword": (c_int, [PGconn_p]), + "PQconnectionUsedPassword": (c_int, [PGconn_p]), + "PQconnectionUsedGSSAPI": (c_int, [PGconn_p]), + "PQclientEncoding": (c_int, [PGconn_p]), + "PQsetClientEncoding": (c_int, [PGconn_p, c_char_p]), + # --- Synchronous command execution ------------------------------------- + "PQexec": (PGresult_p, [PGconn_p, c_char_p]), + "PQexecParams": ( + PGresult_p, + [PGconn_p, c_char_p, c_int, _Oid_p, _charpp, _int_p, _int_p, c_int], + ), + "PQprepare": (PGresult_p, [PGconn_p, c_char_p, c_char_p, c_int, _Oid_p]), + "PQexecPrepared": ( + PGresult_p, + [PGconn_p, c_char_p, c_int, _charpp, _int_p, _int_p, c_int], + ), + "PQdescribePrepared": (PGresult_p, [PGconn_p, c_char_p]), + "PQdescribePortal": (PGresult_p, [PGconn_p, c_char_p]), + "PQclosePrepared": (PGresult_p, [PGconn_p, c_char_p]), + "PQclosePortal": (PGresult_p, [PGconn_p, c_char_p]), + "PQchangePassword": (PGresult_p, [PGconn_p, c_char_p, c_char_p]), + "PQclear": (None, [PGresult_p]), + # --- Result inspection ------------------------------------------------- + "PQresultStatus": (c_int, [PGresult_p]), + "PQresStatus": (c_char_p, [c_int]), + "PQresultErrorMessage": (c_char_p, [PGresult_p]), + "PQresultErrorField": (c_char_p, [PGresult_p, c_int]), + "PQntuples": (c_int, [PGresult_p]), + "PQnfields": (c_int, [PGresult_p]), + "PQbinaryTuples": (c_int, [PGresult_p]), + "PQfname": (c_char_p, [PGresult_p, c_int]), + "PQfnumber": (c_int, [PGresult_p, c_char_p]), + "PQftable": (Oid, [PGresult_p, c_int]), + "PQftablecol": (c_int, [PGresult_p, c_int]), + "PQfformat": (c_int, [PGresult_p, c_int]), + "PQftype": (Oid, [PGresult_p, c_int]), + "PQfsize": (c_int, [PGresult_p, c_int]), + "PQfmod": (c_int, [PGresult_p, c_int]), + "PQcmdStatus": (c_char_p, [PGresult_p]), + "PQoidValue": (Oid, [PGresult_p]), + "PQcmdTuples": (c_char_p, [PGresult_p]), + "PQgetvalue": (c_char_p, [PGresult_p, c_int, c_int]), + "PQgetlength": (c_int, [PGresult_p, c_int, c_int]), + "PQgetisnull": (c_int, [PGresult_p, c_int, c_int]), + "PQnparams": (c_int, [PGresult_p]), + "PQparamtype": (Oid, [PGresult_p, c_int]), + # --- Asynchronous command processing ----------------------------------- + "PQsendQuery": (c_int, [PGconn_p, c_char_p]), + "PQsendQueryParams": ( + c_int, + [PGconn_p, c_char_p, c_int, _Oid_p, _charpp, _int_p, _int_p, c_int], + ), + "PQsendPrepare": (c_int, [PGconn_p, c_char_p, c_char_p, c_int, _Oid_p]), + "PQgetResult": (PGresult_p, [PGconn_p]), + "PQisBusy": (c_int, [PGconn_p]), + "PQconsumeInput": (c_int, [PGconn_p]), + "PQsetnonblocking": (c_int, [PGconn_p, c_int]), + "PQisnonblocking": (c_int, [PGconn_p]), + "PQflush": (c_int, [PGconn_p]), + # --- Pipeline mode ----------------------------------------------------- + "PQpipelineStatus": (c_int, [PGconn_p]), + "PQenterPipelineMode": (c_int, [PGconn_p]), + "PQexitPipelineMode": (c_int, [PGconn_p]), + "PQpipelineSync": (c_int, [PGconn_p]), + "PQsendFlushRequest": (c_int, [PGconn_p]), + "PQsendPipelineSync": (c_int, [PGconn_p]), + # --- Notifications ----------------------------------------------------- + # PQnotifies returns PGnotify *; keep the raw pointer (c_void_p) so the + # original allocation can be handed back to PQfreemem. + "PQnotifies": (c_void_p, [PGconn_p]), + "PQfreemem": (None, [c_void_p]), + # Resolved connection options (the actual values libpq used, including the + # service file it settled on). PQconninfo's array must be freed with + # PQconninfoFree. + "PQconninfo": (_PQconninfoOption_p, [PGconn_p]), + "PQconninfoFree": (None, [_PQconninfoOption_p]), + # --- Notice processing ------------------------------------------------- + "PQsetNoticeProcessor": (c_void_p, [PGconn_p, NOTICE_PROCESSOR, c_void_p]), +} + + +def load(libpath): + """Open *libpath* and apply every prototype in :data:`PROTOTYPES`. + + Returns the configured ``ctypes.CDLL``. Raises if a function is missing + from the library or if any prototype failed to apply. + """ + if sys.platform == "win32": + # libpq.dll's dependent DLLs (OpenSSL, zlib, libintl, ...) are not + # beside it; they are found via PATH. winmode=0 selects the standard + # Windows search order, which consults PATH, whereas the ctypes default + # since Python 3.8 (LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) does not and so + # fails to resolve them. + lib = ctypes.CDLL(libpath, winmode=0) + else: + lib = ctypes.CDLL(libpath) + for name, (restype, argtypes) in PROTOTYPES.items(): + fn = getattr(lib, name) # AttributeError here = symbol missing + fn.restype = restype + fn.argtypes = argtypes + + # Defense in depth: confirm every prototype actually took, so a forgotten + # restype can never reach a caller as a silent c_int default. + for name, (restype, _argtypes) in PROTOTYPES.items(): + applied = getattr(lib, name).restype + assert applied is restype, f"prototype not applied for {name}" + + return lib diff --git a/src/test/pytest/libpq/constants.py b/src/test/pytest/libpq/constants.py new file mode 100644 index 00000000000..d70ddbe3b09 --- /dev/null +++ b/src/test/pytest/libpq/constants.py @@ -0,0 +1,130 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""libpq enum constants used by the ctypes backend. + +The values are the integer codes from libpq-fe.h and are exposed both as +IntEnum members (so they print their symbolic name in errors) and as +module-level names so framework code can use the bare symbols. +""" + +import enum + + +class ConnStatusType(enum.IntEnum): + """Connection status codes (ConnStatusType in libpq-fe.h).""" + + CONNECTION_OK = 0 + CONNECTION_BAD = 1 + CONNECTION_STARTED = 2 + CONNECTION_MADE = 3 + CONNECTION_AWAITING_RESPONSE = 4 + CONNECTION_AUTH_OK = 5 + CONNECTION_SETENV = 6 + CONNECTION_SSL_STARTUP = 7 + CONNECTION_NEEDED = 8 + CONNECTION_CHECK_WRITABLE = 9 + CONNECTION_CONSUME = 10 + CONNECTION_GSS_STARTUP = 11 + CONNECTION_CHECK_TARGET = 12 + CONNECTION_CHECK_STANDBY = 13 + CONNECTION_ALLOCATED = 14 + + +class ExecStatusType(enum.IntEnum): + """Result status codes returned by PQresultStatus().""" + + PGRES_EMPTY_QUERY = 0 + PGRES_COMMAND_OK = 1 + PGRES_TUPLES_OK = 2 + PGRES_COPY_OUT = 3 + PGRES_COPY_IN = 4 + PGRES_BAD_RESPONSE = 5 + PGRES_NONFATAL_ERROR = 6 + PGRES_FATAL_ERROR = 7 + PGRES_COPY_BOTH = 8 + PGRES_SINGLE_TUPLE = 9 + PGRES_PIPELINE_SYNC = 10 + PGRES_PIPELINE_ABORTED = 11 + PGRES_TUPLES_CHUNK = 12 + + +class PostgresPollingStatusType(enum.IntEnum): + """Async connection polling status (PQconnectPoll()).""" + + PGRES_POLLING_FAILED = 0 + PGRES_POLLING_READING = 1 + PGRES_POLLING_WRITING = 2 + PGRES_POLLING_OK = 3 + PGRES_POLLING_ACTIVE = 4 + + +class PGPing(enum.IntEnum): + """Server status codes returned by PQping().""" + + PQPING_OK = 0 + PQPING_REJECT = 1 + PQPING_NO_RESPONSE = 2 + PQPING_NO_ATTEMPT = 3 + + +class PGTransactionStatusType(enum.IntEnum): + """Transaction status codes returned by PQtransactionStatus().""" + + PQTRANS_IDLE = 0 + PQTRANS_ACTIVE = 1 + PQTRANS_INTRANS = 2 + PQTRANS_INERROR = 3 + PQTRANS_UNKNOWN = 4 + + +# Module-level aliases for every member (CONNECTION_OK, PGRES_TUPLES_OK, ...) +# so test/framework code can use the bare names, while comparisons against +# IntEnum members still succeed. Spelled out explicitly (rather than built +# with a globals() loop) so static analysis can see the names. + +CONNECTION_OK = ConnStatusType.CONNECTION_OK +CONNECTION_BAD = ConnStatusType.CONNECTION_BAD +CONNECTION_STARTED = ConnStatusType.CONNECTION_STARTED +CONNECTION_MADE = ConnStatusType.CONNECTION_MADE +CONNECTION_AWAITING_RESPONSE = ConnStatusType.CONNECTION_AWAITING_RESPONSE +CONNECTION_AUTH_OK = ConnStatusType.CONNECTION_AUTH_OK +CONNECTION_SETENV = ConnStatusType.CONNECTION_SETENV +CONNECTION_SSL_STARTUP = ConnStatusType.CONNECTION_SSL_STARTUP +CONNECTION_NEEDED = ConnStatusType.CONNECTION_NEEDED +CONNECTION_CHECK_WRITABLE = ConnStatusType.CONNECTION_CHECK_WRITABLE +CONNECTION_CONSUME = ConnStatusType.CONNECTION_CONSUME +CONNECTION_GSS_STARTUP = ConnStatusType.CONNECTION_GSS_STARTUP +CONNECTION_CHECK_TARGET = ConnStatusType.CONNECTION_CHECK_TARGET +CONNECTION_CHECK_STANDBY = ConnStatusType.CONNECTION_CHECK_STANDBY +CONNECTION_ALLOCATED = ConnStatusType.CONNECTION_ALLOCATED + +PGRES_EMPTY_QUERY = ExecStatusType.PGRES_EMPTY_QUERY +PGRES_COMMAND_OK = ExecStatusType.PGRES_COMMAND_OK +PGRES_TUPLES_OK = ExecStatusType.PGRES_TUPLES_OK +PGRES_COPY_OUT = ExecStatusType.PGRES_COPY_OUT +PGRES_COPY_IN = ExecStatusType.PGRES_COPY_IN +PGRES_BAD_RESPONSE = ExecStatusType.PGRES_BAD_RESPONSE +PGRES_NONFATAL_ERROR = ExecStatusType.PGRES_NONFATAL_ERROR +PGRES_FATAL_ERROR = ExecStatusType.PGRES_FATAL_ERROR +PGRES_COPY_BOTH = ExecStatusType.PGRES_COPY_BOTH +PGRES_SINGLE_TUPLE = ExecStatusType.PGRES_SINGLE_TUPLE +PGRES_PIPELINE_SYNC = ExecStatusType.PGRES_PIPELINE_SYNC +PGRES_PIPELINE_ABORTED = ExecStatusType.PGRES_PIPELINE_ABORTED +PGRES_TUPLES_CHUNK = ExecStatusType.PGRES_TUPLES_CHUNK + +PGRES_POLLING_FAILED = PostgresPollingStatusType.PGRES_POLLING_FAILED +PGRES_POLLING_READING = PostgresPollingStatusType.PGRES_POLLING_READING +PGRES_POLLING_WRITING = PostgresPollingStatusType.PGRES_POLLING_WRITING +PGRES_POLLING_OK = PostgresPollingStatusType.PGRES_POLLING_OK +PGRES_POLLING_ACTIVE = PostgresPollingStatusType.PGRES_POLLING_ACTIVE + +PQPING_OK = PGPing.PQPING_OK +PQPING_REJECT = PGPing.PQPING_REJECT +PQPING_NO_RESPONSE = PGPing.PQPING_NO_RESPONSE +PQPING_NO_ATTEMPT = PGPing.PQPING_NO_ATTEMPT + +PQTRANS_IDLE = PGTransactionStatusType.PQTRANS_IDLE +PQTRANS_ACTIVE = PGTransactionStatusType.PQTRANS_ACTIVE +PQTRANS_INTRANS = PGTransactionStatusType.PQTRANS_INTRANS +PQTRANS_INERROR = PGTransactionStatusType.PQTRANS_INERROR +PQTRANS_UNKNOWN = PGTransactionStatusType.PQTRANS_UNKNOWN diff --git a/src/test/pytest/libpq/errors.py b/src/test/pytest/libpq/errors.py new file mode 100644 index 00000000000..f61d0631b01 --- /dev/null +++ b/src/test/pytest/libpq/errors.py @@ -0,0 +1,15 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Exceptions raised by the libpq wrapper.""" + + +class LibpqError(Exception): + """Base class for libpq-related errors (connection or query failure).""" + + +class PqConnectionError(LibpqError): + """Raised when a libpq connection cannot be established.""" + + +class QueryError(LibpqError): + """Raised by the *_safe query helpers when a statement fails.""" diff --git a/src/test/pytest/libpq/findlib.py b/src/test/pytest/libpq/findlib.py new file mode 100644 index 00000000000..30a80fde050 --- /dev/null +++ b/src/test/pytest/libpq/findlib.py @@ -0,0 +1,142 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Locate the libpq shared library. + +A lightweight replacement for ctypes.util that searches caller-supplied +directories first and then common system locations, honoring LD_LIBRARY_PATH / +DYLD_LIBRARY_PATH, and returns the full path to the library file. +""" + +import ctypes +import glob +import os +import sys + + +def libpq_abi_skip_reason(libdir): + """Return a reason to skip if this Python cannot load the build's libpq. + + The framework loads libpq in-process via ctypes, so the interpreter and + the library must share an ABI. The common mismatch is a 64-bit Python + against a 32-bit libpq (meson's ``-m32`` build), which otherwise fails + every test with ``OSError: wrong ELF class``. Detect it by reading the + library's ELF header rather than dlopen()ing it -- a trial dlopen of an + ASan-instrumented libpq would abort the process, not raise. Returns None + when the ABI matches, when libpq cannot be located (the normal load path + reports that), or when the file is not ELF (macOS/Windows). + """ + try: + if libdir: + path = find_lib_or_die("pq", libpath=[libdir], systempath=False) + else: + path = find_lib_or_die("pq", systempath=True) + except RuntimeError: + return None + + elf_class = _elf_class(path) + if elf_class is None: + return None + + py_bits = ctypes.sizeof(ctypes.c_void_p) * 8 + lib_bits = 64 if elf_class == 2 else 32 + if py_bits != lib_bits: + return ( + f"{py_bits}-bit Python cannot load {lib_bits}-bit libpq ({path}); " + f"the in-process libpq framework needs a {lib_bits}-bit interpreter" + ) + return None + + +def _elf_class(path): + """Return 1 (ELFCLASS32), 2 (ELFCLASS64), or None if *path* is not ELF.""" + try: + with open(path, "rb") as fh: + ident = fh.read(5) + except OSError: + return None + if ident[:4] != b"\x7fELF": + return None + return ident[4] # e_ident[EI_CLASS]: 1 = 32-bit, 2 = 64-bit + + +def find_lib_or_die(lib, libpath=None, systempath=True): + """Return the full path to the shared library named *lib* (e.g. "pq"). + + *libpath* is an iterable of directories searched first. When + *systempath* is false the common system locations are not searched (used + when the caller passes the cluster's own --libdir and wants exactly that). + Raises RuntimeError if nothing is found. + """ + search_paths = list(libpath or []) + if systempath: + search_paths.extend(_system_lib_paths()) + search_paths = _with_windows_bindir(search_paths) + + patterns = _lib_patterns(lib) + + for directory in search_paths: + if not os.path.isdir(directory): + continue + for pattern in patterns: + for match in sorted(glob.glob(os.path.join(directory, pattern))): + if os.path.isfile(match) and os.access(match, os.R_OK): + return match + + raise RuntimeError( + f"find_lib_or_die: unable to find lib{lib} in: " + ", ".join(search_paths) + ) + + +def _with_windows_bindir(paths): + """On Windows, add the sibling ``bin`` of each search directory. + + The runtime DLL is installed in ``bin`` there, while ``lib`` (which is what + ``pg_config --libdir`` reports) holds only the import library. Elsewhere + the list is returned unchanged. + """ + if sys.platform not in ("win32", "cygwin"): + return paths + expanded = [] + for directory in paths: + if directory not in expanded: + expanded.append(directory) + sibling_bin = os.path.join(os.path.dirname(directory.rstrip("\\/")), "bin") + if sibling_bin not in expanded: + expanded.append(sibling_bin) + return expanded + + +def _lib_patterns(lib): + if sys.platform == "darwin": + return (f"lib{lib}.dylib", f"lib{lib}.*.dylib") + if sys.platform in ("win32", "cygwin"): + return (f"{lib}.dll", f"lib{lib}.dll") + # Linux and other Unix-like systems + return (f"lib{lib}.so", f"lib{lib}.so.*") + + +def _system_lib_paths(): + paths = [] + + # Honor the loader's search path first, so a libpq placed on + # LD_LIBRARY_PATH / DYLD_LIBRARY_PATH wins over a system-installed one, + # matching how the dynamic linker resolves the library. + if sys.platform.startswith("linux") and os.environ.get("LD_LIBRARY_PATH"): + paths += os.environ["LD_LIBRARY_PATH"].split(os.pathsep) + if sys.platform == "darwin" and os.environ.get("DYLD_LIBRARY_PATH"): + paths += os.environ["DYLD_LIBRARY_PATH"].split(os.pathsep) + + paths += ["/usr/lib", "/usr/local/lib", "/lib"] + + if sys.platform.startswith("linux"): + paths += [ + "/usr/lib/x86_64-linux-gnu", + "/usr/lib/aarch64-linux-gnu", + "/usr/lib64", + "/lib64", + ] + + if sys.platform == "darwin": + paths += ["/opt/homebrew/lib", "/usr/local/opt/libpq/lib"] + + return paths diff --git a/src/test/pytest/libpq/oids.py b/src/test/pytest/libpq/oids.py new file mode 100644 index 00000000000..adce1e1d528 --- /dev/null +++ b/src/test/pytest/libpq/oids.py @@ -0,0 +1,151 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""PostgreSQL backend type OID constants. + +Values come from src/include/catalog/pg_type_d.h and are used to identify +column types in query results via PQftype(). +""" + +# Basic types +BOOLOID = 16 +BYTEAOID = 17 +CHAROID = 18 +NAMEOID = 19 +INT8OID = 20 +INT2OID = 21 +INT2VECTOROID = 22 +INT4OID = 23 +TEXTOID = 25 +OIDOID = 26 +TIDOID = 27 +XIDOID = 28 +CIDOID = 29 +OIDVECTOROID = 30 +JSONOID = 114 +XMLOID = 142 +XID8OID = 5069 +POINTOID = 600 +LSEGOID = 601 +PATHOID = 602 +BOXOID = 603 +POLYGONOID = 604 +LINEOID = 628 +FLOAT4OID = 700 +FLOAT8OID = 701 +UNKNOWNOID = 705 +CIRCLEOID = 718 +MONEYOID = 790 +MACADDROID = 829 +INETOID = 869 +CIDROID = 650 +MACADDR8OID = 774 +ACLITEMOID = 1033 +BPCHAROID = 1042 +VARCHAROID = 1043 +DATEOID = 1082 +TIMEOID = 1083 +TIMESTAMPOID = 1114 +TIMESTAMPTZOID = 1184 +INTERVALOID = 1186 +TIMETZOID = 1266 +BITOID = 1560 +VARBITOID = 1562 +NUMERICOID = 1700 +REFCURSOROID = 1790 +UUIDOID = 2950 +TSVECTOROID = 3614 +GTSVECTOROID = 3642 +TSQUERYOID = 3615 +JSONBOID = 3802 +JSONPATHOID = 4072 +TXID_SNAPSHOTOID = 2970 + +# Range types +INT4RANGEOID = 3904 +NUMRANGEOID = 3906 +TSRANGEOID = 3908 +TSTZRANGEOID = 3910 +DATERANGEOID = 3912 +INT8RANGEOID = 3926 + +# Multirange types +INT4MULTIRANGEOID = 4451 +NUMMULTIRANGEOID = 4532 +TSMULTIRANGEOID = 4533 +TSTZMULTIRANGEOID = 4534 +DATEMULTIRANGEOID = 4535 +INT8MULTIRANGEOID = 4536 + +# Pseudo types +RECORDOID = 2249 +RECORDARRAYOID = 2287 +CSTRINGOID = 2275 +VOIDOID = 2278 +TRIGGEROID = 2279 +EVENT_TRIGGEROID = 3838 + +# Array types +BOOLARRAYOID = 1000 +BYTEAARRAYOID = 1001 +CHARARRAYOID = 1002 +NAMEARRAYOID = 1003 +INT8ARRAYOID = 1016 +INT2ARRAYOID = 1005 +INT2VECTORARRAYOID = 1006 +INT4ARRAYOID = 1007 +TEXTARRAYOID = 1009 +OIDARRAYOID = 1028 +TIDARRAYOID = 1010 +XIDARRAYOID = 1011 +CIDARRAYOID = 1012 +OIDVECTORARRAYOID = 1013 +JSONARRAYOID = 199 +XMLARRAYOID = 143 +XID8ARRAYOID = 271 +POINTARRAYOID = 1017 +LSEGARRAYOID = 1018 +PATHARRAYOID = 1019 +BOXARRAYOID = 1020 +POLYGONARRAYOID = 1027 +LINEARRAYOID = 629 +FLOAT4ARRAYOID = 1021 +FLOAT8ARRAYOID = 1022 +CIRCLEARRAYOID = 719 +MONEYARRAYOID = 791 +MACADDRARRAYOID = 1040 +INETARRAYOID = 1041 +CIDRARRAYOID = 651 +MACADDR8ARRAYOID = 775 +ACLITEMARRAYOID = 1034 +BPCHARARRAYOID = 1014 +VARCHARARRAYOID = 1015 +DATEARRAYOID = 1182 +TIMEARRAYOID = 1183 +TIMESTAMPARRAYOID = 1115 +TIMESTAMPTZARRAYOID = 1185 +INTERVALARRAYOID = 1187 +TIMETZARRAYOID = 1270 +BITARRAYOID = 1561 +VARBITARRAYOID = 1563 +NUMERICARRAYOID = 1231 +REFCURSORARRAYOID = 2201 +UUIDARRAYOID = 2951 +TSVECTORARRAYOID = 3643 +GTSVECTORARRAYOID = 3644 +TSQUERYARRAYOID = 3645 +JSONBARRAYOID = 3807 +JSONPATHARRAYOID = 4073 +TXID_SNAPSHOTARRAYOID = 2949 +INT4RANGEARRAYOID = 3905 +NUMRANGEARRAYOID = 3907 +TSRANGEARRAYOID = 3909 +TSTZRANGEARRAYOID = 3911 +DATERANGEARRAYOID = 3913 +INT8RANGEARRAYOID = 3927 +INT4MULTIRANGEARRAYOID = 6150 +NUMMULTIRANGEARRAYOID = 6151 +TSMULTIRANGEARRAYOID = 6152 +TSTZMULTIRANGEARRAYOID = 6153 +DATEMULTIRANGEARRAYOID = 6155 +INT8MULTIRANGEARRAYOID = 6157 +CSTRINGARRAYOID = 1263 diff --git a/src/test/pytest/libpq/pgnotify.py b/src/test/pytest/libpq/pgnotify.py new file mode 100644 index 00000000000..55ba9f30be1 --- /dev/null +++ b/src/test/pytest/libpq/pgnotify.py @@ -0,0 +1,44 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""The PGnotify struct and a helper to read a notification. + +PQnotifies() returns a pointer to a heap-allocated PGnotify that the caller +must free with PQfreemem(); we keep that pointer (as c_void_p from bindings) +for the free, cast it to read the fields, and decode the strings BEFORE +freeing. +""" + +import ctypes + + +class PGnotify(ctypes.Structure): + """typedef struct pgNotify { char *relname; int be_pid; char *extra; }.""" + + _fields_ = [ + ("relname", ctypes.c_char_p), # notification channel name + ("be_pid", ctypes.c_int), # PID of the notifying backend + ("extra", ctypes.c_char_p), # notification payload string + ] + + +_PGnotify_p = ctypes.POINTER(PGnotify) + + +def read_notification(lib, raw): + """Turn the raw PQnotifies pointer *raw* into a dict and free it. + + Returns ``{"channel", "pid", "payload"}`` or ``None`` if *raw* is NULL. + """ + if not raw: + return None + notify = ctypes.cast(raw, _PGnotify_p).contents + # Decode while the memory is still valid (before PQfreemem). + result = { + "channel": ( + notify.relname.decode("utf-8", "replace") if notify.relname else None + ), + "pid": notify.be_pid, + "payload": (notify.extra.decode("utf-8", "replace") if notify.extra else None), + } + lib.PQfreemem(ctypes.c_void_p(raw)) + return result diff --git a/src/test/pytest/libpq/result.py b/src/test/pytest/libpq/result.py new file mode 100644 index 00000000000..1bebbd0edce --- /dev/null +++ b/src/test/pytest/libpq/result.py @@ -0,0 +1,73 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Result extraction from a libpq PGresult. + +A query result is represented by :class:`ResultData`, which exposes status, +error_message, names, types, rows, and psqlout. All values come back as text +(result format 0), so ``psqlout`` matches ``psql -A -t`` output. +""" + +from dataclasses import dataclass, field +from typing import List, Optional + +from .constants import ExecStatusType + + +def _decode(raw): + """Decode a libpq C string (bytes or None) to str/None.""" + if raw is None: + return None + return raw.decode("utf-8", "replace") + + +@dataclass +class ResultData: + """Structured form of the data returned by Session query methods.""" + + status: int + error_message: Optional[str] = None + names: List[str] = field(default_factory=list) + types: List[int] = field(default_factory=list) + rows: List[List[Optional[str]]] = field(default_factory=list) + psqlout: str = "" + + +def extract_result_data(lib, result, conn): + """Build a :class:`ResultData` from a PGresult pointer. + + On a failed status the error comes from this result + (PQresultErrorMessage), falling back to the connection-level message + (*conn*) only when the result carries no error text. + """ + status = lib.PQresultStatus(result) + res = ResultData(status=status) + + if status not in (ExecStatusType.PGRES_TUPLES_OK, ExecStatusType.PGRES_COMMAND_OK): + res.error_message = _decode(lib.PQresultErrorMessage(result)) or _decode( + lib.PQerrorMessage(conn) + ) + return res + if status == ExecStatusType.PGRES_COMMAND_OK: + return res + + ntuples = lib.PQntuples(result) + nfields = lib.PQnfields(result) + for fld in range(nfields): + res.names.append(_decode(lib.PQfname(result, fld))) + res.types.append(lib.PQftype(result, fld)) + + textrows = [] + for nrow in range(ntuples): + row = [] + for fld in range(nfields): + val = _decode(lib.PQgetvalue(result, nrow, fld)) + if (val or "") == "" and lib.PQgetisnull(result, nrow, fld): + val = None + row.append(val) + res.rows.append(row) + # join renders NULL (None) as the empty string. + textrows.append("|".join("" if v is None else v for v in row)) + + if ntuples: + res.psqlout = "\n".join(textrows) + return res diff --git a/src/test/pytest/libpq/session.py b/src/test/pytest/libpq/session.py new file mode 100644 index 00000000000..9111c5d2a96 --- /dev/null +++ b/src/test/pytest/libpq/session.py @@ -0,0 +1,597 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""A libpq session for tests. + +A :class:`Session` owns one libpq connection and runs queries in-process, so +tests do not have to spawn psql. Several methods return a +:class:`~libpq.result.ResultData`. + +Asynchronous waits use PQsocketPoll, with one-second periodic deadline checks. +""" + +import getpass +import os +import re +import sys +import time +from ctypes import c_char_p + +from . import bindings +from .constants import ( + CONNECTION_BAD, + CONNECTION_OK, + PGRES_COMMAND_OK, + PGRES_PIPELINE_ABORTED, + PGRES_PIPELINE_SYNC, + PGRES_POLLING_FAILED, + PGRES_POLLING_OK, + PGRES_POLLING_READING, + PGRES_POLLING_WRITING, + PGRES_TUPLES_OK, + PQTRANS_INERROR, +) +from .errors import PqConnectionError +from .errors import QueryError +from .pgnotify import read_notification +from .result import extract_result_data + +# Default per-operation timeout in seconds. +DEFAULT_TIMEOUT = int(os.environ.get("PG_TEST_TIMEOUT_DEFAULT") or "180") + +# Cache of loaded libpq handles, keyed by resolved library path, so multiple +# clusters with different libdirs each get the right library exactly once. +_LIBS: dict = {} + +# Last connection error, for callers that treat a failed connect as fatal but +# obtained None/raise without the libpq message handy. +connect_error = None + + +def _load_lib(libdir): + from .findlib import find_lib_or_die + + if libdir: + path = find_lib_or_die("pq", libpath=[libdir], systempath=False) + else: + path = find_lib_or_die("pq", systempath=True) + lib = _LIBS.get(path) + if lib is None: + lib = bindings.load(path) + _LIBS[path] = lib + return lib + + +def _str_array(values): + """Build a char** from a list of str/None (None -> SQL NULL), or None.""" + if not values: + return None + arr = (c_char_p * len(values))() + for i, val in enumerate(values): + arr[i] = None if val is None else val.encode("utf-8") + return arr + + +def _enc(text): + return text.encode("utf-8") if text is not None else None + + +def _dec(raw): + return raw.decode("utf-8", "replace") if raw is not None else None + + +def conninfo_quote(value): + """Escape *value* for use inside single quotes in a libpq conninfo string. + + libpq treats backslash as an escape inside single quotes, so a literal + backslash or single quote in the value must be backslash-escaped. + """ + return str(value).replace("\\", "\\\\").replace("'", "\\'") + + +class Session: + """A libpq connection with synchronous, async and pipeline helpers.""" + + def __init__( + self, + connstr=None, + node=None, + dbname="postgres", + libdir=None, + user=None, + wait=True, + timeout=DEFAULT_TIMEOUT, + ): + global connect_error # pylint: disable=global-statement + + if libdir is None and node is not None: + libdir = node.libdir + self._lib = _load_lib(libdir) + + if connstr is None: + if node is None: + raise ValueError("Session requires connstr or node") + connstr = node.connstr(dbname) + + # Pin the connecting role unless the connection string names one, so a + # stray PGUSER cannot select a role the cluster does not recognize. + if not re.search(r"\buser\s*=", connstr): + if user is None: + user = ( + os.environ.get("USERNAME") + if sys.platform == "win32" + else getpass.getuser() + ) + if user: + connstr += f" user='{conninfo_quote(user)}'" + + self.connstr = connstr + self._notices = [] + self._notice_cb = None + self._last_error = None + self._closed = False + self._timeout = timeout + lib = self._lib + + if wait: + self._conn = lib.PQconnectdb(_enc(connstr)) + if lib.PQstatus(self._conn) != CONNECTION_OK: + connect_error = _dec(lib.PQerrorMessage(self._conn)) + msg = connect_error + self.close() + raise PqConnectionError(msg) + self._setup_notice_processor() + else: + self._conn = lib.PQconnectStart(_enc(connstr)) + if lib.PQstatus(self._conn) == CONNECTION_BAD: + connect_error = _dec(lib.PQerrorMessage(self._conn)) + msg = connect_error + self.close() + raise PqConnectionError(msg) + + # -- connection lifecycle ------------------------------------------------ + + def _setup_notice_processor(self): + notices = self._notices + + def _cb(_arg, message): + notices.append(_dec(message) or "") + + # Keep a reference so libpq's stored function pointer stays valid. + self._notice_cb = bindings.NOTICE_PROCESSOR(_cb) + self._lib.PQsetNoticeProcessor(self._conn, self._notice_cb, None) + + def wait_connect(self, timeout=DEFAULT_TIMEOUT): + """Drive an async (wait=False) connection to CONNECTION_OK.""" + lib = self._lib + conn = self._conn + start = time.monotonic() + while True: + poll_res = lib.PQconnectPoll(conn) + status = lib.PQstatus(conn) + if poll_res == PGRES_POLLING_OK or status == CONNECTION_OK: + self._setup_notice_processor() + return + if poll_res == PGRES_POLLING_FAILED or status == CONNECTION_BAD: + raise PqConnectionError( + "connection failed: " + (_dec(lib.PQerrorMessage(conn)) or "") + ) + if time.monotonic() - start > timeout: + raise TimeoutError("timed out waiting for connection") + sock = lib.PQsocket(conn) + if sock >= 0: + for_read = 1 if poll_res == PGRES_POLLING_READING else 0 + for_write = 1 if poll_res == PGRES_POLLING_WRITING else 0 + end_time = lib.PQgetCurrentTimeUSec() + 1_000_000 + lib.PQsocketPoll(sock, for_read, for_write, end_time) + + def poll_connect(self): + """Single non-blocking step of async connection polling.""" + return self._lib.PQconnectPoll(self._conn) + + def close(self): + if getattr(self, "_closed", True): + return + conn = getattr(self, "_conn", None) + if conn is not None: + self._lib.PQfinish(conn) + self._conn = None + self._notice_cb = None + self._closed = True + + quit = close + + def __del__(self): + try: + self.close() + except Exception: # pylint: disable=broad-exception-caught + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + def reconnect(self): + if not self._closed: + self.close() + lib = self._lib + self._conn = lib.PQconnectdb(_enc(self.connstr)) + self._closed = False + status = lib.PQstatus(self._conn) + if status == CONNECTION_OK: + self._setup_notice_processor() + else: + # Failed reconnect: finish the dead conn rather than leaving a + # half-open session whose later calls would deref a bad PGconn. + self.close() + return status + + def reconnect_and_clear(self): + status = self.reconnect() + self.clear_notices() + return status + + def conn_status(self): + return self._lib.PQstatus(self._conn) if not self._closed else None + + def connected(self): + """True if the session has a live connection (status CONNECTION_OK).""" + return not self._closed and self._lib.PQstatus(self._conn) == CONNECTION_OK + + def backend_pid(self): + return self._lib.PQbackendPID(self._conn) + + # -- notice / stderr capture -------------------------------------------- + + def conninfo_value(self, keyword): + """Return libpq's resolved value for connection option *keyword*. + + For example ``conninfo_value("servicefile")`` reports the service file + libpq actually settled on, the same value psql exposes as the + :SERVICEFILE variable. Returns None if the option has no value. + """ + lib = self._lib + opts = lib.PQconninfo(self._conn) + if not opts: + return None + try: + i = 0 + while opts[i].keyword: + if _dec(opts[i].keyword) == keyword: + return _dec(opts[i].val) + i += 1 + return None + finally: + lib.PQconninfoFree(opts) + + def get_notices_str(self): + return "".join(self._notices) + + def clear_notices(self): + # Clear in place: the notice callback holds a reference to this list. + self._notices[:] = [] + + def get_stderr(self): + stderr = self.get_notices_str() + if self._last_error is not None: + stderr += self._last_error + return stderr + + def clear_stderr(self): + self.clear_notices() + self._last_error = None + + # -- synchronous execution ---------------------------------------------- + + def do(self, *sql_statements): + """Run statements with PQexec; return the status of the last one.""" + lib = self._lib + conn = self._conn + status = None + for sql in sql_statements: + result = lib.PQexec(conn, _enc(sql)) + status = lib.PQresultStatus(result) + lib.PQclear(result) + if status != PGRES_COMMAND_OK: + return status + return status + + def query(self, sql): + """Run SQL that may return tuples; return a ResultData. + + *sql* may contain several semicolon-separated statements; their output + is collected like psql. Note, however, that the whole string is sent as + a single libpq command and therefore runs in ONE implicit transaction -- + unlike psql, which runs each statement in its own autocommit + transaction. Statements that cannot run inside a transaction block + (CREATE/DROP DATABASE, VACUUM, CHECKPOINT, CREATE/ALTER/DROP + SUBSCRIPTION, CREATE TABLESPACE, ...) must be issued one per call. + """ + lib = self._lib + conn = self._conn + + if not lib.PQsendQuery(conn, _enc(sql)): + from .result import ResultData + + return ResultData(status=-1, error_message=_dec(lib.PQerrorMessage(conn))) + + final_res = None + last_error = None + error_status = None + all_psqlout = [] + while True: + result = self._get_result() + if not result: + break + res = extract_result_data(lib, result, conn) + lib.PQclear(result) + if res.psqlout != "": + all_psqlout.append(res.psqlout) + if res.error_message is not None: + last_error = res.error_message + error_status = res.status + if res.status == PGRES_TUPLES_OK or final_res is None: + final_res = res + + if final_res is None: + from .result import ResultData + + final_res = ResultData(status=PGRES_COMMAND_OK) + + if all_psqlout: + final_res.psqlout = "\n".join(all_psqlout) + if last_error is not None: + final_res.error_message = last_error + # Reflect a later statement's error in the status even when an earlier + # statement returned tuples, so callers that check status (not just + # error_message) see the failure. + if error_status is not None: + final_res.status = error_status + self._last_error = last_error + + # A multi-statement query that errors can leave the session in an open, + # aborted transaction: libpq aborts processing of the query string at + # the error, so a trailing COMMIT (e.g. "BEGIN; ; COMMIT") never + # runs. Roll back so a later query on this reused session is not + # rejected with "current transaction is aborted". + if lib.PQtransactionStatus(conn) == PQTRANS_INERROR: + rb = lib.PQexec(conn, b"ROLLBACK") + if rb: + lib.PQclear(rb) + return final_res + + def query_safe(self, sql): + """query() that raises on error; returns the psqlout string.""" + res = self.query(sql) + if res.error_message is not None: + short = re.sub(r"\s+", " ", sql[:100]) + raise QueryError(f"query_safe failed on [{short}...]: {res.error_message}") + return res.psqlout + + def query_oneval(self, sql, missing_ok=False): + """Return the single value of a one-row, one-column query.""" + lib = self._lib + conn = self._conn + result = lib.PQexec(conn, _enc(sql)) + status = lib.PQresultStatus(result) + if status != PGRES_TUPLES_OK: + if result: + lib.PQclear(result) + raise QueryError(_dec(lib.PQerrorMessage(conn))) + ntuples = lib.PQntuples(result) + if missing_ok and not ntuples: + lib.PQclear(result) + return None + nfields = lib.PQnfields(result) + if ntuples != 1 or nfields != 1: + lib.PQclear(result) + raise QueryError(f"{ntuples} tuples != 1 or {nfields} fields != 1") + val = _dec(lib.PQgetvalue(result, 0, 0)) + if val == "" and lib.PQgetisnull(result, 0, 0): + val = None + lib.PQclear(result) + return val + + def query_tuples(self, *sql_statements): + """Run queries and return output like ``psql -A -t``.""" + # Use the pipelined path for 4+ queries. + if len(sql_statements) >= 4: + return self.query_tuples_pipelined(*sql_statements) + + results = [] + for sql in sql_statements: + res = self.query(sql) + if res.status != PGRES_TUPLES_OK: + raise QueryError(res.error_message) + # query() already built psqlout in "psql -A -t" form; skip only + # when there are no rows. + if res.rows: + results.append(res.psqlout) + return "\n".join(results) + + # -- asynchronous execution --------------------------------------------- + + def do_async(self, sql): + """Send a single statement with PQsendQuery; return bool success.""" + return bool(self._lib.PQsendQuery(self._conn, _enc(sql))) + + def _get_result(self): + """Fetch the next async result, waiting on the socket with a deadline. + + Waits for the socket to become readable -- and also writable while + PQflush() reports unsent data, since on a non-blocking connection the + request may not be fully flushed yet and waiting only for readable would + deadlock (the server cannot reply to a request it has not received). + Raises TimeoutError once the per-session timeout passes. + """ + lib = self._lib + conn = self._conn + sock = lib.PQsocket(conn) + deadline = lib.PQgetCurrentTimeUSec() + self._timeout * 1_000_000 + while lib.PQisBusy(conn): + flush = lib.PQflush(conn) + if flush < 0: + raise QueryError( + "PQflush failed: " + (_dec(lib.PQerrorMessage(conn)) or "") + ) + now = lib.PQgetCurrentTimeUSec() + if now >= deadline: + raise TimeoutError("timed out waiting for query result") + # Wake at least once a second to recheck the deadline. + end = min(now + 1_000_000, deadline) + lib.PQsocketPoll(sock, 1, 1 if flush > 0 else 0, end) + if lib.PQconsumeInput(conn) == 0: + # Connection trouble (including the server closing the socket + # right after a FATAL error, e.g. "cannot alter invalid + # database"). Stop and return whatever PQgetResult yields: any + # error result already received is reported, and a clean drop + # with no result comes back as NULL, which get_async_result() + # surfaces as None for crash-detection callers. + break + return lib.PQgetResult(conn) + + def wait_for_completion(self): + """Drain and discard all outstanding async results.""" + lib = self._lib + while True: + res = self._get_result() + if not res: + break + lib.PQclear(res) + + def get_async_result(self): + """Wait for and return the next async result as ResultData.""" + lib = self._lib + conn = self._conn + result = self._get_result() + if not result: + return None + res = extract_result_data(lib, result, conn) + lib.PQclear(result) + while True: + extra = self._get_result() + if not extra: + break + lib.PQclear(extra) + return res + + # -- password change ----------------------------------------------------- + + def set_password(self, user, password): + lib = self._lib + conn = self._conn + result = lib.PQchangePassword(conn, _enc(user), _enc(password)) + ret = extract_result_data(lib, result, conn) + lib.PQclear(result) + return ret + + # -- pipeline mode ------------------------------------------------------- + + def setnonblocking(self, val): + if self._lib.PQsetnonblocking(self._conn, val): + raise QueryError("problem setting non-blocking") + + # The camelCase names below mirror the libpq PQ* functions they wrap. + + def enterPipelineMode(self): # pylint: disable=invalid-name + return self._lib.PQenterPipelineMode(self._conn) + + def pipelineSync(self): # pylint: disable=invalid-name + return self._lib.PQpipelineSync(self._conn) + + def do_pipeline(self, statement, *args): + arr = _str_array(list(args)) + return self._lib.PQsendQueryParams( + self._conn, _enc(statement), len(args), None, arr, None, None, 0 + ) + + def query_tuples_pipelined(self, *queries): + """Run multiple queries in one pipeline round trip.""" + lib = self._lib + conn = self._conn + results = [] + + if not lib.PQenterPipelineMode(conn): + raise QueryError("Failed to enter pipeline mode") + + for sql in queries: + if not lib.PQsendQueryParams(conn, _enc(sql), 0, None, None, None, None, 0): + lib.PQexitPipelineMode(conn) + raise QueryError( + "Failed to send query: " + (_dec(lib.PQerrorMessage(conn)) or "") + ) + + if not lib.PQpipelineSync(conn): + lib.PQexitPipelineMode(conn) + raise QueryError("Failed to sync pipeline") + + for i in range(len(queries)): + result = self._get_result() + if not result: + lib.PQexitPipelineMode(conn) + raise QueryError(f"No result for query {i}") + status = lib.PQresultStatus(result) + if status == PGRES_PIPELINE_ABORTED: + lib.PQclear(result) + lib.PQexitPipelineMode(conn) + raise QueryError(f"Pipeline aborted at query {i}") + if status == PGRES_TUPLES_OK: + res = extract_result_data(lib, result, conn) + if res.rows: + tuples = [ + "|".join("" if v is None else v for v in row) + for row in res.rows + ] + results.append("\n".join(tuples)) + elif status != PGRES_COMMAND_OK: + err = _dec(lib.PQerrorMessage(conn)) or "" + lib.PQclear(result) + lib.PQexitPipelineMode(conn) + raise QueryError(f"Query {i} failed: {err}") + lib.PQclear(result) + + # Consume the NULL result that ends this query's results. + while True: + extra = lib.PQgetResult(conn) + if not extra: + break + lib.PQclear(extra) + + sync_result = self._get_result() + if sync_result: + status = lib.PQresultStatus(sync_result) + lib.PQclear(sync_result) + if status != PGRES_PIPELINE_SYNC: + lib.PQexitPipelineMode(conn) + raise QueryError(f"Expected PGRES_PIPELINE_SYNC, got {status}") + + if not lib.PQexitPipelineMode(conn): + raise QueryError("Failed to exit pipeline mode") + + return "\n".join(results) + + # -- notifications ------------------------------------------------------- + + def get_notification(self): + """Return one pending LISTEN/NOTIFY notification, or None.""" + lib = self._lib + conn = self._conn + lib.PQconsumeInput(conn) + raw = lib.PQnotifies(conn) + return read_notification(lib, raw) + + def get_all_notifications(self): + """Return all pending notifications as a list of dicts.""" + notifications = [] + while True: + notify = self.get_notification() + if notify is None: + break + notifications.append(notify) + return notifications + + +def connect(connstr=None, node=None, dbname="postgres", libdir=None, **kwargs): + """Convenience constructor for a :class:`Session`.""" + return Session(connstr=connstr, node=node, dbname=dbname, libdir=libdir, **kwargs) From df8b05129aa8c4b0635400eeb577c2e66a7cc88d Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:12:47 -0400 Subject: [PATCH 03/18] python tests: add the PostgresServer framework and pytest fixtures PostgresServer manages a cluster's lifecycle (initdb, start/stop/restart, promote), configuration, in-process SQL, log inspection, backup/streaming/ archiving/restore, WAL helpers, replication-slot helpers and connect_ok/ connect_fails connection assertions. PgBin runs client programs; the fixtures (pg_bin, create_pg, pg, conn, bindir, libdir) build the common test objects and tear them down automatically. Author: Jelte Fennema-Nio Reviewed-by: Andrew Dunstan --- src/test/pytest/pypg/__init__.py | 19 + src/test/pytest/pypg/_env.py | 45 + src/test/pytest/pypg/command.py | 177 ++++ src/test/pytest/pypg/fixtures.py | 371 +++++++++ src/test/pytest/pypg/server.py | 1315 ++++++++++++++++++++++++++++++ src/test/pytest/pypg/util.py | 213 +++++ 6 files changed, 2140 insertions(+) create mode 100644 src/test/pytest/pypg/__init__.py create mode 100644 src/test/pytest/pypg/_env.py create mode 100644 src/test/pytest/pypg/command.py create mode 100644 src/test/pytest/pypg/fixtures.py create mode 100644 src/test/pytest/pypg/server.py create mode 100644 src/test/pytest/pypg/util.py diff --git a/src/test/pytest/pypg/__init__.py b/src/test/pytest/pypg/__init__.py new file mode 100644 index 00000000000..e2df717155c --- /dev/null +++ b/src/test/pytest/pypg/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""pypg: PostgreSQL test framework (server management and command helpers). + +The pytest fixtures live in :mod:`pypg.fixtures` and are loaded via the +``-p pypg.fixtures`` option configured in pyproject.toml. +""" + +from .command import CommandResult, PgBin +from .server import PostgresServer +from .util import append_to_file, slurp_file + +__all__ = [ + "CommandResult", + "PgBin", + "PostgresServer", + "append_to_file", + "slurp_file", +] diff --git a/src/test/pytest/pypg/_env.py b/src/test/pytest/pypg/_env.py new file mode 100644 index 00000000000..0b9ab44a910 --- /dev/null +++ b/src/test/pytest/pypg/_env.py @@ -0,0 +1,45 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Process-environment setup for the test suite. + +Forces a C locale for stable messages and clears any inherited PG* connection +variables that would otherwise steer client programs at the wrong server. Call +:func:`prepare_environment` once before any server is started. +""" + +import os + +# PG* variables that must not leak into the test environment (they would +# override host/port/user/db that the framework sets explicitly). +_PG_VARS_TO_CLEAR = ( + "PGDATABASE", + "PGUSER", + "PGPORT", + "PGHOST", + "PGHOSTADDR", + "PGSERVICE", + "PGSSLMODE", + "PGREQUIRESSL", + "PGCONNECT_TIMEOUT", + "PGDATA", + "PGCLIENTENCODING", + "PGOPTIONS", +) + +_prepared = False + + +def prepare_environment(): + """Idempotently set C locale and clear PG* variables.""" + global _prepared # pylint: disable=global-statement + if _prepared: + return + os.environ["LC_ALL"] = "C" + os.environ["LC_MESSAGES"] = "C" + for var in _PG_VARS_TO_CLEAR: + os.environ.pop(var, None) + # Default the database to "postgres" so a connection string without an + # explicit dbname (e.g. in load-balancing tests) does not fall through to + # the OS user name. + os.environ["PGDATABASE"] = "postgres" + _prepared = True diff --git a/src/test/pytest/pypg/command.py b/src/test/pytest/pypg/command.py new file mode 100644 index 00000000000..fca68812b2f --- /dev/null +++ b/src/test/pytest/pypg/command.py @@ -0,0 +1,177 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Running programs and asserting on their results. + +A :class:`PgBin` runs binaries from a given bindir (optionally with extra +environment, e.g. a node's PGHOST/PGPORT) and asserts on exit code, stdout and +stderr. Failures raise AssertionError so pytest/pgtap report them. +""" + +import os +from dataclasses import dataclass +from typing import Optional, Sequence + +from .util import TIMEOUT_DEFAULT, run_captured + + +@dataclass +class CommandResult: + """Outcome of running a command.""" + + returncode: int + stdout: str + stderr: str + + +# Mirrors program_help_ok's line-length convention. +_MAX_HELP_LINE_LENGTH = 95 + + +def _describe(cmd: Sequence[str], result: CommandResult) -> str: + return "command: {}\nexit code: {}\nstderr:\n{}\nstdout:\n{}".format( + " ".join(str(c) for c in cmd), result.returncode, result.stderr, result.stdout + ) + + +class PgBin: + """Runs PostgreSQL binaries located in *bindir*.""" + + def __init__(self, bindir, extra_env: Optional[dict] = None): + self.bindir = str(bindir) + self.extra_env = dict(extra_env or {}) + + def command_env(self, extra_env: Optional[dict]) -> dict: + env = dict(os.environ) + env.update(self.extra_env) + if extra_env: + env.update(extra_env) + # A None value means "unset this variable" (e.g. to drop an inherited + # TZ), since subprocess env values must all be strings. + return {k: v for k, v in env.items() if v is not None} + + def resolve(self, name): + """Resolve a program name to its path within bindir if present.""" + candidate = os.path.join(self.bindir, name) + return candidate if os.path.exists(candidate) else name + + def result( + self, cmd: Sequence[str], *, extra_env=None, timeout=TIMEOUT_DEFAULT + ) -> CommandResult: + """Run *cmd* (list) and capture its result. cmd[0] is resolved in bindir. + + A wedged program is killed after *timeout* seconds (raising + subprocess.TimeoutExpired); pass timeout=None to wait indefinitely. + """ + argv = [self.resolve(cmd[0]), *map(str, cmd[1:])] + print("# Running: " + " ".join(argv)) + # Capture via files, not pipes: a program that launches a server (e.g. + # "pg_ctl start") leaves the postmaster holding the pipe open on + # Windows, which would deadlock the read. Output is decoded leniently + # since programs may emit non-UTF-8 bytes (e.g. LATIN1 object names) + # that we only regex-match. + returncode, stdout, stderr = run_captured( + argv, env=self.command_env(extra_env), timeout=timeout + ) + return CommandResult(returncode, stdout, stderr) + + # -- command_* assertions ----------------------------------------------- + + def command_ok(self, cmd, msg=None, *, extra_env=None) -> CommandResult: + res = self.result(cmd, extra_env=extra_env) + assert res.returncode == 0, ( + (msg or "command should succeed") + "\n" + _describe(cmd, res) + ) + return res + + def command_fails(self, cmd, msg=None, *, extra_env=None) -> CommandResult: + res = self.result(cmd, extra_env=extra_env) + assert res.returncode != 0, ( + (msg or "command should fail") + "\n" + _describe(cmd, res) + ) + return res + + def command_exit_is(self, cmd, code, msg=None, *, extra_env=None) -> CommandResult: + res = self.result(cmd, extra_env=extra_env) + assert res.returncode == code, ( + (msg or f"exit code should be {code}") + "\n" + _describe(cmd, res) + ) + return res + + def command_like(self, cmd, pattern, msg=None, *, extra_env=None) -> CommandResult: + import re + + res = self.result(cmd, extra_env=extra_env) + assert res.returncode == 0, ( + (msg or "command should succeed") + "\n" + _describe(cmd, res) + ) + assert res.stderr == "", (msg or "no stderr") + "\n" + _describe(cmd, res) + assert re.search(pattern, res.stdout), ( + (msg or "stdout should match") + f" /{pattern}/\n" + _describe(cmd, res) + ) + return res + + def command_fails_like( + self, cmd, pattern, msg=None, *, extra_env=None + ) -> CommandResult: + import re + + res = self.result(cmd, extra_env=extra_env) + assert res.returncode != 0, ( + (msg or "command should fail") + "\n" + _describe(cmd, res) + ) + assert re.search(pattern, res.stderr), ( + (msg or "stderr should match") + f" /{pattern}/\n" + _describe(cmd, res) + ) + return res + + def command_checks_all( + self, cmd, expected_ret, stdout_res, stderr_res, msg=None, *, extra_env=None + ) -> CommandResult: + """Check exit code plus a list of stdout and stderr regexes.""" + import re + + res = self.result(cmd, extra_env=extra_env) + label = msg or "command" + assert res.returncode == expected_ret, ( + f"{label} status (got {res.returncode} vs expected {expected_ret})\n" + + _describe(cmd, res) + ) + for pattern in stdout_res: + assert re.search( + pattern, res.stdout + ), f"{label} stdout /{pattern}/\n" + _describe(cmd, res) + for pattern in stderr_res: + assert re.search( + pattern, res.stderr + ), f"{label} stderr /{pattern}/\n" + _describe(cmd, res) + return res + + # -- program_* assertions ----------------------------------------------- + + def program_help_ok(self, name): + res = self.result([name, "--help"]) + assert res.returncode == 0, f"{name} --help exit code 0\n" + _describe( + [name, "--help"], res + ) + assert res.stdout != "", f"{name} --help goes to stdout" + assert res.stderr == "", f"{name} --help nothing to stderr:\n{res.stderr}" + long_lines = [ + ln for ln in res.stdout.splitlines() if len(ln) > _MAX_HELP_LINE_LENGTH + ] + assert not long_lines, ( + f"{name} --help maximum line length (>{_MAX_HELP_LINE_LENGTH}):\n" + + "\n".join(long_lines) + ) + + def program_version_ok(self, name): + res = self.result([name, "--version"]) + assert res.returncode == 0, f"{name} --version exit code 0\n" + _describe( + [name, "--version"], res + ) + assert res.stdout != "", f"{name} --version goes to stdout" + assert res.stderr == "", f"{name} --version nothing to stderr:\n{res.stderr}" + + def program_options_handling_ok(self, name): + res = self.result([name, "--not-a-valid-option"]) + assert res.returncode != 0, f"{name} with invalid option nonzero exit code" + assert res.stderr != "", f"{name} with invalid option prints error message" diff --git a/src/test/pytest/pypg/fixtures.py b/src/test/pytest/pypg/fixtures.py new file mode 100644 index 00000000000..7bbf92ce18f --- /dev/null +++ b/src/test/pytest/pypg/fixtures.py @@ -0,0 +1,371 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Pytest fixtures for PostgreSQL tests. + +Loaded as a plugin (``-p pypg.fixtures``). Provides the building blocks tests +use: ``pg_bin`` to run client programs, ``create_pg`` to spin up servers, and +``pg``/``conn`` for the common single-server case. Servers are cleaned up +automatically at the end of the test. +""" + +import os +import pathlib +import re +import shutil +import socket +import subprocess + +import pytest + +from . import _env +from .command import PgBin +from .server import PostgresServer +from .util import short_tempdir + + +@pytest.fixture(scope="session", autouse=True) +def _prepare_env(): + """Force C locale and clear PG* variables before anything starts.""" + _env.prepare_environment() + + +def _pg_config_value(pg_config, option): + return subprocess.run( + [pg_config, option], stdout=subprocess.PIPE, text=True, check=True + ).stdout.strip() + + +@pytest.fixture(scope="session") +def pg_config(): + path = shutil.which("pg_config") + if not path: + pytest.skip("pg_config not found on PATH") + return path + + +@pytest.fixture(scope="session") +def bindir(pg_config): + return _pg_config_value(pg_config, "--bindir") + + +@pytest.fixture(scope="session") +def libdir(pg_config): + return _pg_config_value(pg_config, "--libdir") + + +@pytest.fixture(scope="session", autouse=True) +def _check_libpq_abi(libdir): + """Skip the suite when this Python cannot load the build's libpq. + + The in-process libpq layer is loaded via ctypes, so the interpreter must + match libpq's ABI. A 64-bit Python cannot dlopen the 32-bit libpq from a + ``-m32`` build, which would otherwise fail every test with an OSError; skip + with a clear reason instead. See findlib.libpq_abi_skip_reason. + """ + from libpq.findlib import libpq_abi_skip_reason + + reason = libpq_abi_skip_reason(libdir) + if reason: + pytest.skip(reason) + + +@pytest.fixture(scope="session") +def pg_bin(bindir): + """A PgBin for running client programs that do not need a server.""" + return PgBin(bindir) + + +def _safe_node_name(request): + return re.sub(r"[^A-Za-z0-9_.-]", "_", request.node.name) + + +def _test_failed(request): + """Whether the test's setup or call phase failed. + + Reads the per-phase reports stashed by pgtap's pytest_runtest_makereport + hook; absent (e.g. plugin not active) is treated as "not failed". + """ + return any( + getattr(getattr(request.node, f"_phase_{p}", None), "failed", False) + for p in ("setup", "call") + ) + + +@pytest.fixture +def test_datadir(request, tmp_path_factory): + """The per-test directory for servers and other test data. + + Under the meson/testwrap harness this is rooted at the per-file TESTDATADIR + (as PostgreSQL::Test::Utils uses for tmp_check); for a standalone pytest run + it falls back to a directory minted from pytest's tmp_path_factory. Using + TESTDATADIR rather than pytest's shared pytest-of- base matters + because that base is created with mode 0o700: on Windows that is an + owner-only ACL the postmaster's restricted access token cannot use, so + server-created paths under it (data dirs, tablespaces) fail with + "Permission denied". + + TESTDATADIR is shared by every test function in a file, so append a + per-function subdirectory to keep functions from colliding. + """ + root = os.environ.get("TESTDATADIR") + safe = _safe_node_name(request) + if not root: + # pytest's tmp_path_factory manages its own cleanup (keeps the last + # few runs), so just hand out a fresh directory. + yield tmp_path_factory.mktemp(safe, numbered=True) + return + path = pathlib.Path(root) / safe + path.mkdir(parents=True, exist_ok=True) + yield path + # TESTDATADIR is shared by every test in the file and kept for the whole CI + # job, so removing this test's data (server data dirs, WAL, aux server + # dirs, scratch) on success keeps it from accumulating gigabytes and + # filling the runner disk. Keep it on failure for post-mortem, like the + # Perl framework. + if not _test_failed(request): + shutil.rmtree(path, ignore_errors=True) + + +@pytest.fixture +def tmp_path(test_datadir): + """Override pytest's built-in tmp_path so per-test scratch space lives + under the harness directory (TESTDATADIR) rather than pytest's own base. + + pytest creates its base (pytest-of-) with mode 0o700, which on Windows + is an owner-only ACL the postmaster's restricted access token cannot use -- + initdb, tablespaces and other server-created paths under it then fail with + "Permission denied". Rooting at TESTDATADIR (created by the harness with an + inheritable ACL) avoids that and is where servers already live. + """ + return test_datadir + + +def _free_port(): + """Return an unused TCP port number (used to name the unix socket).""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@pytest.fixture +def create_pg(bindir, libdir, test_datadir): + """Factory creating PostgresServer instances, torn down after the test. + + ``create_pg(name="main", start=True, initdb_extra=None, + allows_streaming=False, has_archiving=False, has_restoring=False, + port=None, own_host=False)`` returns an initialized (and, by default, + started) server; ``initdb_extra`` is a list of extra arguments passed to + initdb (e.g. ``["--no-data-checksums"]``). The streaming/archiving/ + restoring flags are forwarded to :meth:`PostgresServer.init`. + + ``port`` pins an explicit port (otherwise a free one is chosen); ``own_host`` + binds the node to its own loopback address (127.0.0.1, .2, .3, ...) over + TCP. Together they let several nodes share one port, distinguished by IP + -- needed for DNS-based load-balancing tests. + + Data dirs live under the test's own data directory; the unix socket lives + in a short /tmp directory to stay within the socket path length limit. + """ + servers = [] + sockdirs = [] + # Per-test loopback-IP counter for own_host nodes (127.0.0.1, .2, .3, ...). + own_host_counter = [0] + + def _create( + name="main", + *, + start=True, + initdb_extra=None, + allows_streaming=False, + has_archiving=False, + has_restoring=False, + port=None, + own_host=False, + ): + sockdir = short_tempdir() + sockdirs.append(sockdir) + listen_host = None + if own_host: + own_host_counter[0] += 1 + if own_host_counter[0] > 254: + raise RuntimeError("too many own_host nodes") + listen_host = f"127.0.0.{own_host_counter[0]}" + server = PostgresServer( + name, + bindir, + libdir, + str(test_datadir / name), + _free_port() if port is None else port, + sockdir, + listen_host=listen_host, + ) + # Track for teardown before init()/start(), so a failure in either + # still stops a postmaster that may have been left running (start() + # can set running=True and then raise when pg_ctl times out but the + # postmaster is in fact alive). + servers.append(server) + server.init( + extra=initdb_extra, + allows_streaming=allows_streaming, + has_archiving=has_archiving, + has_restoring=has_restoring, + ) + if start: + server.start() + return server + + yield _create + + for server in servers: + server.teardown() + for sockdir in sockdirs: + shutil.rmtree(sockdir, ignore_errors=True) + + +@pytest.fixture +def tempdir_short(): + """A temporary directory with a short pathname, removed after the test. + + Some uses need a path short enough to fit tar's ~100-byte symlink-target + limit -- notably tablespace locations, whose symlinks are written into a + base backup's tar stream. The per-test data directory can exceed that (its + deep layout is especially long on macOS), so use a directory directly under + the system temp area instead. + """ + d = short_tempdir() + yield d + shutil.rmtree(d, ignore_errors=True) + + +@pytest.fixture +def pg(create_pg): + """A single started PostgresServer for the test.""" + return create_pg("main") + + +@pytest.fixture +def conn(pg): + """A persistent libpq Session connected to the ``pg`` server's postgres + database, closed at the end of the test.""" + sess = pg.connect() + yield sess + sess.close() + + +@pytest.fixture +def ldap_server(test_datadir): + """Factory creating LdapServer (slapd) instances, stopped after the test. + + ``ldap_server(rootpw, authtype)`` returns a running server (authtype is + 'users' or 'anonymous'). Skips the test if no usable slapd is found. The + SSL certs come from src/test/ssl/ssl. + """ + from . import ldapserver + + if not ldapserver.AVAILABLE: + pytest.skip(ldapserver.SETUP_ERROR) + + # repo root is four levels up from this file (src/test/pytest/pypg). + repo = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + certdir = os.path.join(repo, "src", "test", "ssl", "ssl") + + servers = [] + counter = [0] + + def _create(rootpw, authtype): + counter[0] += 1 + basedir = test_datadir / f"ldap{counter[0]}" + basedir.mkdir() + server = ldapserver.LdapServer(basedir, rootpw, authtype, certdir) + servers.append(server) + return server + + yield _create + + for server in servers: + server.stop() + + +@pytest.fixture +def kerberos(test_datadir): + """Factory creating a Kerberos KDC, stopped after the test. + + ``kerberos(host, hostaddr, realm, srvnam="postgres")`` sets up a realm + + service principal and starts krb5kdc, setting KRB5_CONFIG/KRB5_KDC_PROFILE/ + KRB5CCNAME in the environment (so a postmaster started afterward inherits + them). Those env vars are restored at teardown. Skips if MIT krb5 is not + installed. + """ + from . import kerberos as krb + + if not krb.AVAILABLE: + pytest.skip(krb.SETUP_ERROR) + + saved_env = { + k: os.environ.get(k) for k in ("KRB5_CONFIG", "KRB5_KDC_PROFILE", "KRB5CCNAME") + } + kdcs = [] + counter = [0] + + def _create(host, hostaddr, realm, srvnam="postgres"): + counter[0] += 1 + basedir = test_datadir / f"krb{counter[0]}" + basedir.mkdir() + kdc = krb.Kerberos(basedir, host, hostaddr, realm, srvnam) + kdcs.append(kdc) + return kdc + + yield _create + + for kdc in kdcs: + kdc.stop() + for k, v in saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +@pytest.fixture +def oauth_server(): + """Factory starting the mock OAuth provider (oauth_server.py). + + ``oauth_server(script_path)`` returns a running OAuthServer with a ``.port`` + attribute; it is stopped at teardown. + """ + from .oauthserver import OAuthServer + + servers = [] + + def _create(script): + srv = OAuthServer(script) + servers.append(srv) + return srv + + yield _create + + for srv in servers: + srv.stop() + + +@pytest.fixture +def ssl_server(bindir, test_datadir): + """An SSLServer (OpenSSL backend) for configuring a cluster for SSL. + + Skips the test unless this build uses OpenSSL (with_ssl=openssl). Client + keys are copied, with private permissions, under the test data directory. + """ + from .ssl_server import SSLServer + + if os.environ.get("with_ssl") != "openssl": + pytest.skip("SSL not supported by this build") + + repo = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + ssl_dir = os.path.join(repo, "src", "test", "ssl", "ssl") + keydir = test_datadir / "ssl-keys" + keydir.mkdir() + return SSLServer(ssl_dir, keydir, bindir) diff --git a/src/test/pytest/pypg/server.py b/src/test/pytest/pypg/server.py new file mode 100644 index 00000000000..9961b918bc8 --- /dev/null +++ b/src/test/pytest/pypg/server.py @@ -0,0 +1,1315 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""A managed PostgreSQL server instance for tests. + +:class:`PostgresServer` manages the lifecycle (initdb, start, stop, restart, +reload), configuration, and query execution of a server instance. Queries run +in-process through :class:`libpq.Session` (no psql subprocess); the command_* +helpers run client programs with this server's PGHOST/PGPORT. +""" + +import os +import re +import shutil +import signal +import socket +import subprocess +import time + +from libpq import Session, conninfo_quote +from libpq.errors import PqConnectionError, QueryError + +from .command import CommandResult, PgBin +from .util import ( + TIMEOUT_DEFAULT, + USE_UNIX_SOCKETS, + WINDOWS_OS, + poll_until, + run_captured, +) + + +def _pid_alive(pid): + """Whether process *pid* currently exists. + + On Windows ``os.kill(pid, 0)`` would terminate the process (any signal maps + to TerminateProcess), so probe with OpenProcess/GetExitCodeProcess instead. + """ + if WINDOWS_OS: + import ctypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + try: + code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return False + return code.value == STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +# The breadth of this class matches the cluster-management API the tests +# need; splitting it would not make the tests clearer. +class PostgresServer: # pylint: disable=too-many-public-methods + """One initdb'd data directory and the server running on it.""" + + def __init__(self, name, bindir, libdir, basedir, port, sockdir, listen_host=None): + self.name = name + self._bindir = str(bindir) + self.libdir = str(libdir) + self.basedir = str(basedir) + self.port = int(port) + self._sockdir = str(sockdir) + # The connection host. When listen_host is given (own_host), bind that + # loopback address over TCP; combined with an explicit shared port this + # lets several nodes coexist, distinguished by IP. Otherwise use the + # socket directory with Unix-domain sockets, or 127.0.0.1 on TCP + # (Windows). Backslashes in a Windows socket path are converted to '/' + # so the value is valid in postgresql.conf. + self._own_host = listen_host is not None + if self._own_host: + self.host = listen_host + elif USE_UNIX_SOCKETS: + self.host = self._sockdir.replace("\\", "/") + else: + self.host = "127.0.0.1" + self.running = False + self._logfile_generation = 0 + os.makedirs(self.basedir, exist_ok=True) + + # -- paths / connection info -------------------------------------------- + + @property + def data_dir(self): + return os.path.join(self.basedir, "pgdata") + + @property + def logfile(self): + # Generation 0 keeps the plain "server.log" name that other tests and + # helpers expect; rotate_logfile() bumps the generation for tests that + # must keep a fresh log across a restart. + if self._logfile_generation == 0: + return os.path.join(self.basedir, "server.log") + return os.path.join(self.basedir, f"server_{self._logfile_generation}.log") + + def rotate_logfile(self): + """Switch to a fresh log file for the next start. + + Needed where the old log can't be reopened for writing (e.g. on + Windows) or a test wants to scan only the newest postmaster's output. + """ + self._logfile_generation += 1 + return self.logfile + + @property + def pidfile(self): + return os.path.join(self.data_dir, "postmaster.pid") + + @property + def backup_dir(self): + """Output path for backups taken with :meth:`backup`.""" + return os.path.join(self.basedir, "backup") + + @property + def archive_dir(self): + """Directory WAL is archived into when has_archiving is enabled.""" + return os.path.join(self.basedir, "archives") + + @property + def bindir(self): + return self._bindir + + def connstr(self, dbname="postgres"): + return ( + f"host='{conninfo_quote(self.host)}' port={self.port} " + f"dbname='{conninfo_quote(dbname)}'" + ) + + def _listen_conf_lines(self): + """postgresql.conf lines selecting the connection transport. + + Unix-domain sockets in this node's private directory, or TCP on the + loopback address (Windows, or any own_host node). + """ + if self._own_host or not USE_UNIX_SOCKETS: + return [ + f"listen_addresses = '{self.host}'", + "unix_socket_directories = ''", + ] + return [ + "listen_addresses = ''", + f"unix_socket_directories = '{self.host}'", + ] + + def raw_connect(self): + """Open and return a raw socket to the server, caller closes it. + + Connects to the Unix-domain socket (``/.s.PGSQL.``) or, on + TCP, to (host, port), for tests that speak the wire protocol directly + or just consume a connection. + """ + if USE_UNIX_SOCKETS: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(os.path.join(self.host, f".s.PGSQL.{self.port}")) + else: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((self.host, self.port)) + return sock + + def raw_connect_works(self): + """Whether :meth:`raw_connect` is usable on this platform. + + Always true on TCP. With Unix-domain sockets it needs a working + AF_UNIX implementation (absent on some Windows pythons); probe once. + """ + if USE_UNIX_SOCKETS: + if not hasattr(socket, "AF_UNIX"): + return False + try: + self.raw_connect().close() + except OSError: + return False + return True + + @property + def pg_bin(self): + """A PgBin whose environment targets this server. + + Sets PGHOST/PGPORT and defaults PGDATABASE to 'postgres', so client + programs invoked without an explicit database connect to a database + that exists. + """ + return PgBin( + self._bindir, + extra_env={ + "PGHOST": self.host, + "PGPORT": str(self.port), + "PGDATABASE": "postgres", + }, + ) + + # -- internal command runner -------------------------------------------- + + def _run(self, *argv, check=True, timeout=TIMEOUT_DEFAULT): + argv = [self.resolve(argv[0]), *map(str, argv[1:])] + print("# Running: " + " ".join(argv)) + # Capture via files, not pipes: "pg_ctl start" leaves a postmaster + # holding the pipe open on Windows, which would deadlock the read. + # A wedged command is killed after *timeout* seconds (TimeoutExpired); + # pass timeout=None to wait indefinitely. + returncode, stdout, _ = run_captured(argv, combine_stderr=True, timeout=timeout) + if check and returncode != 0: + raise RuntimeError( + f"command failed ({returncode}): {' '.join(argv)}\n{stdout}" + ) + return CommandResult(returncode, stdout, "") + + def resolve(self, name): + candidate = os.path.join(self._bindir, name) + return candidate if os.path.exists(candidate) else name + + # -- lifecycle ----------------------------------------------------------- + + def init( + self, + extra=None, + *, + allows_streaming=False, + has_archiving=False, + has_restoring=False, + wal_level=None, + ): + """Run initdb and write a test configuration. + + Keyword params (all default off, preserving the existing minimal + config): + + - ``allows_streaming``: set up postgresql.conf for replication. + Pass ``"logical"`` for ``wal_level = logical``; any other truthy + value yields ``wal_level = replica``. + - ``has_archiving``: enable ``archive_mode`` with an archive_command + that copies WAL into :attr:`archive_dir`. + - ``has_restoring``: accepted but has no effect here; restoring is + actually configured on a standby in :meth:`init_from_backup`. + - ``wal_level``: explicit override of ``wal_level``. + """ + os.makedirs(self.backup_dir, exist_ok=True) + os.makedirs(self.archive_dir, exist_ok=True) + # Pre-create the (empty) data directory so initdb takes its + # "present but empty" path instead of calling pg_mkdir_p. Python's + # makedirs tolerates a concurrent create of a shared parent, whereas + # initdb's pg_mkdir_p does not: under parallel test execution several + # initdb processes race to create a common temp ancestor and all but + # one fail with "File exists". + os.makedirs(self.data_dir, exist_ok=True) + + argv = [ + "initdb", + "-D", + self.data_dir, + "--no-sync", + "--no-instructions", + "-A", + "trust", + "--locale=C", + "--encoding=UTF8", + ] + if extra: + argv += list(extra) + self._run(*argv) + + lines = [] + if allows_streaming: + if wal_level is None: + wal_level = "logical" if allows_streaming == "logical" else "replica" + lines += [ + f"wal_level = {wal_level}", + "max_wal_senders = 10", + "max_replication_slots = 10", + "wal_log_hints = on", + "hot_standby = on", + # conservative settings to ensure we can run multiple postmasters: + "shared_buffers = 1MB", + "max_connections = 10", + # limit disk space consumption, too: + "max_wal_size = 128MB", + ] + elif wal_level is not None: + lines.append(f"wal_level = {wal_level}") + + lines.append(f"port = {self.port}") + lines += self._listen_conf_lines() + lines += ["fsync = off", ""] + self.append_conf("\n".join(lines)) + + if has_archiving: + self.enable_archiving() + + @staticmethod + def _file_copy_command(src, dst): + """A shell command that copies file *src* to *dst*. + + *src*/*dst* may embed the archive/restore ``%p``/``%f`` placeholders. + Elsewhere this is ``cp`` with forward-slash paths. On Windows it is + cmd's ``copy``; there the path's backslashes must be doubled for the + command to work once stored in postgresql.conf and to identify the + target file, and both paths are double-quoted to tolerate spaces. + """ + if WINDOWS_OS: + + def winpath(p): + return p.replace("/", "\\").replace("\\", "\\\\") + + return f'copy "{winpath(src)}" "{winpath(dst)}"' + return f'cp "{src}" "{dst}"' + + def enable_archiving(self): + """Enable WAL archiving into :attr:`archive_dir`. + + Internal helper. + """ + copy_command = self._file_copy_command("%p", f"{self.archive_dir}/%f") + self.append_conf( + "\n".join( + [ + "", + "archive_mode = on", + f"archive_command = '{copy_command}'", + "", + ] + ) + ) + + def append_conf(self, text, filename="postgresql.conf"): + with open(os.path.join(self.data_dir, filename), "a", encoding="utf-8") as fh: + if not text.endswith("\n"): + text += "\n" + fh.write(text) + + def start(self, fail_ok=False): + """Start the postmaster. Returns True on success. + + With *fail_ok* a failed start returns False instead of raising, like + Cluster::start(fail_ok => 1). If pg_ctl reports failure but a + postmaster is in fact still alive (e.g. pg_ctl timed out waiting), the + running flag is set so a later stop() cleans it up. + """ + proc = self._run( + "pg_ctl", + "-D", + self.data_dir, + "-l", + self.logfile, + "-w", + "start", + check=False, + ) + if proc.returncode == 0: + self.running = True + return True + self.running = self.postmaster_alive() + if not fail_ok: + # pg_ctl's own output rarely says why; include the server log, + # which holds the actual startup error. + try: + with open(self.logfile, encoding="utf-8", errors="replace") as fh: + log = fh.read() + except OSError: + log = "(could not read log file)" + raise RuntimeError( + f'pg_ctl start failed for node "{self.name}":\n{proc.stdout}\n' + f"--- {self.logfile} ---\n{log}" + ) + return False + + def stop(self, mode="fast", fail_ok=False): + """Stop the postmaster. Returns True on success (or if not running).""" + if not self.running: + return True + proc = self._run( + "pg_ctl", + "-D", + self.data_dir, + "-m", + mode, + "-w", + "stop", + check=not fail_ok, + ) + # Only clear running on a confirmed stop, so a failed fail_ok stop + # leaves the flag set and teardown can force-kill the survivor. + if proc.returncode == 0: + self.running = False + return True + return False + + def postmaster_pid(self): + """Return the postmaster PID from postmaster.pid, or None.""" + try: + with open(self.pidfile, encoding="utf-8") as fh: + return int(fh.readline().strip()) + except (FileNotFoundError, ValueError): + return None + + def postmaster_alive(self): + pid = self.postmaster_pid() + if pid is None: + return False + return _pid_alive(pid) + + def signal_backend(self, pid, signame): + """Send signal *signame* (e.g. "QUIT", "KILL", "TERM") to process *pid*. + + Uses ``pg_ctl kill``, which delivers the signal through the server's own + mechanism and so works on every platform (Windows has no Unix signals). + """ + self._run("pg_ctl", "kill", signame, str(pid)) + + def kill9(self): + """Hard-kill the postmaster (no chance to clean up). + + Postmaster children normally exit on their own once the postmaster is + gone; a backend stuck in a CPU-bound loop is the exception this test + relies on. + """ + pid = self.postmaster_pid() + if pid is not None: + print(f'### Killing node "{self.name}" using signal 9') + if WINDOWS_OS: + # No SIGKILL on Windows; terminate the process tree forcibly. + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + else: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.running = False + + def restart(self, mode="fast"): + self._run( + "pg_ctl", + "-D", + self.data_dir, + "-l", + self.logfile, + "-m", + mode, + "-w", + "restart", + ) + self.running = True + + def reload(self): + self._run("pg_ctl", "-D", self.data_dir, "reload") + + def promote(self): + self._run("pg_ctl", "-D", self.data_dir, "-w", "promote") + + # -- backup / streaming replication -------------------------------------- + + def backup(self, backup_name, backup_options=None): + """Take a pg_basebackup into ``backup_dir/``. + + WAL is fetched at the end of the backup unless overridden via + *backup_options* (e.g. + ``["-X", "stream"]``). The resulting backup is usable by + :meth:`init_from_backup`. + """ + backup_path = os.path.join(self.backup_dir, backup_name) + print(f'# Taking pg_basebackup {backup_name} from node "{self.name}"') + argv = [ + "pg_basebackup", + "--no-sync", + "--pgdata", + backup_path, + "--host", + self.host, + "--port", + str(self.port), + "--checkpoint", + "fast", + ] + if backup_options: + argv += list(backup_options) + self._run(*argv) + print("# Backup finished") + + def backup_fs_cold(self, backup_name): + """Take a filesystem-level cold backup (the server must be stopped). + + Copies the data directory into ``backup_dir/``, excluding + the log directory and postmaster.pid, producing a tree usable by + :meth:`init_from_backup`. + """ + dest = os.path.join(self.backup_dir, backup_name) + os.makedirs(self.backup_dir, exist_ok=True) + shutil.copytree( + self.data_dir, + dest, + symlinks=True, + ignore=shutil.ignore_patterns("log", "postmaster.pid"), + ) + + def init_from_backup( + self, + root_node, + backup_name, + *, + has_streaming=False, + has_restoring=False, + standby=True, + ): + """Initialize this node's data dir from *root_node*'s named backup. + + Plain-format backups only; tar/incremental/tablespace variants are not + supported by this framework. Does not start the node. + + - ``has_streaming``: configure ``primary_conninfo`` pointing at + *root_node* and place ``standby.signal`` (streaming replication). + - ``has_restoring``: configure a ``restore_command`` reading from + *root_node*'s archive dir. Standby mode is used by default; pass + ``standby=False`` for crash-recovery (recovery.signal) mode. + """ + backup_path = os.path.join(root_node.backup_dir, backup_name) + print( + f'# Initializing node "{self.name}" from backup "{backup_name}" ' + f'of node "{root_node.name}"' + ) + if not os.path.isdir(backup_path): + raise RuntimeError( + f'Backup "{backup_name}" does not exist at {backup_path}' + ) + + os.makedirs(self.backup_dir, exist_ok=True) + os.makedirs(self.archive_dir, exist_ok=True) + + # Copy the backup tree into this node's data directory, leaving the + # original backup unmodified. + data_path = self.data_dir + if os.path.isdir(data_path): + shutil.rmtree(data_path) + shutil.copytree(backup_path, data_path, symlinks=True) + os.chmod(data_path, 0o700) + + # Base configuration for this node. + self.append_conf( + "\n".join(["", f"port = {self.port}"] + self._listen_conf_lines() + [""]) + ) + + if has_streaming: + self.enable_streaming(root_node) + if has_restoring: + self.enable_restoring(root_node, standby) + + def enable_streaming(self, root_node): + """Configure streaming replication from *root_node* and go standby. + + Internal helper. The standby's application_name defaults to its node + name so that + :meth:`wait_for_catchup` can locate it in pg_stat_replication. + """ + print(f'### Enabling streaming replication for node "{self.name}"') + root_connstr = ( + f"host={root_node.host} port={root_node.port} " + f"application_name={self.name}" + ) + self.append_conf(f"\nprimary_conninfo='{root_connstr}'\n") + self.set_standby_mode() + + def enable_restoring(self, root_node, standby=True): + """Configure WAL restore from *root_node*'s archive dir. + + Internal helper. + """ + print(f'### Enabling WAL restore for node "{self.name}"') + copy_command = self._file_copy_command(f"{root_node.archive_dir}/%f", "%p") + self.append_conf(f"\nrestore_command = '{copy_command}'\n") + if standby: + self.set_standby_mode() + else: + self.set_recovery_mode() + + def set_standby_mode(self): + """Place a standby.signal file.""" + self.append_conf("", filename="standby.signal") + + def set_recovery_mode(self): + """Place a recovery.signal file.""" + self.append_conf("", filename="recovery.signal") + + # -- LSN / replication progress ------------------------------------------ + + _LSN_MODES = { + "insert": "pg_current_wal_insert_lsn()", + "flush": "pg_current_wal_flush_lsn()", + "write": "pg_current_wal_lsn()", + "receive": "pg_last_wal_receive_lsn()", + "replay": "pg_last_wal_replay_lsn()", + } + + def lsn(self, mode): + """Return the current LSN for *mode*. + + Valid modes: insert, flush, write, receive, replay. Returns ``None`` + if the underlying function returns an empty result. + """ + if mode not in self._LSN_MODES: + raise ValueError( + f"unknown mode for 'lsn': {mode!r}, valid modes are " + + ", ".join(self._LSN_MODES) + ) + result = self.safe_sql(f"SELECT {self._LSN_MODES[mode]}").strip() + return result if result != "" else None + + # -- WAL generation / manipulation --------------------------------------- + + def _insert_lsn_bytes(self): + """Current insert LSN as an integer byte offset (LSN - '0/0').""" + return int(self.safe_sql("SELECT pg_current_wal_insert_lsn() - '0/0'")) + + def emit_wal(self, size): + """Emit *size* bytes of WAL via pg_logical_emit_message; return end LSN. + + Returns the numeric LSN. + """ + return int( + self.safe_sql( + f"SELECT pg_logical_emit_message(true, '', repeat('a', {size})) - '0/0'" + ) + ) + + def advance_wal(self, num): + """Advance WAL by *num* segments.""" + for _ in range(num): + self.safe_sql("SELECT pg_logical_emit_message(false, '', 'foo')") + self.safe_sql("SELECT pg_switch_wal()") + + def advance_wal_out_of_record_splitting_zone(self, wal_block_size): + """Advance WAL away from a page boundary.""" + page_threshold = wal_block_size // 4 + end_lsn = self._insert_lsn_bytes() + page_offset = end_lsn % wal_block_size + while page_offset >= wal_block_size - page_threshold: + self.emit_wal(page_threshold) + end_lsn = self._insert_lsn_bytes() + page_offset = end_lsn % wal_block_size + return end_lsn + + def advance_wal_to_record_splitting_zone(self, wal_block_size): + """Advance WAL close to a page boundary.""" + record_header_size = 24 + end_lsn = self._insert_lsn_bytes() + page_offset = end_lsn % wal_block_size + # Get fairly close to the end of a page in big steps. + while page_offset <= wal_block_size - 512: + self.emit_wal(wal_block_size - page_offset - 256) + end_lsn = self._insert_lsn_bytes() + page_offset = end_lsn % wal_block_size + # Calibrate the message size to approach 8 bytes at a time. + message_size = wal_block_size - 80 + while page_offset <= wal_block_size - record_header_size: + self.emit_wal(message_size) + end_lsn = self._insert_lsn_bytes() + old_offset = page_offset + page_offset = end_lsn % wal_block_size + delta = page_offset - old_offset + if delta > 8: + message_size -= 8 + elif delta <= 0: + message_size += 8 + return end_lsn + + def write_wal(self, tli, lsn, segment_size, data): + """Write raw *data* bytes at *lsn* in the WAL.""" + segment = lsn // segment_size + offset = lsn % segment_size + path = os.path.join(self.data_dir, "pg_wal", "%08X%08X%08X" % (tli, 0, segment)) + with open(path, "r+b") as fh: + fh.seek(offset) + fh.write(data) + return path + + def wait_for_catchup(self, standby_name, mode="replay", target_lsn=None): + """Wait until *standby_name* has caught up on this (primary) node. + + Polls pg_stat_replication on self until the standby's ``_lsn`` + reaches *target_lsn* (default: this node's current write LSN, or its + replay LSN when self is itself in recovery). + + *standby_name* may be a :class:`PostgresServer` (its name is used as + the application_name) or an application_name string. Valid modes: + sent, write, flush, replay. + """ + valid_modes = ("sent", "write", "flush", "replay") + if mode not in valid_modes: + raise ValueError( + f"unknown mode {mode} for 'wait_for_catchup', valid modes are " + + ", ".join(valid_modes) + ) + + if isinstance(standby_name, PostgresServer): + standby_name = standby_name.name + + if target_lsn is None: + isrecovery = self.safe_sql("SELECT pg_is_in_recovery()").strip() + target_lsn = self.lsn("replay" if isrecovery == "t" else "write") + if target_lsn is None: + raise RuntimeError( + f"{self.name}: could not determine a target LSN for " + "wait_for_catchup (no WAL position reported yet)" + ) + + print( + f"Waiting for replication conn {standby_name}'s {mode}_lsn to pass " + f"{target_lsn} on {self.name}" + ) + # Match the connection whose application_name is *standby_name*. + # Standbys with a tool-generated primary_conninfo (pg_rewind / + # pg_basebackup --write-recovery-conf) connect without setting + # application_name and so report 'walreceiver'; fall back to that, but + # only when no connection with the requested name exists. Otherwise an + # unrelated 'walreceiver' connection (e.g. a physical standby running + # alongside a named logical subscriber) would also match, and the + # per-row query would return more than one row, which + # poll_query_until's single-"t" comparison never satisfies. + query = ( + f"SELECT '{target_lsn}' <= {mode}_lsn AND state = 'streaming' " + "FROM pg_catalog.pg_stat_replication " + f"WHERE application_name = '{standby_name}' " + " OR (application_name = 'walreceiver' " + " AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_stat_replication " + f" WHERE application_name = '{standby_name}'))" + ) + if not self.poll_query_until(query): + details = self.safe_sql("SELECT * FROM pg_catalog.pg_stat_replication") + raise TimeoutError( + "timed out waiting for catchup\n" + f"Last pg_stat_replication contents:\n{details}" + ) + print("done") + + def wait_for_event(self, backend_type, wait_event_name): + """Wait until a *backend_type* backend reaches *wait_event_name*. + + Polls pg_stat_activity; used with injection points and other tests + that synchronize on a backend reaching a specific wait event. + """ + if not self.poll_query_until( + "SELECT count(*) > 0 FROM pg_stat_activity " + f"WHERE backend_type = '{backend_type}' " + f"AND wait_event = '{wait_event_name}'" + ): + raise TimeoutError( + f"timed out waiting for {backend_type} to reach wait event " + f"'{wait_event_name}'" + ) + + def wait_for_replay_catchup(self, standby_name, base_node=None): + """Wait for *standby_name*'s replay_lsn to reach *base_node*'s flush LSN. + + *base_node* defaults to self. + """ + if base_node is None: + base_node = self + self.wait_for_catchup(standby_name, "replay", base_node.lsn("flush")) + + def wait_for_subscription_sync( + self, publisher=None, subname=None, dbname="postgres" + ): + """Wait for logical replication initial sync to complete. + + Polls pg_subscription_rel until all tables are in 'r'/'s' state; if + *publisher* is given, additionally waits for the publisher to catch up + to *subname*. + """ + print(f'Waiting for all subscriptions in "{self.name}" to synchronize data') + query = ( + "SELECT count(1) = 0 FROM pg_subscription_rel " + "WHERE srsubstate NOT IN ('r', 's')" + ) + if not self.poll_query_until(query, dbname=dbname): + details = self.safe_sql("SELECT * FROM pg_subscription_rel", dbname=dbname) + raise TimeoutError( + "timed out waiting for subscriber to synchronize data\n" + f"Last pg_subscription_rel contents:\n{details}" + ) + if publisher is not None: + if subname is None: + raise ValueError("subscription name must be specified") + publisher.wait_for_catchup(subname) + print("done") + + # -- query execution (in-process via libpq) ----------------------------- + + def _open_session(self, dbname): + """Open a Session, retrying briefly past a server that is still + starting up or in recovery. + + Just after start()/restart() (or while a node finishes crash recovery) + the server can transiently reject connections with "the database system + is starting up", "... is not yet accepting connections" or "... is in + recovery mode" before it is ready; those states are temporary for a + node that is meant to be up, so retry until the deadline rather than + fail the whole test on a startup race. + """ + deadline = time.monotonic() + TIMEOUT_DEFAULT + transient_markers = ( + "is starting up", + "not yet accepting connections", + "in recovery mode", + "Connection refused", # postmaster not listening on its port yet + "No such file or directory", # Unix socket file not created yet + ) + while True: + try: + return Session(connstr=self.connstr(dbname), libdir=self.libdir) + except PqConnectionError as exc: + transient = any(m in str(exc) for m in transient_markers) + if not transient or time.monotonic() > deadline: + raise + time.sleep(0.1) + + def session(self, dbname="postgres"): + """Open a fresh persistent libpq Session for *dbname*. + + Each call returns its own independent connection -- sessions never share + state (GUCs, temp tables, transactions). The caller owns it and should + close() it; otherwise it is dropped when the server is stopped. + """ + return self._open_session(dbname) + + def connect(self, dbname="postgres", user=None, password=None, options=None): + """Open a fresh (uncached) libpq Session with extra connection params. + + Use this when a test needs to connect as a specific role, with a + password, or with per-connection GUCs (the libpq "options" keyword, + equivalent to PGOPTIONS). The caller owns the returned Session and + should close() it. + """ + connstr = self.connstr(dbname) + if user is not None: + connstr += f" user='{conninfo_quote(user)}'" + if password is not None: + connstr += f" password='{conninfo_quote(password)}'" + if options is not None: + connstr += f" options='{conninfo_quote(options)}'" + return Session(connstr=connstr, libdir=self.libdir) + + # -- connection-attempt assertions (auth tests) ------------------------- + + def _full_connstr(self, connstr): + """Combine this node's host/port/dbname with a test *connstr*. + + Callers of :meth:`connect_ok` / :meth:`connect_fails` pass a partial + conninfo (e.g. "user=test1 require_auth=password") carrying just the + auth bits. Here we prepend the node's host/port/dbname; later keywords + win in libpq, so anything the caller specifies (dbname, user, sslmode, + ...) overrides the defaults. + """ + return f"{self.connstr('postgres')} {connstr}" + + def log_check(self, test_name, offset, *, log_like=None, log_unlike=None): + """Check the log written since *offset* against regex lists. + + Every pattern in *log_like* must match; none in *log_unlike* may. + """ + if not log_like and not log_unlike: + return + contents = self.log_content()[offset:] + for regex in log_like or []: + assert re.search(regex, contents), f"{test_name}: log matches {regex!r}" + for regex in log_unlike or []: + assert not re.search( + regex, contents + ), f"{test_name}: log does not match {regex!r}" + + def attempt_connection(self, connstr, sql): + """Connect with *connstr* via a psql subprocess and run *sql*. + + Returns ``(ok, stdout, stderr)`` (psql's exit status, stdout and + stderr). A subprocess -- not the in-process Session -- is used so the + child inherits the environment (PGPASSWORD and the like) and performs a + real connection handshake: that connection-time behavior is exactly what + the auth/SSL tests exercise, and relying on the in-process library to + read the environment is not portable. ``-w`` keeps psql from blocking + on a password prompt; ``-XAtq`` gives unaligned, tuples-only, quiet + output (no command tags like ``CREATE TABLE``), matching what the + assertions expect. + """ + argv = [ + "psql", + "-w", + "-X", + "-A", + "-q", + "-t", + "-d", + self._full_connstr(connstr), + "-c", + sql if sql is not None else "SELECT 1", + ] + res = self.pg_bin.result(argv) + return res.returncode == 0, res.stdout, res.stderr + + def connect_ok( + self, + connstr, + test_name, + *, + sql=None, + expected_stdout=None, + stdout_unlike=None, + expected_stderr=None, + log_like=None, + log_unlike=None, + ): + """Assert a connection with *connstr* succeeds. + + Connects with a psql subprocess, runs *sql* (default a trivial SELECT), + and checks stdout/stderr and the server log. *expected_stdout* (if + given) must match the query output; *stdout_unlike* (if given) must + not. + """ + if sql is None: + sql = f"SELECT $$connected with {connstr}$$" + log_location = self.log_position() + + ok, stdout, stderr = self.attempt_connection(connstr, sql) + + assert ok, f"{test_name}: connection should succeed\n{stderr}" + if expected_stdout is not None: + assert re.search( + expected_stdout, stdout + ), f"{test_name}: stdout matches {expected_stdout!r}, got {stdout!r}" + if stdout_unlike is not None: + assert not re.search(stdout_unlike, stdout), ( + f"{test_name}: stdout must not match {stdout_unlike!r}, " + f"got {stdout!r}" + ) + if expected_stderr is not None: + assert re.search( + expected_stderr, stderr + ), f"{test_name}: stderr matches {expected_stderr!r}, got {stderr!r}" + else: + assert stderr == "", f"{test_name}: no stderr, got {stderr!r}" + self.log_check( + test_name, log_location, log_like=log_like, log_unlike=log_unlike + ) + + def connect_fails( + self, + connstr, + test_name, + *, + expected_stderr=None, + log_like=None, + log_unlike=None, + ): + """Assert a connection with *connstr* fails. + + When log_like/log_unlike are given, first wait for the backend + fork/exit log records so the relevant lines are present before checking. + """ + log_location = self.log_position() + + # If the connection unexpectedly succeeds, the trivial query surfaces + # any error; "failed" is then false and the assertion below fires. + ok, _stdout, stderr = self.attempt_connection(connstr, "SELECT 1") + failed = not ok + + assert failed, f"{test_name}: connection should fail" + if expected_stderr is not None: + assert re.search( + expected_stderr, stderr + ), f"{test_name}: stderr matches {expected_stderr!r}, got {stderr!r}" + if log_like or log_unlike: + # Wait for the failed backend's log records to be flushed before + # checking. On most platforms the postmaster logs a per-backend + # "forked new client backend, pid=N socket=..." record that can be + # paired with the matching "client backend (PID N) exited" record; + # Windows does not log the fork record, so wait for the exit record + # alone (connect_fails issues one connection at a time, so the next + # backend exit after this point is the one we triggered). + if WINDOWS_OS: + fork_exit = r"client backend \(PID \d+\) exited with exit code \d" + else: + fork_exit = ( + r"(?s)DEBUG: (?:00000: )?forked new client backend, " + r"pid=(\d+) socket.*DEBUG: (?:00000: )?client backend " + r"\(PID \1\) exited with exit code \d" + ) + self.wait_for_log(fork_exit, log_location) + self.log_check( + test_name, log_location, log_like=log_like, log_unlike=log_unlike + ) + + @staticmethod + def _print_notices(sess): + """Surface a session's captured NOTICE/WARNING output to the test log.""" + notices = sess.get_notices_str() + if notices: + print("#### Begin standard error") + print(notices, end="") + print("\n#### End standard error") + + def sql(self, query, dbname="postgres"): + """Run *query* on a fresh connection and return its ResultData (does not raise). + + See :meth:`safe_sql` for the important caveat about how a multi-statement + *query* is executed as a single implicit transaction. + """ + sess = self._open_session(dbname) + try: + res = sess.query(query) + self._print_notices(sess) + return res + finally: + sess.close() + + def safe_sql(self, query, dbname="postgres"): + """Run *query* on a fresh connection; return its trimmed text output, raising on error. + + Output formatting matches ``psql -A -t`` (rows joined by newlines, + columns by ``|``). Each call opens its own short-lived in-process libpq + connection, runs the query and closes it, so calls do not share session + state (GUCs, temp tables, transaction state); use :meth:`connect` for a + persistent session. + + IMPORTANT -- multiple statements run in ONE implicit transaction. + A *query* with several semicolon-separated statements is sent as a single + libpq command, which wraps them in one implicit transaction. That + differs from psql, which sends each statement separately (one autocommit + transaction each). So a statement that cannot run inside a transaction + block -- CREATE/DROP DATABASE, CREATE/DROP/ALTER SUBSCRIPTION, CREATE + TABLESPACE, VACUUM, CHECKPOINT, REINDEX CONCURRENTLY, + PREPARE/COMMIT/ROLLBACK PREPARED, etc. -- must be issued in its own + ``safe_sql`` call rather than combined with others. Beware too that + grouping unrelated statements gives them shared-transaction semantics + they would not have under psql (e.g. statements meant to run as separate + transactions will instead see each other's uncommitted effects). + """ + sess = self._open_session(dbname) + try: + out = sess.query_safe(query) + self._print_notices(sess) + return out + finally: + sess.close() + + def poll_query_until( + self, query, expected="t", dbname="postgres", timeout=TIMEOUT_DEFAULT + ): + """Run *query* repeatedly until its output equals *expected*. + + Reuses one connection across attempts, reconnecting only if it drops + (e.g. across a server restart), rather than opening a fresh connection + every iteration. + """ + sess = None + + def _ready(): + nonlocal sess + try: + if sess is not None and not sess.connected(): + sess.close() + sess = None + if sess is None: + sess = self._open_session(dbname) + res = sess.query(query) + # An errored query (transient SQL error, or the connection + # dropped -> status -1) is not a match; let other exceptions + # (bugs, timeouts) propagate. + return res.error_message is None and res.psqlout == expected + except (QueryError, PqConnectionError): + # Server not accepting connections yet / connection dropped: + # retry (a dead session is detected via connected() next time). + return False + + try: + return poll_until(_ready, timeout=timeout) + finally: + if sess is not None: + sess.close() + + # -- logical replication slots ------------------------------------------ + + def slot(self, slot_name): + """Return pg_replication_slots columns for *slot_name* as a dict. + + Missing values -- including the case of a nonexistent slot -- come back + as empty strings. + """ + columns = [ + "plugin", + "slot_type", + "datoid", + "database", + "active", + "active_pid", + "xmin", + "catalog_xmin", + "restart_lsn", + ] + res = self.sql( + "SELECT " + ", ".join(columns) + " FROM pg_catalog.pg_replication_slots" + f" WHERE slot_name = '{slot_name}'" + ) + row = res.rows[0] if res.rows else [None] * len(columns) + return {c: ("" if v is None else str(v)) for c, v in zip(columns, row)} + + def log_standby_snapshot(self, standby, slot_name): + """Trigger the xl_running_xacts record a standby logical slot waits for. + + *self* is the primary; *standby* holds the logical *slot_name*. Waits + until the slot's restart_lsn is determined, then logs a standby + snapshot. + """ + assert standby.poll_query_until( + "SELECT restart_lsn IS NOT NULL" + " FROM pg_catalog.pg_replication_slots" + f" WHERE slot_name = '{slot_name}'" + ), "timed out waiting for logical slot to calculate its restart_lsn" + self.safe_sql("SELECT pg_log_standby_snapshot()") + + def create_logical_slot_on_standby(self, primary, slot_name, dbname="postgres"): + """Create logical slot *slot_name* on this standby. + + Starts ``pg_recvlogical --create-slot`` (which blocks until the needed + xl_running_xacts record appears), has *primary* log a standby snapshot + to produce it, then waits for slot creation to finish. + """ + argv = [ + self.resolve("pg_recvlogical"), + "--dbname", + self.connstr(dbname), + "--plugin", + "test_decoding", + "--slot", + slot_name, + "--create-slot", + ] + print("# Running (background): " + " ".join(argv)) + with subprocess.Popen( + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) as proc: + # Arrange for the xl_running_xacts record pg_recvlogical waits for. + primary.log_standby_snapshot(self, slot_name) + out, err = proc.communicate() + assert ( + self.slot(slot_name)["slot_type"] == "logical" + ), f"could not create slot {slot_name}: stdout={out!r} stderr={err!r}" + + def pg_recvlogical_upto( + self, dbname, slot_name, endpos, timeout_secs=None, **plugin_options + ): + """Read from *slot_name* with pg_recvlogical until *endpos*. + + Returns a CommandResult; on timeout the returncode is None. + ``--no-loop`` prevents pg_recvlogical from internally retrying on error. + """ + argv = [ + self.resolve("pg_recvlogical"), + "--slot", + slot_name, + "--dbname", + self.connstr(dbname), + "--endpos", + str(endpos), + "--file", + "-", + "--no-loop", + "--start", + ] + for k, v in plugin_options.items(): + if "=" in str(k): + raise ValueError( + "= is not permitted to appear in replication option name" + ) + argv += ["--option", f"{k}={v}"] + print("# Running: " + " ".join(argv)) + try: + proc = subprocess.run( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + timeout=timeout_secs if timeout_secs else None, + ) + except subprocess.TimeoutExpired as exc: + return CommandResult( + None, + exc.stdout if isinstance(exc.stdout, str) else "", + exc.stderr if isinstance(exc.stderr, str) else "", + ) + return CommandResult(proc.returncode, proc.stdout, proc.stderr) + + def corrupt_page_checksum(self, file, page_offset=0): + """Invert the pd_checksum field of a page in *file* (offline cluster). + + *file* is relative to the data directory. + """ + path = os.path.join(self.data_dir, file) + # Inverts the pd_checksum field (bytes 8-9 of the page header) only. + mask = b"\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff" + with open(path, "r+b") as fh: + fh.seek(page_offset) + header = bytearray(fh.read(24)) + for i, byte in enumerate(mask): + header[i] ^= byte + fh.seek(page_offset) + fh.write(header) + + # -- server log access --------------------------------------------------- + + def log_content(self): + """Return the entire current server log as text.""" + if not os.path.exists(self.logfile): + return "" + with open(self.logfile, "r", encoding="utf-8", errors="replace") as fh: + return fh.read() + + def log_position(self): + """Return the current end position in the log, for use as an offset. + + This is the character length of :meth:`log_content` (which reads the + file in text mode, normalising CRLF to LF) rather than the raw byte + size. The offset is later used to slice ``log_content()[offset:]``, so + it must be a position in that normalised text: on Windows the log file + has CRLF line endings, and a byte offset would overshoot the folded + text and skip past the lines being checked. + """ + return len(self.log_content()) + + def log_contains(self, pattern, offset=0): + """Return True if *pattern* matches the log at/after byte *offset*.""" + content = self.log_content() + return re.search(pattern, content[offset:]) is not None + + def wait_for_log(self, pattern, offset=0, timeout=TIMEOUT_DEFAULT): + """Wait until *pattern* appears in the log at/after *offset*. + + Returns the log length when matched; raises on timeout. + """ + regex = re.compile(pattern) + + def _found(): + return regex.search(self.log_content()[offset:]) is not None + + if not poll_until(_found, timeout=timeout): + raise TimeoutError(f"timed out waiting for log pattern {pattern!r}") + return len(self.log_content()) + + # -- node-scoped command_* assertions ------------------------------------ + + def command_ok(self, cmd, msg=None): + return self.pg_bin.command_ok(cmd, msg) + + def command_fails(self, cmd, msg=None): + return self.pg_bin.command_fails(cmd, msg) + + def command_like(self, cmd, pattern, msg=None): + return self.pg_bin.command_like(cmd, pattern, msg) + + def command_fails_like(self, cmd, pattern, msg=None): + return self.pg_bin.command_fails_like(cmd, pattern, msg) + + def command_exit_is(self, cmd, code, msg=None): + return self.pg_bin.command_exit_is(cmd, code, msg) + + def command_checks_all(self, cmd, expected_ret, stdout_res, stderr_res, msg=None): + return self.pg_bin.command_checks_all( + cmd, expected_ret, stdout_res, stderr_res, msg + ) + + def issues_sql_like(self, cmd, pattern, msg=None): + """Run *cmd* successfully and assert *pattern* appears in the server log. + + The cluster must have log_statement enabled for the SQL to be logged. + """ + offset = self.log_position() + self.command_ok(cmd, msg) + log = self.log_content()[offset:] + assert re.search(pattern, log), ( + msg or "issues_sql_like" + ) + f": SQL /{pattern}/ not found in server log\n{log}" + + def issues_sql_unlike(self, cmd, pattern, msg=None): + """Run *cmd* successfully and assert *pattern* does NOT appear in the log.""" + offset = self.log_position() + self.command_ok(cmd, msg) + log = self.log_content()[offset:] + assert not re.search(pattern, log), ( + msg or "issues_sql_unlike" + ) + f": SQL /{pattern}/ unexpectedly found in server log\n{log}" + + # -- teardown ------------------------------------------------------------ + + def teardown(self): + if not self.running: + return + try: + self.stop("immediate", fail_ok=True) + except Exception as exc: # pylint: disable=broad-exception-caught + # e.g. pg_ctl stop hung and hit the command timeout. + print(f'### node "{self.name}": stop failed during teardown: {exc}') + # Never leak a postmaster past the test: force-kill any survivor. + if self.postmaster_alive(): + self.kill9() diff --git a/src/test/pytest/pypg/util.py b/src/test/pytest/pypg/util.py new file mode 100644 index 00000000000..0fddc2ec8aa --- /dev/null +++ b/src/test/pytest/pypg/util.py @@ -0,0 +1,213 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Small file and polling helpers used by the test framework.""" + +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import time + +# Default per-operation timeout in seconds (PG_TEST_TIMEOUT_DEFAULT, 180). +TIMEOUT_DEFAULT = int(os.environ.get("PG_TEST_TIMEOUT_DEFAULT") or "180") + +# Connection transport: use Unix-domain sockets everywhere except Windows, +# where we listen on TCP (127.0.0.1). PG_TEST_USE_UNIX_SOCKETS forces Unix +# sockets even on Windows. +WINDOWS_OS = sys.platform in ("win32", "cygwin") +USE_UNIX_SOCKETS = (not WINDOWS_OS) or ("PG_TEST_USE_UNIX_SOCKETS" in os.environ) + + +def run_captured(argv, *, env=None, combine_stderr=False, timeout=None): + """Run *argv*, capturing output through temporary files instead of pipes. + + Returns ``(returncode, stdout, stderr)`` as text. With *combine_stderr*, + stderr is folded into stdout and the returned stderr is ``""``. + + Output is captured to temporary files rather than ``subprocess.PIPE`` + because of how starting a server behaves on Windows: ``pg_ctl start`` + launches a postmaster that inherits and holds open the write end of the + parent's stdout/stderr pipe for its entire lifetime. Reading such a pipe + to end-of-file -- as subprocess does to collect output -- then blocks until + the postmaster exits, i.e. forever. A regular file handle has no + end-of-file dependency on the writer staying alive, so the parent reads the + captured output as soon as the launched program returns. + """ + out = tempfile.TemporaryFile() + err = subprocess.STDOUT if combine_stderr else tempfile.TemporaryFile() + try: + proc = subprocess.run( + argv, env=env, stdout=out, stderr=err, timeout=timeout, check=False + ) + out.seek(0) + stdout = _decode(out.read()) + if combine_stderr: + stderr = "" + else: + err.seek(0) + stderr = _decode(err.read()) + finally: + out.close() + if err is not subprocess.STDOUT: + err.close() + return proc.returncode, stdout, stderr + + +def _decode(data): + """Decode captured output as text, translating newlines like text mode. + + Programs may emit non-UTF-8 bytes (e.g. LATIN1 object names) that we only + regex-match, so decode leniently. Reading a file gives no universal-newline + handling, so fold CRLF/CR to LF to match what text-mode capture produced and + what tests expect. + """ + text = data.decode("utf-8", "replace") + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def dir_symlink(target, link): + """Create *link* as a link to directory *target*. + + On Windows create a junction (via cmd's ``mklink /j``): that is what the + server expects for linked directories such as pg_wal, and an ordinary + symlink there is read back with an error. Elsewhere create a real symlink. + """ + if WINDOWS_OS: + target = target.replace("/", "\\") + link = link.replace("/", "\\") + subprocess.run(["cmd", "/c", "mklink", "/j", link, target], check=True) + else: + os.symlink(target, link) + + +def remove_dir_symlink(link): + """Remove a link created by :func:`dir_symlink`. + + On Windows the link is a directory junction, which must be removed with + os.rmdir -- os.remove/os.unlink fail on it with "Access is denied". + Elsewhere it is an ordinary symlink, removed with os.unlink. + """ + if WINDOWS_OS: + os.rmdir(link) + else: + os.unlink(link) + + +def enable_localhost_tcp(node): + """Make *node* also listen on localhost TCP (no-op except on Windows). + + pg_upgrade connects to the clusters it starts over localhost TCP on Windows + -- it never uses Unix sockets there (see the ``#if !defined(WIN32)`` guard + in src/bin/pg_upgrade/server.c). A node passed to pg_upgrade must therefore + listen on TCP even when the suite is otherwise running over Unix sockets + (PG_TEST_USE_UNIX_SOCKETS), which sets listen_addresses=''. Appending this + later wins, while the Unix socket the framework uses stays available. + + We listen on the name "localhost" rather than the literal "127.0.0.1" so + the server binds exactly what the client resolves. pg_upgrade passes no + host on Windows, so libpq connects to its default (localhost), which on an + IPv6-enabled host resolves to ::1 first. Binding only 127.0.0.1 would leave + the ::1 candidate refused; pg_upgrade's parallel task framework waits on the + connection socket with select() but without an exception set, so on Windows + a refused async connect is never reported and it hangs forever. Binding + "localhost" covers every address the client may try (and degrades to just + 127.0.0.1 where IPv6 is unavailable), so the first candidate always lands. + """ + if WINDOWS_OS: + node.append_conf("listen_addresses = 'localhost'") + + +def short_tempdir(prefix="pgt"): + """Create and return a uniquely-named directory under the system temp area. + + The short pathname keeps Unix-socket and tablespace-symlink targets within + their length limits (a unix socket path and tar's ~100-byte symlink-target + field). The caller owns the directory and is responsible for removing it. + + On Windows the directory is created with the default mode so that it + inherits the parent's access-control list. That matters when the postmaster + runs under a restricted access token (one with the Administrators group + disabled, as happens when launched from an elevated context): it must be + able to create files such as the socket lock file inside the directory, and + the owner-only DACL that a private (0o700) mode produces would deny that. + Elsewhere the directory is created private, like the rest of the framework. + """ + base = tempfile.gettempdir() + while True: + path = os.path.join(base, prefix + secrets.token_hex(8)) + try: + if sys.platform == "win32": + os.mkdir(path) + else: + os.mkdir(path, 0o700) + return path + except FileExistsError: + continue + + +def slurp_file(path, offset=0): + """Return the contents of *path* as text, from character *offset* onward. + + *offset* is a character position in the text-decoded (CRLF-normalized) + contents -- the kind of value PostgresServer.log_position() returns -- not + a raw byte offset, so it stays correct for multibyte content and on Windows + where the on-disk log has CRLF line endings. + """ + with open(path, "r", encoding="utf-8", errors="replace") as fh: + text = fh.read() + return text[offset:] if offset else text + + +def append_to_file(path, text): + """Append *text* to *path* (creating it if needed). + + newline="" disables newline translation so the bytes are written exactly + as given: on Windows the default text mode turns "\\n" into "\\r\\n", which + corrupts files with a strict line format such as backup_label. + """ + with open(path, "a", encoding="utf-8", newline="") as fh: + fh.write(text) + + +def copy_live_tree(src, dst): + """Recursively copy *src* to *dst*, tolerating entries that vanish. + + Copying a running server's data directory races with the server: a file + present when a directory is scanned (e.g. a WAL archive_status flag) can be + gone before it is copied. Such ENOENT cases are silently skipped, as a + low-level base backup must. Symlinks are recreated as symlinks. + """ + os.makedirs(dst, exist_ok=True) + try: + entries = list(os.scandir(src)) + except FileNotFoundError: + return + for entry in entries: + srcpath = os.path.join(src, entry.name) + dstpath = os.path.join(dst, entry.name) + try: + if entry.is_symlink(): + os.symlink(os.readlink(srcpath), dstpath) + elif entry.is_dir(): + copy_live_tree(srcpath, dstpath) + else: + shutil.copy2(srcpath, dstpath) + except FileNotFoundError: + # Entry vanished between the scan and the copy; skip it. + continue + + +def poll_until(predicate, timeout=TIMEOUT_DEFAULT, interval=0.1): + """Call *predicate* until it returns truthy or *timeout* seconds elapse. + + Returns True on success, False on timeout. + """ + deadline = time.monotonic() + timeout + while True: + if predicate(): + return True + if time.monotonic() > deadline: + return False + time.sleep(interval) From 00e4f0e788752d1ad73683ac12f86d460e04aacc Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:12:47 -0400 Subject: [PATCH 04/18] python tests: add the pgtap plugin and wire pytest into the meson build pgtap is a pytest plugin that emits TAP for the meson/prove harness when TESTLOGDIR is set, and maps a whole-module skip to success. The repository pyproject.toml carries the pytest configuration. meson gains a pytest feature option and a kind=='pytest' test branch, so each directory can list pytest suites beside its tap suites. Includes the suite's own self-tests. The root pyproject.toml also configures the suite's code quality gates: black for formatting, and pylint and mypy for linting and type checking (the dev-tooling dependency group lives in src/test/pytest/pyproject.toml; none of it is needed to run the tests). The whole suite is kept black-clean, pylint 10.00/10 and mypy-clean. Author: Jelte Fennema-Nio Reviewed-by: Andrew Dunstan --- meson.build | 76 +++++++++++++ meson_options.txt | 6 ++ pyproject.toml | 114 ++++++++++++++++++++ src/test/meson.build | 1 + src/test/pytest/meson.build | 44 ++++++++ src/test/pytest/pgtap.py | 135 ++++++++++++++++++++++++ src/test/pytest/pyproject.toml | 28 +++++ src/test/pytest/pyt/test_libpq.py | 58 ++++++++++ src/test/pytest/pyt/test_replication.py | 32 ++++++ 9 files changed, 494 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/test/pytest/meson.build create mode 100644 src/test/pytest/pgtap.py create mode 100644 src/test/pytest/pyproject.toml create mode 100644 src/test/pytest/pyt/test_libpq.py create mode 100644 src/test/pytest/pyt/test_replication.py diff --git a/meson.build b/meson.build index 568e0e150bf..fac480daedb 100644 --- a/meson.build +++ b/meson.build @@ -3951,6 +3951,32 @@ install_suites = [] testwrap = files('src/tools/testwrap') +# Detect pytest for the Python-based test suite (src/test/pytest). The suite +# is optional: it is enabled when the 'pytest' feature is enabled, or (in the +# default 'auto' mode) when a usable pytest is found. We prefer a 'pytest' +# program on PATH and fall back to "python -m pytest". +pytest_enabled = false +pytest_cmd = [] +pytest_pythonpath = meson.project_source_root() / 'src' / 'test' / 'pytest' +pytestopt = get_option('pytest') +if not pytestopt.disabled() + pytest_prog = find_program(get_option('PYTEST'), native: true, required: false) + if pytest_prog.found() + pytest_enabled = true + pytest_cmd = [pytest_prog.full_path()] + else + pytest_check = run_command( + python, '-m', 'pytest', '--version', check: false) + if pytest_check.returncode() == 0 + pytest_enabled = true + pytest_cmd = [python.full_path(), '-m', 'pytest'] + endif + endif + if not pytest_enabled and pytestopt.enabled() + error('pytest not found') + endif +endif + foreach test_dir : tests testwrap_base = [ testwrap, @@ -4118,6 +4144,56 @@ foreach test_dir : tests ) endforeach install_suites += test_group + elif kind == 'pytest' + testwrap_pytest = testwrap_base + if not pytest_enabled + testwrap_pytest += ['--skip', 'pytest not enabled'] + endif + + # Make the in-tree libpq/ and pypg/ packages importable, and put the + # temporary install (and per-directory build outputs) on PATH so client + # programs and pg_config resolve there. The 'test' subdir mirrors the + # tap branch: some dirs (e.g. libpq) build their test client programs + # into /test. + env = test_env + env.prepend('PATH', temp_install_bindir, test_dir['bd'], test_dir['bd'] / 'test') + env.prepend('PYTHONPATH', pytest_pythonpath) + + foreach name, value : t.get('env', {}) + env.set(name, value) + endforeach + + test_group = test_dir['name'] + test_kwargs = { + 'protocol': 'tap', + 'suite': test_group, + 'timeout': 1000, + 'depends': test_deps + t.get('deps', []), + 'env': env, + } + t.get('test_kwargs', {}) + + foreach onepyt : t['tests'] + # Make pytest test names prettier: drop pyt/ and .py + onepyt_p = onepyt + if onepyt_p.startswith('pyt/') + onepyt_p = onepyt.split('pyt/')[1] + endif + if onepyt_p.endswith('.py') + onepyt_p = fs.stem(onepyt_p) + endif + + test(test_dir['name'] / onepyt_p, + python, + kwargs: test_kwargs, + args: testwrap_pytest + [ + '--testgroup', test_dir['name'], + '--testname', onepyt_p, + '--', pytest_cmd, + test_dir['sd'] / onepyt, + ], + ) + endforeach + install_suites += test_group else error('unknown kind @0@ of test in @1@'.format(kind, test_dir['sd'])) endif diff --git a/meson_options.txt b/meson_options.txt index 6a793f3e479..878f10fd541 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -43,6 +43,9 @@ option('cassert', type: 'boolean', value: false, option('tap_tests', type: 'feature', value: 'auto', description: 'Enable TAP tests') +option('pytest', type: 'feature', value: 'auto', + description: 'Enable Python (pytest) test suites') + option('injection_points', type: 'boolean', value: false, description: 'Enable injection points') @@ -198,6 +201,9 @@ option('PROVE', type: 'string', value: 'prove', option('PYTHON', type: 'array', value: ['python3', 'python'], description: 'Path to python binary') +option('PYTEST', type: 'array', value: ['pytest', 'py.test'], + description: 'Path to pytest binary') + option('SED', type: 'string', value: 'gsed', description: 'Path to sed binary') diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000000..44a9b6015e0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,114 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group +# +# This file exists solely to configure pytest for the Python-based test suite +# (see src/test/pytest). Placing the configuration at the repository root +# makes it the pytest "rootdir" config, so it applies to test files located +# throughout the tree (e.g. src/bin/*/pyt, contrib/*/pyt) as well as the +# infrastructure tests under src/test/pytest/pyt. + +[tool.pytest.ini_options] +minversion = "7.0" + +# Make the in-tree libpq/ and pypg/ packages importable from any test. +pythonpath = ["src/test/pytest"] + +# Load the TAP-output plugin (pgtap) and the shared fixtures (pypg.fixtures). +# Use importlib import mode so identically named test files in different +# directories (e.g. many t/001_basic) do not collide. +addopts = ["-p", "pgtap", "-p", "pypg.fixtures", "--import-mode=importlib"] + +# Tests live in pyt/ subdirectories using the test_*.py convention. +python_files = ["test_*.py"] + +# --------------------------------------------------------------------------- +# Code quality gates for the Python test suite (src/test/pytest and the pyt/ +# directories). Run from the repository root, e.g.: +# +# black --check src/test/pytest $(git ls-files '*/pyt/*.py') +# pylint src/test/pytest/libpq src/test/pytest/pypg src/test/pytest/pgtap.py +# mypy src/test/pytest +# +# These configure the tools only; installing them is a dev-only step (see the +# dependency group in src/test/pytest/pyproject.toml). They are never needed +# to run the tests. + +[tool.black] +# black's default line length; pylint is aligned to it below. +line-length = 88 + +[tool.mypy] +# No explicit python_version: type-check against the dev interpreter. The +# suite leans on ctypes/libpq and third-party libs without stubs; be pragmatic +# rather than strict, while still catching real type errors in our own code. +ignore_missing_imports = true +follow_imports = "silent" +warn_unused_ignores = true +warn_redundant_casts = true +no_implicit_optional = true +# Test trees have many same-named modules (conftest.py, test_*.py) that are +# not importable packages; these settings let mypy map files to distinct +# modules by path. +namespace_packages = true +explicit_package_bases = true + +[tool.pylint.main] +# The suite's runtime floor (requires-python in src/test/pytest/pyproject.toml). +py-version = "3.8" +# Resolve the in-tree pypg/ and libpq/ packages (pytest puts this directory on +# sys.path via the pythonpath setting above). +source-roots = ["src/test/pytest"] + +[tool.pylint.basic] +# Allow names that mirror the entities under test: node_A/node_B-style names +# (multi-node tests label nodes A/B/C for readability) and ALL_CAPS for +# function-local constants. +good-names-rgxs = ["^[a-z_][a-z0-9_]*[A-Z]{0,2}$", "^[A-Z][A-Z0-9_]*$"] + +[tool.pylint.format] +max-line-length = 88 + +[tool.pylint."messages control"] +# Curated relaxations for pytest/ctypes idioms (not bug-hiding). The checks +# that matter for a cross-platform suite (encoding, broad-except, +# with-resources, real errors) stay enabled and are satisfied in code, not +# silenced here. +disable = [ + "redefined-outer-name", # pytest passes fixtures by name on purpose + "missing-module-docstring", + "missing-function-docstring", # tests and plugin hooks self-describe + "too-few-public-methods", + "duplicate-code", # parallel tests legitimately share shape + "import-outside-toplevel", # required by the importorskip() pattern + "unused-argument", # pytest hook/fixture signatures are fixed + "wrong-import-order", # in-tree pypg/libpq misread as third-party + "fixme", # TODO/XXX are tracked intentionally + "use-dict-literal", # dict(opt=...) reads well for conn options + "consider-using-f-string", + # Formatting is owned by black; the only lines it leaves over the limit + # are long string literals (SQL, log patterns, error messages) carried + # over verbatim from the tests these were ported from. Splitting those + # strings would obscure the comparison with the originals. + "line-too-long", + # Size metrics: the test files are faithful ports of the corresponding + # Perl TAP tests, some of which are very large single scripts. Splitting + # them up would hurt reviewability against the originals, so function + # size is managed by review rather than a hard threshold. + "too-many-statements", + "too-many-locals", + "too-many-branches", + "too-many-lines", +] + +[tool.pylint.design] +# Advisory complexity metrics. A server-management harness and a ctypes/error +# wrapper are inherently "wide" (many attributes/args/branches); the Perl +# equivalents are larger still. Complexity is managed by review, not a hard +# pylint threshold, so these refactor-nudges are relaxed rather than silenced +# bug checks. +max-args = 15 +max-positional-arguments = 10 +max-attributes = 15 +max-returns = 10 +# The server-management class mirrors the breadth of the Perl Cluster +# harness, which has on the order of a hundred methods. +max-public-methods = 60 diff --git a/src/test/meson.build b/src/test/meson.build index cd45cbf57fb..cc74c0dc702 100644 --- a/src/test/meson.build +++ b/src/test/meson.build @@ -26,3 +26,4 @@ if icu.found() endif subdir('perl') +subdir('pytest') diff --git a/src/test/pytest/meson.build b/src/test/pytest/meson.build new file mode 100644 index 00000000000..f0b487a7f36 --- /dev/null +++ b/src/test/pytest/meson.build @@ -0,0 +1,44 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +# Register the infrastructure self-tests. Whether they actually run is +# governed by the 'pytest' feature option, handled in the test-generation loop +# in the top-level meson.build (it emits a skip when pytest is not enabled). +tests += { + 'name': 'pytest', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'pytest': { + 'tests': [ + 'pyt/test_libpq.py', + 'pyt/test_replication.py', + ], + }, +} + +# Install the Python test framework alongside the Perl one, so out-of-tree +# (PGXS) test suites can import it. +install_data( + 'pgtap.py', + 'pyproject.toml', + install_dir: dir_pgxs / 'src/test/pytest') + +install_data( + 'libpq/__init__.py', + 'libpq/bindings.py', + 'libpq/constants.py', + 'libpq/errors.py', + 'libpq/findlib.py', + 'libpq/oids.py', + 'libpq/pgnotify.py', + 'libpq/result.py', + 'libpq/session.py', + install_dir: dir_pgxs / 'src/test/pytest/libpq') + +install_data( + 'pypg/__init__.py', + 'pypg/_env.py', + 'pypg/command.py', + 'pypg/fixtures.py', + 'pypg/server.py', + 'pypg/util.py', + install_dir: dir_pgxs / 'src/test/pytest/pypg') diff --git a/src/test/pytest/pgtap.py b/src/test/pytest/pgtap.py new file mode 100644 index 00000000000..87c6a25c9f3 --- /dev/null +++ b/src/test/pytest/pgtap.py @@ -0,0 +1,135 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""A pytest plugin that emits TAP output for the meson/prove harness. + +When the meson test harness runs the suite it sets TESTLOGDIR. In that case +this plugin hijacks the standard streams as early as possible: pytest's own +output is redirected to ``$TESTLOGDIR/pytest.log`` and the TAP stream (plan +line plus one ``ok``/``not ok`` per test) is written to the real stdout, which +is what meson's ``protocol: 'tap'`` consumes. + +When TESTLOGDIR is unset (a developer running ``pytest`` directly) the plugin +stays out of the way and lets pytest print normally. +""" + +import os +import sys + +import pytest + +_enabled = False +# Per-test accumulated state, keyed by nodeid, filled across setup/call/teardown. +_results: dict = {} + + +class _Tap: + def __init__(self): + self.count = 0 + + def _emit(self, *args): + print(*args, file=sys.__stdout__) + + def plan(self, num): + self._emit(f"1..{num}") + + def ok(self, name): + self.count += 1 + self._emit(f"ok {self.count} - {name}") + + def not_ok(self, name, details=""): + self.count += 1 + self._emit(f"not ok {self.count} - {name}") + # meson does not surface TAP diagnostics reliably, so send the details + # to the real stderr where they show up in the failure report. + if details: + print(details, file=sys.__stderr__) + + def skip(self, name, reason): + self.count += 1 + suffix = f" # skip {reason}" if reason else " # skip" + self._emit(f"ok {self.count} - {name}{suffix}") + + +_tap = _Tap() + + +@pytest.hookimpl(tryfirst=True) +def pytest_configure(config): # noqa: ARG001 (pytest calls with config) + global _enabled # pylint: disable=global-statement + logdir = os.getenv("TESTLOGDIR") + if not logdir: + return + _enabled = True + os.makedirs(logdir, exist_ok=True) + logpath = os.path.join(logdir, "pytest.log") + # The stream intentionally stays open as stdout/stderr for the whole run. + # pylint: disable-next=consider-using-with + stream = open(logpath, "a", buffering=1, encoding="utf-8") # noqa: SIM115 + sys.stdout = stream + sys.stderr = stream + + +def pytest_collection_finish(session): + if _enabled: + _tap.plan(len(session.items)) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Stash each phase's report on the item so fixtures can tell, at teardown, + whether the test failed (used to keep data dirs on failure).""" + outcome = yield + setattr(item, f"_phase_{call.when}", outcome.get_result()) + + +def pytest_runtest_logreport(report): + if not _enabled: + return + rec = _results.setdefault( + report.nodeid, {"failed": False, "skipped": False, "reason": "", "details": ""} + ) + if report.failed: + rec["failed"] = True + rec["details"] += report.longreprtext + elif report.skipped: + rec["skipped"] = True + rec["reason"] = _skip_reason(report) + + +def pytest_runtest_logfinish(nodeid, location): # noqa: ARG001 + if not _enabled: + return + rec = _results.pop( + nodeid, {"failed": False, "skipped": False, "reason": "", "details": ""} + ) + # Check failed before skipped: a test can be reported skipped (setup/call) + # and then have a teardown that fails; the failure must win, otherwise a + # broken finalizer on a skipped test would be emitted as "ok # skip". + if rec["failed"]: + _tap.not_ok(nodeid, rec["details"]) + elif rec["skipped"]: + _tap.skip(nodeid, rec["reason"]) + else: + _tap.ok(nodeid) + + +def pytest_sessionfinish(session, exitstatus): # noqa: ARG001 + if not _enabled: + return + # A whole-module skip (``pytest.skip(..., allow_module_level=True)``) + # collects zero test items, which pytest reports as exit code 5 + # (NO_TESTS_COLLECTED). Under the meson harness that is a legitimate + # "entire test skipped" outcome, and pytest_collection_finish has already + # emitted a ``1..0`` TAP plan that + # meson reads as a skip. Map the exit code to success so meson does not + # treat the skip as an error. + if exitstatus == pytest.ExitCode.NO_TESTS_COLLECTED: + session.exitstatus = pytest.ExitCode.OK + + +def _skip_reason(report): + longrepr = getattr(report, "longrepr", None) + # Skips are reported as a (path, lineno, "Skipped: reason") tuple. + if isinstance(longrepr, tuple) and len(longrepr) == 3: + return str(longrepr[2]).removeprefix("Skipped: ") + return "" diff --git a/src/test/pytest/pyproject.toml b/src/test/pytest/pyproject.toml new file mode 100644 index 00000000000..ebdee71570c --- /dev/null +++ b/src/test/pytest/pyproject.toml @@ -0,0 +1,28 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +[project] +name = "postgresql-pytest" +version = "0.1.0" +description = "Python (pytest) test infrastructure for PostgreSQL" +requires-python = ">=3.8" +# The only runtime dependency is pytest itself. The libpq layer uses the +# stdlib ctypes module, so no database driver (psycopg/asyncpg) is required. +dependencies = [ + "pytest >= 7.0", +] + +# NOTE: the active pytest configuration ([tool.pytest.ini_options]) lives in +# the repository-root pyproject.toml so it applies to test files throughout +# the tree (src/bin/.../pyt, contrib/.../pyt, ...). This file only records the +# package metadata for the in-tree libpq/ and pypg/ packages. + +# Dev tooling for the code-quality gates configured in the repository-root +# pyproject.toml. Only needed to lint/format/type-check the suite, never to +# run the tests. Install with e.g.: pip install --group dev (pip >= 25.1) +# or: pip install 'black>=24,<26' 'mypy>=1.8,<2' 'pylint>=3,<4' +[dependency-groups] +dev = [ + "black >= 24, < 26", + "mypy >= 1.8, < 2", + "pylint >= 3, < 4", +] diff --git a/src/test/pytest/pyt/test_libpq.py b/src/test/pytest/pyt/test_libpq.py new file mode 100644 index 00000000000..cee59f95dfa --- /dev/null +++ b/src/test/pytest/pyt/test_libpq.py @@ -0,0 +1,58 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Self-tests for the in-process libpq Session layer. + +These exercise the parts of the Session API that distinguish it from a thin +synchronous wrapper: tuple results, one-value queries, LISTEN/NOTIFY, async +execution, and pipeline mode. +""" + +from libpq import ExecStatusType + + +def test_query_oneval(conn): + assert conn.query_oneval("SELECT 1") == "1" + assert conn.query_oneval("SELECT 'hello'") == "hello" + assert conn.query_oneval("SELECT NULL") is None + + +def test_query_tuples_and_metadata(conn): + res = conn.query("SELECT n, s FROM (VALUES (1, 'a'), (2, 'b')) t(n, s) ORDER BY n") + assert res.status == ExecStatusType.PGRES_TUPLES_OK + assert res.names == ["n", "s"] + assert res.rows == [["1", "a"], ["2", "b"]] + assert res.psqlout == "1|a\n2|b" + + +def test_do_and_error_capture(conn): + assert conn.do("CREATE TEMP TABLE t (a int)") == ExecStatusType.PGRES_COMMAND_OK + res = conn.query("SELECT * FROM no_such_table") + assert res.error_message is not None + assert "no_such_table" in res.error_message + + +def test_listen_notify(conn): + conn.do("LISTEN test_chan") + conn.do("NOTIFY test_chan, 'payload-1'") + note = conn.get_notification() + assert note is not None + assert note["channel"] == "test_chan" + assert note["payload"] == "payload-1" + assert note["pid"] == conn.backend_pid() + + +def test_async_query(conn): + assert conn.do_async("SELECT 42") + res = conn.get_async_result() + assert res is not None + assert res.psqlout == "42" + + +def test_pipeline(conn): + out = conn.query_tuples_pipelined("SELECT 1", "SELECT 2", "SELECT 3", "SELECT 4") + assert out == "1\n2\n3\n4" + + +def test_query_tuples_helper(conn): + # Fewer than 4 queries: non-pipelined path. + assert conn.query_tuples("SELECT 1", "SELECT 2") == "1\n2" diff --git a/src/test/pytest/pyt/test_replication.py b/src/test/pytest/pyt/test_replication.py new file mode 100644 index 00000000000..e9f681b7dbd --- /dev/null +++ b/src/test/pytest/pyt/test_replication.py @@ -0,0 +1,32 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Smoke test for streaming-replication support in the pypg framework.""" + + +def test_streaming_replication(create_pg): + primary = create_pg("primary", allows_streaming=True) + primary.safe_sql("CREATE TABLE t (id int)") + primary.safe_sql("INSERT INTO t SELECT generate_series(1, 10)") + + primary.backup("b1") + + standby = create_pg("standby", start=False) + standby.init_from_backup(primary, "b1", has_streaming=True, standby=True) + standby.start() + + # More rows after the standby is up. + primary.safe_sql("INSERT INTO t SELECT generate_series(11, 20)") + primary.wait_for_catchup("standby") + + # The standby must report its node name as application_name; that is what + # wait_for_catchup matches on, so assert it explicitly -- if the framework + # ever stopped setting it, wait_for_catchup would time out instead of + # failing clearly here. + assert ( + primary.safe_sql("SELECT application_name FROM pg_catalog.pg_stat_replication") + == "standby" + ) + + # The standby is read-only and should see all 20 rows. + assert standby.safe_sql("SELECT pg_is_in_recovery()") == "t" + assert standby.safe_sql("SELECT count(*) FROM t") == "20" From a3bf0a2ea442099c8d8f45c8526f0d2708249a29 Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:12:47 -0400 Subject: [PATCH 05/18] python tests: add SSL, LDAP, Kerberos, OAuth and pg_regress helpers Helpers used by the heavier test suites: a pg_regress runner, an OpenSSL-backed SSL server configurator, an slapd launcher, a stand-alone Kerberos KDC, and a launcher for the mock OAuth provider. --- src/test/pytest/pypg/kerberos.py | 186 ++++++++++++++++++++ src/test/pytest/pypg/ldapserver.py | 211 +++++++++++++++++++++++ src/test/pytest/pypg/oauthserver.py | 56 ++++++ src/test/pytest/pypg/regress.py | 86 ++++++++++ src/test/pytest/pypg/ssl_server.py | 254 ++++++++++++++++++++++++++++ 5 files changed, 793 insertions(+) create mode 100644 src/test/pytest/pypg/kerberos.py create mode 100644 src/test/pytest/pypg/ldapserver.py create mode 100644 src/test/pytest/pypg/oauthserver.py create mode 100644 src/test/pytest/pypg/regress.py create mode 100644 src/test/pytest/pypg/ssl_server.py diff --git a/src/test/pytest/pypg/kerberos.py b/src/test/pytest/pypg/kerberos.py new file mode 100644 index 00000000000..4e3e093caff --- /dev/null +++ b/src/test/pytest/pypg/kerberos.py @@ -0,0 +1,186 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""A stand-alone KDC for testing PostgreSQL GSSAPI / Kerberos support. + +Import probes for MIT krb5 binaries; :data:`AVAILABLE` / :data:`SETUP_ERROR` +report usability. + +The constructor writes krb5.conf / kdc.conf, sets the KRB5_CONFIG / +KRB5_KDC_PROFILE / KRB5CCNAME environment variables (so the postmaster and +client tools started afterward inherit them), creates the realm and service +principal, and starts krb5kdc. Call :meth:`stop` (or use the ``kerberos`` +fixture) to shut the KDC down. +""" + +import os +import shutil +import signal +import socket +import subprocess +import sys + + +def _detect(): + """Locate the krb5 binaries; return a dict or None if unavailable.""" + bin_dir = sbin_dir = None + if sys.platform == "darwin": + base = ( + "/opt/homebrew/opt/krb5" + if os.path.isdir("/opt/homebrew") + else "/usr/local/opt/krb5" + ) + bin_dir, sbin_dir = base + "/bin", base + "/sbin" + elif sys.platform.startswith("freebsd"): + bin_dir, sbin_dir = "/usr/local/bin", "/usr/local/sbin" + elif sys.platform.startswith("linux"): + sbin_dir = "/usr/sbin" + + def _bin(name, d): + if d and os.path.isdir(d) and os.path.exists(os.path.join(d, name)): + return os.path.join(d, name) + return shutil.which(name) + + tools = { + "krb5_config": _bin("krb5-config", bin_dir), + "kinit": _bin("kinit", bin_dir), + "klist": _bin("klist", bin_dir), + "kdb5_util": _bin("kdb5_util", sbin_dir), + "kadmin_local": _bin("kadmin.local", sbin_dir), + "krb5kdc": _bin("krb5kdc", sbin_dir), + } + if not all(tools.values()): + return None + return tools + + +_TOOLS = _detect() +AVAILABLE = _TOOLS is not None +SETUP_ERROR = None if AVAILABLE else "MIT Kerberos 5 installation not found" + + +class Kerberos: + """A running krb5kdc with one realm and a PostgreSQL service principal. + + *basedir* is a writable scratch dir (tmp_path). *srvnam* is the Kerberos + service name (postgres), i.e. the with_krb_srvnam build value. + """ + + def __init__(self, basedir, host, hostaddr, realm, srvnam="postgres"): + if not AVAILABLE: + raise RuntimeError(SETUP_ERROR) + t = _TOOLS + + basedir = str(basedir) + self.krb5_conf = os.path.join(basedir, "krb5.conf") + self.kdc_conf = os.path.join(basedir, "kdc.conf") + self.krb5_cache = os.path.join(basedir, "krb5cc") + self.krb5_log = os.path.join(basedir, "krb5libs.log") + self.kdc_log = os.path.join(basedir, "krb5kdc.log") + self.kdc_datadir = os.path.join(basedir, "krb5kdc") + self.kdc_pidfile = os.path.join(basedir, "krb5kdc.pid") + self.keytab = os.path.join(basedir, "krb5.keytab") + self.kdc_port = _free_port() + self.realm = realm + + ver = subprocess.run( + [t["krb5_config"], "--version"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout + if "heimdal" in ver.lower(): + raise RuntimeError("Heimdal is not supported") + m = ver and __import__("re").search(r"Kerberos 5 release (\d+\.\d+)", ver) + krb5_version = float(m.group(1)) if m else 0.0 + + with open(self.krb5_conf, "w", encoding="utf-8") as fh: + fh.write( + f"""[logging] +default = FILE:{self.krb5_log} +kdc = FILE:{self.kdc_log} + +[libdefaults] +dns_lookup_realm = false +dns_lookup_kdc = false +default_realm = {realm} +forwardable = false +rdns = false + +[realms] +{realm} = {{ + kdc = {hostaddr}:{self.kdc_port} +}} +""" + ) + + with open(self.kdc_conf, "w", encoding="utf-8") as fh: + fh.write("[kdcdefaults]\n") + if krb5_version >= 1.15: + fh.write(f"kdc_listen = {hostaddr}:{self.kdc_port}\n") + fh.write(f"kdc_tcp_listen = {hostaddr}:{self.kdc_port}\n") + else: + fh.write(f"kdc_ports = {self.kdc_port}\n") + fh.write(f"kdc_tcp_ports = {self.kdc_port}\n") + fh.write( + f""" +[realms] +{realm} = {{ + database_name = {self.kdc_datadir}/principal + admin_keytab = FILE:{self.kdc_datadir}/kadm5.keytab + acl_file = {self.kdc_datadir}/kadm5.acl + key_stash_file = {self.kdc_datadir}/_k5.{realm} +}}""" + ) + + os.mkdir(self.kdc_datadir) + + # Make the test's config and cache files, not global ones, take effect. + # The postmaster and client tools started later inherit these. + os.environ["KRB5_CONFIG"] = self.krb5_conf + os.environ["KRB5_KDC_PROFILE"] = self.kdc_conf + os.environ["KRB5CCNAME"] = self.krb5_cache + + self._kdb5_util = t["kdb5_util"] + self._kadmin_local = t["kadmin_local"] + self._krb5kdc = t["krb5kdc"] + self._kinit = t["kinit"] + self._klist = t["klist"] + + service_principal = f"{srvnam}/{host}" + self._bail(self._kdb5_util, "create", "-s", "-P", "secret0") + self._bail(self._kadmin_local, "-q", f"addprinc -randkey {service_principal}") + self._bail( + self._kadmin_local, "-q", f"ktadd -k {self.keytab} {service_principal}" + ) + self._bail(self._krb5kdc, "-P", self.kdc_pidfile) + + @staticmethod + def _bail(*argv): + subprocess.run(list(argv), check=True) + + def create_principal(self, principal, password): + self._bail(self._kadmin_local, "-q", f"addprinc -pw {password} {principal}") + + def create_ticket(self, principal, password, forwardable=False): + cmd = [self._kinit, principal] + if forwardable: + cmd.append("-f") + subprocess.run(cmd, input=password + "\n", text=True, check=True) + subprocess.run([self._klist, "-f"], check=True) + + def stop(self): + try: + with open(self.kdc_pidfile, encoding="utf-8") as fh: + pid = int(fh.readline().strip()) + except (FileNotFoundError, ValueError): + return + try: + os.kill(pid, signal.SIGINT) + except ProcessLookupError: + pass + + +def _free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] diff --git a/src/test/pytest/pypg/ldapserver.py b/src/test/pytest/pypg/ldapserver.py new file mode 100644 index 00000000000..d84567772d5 --- /dev/null +++ b/src/test/pytest/pypg/ldapserver.py @@ -0,0 +1,211 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""An OpenLDAP (slapd) server for testing pg_hba.conf ldap authentication. + +Module import probes for a usable slapd binary and the OpenLDAP schema +directory; :data:`AVAILABLE` says whether a server can be set up and +:data:`SETUP_ERROR` explains why not. +""" + +import os +import shutil +import signal +import socket +import subprocess +import sys +import time + +from .util import TIMEOUT_DEFAULT + + +def _detect_slapd(): + """Return (slapd_path, schema_dir) or (None, None) if unavailable.""" + if sys.platform == "darwin": + candidates = [ + ( + "/opt/homebrew/opt/openldap/libexec/slapd", + "/opt/homebrew/etc/openldap/schema", + ), + ("/usr/local/opt/openldap/libexec/slapd", "/usr/local/etc/openldap/schema"), + ("/opt/local/libexec/slapd", "/opt/local/etc/openldap/schema"), + ] + for slapd, schema in candidates: + if os.path.isdir(os.path.dirname(schema)) and os.path.isdir(schema): + return slapd, schema + return None, None + if sys.platform.startswith("linux"): + for schema in ("/etc/ldap/schema", "/etc/openldap/schema"): + if os.path.isdir(schema): + return "/usr/sbin/slapd", schema + return None, None + if sys.platform.startswith("freebsd"): + schema = "/usr/local/etc/openldap/schema" + if os.path.isdir(schema): + return "/usr/local/libexec/slapd", schema + return None, None + return None, None + + +SLAPD, SCHEMA_DIR = _detect_slapd() +AVAILABLE = bool(SLAPD) and os.path.exists(SLAPD or "") +SETUP_ERROR = ( + None + if AVAILABLE + else ( + "ldap tests not supported on this platform" + if sys.platform not in ("darwin",) + and not sys.platform.startswith(("linux", "freebsd")) + else "OpenLDAP server installation not found" + ) +) + + +def _free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class LdapServer: + """A running slapd instance for a single test. + + *basedir* is a writable scratch directory (e.g. tmp_path); *certdir* is the + directory holding the test SSL certs (src/test/ssl/ssl). + """ + + def __init__(self, basedir, rootpw, authtype, certdir): + if not AVAILABLE: + raise RuntimeError(SETUP_ERROR or "no suitable LDAP binaries found") + + basedir = str(basedir) + ldap_datadir = os.path.join(basedir, "openldap-data") + slapd_certs = os.path.join(basedir, "slapd-certs") + self.pidfile = os.path.join(basedir, "slapd.pid") + slapd_conf = os.path.join(basedir, "slapd.conf") + slapd_logfile = os.path.join(basedir, "slapd.log") + + self.server = "localhost" + self.port = _free_port() + self.s_port = _free_port() + self.url = f"ldap://{self.server}:{self.port}" + self.s_url = f"ldaps://{self.server}:{self.s_port}" + self.basedn = "dc=example,dc=net" + self.rootdn = "cn=Manager,dc=example,dc=net" + self.pwfile = os.path.join(basedir, "ldappassword") + + conf = f"""\ +include {SCHEMA_DIR}/core.schema +include {SCHEMA_DIR}/cosine.schema +include {SCHEMA_DIR}/nis.schema +include {SCHEMA_DIR}/inetorgperson.schema + +pidfile {self.pidfile} +logfile {slapd_logfile} + +access to * + by * read + by {authtype} auth + +database ldif +directory {ldap_datadir} + +TLSCACertificateFile {slapd_certs}/ca.crt +TLSCertificateFile {slapd_certs}/server.crt +TLSCertificateKeyFile {slapd_certs}/server.key + +suffix "dc=example,dc=net" +rootdn "{self.rootdn}" +rootpw "{rootpw}" +""" + with open(slapd_conf, "w", encoding="utf-8") as fh: + fh.write(conf) + + os.mkdir(ldap_datadir) + os.mkdir(slapd_certs) + shutil.copy( + os.path.join(certdir, "server_ca.crt"), os.path.join(slapd_certs, "ca.crt") + ) + shutil.copy( + os.path.join(certdir, "server-cn-only.crt"), + os.path.join(slapd_certs, "server.crt"), + ) + shutil.copy( + os.path.join(certdir, "server-cn-only.key"), + os.path.join(slapd_certs, "server.key"), + ) + + with open(self.pwfile, "w", encoding="utf-8") as fh: + fh.write(rootpw) + os.chmod(self.pwfile, 0o600) + + # -s0 prevents log messages ending up in syslog. + subprocess.run( + [SLAPD, "-f", slapd_conf, "-s0", "-h", f"{self.url} {self.s_url}"], + check=True, + ) + + # Wait until slapd accepts requests. + deadline = time.monotonic() + TIMEOUT_DEFAULT + while True: + rc = subprocess.run( + [ + "ldapsearch", + "-sbase", + "-H", + self.url, + "-b", + self.basedn, + "-D", + self.rootdn, + "-y", + self.pwfile, + "-n", + "objectclass=*", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode + if rc == 0: + break + if time.monotonic() > deadline: + raise RuntimeError("cannot connect to slapd") + time.sleep(0.5) + + def _env(self): + env = dict(os.environ) + env["LDAPURI"] = self.url + env["LDAPBINDDN"] = self.rootdn + return env + + def ldapadd_file(self, path): + """Add the LDIF data in *path* to the server.""" + subprocess.run( + ["ldapadd", "-x", "-y", self.pwfile, "-f", str(path)], + env=self._env(), + check=True, + ) + + def ldapsetpw(self, user, password): + """Set *user*'s password on the server.""" + subprocess.run( + ["ldappasswd", "-x", "-y", self.pwfile, "-s", password, user], + env=self._env(), + check=True, + ) + + def prop(self, *names): + """Return the requested properties (url, port, basedn, ...).""" + return [getattr(self, n) for n in names] + + def stop(self): + """Terminate the slapd instance.""" + try: + with open(self.pidfile, encoding="utf-8") as fh: + pid = int(fh.readline().strip()) + except (FileNotFoundError, ValueError): + return + try: + os.kill(pid, signal.SIGINT) + except ProcessLookupError: + pass diff --git a/src/test/pytest/pypg/oauthserver.py b/src/test/pytest/pypg/oauthserver.py new file mode 100644 index 00000000000..6a032b0abc2 --- /dev/null +++ b/src/test/pytest/pypg/oauthserver.py @@ -0,0 +1,56 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Launches the mock OAuth authorization server used by the oauth tests. + +The actual server is the Python daemon t/oauth_server.py; this is just the glue +that starts it, reads the ephemeral port it prints to stdout, and stops it. +""" + +import os +import signal +import subprocess +import sys + + +class OAuthServer: + """A running instance of the mock OAuth provider (oauth_server.py). + + *script* is the path to oauth_server.py; it is run with its grandparent + (the module source dir) as the working directory, so it invokes + "t/oauth_server.py" from the test directory. + """ + + def __init__(self, script): + python = os.environ.get("PYTHON") or sys.executable + script = os.path.abspath(script) + cwd = os.path.dirname(os.path.dirname(script)) # the module source dir + rel = os.path.join("t", os.path.basename(script)) + + # The daemon's lifetime is managed by stop(), not a with block. + # pylint: disable-next=consider-using-with + self._proc = subprocess.Popen( + [python, rel], + cwd=cwd, + stdout=subprocess.PIPE, + text=True, + ) + # The daemon prints its port then closes stdout, so a full read blocks + # only until the port is known. + line = self._proc.stdout.readline() + if not line.strip().isdigit(): + raise RuntimeError(f"server did not advertise a valid port: {line!r}") + self.port = int(line.strip()) + + def stop(self): + if self._proc is None: + return + try: + self._proc.send_signal(signal.SIGTERM) + except ProcessLookupError: + pass + try: + self._proc.stdout.close() + except Exception: # pylint: disable=broad-exception-caught + pass + self._proc.wait() + self._proc = None diff --git a/src/test/pytest/pypg/regress.py b/src/test/pytest/pypg/regress.py new file mode 100644 index 00000000000..92760728f0d --- /dev/null +++ b/src/test/pytest/pypg/regress.py @@ -0,0 +1,86 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Run the core SQL regression suite (pg_regress) against a running node. + +A handful of tests that exercise the full regression suite -- e.g. the +streaming-regression recovery test and the pg_upgrade test -- shell out to the +pg_regress C driver rather than running SQL through the in-process Session. +This helper builds that command line from the PG_REGRESS / REGRESS_SHLIB +environment variables the meson harness supplies (see test_env in meson.build). +""" + +import os +import shlex +import subprocess + +from pypg.command import CommandResult + + +def pg_regress_available(): + """True if PG_REGRESS is set (the build provides the pg_regress driver).""" + return bool(os.environ.get("PG_REGRESS")) + + +def run_pg_regress( + node, + *, + inputdir, + outputdir, + schedule=None, + tests=None, + dlpath=None, + bindir="", + max_concurrent_tests=None, + extra_opts=None, + extra_args=None, +): + """Run pg_regress against *node* and return its CommandResult. + + *inputdir* holds the sql/ and expected/ trees; *outputdir* receives + results/. Pass either a *schedule* file or an explicit list of *tests*. + *dlpath* defaults to the directory of REGRESS_SHLIB (where regress.so + lives). EXTRA_REGRESS_OPTS from the environment is honored. The caller + asserts on the returncode. + """ + pg_regress = os.environ.get("PG_REGRESS") + if not pg_regress: + raise RuntimeError("PG_REGRESS is not set; pg_regress is unavailable") + + if dlpath is None: + shlib = os.environ.get("REGRESS_SHLIB") + dlpath = os.path.dirname(shlib) if shlib else "." + + argv = [pg_regress] + # EXTRA_REGRESS_OPTS is split on whitespace. + argv += shlex.split(os.environ.get("EXTRA_REGRESS_OPTS", "")) + if extra_opts: + argv += list(extra_opts) + argv += [ + f"--dlpath={dlpath}", + f"--bindir={bindir}", + f"--host={node.host}", + f"--port={node.port}", + f"--inputdir={inputdir}", + f"--outputdir={outputdir}", + ] + if max_concurrent_tests is not None: + argv.append(f"--max-concurrent-tests={max_concurrent_tests}") + if schedule is not None: + argv.append(f"--schedule={schedule}") + if extra_args: + argv += list(extra_args) + if tests: + argv += list(tests) + + os.makedirs(outputdir, exist_ok=True) + print("# Running pg_regress: " + " ".join(argv)) + proc = subprocess.run( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + return CommandResult(proc.returncode, proc.stdout, proc.stderr) diff --git a/src/test/pytest/pypg/ssl_server.py b/src/test/pytest/pypg/ssl_server.py new file mode 100644 index 00000000000..37985b4b241 --- /dev/null +++ b/src/test/pytest/pypg/ssl_server.py @@ -0,0 +1,254 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Configure a PostgreSQL test cluster for SSL, for the ssl regression tests. + +Only the OpenSSL backend is supported. + +Certificates and keys live in src/test/ssl/ssl (built by the source tree). +The helper installs the server certs/keys into the cluster's data directory, +copies the client keys to a private-perms temp dir (so libpq accepts them), +sets up the trustdb/certdb/... databases and test roles, and rewrites +pg_hba.conf / pg_ident.conf for hostssl + certificate auth. +""" + +import glob +import os +import shutil +import subprocess + +# Server cert/key/CA/CRL files copied into the data directory by init(). +_SERVER_GLOBS = ["server-*.crt", "server-*.key"] +_SERVER_FILES = [ + "root+client_ca.crt", + "root+server_ca.crt", + "root_ca.crt", + "root+client.crl", +] +# Client keys copied to a private temp dir with 0600 perms (0644 for the +# deliberately-wrong-perms copy). +_CLIENT_KEYS = [ + "client.key", + "client-revoked.key", + "client-der.key", + "client-encrypted-pem.key", + "client-encrypted-der.key", + "client-dn.key", + "client_ext.key", + "client-long.key", + "client-revoked-utf8.key", +] + + +class SSLServer: + """OpenSSL-backed SSL configuration for a test cluster. + + *ssl_dir* is the path to src/test/ssl/ssl; *keydir* is a writable temp dir + for the permission-adjusted client keys; *bindir* locates pg_config. + """ + + def __init__(self, ssl_dir, keydir, bindir): + self._library = "OpenSSL" + self.ssl_dir = str(ssl_dir) + self.keydir = str(keydir) + self.bindir = str(bindir) + self.key = {} + + # -- backend (OpenSSL) --------------------------------------------------- + + def _copy_glob(self, pattern, dest): + for src in glob.glob(os.path.join(self.ssl_dir, pattern)): + shutil.copy(src, os.path.join(dest, os.path.basename(src))) + + def _init_backend(self, pgdata): + for pattern in _SERVER_GLOBS: + self._copy_glob(pattern, pgdata) + for key in glob.glob(os.path.join(pgdata, "server-*.key")): + os.chmod(key, 0o600) + for name in _SERVER_FILES: + shutil.copy(os.path.join(self.ssl_dir, name), os.path.join(pgdata, name)) + crldir = os.path.join(pgdata, "root+client-crldir") + os.mkdir(crldir) + self._copy_glob("root+client-crldir/*", crldir) + + # The client private keys must not be world-readable, so work from + # copies under keydir with adjusted permissions. + # The stored paths go into connection strings; use forward slashes so + # libpq does not treat Windows backslashes as escape characters. + for keyfile in _CLIENT_KEYS: + dst = os.path.join(self.keydir, keyfile) + shutil.copy(os.path.join(self.ssl_dir, keyfile), dst) + os.chmod(dst, 0o600) + self.key[keyfile] = dst.replace("\\", "/") + # A deliberately world-readable copy, to test wrong permissions. + wrong = os.path.join(self.keydir, "client_wrongperms.key") + shutil.copy(os.path.join(self.ssl_dir, "client.key"), wrong) + os.chmod(wrong, 0o644) + self.key["client_wrongperms.key"] = wrong.replace("\\", "/") + + def sslkey(self, keyfile): + """Return an ' sslkey=' connection-string fragment.""" + return f" sslkey={self.key[keyfile]}" + + def ssl_library(self): + return self._library + + def is_libressl(self): + # HAVE_SSL_CTX_SET_CERT_CB is undefined for LibreSSL. + return not self.check_pg_config("#define HAVE_SSL_CTX_SET_CERT_CB 1") + + def check_pg_config(self, needle): + out = subprocess.run( + [os.path.join(self.bindir, "pg_config"), "--includedir-server"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + try: + with open(os.path.join(out, "pg_config.h"), encoding="utf-8") as fh: + return any(needle in line for line in fh) + except FileNotFoundError: + return False + + # -- server configuration ----------------------------------------------- + + def configure_test_server_for_ssl( + self, + node, + serverhost, + servercidr, + authmethod, + *, + password=None, + password_enc=None, + extensions=None, + ): + """Set up databases/roles, enable SSL listening, and write pg_hba.""" + pgdata = node.data_dir + databases = [ + "trustdb", + "certdb", + "certdb_dn", + "certdb_dn_re", + "certdb_cn", + "verifydb", + ] + + for role in ("ssltestuser", "md5testuser", "anotheruser", "yetanotheruser"): + node.safe_sql(f"CREATE USER {role}") + for db in databases: + node.safe_sql(f"CREATE DATABASE {db}") + + if password is not None: + assert ( + password_enc is not None + ), "password_enc required when password is set" + node.safe_sql( + f"SET password_encryption='{password_enc}'; " + f"ALTER USER ssltestuser PASSWORD '{password}';" + ) + # md5testuser always has an md5-encrypted password. + node.safe_sql( + f"SET password_encryption='md5'; " + f"ALTER USER md5testuser PASSWORD '{password}';" + ) + node.safe_sql( + f"SET password_encryption='{password_enc}'; " + f"ALTER USER anotheruser PASSWORD '{password}';" + ) + + for extension in extensions or []: + for db in databases: + node.safe_sql(f"CREATE EXTENSION {extension} CASCADE;", dbname=db) + + node.append_conf( + "fsync=off\n" + "log_connections=all\n" + "log_hostname=on\n" + f"listen_addresses='{serverhost}'\n" + "log_statement=all\n" + ) + node.append_conf("include 'sslconfig.conf'") + # The SSL configuration is written into sslconfig.conf later. + with open(os.path.join(pgdata, "sslconfig.conf"), "w", encoding="utf-8"): + pass + + self._init_backend(pgdata) + + # Restart to load listen_addresses. + node.restart() + + # pg_hba must be changed after restart because hostssl requires ssl=on. + self._configure_hba_for_ssl(node, servercidr, authmethod) + + def switch_server_cert( + self, + node, + *, + certfile, + keyfile=None, + cafile=None, + crlfile=None, + crldir=None, + passphrase_cmd=None, + passphrase_cmd_reload=None, + restart=True, + ): + """Point the server at a different cert/key/ca/crl and (re)load.""" + pgdata = node.data_dir + if cafile is None: + cafile = "root+client_ca" + if crlfile is None: + crlfile = "root+client.crl" + if keyfile is None: + keyfile = certfile + + os.unlink(os.path.join(pgdata, "sslconfig.conf")) + lines = ["ssl=on"] + lines.append(f"ssl_cert_file='{certfile}.crt'") + lines.append(f"ssl_key_file='{keyfile}.key'") + lines.append(f"ssl_crl_file='{crlfile}'") + if cafile != "": + lines.append(f"ssl_ca_file='{cafile}.crt'") + else: + lines.append("ssl_ca_file=''") + if crldir is not None: + lines.append(f"ssl_crl_dir='{crldir}'") + # Lists of ECDH curves and cipher suites for syntax testing. + lines.append("ssl_groups=prime256v1:secp521r1") + lines.append("ssl_tls13_ciphers=TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256") + if passphrase_cmd is not None: + lines.append(f"ssl_passphrase_command='{passphrase_cmd}'") + if passphrase_cmd_reload is not None: + lines.append( + f"ssl_passphrase_command_supports_reload='{passphrase_cmd_reload}'" + ) + node.append_conf("\n".join(lines), filename="sslconfig.conf") + + if not restart: + return + node.restart() + + def _configure_hba_for_ssl(self, node, servercidr, authmethod): + pgdata = node.data_dir + os.unlink(os.path.join(pgdata, "pg_hba.conf")) + node.append_conf( + "# TYPE DATABASE USER ADDRESS METHOD OPTIONS\n" + f"hostssl trustdb md5testuser {servercidr} md5\n" + f"hostssl trustdb all {servercidr} {authmethod}\n" + f"hostssl verifydb ssltestuser {servercidr} {authmethod} clientcert=verify-full\n" + f"hostssl verifydb anotheruser {servercidr} {authmethod} clientcert=verify-full\n" + f"hostssl verifydb yetanotheruser {servercidr} {authmethod} clientcert=verify-ca\n" + f"hostssl certdb all {servercidr} cert\n" + f"hostssl certdb_dn all {servercidr} cert clientname=DN map=dn\n" + f"hostssl certdb_dn_re all {servercidr} cert clientname=DN map=dnre\n" + f"hostssl certdb_cn all {servercidr} cert clientname=CN map=cn\n", + filename="pg_hba.conf", + ) + os.unlink(os.path.join(pgdata, "pg_ident.conf")) + node.append_conf( + "# MAPNAME SYSTEM-USERNAME PG-USERNAME\n" + 'dn "CN=ssltestuser-dn,OU=Testing,OU=Engineering,O=PGDG" ssltestuser\n' + 'dnre "/^.*OU=Testing,.*$" ssltestuser\n' + "cn ssltestuser-dn ssltestuser\n", + filename="pg_ident.conf", + ) From 59df7a4580c43ba5efc52a65e246f1103722083c Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:07 -0400 Subject: [PATCH 06/18] python tests: pytest suites for the bin/ client tools initdb, pg_ctl, pg_controldata, pg_resetwal, pg_config, pg_test_fsync, pg_test_timing, pg_archivecleanup, pg_waldump, pg_walsummary and scripts. --- src/bin/initdb/meson.build | 5 + src/bin/initdb/pyt/test_001_initdb.py | 534 +++++++++++++++++ src/bin/pg_archivecleanup/meson.build | 5 + .../pyt/test_010_pg_archivecleanup.py | 140 +++++ src/bin/pg_config/meson.build | 5 + src/bin/pg_config/pyt/test_001_pg_config.py | 30 + src/bin/pg_controldata/meson.build | 5 + .../pyt/test_001_pg_controldata.py | 47 ++ src/bin/pg_ctl/meson.build | 8 + src/bin/pg_ctl/pyt/test_001_start_stop.py | 167 ++++++ src/bin/pg_ctl/pyt/test_002_status.py | 30 + src/bin/pg_ctl/pyt/test_003_promote.py | 67 +++ src/bin/pg_ctl/pyt/test_004_logrotate.py | 121 ++++ src/bin/pg_resetwal/meson.build | 6 + src/bin/pg_resetwal/pyt/test_001_basic.py | 331 +++++++++++ src/bin/pg_resetwal/pyt/test_002_corrupted.py | 67 +++ src/bin/pg_test_fsync/meson.build | 5 + src/bin/pg_test_fsync/pyt/test_001_basic.py | 26 + src/bin/pg_test_timing/meson.build | 5 + src/bin/pg_test_timing/pyt/test_001_basic.py | 46 ++ src/bin/pg_waldump/meson.build | 6 + src/bin/pg_waldump/pyt/test_001_basic.py | 550 ++++++++++++++++++ .../pg_waldump/pyt/test_002_save_fullpage.py | 120 ++++ src/bin/pg_walsummary/meson.build | 8 +- src/bin/pg_walsummary/pyt/test_001_basic.py | 18 + src/bin/pg_walsummary/pyt/test_002_blocks.py | 124 ++++ src/bin/scripts/meson.build | 18 + src/bin/scripts/pyt/test_010_clusterdb.py | 47 ++ src/bin/scripts/pyt/test_011_clusterdb_all.py | 56 ++ src/bin/scripts/pyt/test_020_createdb.py | 539 +++++++++++++++++ src/bin/scripts/pyt/test_040_createuser.py | 160 +++++ src/bin/scripts/pyt/test_050_dropdb.py | 47 ++ src/bin/scripts/pyt/test_070_dropuser.py | 29 + src/bin/scripts/pyt/test_080_pg_isready.py | 23 + src/bin/scripts/pyt/test_090_reindexdb.py | 344 +++++++++++ src/bin/scripts/pyt/test_091_reindexdb_all.py | 66 +++ src/bin/scripts/pyt/test_100_vacuumdb.py | 488 ++++++++++++++++ src/bin/scripts/pyt/test_101_vacuumdb_all.py | 42 ++ .../scripts/pyt/test_102_vacuumdb_stages.py | 53 ++ src/bin/scripts/pyt/test_200_connstr.py | 49 ++ 40 files changed, 4436 insertions(+), 1 deletion(-) create mode 100644 src/bin/initdb/pyt/test_001_initdb.py create mode 100644 src/bin/pg_archivecleanup/pyt/test_010_pg_archivecleanup.py create mode 100644 src/bin/pg_config/pyt/test_001_pg_config.py create mode 100644 src/bin/pg_controldata/pyt/test_001_pg_controldata.py create mode 100644 src/bin/pg_ctl/pyt/test_001_start_stop.py create mode 100644 src/bin/pg_ctl/pyt/test_002_status.py create mode 100644 src/bin/pg_ctl/pyt/test_003_promote.py create mode 100644 src/bin/pg_ctl/pyt/test_004_logrotate.py create mode 100644 src/bin/pg_resetwal/pyt/test_001_basic.py create mode 100644 src/bin/pg_resetwal/pyt/test_002_corrupted.py create mode 100644 src/bin/pg_test_fsync/pyt/test_001_basic.py create mode 100644 src/bin/pg_test_timing/pyt/test_001_basic.py create mode 100644 src/bin/pg_waldump/pyt/test_001_basic.py create mode 100644 src/bin/pg_waldump/pyt/test_002_save_fullpage.py create mode 100644 src/bin/pg_walsummary/pyt/test_001_basic.py create mode 100644 src/bin/pg_walsummary/pyt/test_002_blocks.py create mode 100644 src/bin/scripts/pyt/test_010_clusterdb.py create mode 100644 src/bin/scripts/pyt/test_011_clusterdb_all.py create mode 100644 src/bin/scripts/pyt/test_020_createdb.py create mode 100644 src/bin/scripts/pyt/test_040_createuser.py create mode 100644 src/bin/scripts/pyt/test_050_dropdb.py create mode 100644 src/bin/scripts/pyt/test_070_dropuser.py create mode 100644 src/bin/scripts/pyt/test_080_pg_isready.py create mode 100644 src/bin/scripts/pyt/test_090_reindexdb.py create mode 100644 src/bin/scripts/pyt/test_091_reindexdb_all.py create mode 100644 src/bin/scripts/pyt/test_100_vacuumdb.py create mode 100644 src/bin/scripts/pyt/test_101_vacuumdb_all.py create mode 100644 src/bin/scripts/pyt/test_102_vacuumdb_stages.py create mode 100644 src/bin/scripts/pyt/test_200_connstr.py diff --git a/src/bin/initdb/meson.build b/src/bin/initdb/meson.build index bc6eb2e085c..c748b7c5496 100644 --- a/src/bin/initdb/meson.build +++ b/src/bin/initdb/meson.build @@ -36,6 +36,11 @@ tests += { 't/001_initdb.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_initdb.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/initdb/pyt/test_001_initdb.py b/src/bin/initdb/pyt/test_001_initdb.py new file mode 100644 index 00000000000..9a58840d863 --- /dev/null +++ b/src/bin/initdb/pyt/test_001_initdb.py @@ -0,0 +1,534 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for initdb: option handling, error paths, and successful cluster creation.""" + +# To test successful data directory creation with an additional feature, first +# try to elaborate the "successful creation" test instead of adding a test. +# Successful initdb consumes much time and I/O. + +import os +import re +import stat + +import pytest + +from pypg.util import WINDOWS_OS + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _pg_config_value(pg_config, option): + import subprocess + + return subprocess.run( + [pg_config, option], stdout=subprocess.PIPE, text=True, check=True + ).stdout.strip() + + +def check_pg_config(pg_config, define): + """Return True if *define* appears in the installed pg_config.h.""" + include_server = _pg_config_value(pg_config, "--includedir-server") + header = os.path.join(include_server, "pg_config.h") + with open(header, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + if define in line: + return True + return False + + +def _check_mode_recursive( + directory, expected_dir_mode, expected_file_mode, ignore_list=None +): + """Recursively verify file/dir permission bits under *directory*. + + Pure filesystem check using os.walk + os.lstat; symlinks are followed. + Returns True/False. + """ + ignore = set() + for item in ignore_list or []: + ignore.add(os.path.join(directory, item)) + + result = True + + def check_one(path): + nonlocal result + if path in ignore: + return + try: + st = os.stat(path) + except FileNotFoundError: + # Allow ENOENT. A running server can delete files. + return + mode = stat.S_IMODE(st.st_mode) + if stat.S_ISREG(st.st_mode): + if mode != expected_file_mode: + print("%s mode must be %04o" % (path, expected_file_mode)) + result = False + elif stat.S_ISDIR(st.st_mode): + if mode != expected_dir_mode: + print("%s mode must be %04o" % (path, expected_dir_mode)) + result = False + + # Check the top directory itself, then everything underneath, following + # symlinks. + check_one(directory) + for root, dirs, files in os.walk(directory, followlinks=True): + for name in dirs: + check_one(os.path.join(root, name)) + for name in files: + check_one(os.path.join(root, name)) + + return result + + +@pytest.fixture(scope="module") +def with_icu(pg_config): + return check_pg_config(pg_config, "#define USE_ICU 1") + + +@pytest.fixture(scope="module") +def supports_syncfs(pg_config): + return check_pg_config(pg_config, "#define HAVE_SYNCFS 1") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_program_help_version_options(pg_bin): + pg_bin.program_help_ok("initdb") + pg_bin.program_version_ok("initdb") + pg_bin.program_options_handling_ok("initdb") + + +def test_initial_failures(pg_bin, test_datadir): + """Various invalid invocations that should fail before any creation.""" + xlogdir = str(test_datadir / "pgxlog") + datadir = str(test_datadir / "data") + + pg_bin.command_fails( + ["initdb", "--sync-only", str(test_datadir / "nonexistent")], + "sync missing data directory", + ) + + os.mkdir(xlogdir) + os.mkdir(os.path.join(xlogdir, "lost+found")) + pg_bin.command_fails( + ["initdb", "--waldir", xlogdir, datadir], + "existing nonempty xlog directory", + ) + os.rmdir(os.path.join(xlogdir, "lost+found")) + pg_bin.command_fails( + ["initdb", "--waldir", "pgxlog", datadir], + "relative xlog directory not allowed", + ) + + pg_bin.command_fails( + ["initdb", "--username", "pg_test", datadir], + 'role names cannot begin with "pg_"', + ) + + +def test_successful_creation_and_permissions(pg_bin, test_datadir): + """Successful creation, default permissions, control file and sync.""" + xlogdir = str(test_datadir / "pgxlog") + datadir = str(test_datadir / "data") + os.mkdir(xlogdir) + os.mkdir(datadir) + + # make sure we run one successful test without a TZ setting so we test + # initdb's time zone setting code. Also exercise --text-search-config and + # --set options. PgBin builds the child environment from os.environ, so + # temporarily drop TZ for the duration of this call. + saved_tz = os.environ.pop("TZ", None) + try: + pg_bin.command_ok( + [ + "initdb", + "--no-sync", + "--text-search-config", + "german", + "--set", + "default_text_search_config=german", + "--waldir", + xlogdir, + datadir, + ], + "successful creation", + ) + finally: + if saved_tz is not None: + os.environ["TZ"] = saved_tz + + # Permissions on PGDATA should be default. Unix-style permissions are not + # supported on Windows, so skip the check there. + if not WINDOWS_OS: + assert _check_mode_recursive(datadir, 0o700, 0o600), "check PGDATA permissions" + + # Control file should tell that data checksums are enabled by default. + pg_bin.command_like( + ["pg_controldata", datadir], + re.compile(r"Data page checksum version:.*1"), + "checksums are enabled in control file", + ) + + pg_bin.command_ok(["initdb", "--sync-only", datadir], "sync only") + pg_bin.command_ok( + ["initdb", "--sync-only", "--no-sync-data-files", datadir], + "--no-sync-data-files", + ) + pg_bin.command_fails(["initdb", datadir], "existing data directory") + + +def test_sync_method_syncfs(pg_bin, test_datadir, supports_syncfs): + datadir = str(test_datadir / "data") + os.mkdir(datadir) + pg_bin.command_ok( + ["initdb", "--no-sync", datadir], + "create cluster for syncfs test", + ) + + cmd = ["initdb", "--sync-only", datadir, "--sync-method", "syncfs"] + if supports_syncfs: + pg_bin.command_ok(cmd, "sync method syncfs") + else: + pg_bin.command_fails(cmd, "sync method syncfs") + + +@pytest.mark.skipif(WINDOWS_OS, reason="group access not supported on Windows") +def test_group_access(pg_bin, test_datadir): + """Check group access on PGDATA (Windows/cygwin skipped: Linux only).""" + datadir_group = str(test_datadir / "data_group") + pg_bin.command_ok( + ["initdb", "--allow-group-access", datadir_group], + "successful creation with group access", + ) + assert _check_mode_recursive( + datadir_group, 0o750, 0o640 + ), "check PGDATA permissions" + + +def test_locale_provider_icu(pg_bin, test_datadir, with_icu): + """ICU locale provider tests, or the no-ICU fallback check.""" + if with_icu: + pg_bin.command_fails_like( + [ + "initdb", + "--no-sync", + "--locale-provider", + "icu", + str(test_datadir / "data2"), + ], + re.compile(r"initdb: error: locale must be specified if provider is icu"), + "locale provider ICU requires --icu-locale", + ) + + pg_bin.command_ok( + [ + "initdb", + "--no-sync", + "--locale-provider", + "icu", + "--icu-locale", + "en", + str(test_datadir / "data3"), + ], + "option --icu-locale", + ) + + pg_bin.command_like( + [ + "initdb", + "--no-sync", + "--auth", + "trust", + "--locale-provider", + "icu", + "--locale", + "und", + "--lc-collate", + "C", + "--lc-ctype", + "C", + "--lc-messages", + "C", + "--lc-numeric", + "C", + "--lc-monetary", + "C", + "--lc-time", + "C", + str(test_datadir / "data4"), + ], + re.compile(r"^\s+default collation:\s+und\n", re.MULTILINE), + "options --locale-provider=icu --locale=und --lc-*=C", + ) + + pg_bin.command_fails_like( + [ + "initdb", + "--no-sync", + "--locale-provider", + "icu", + "--icu-locale", + "@colNumeric=lower", + str(test_datadir / "dataX"), + ], + re.compile(r"could not open collator for locale"), + "fails for invalid ICU locale", + ) + + pg_bin.command_fails_like( + [ + "initdb", + "--no-sync", + "--locale-provider", + "icu", + "--encoding", + "SQL_ASCII", + "--icu-locale", + "en", + str(test_datadir / "dataX"), + ], + re.compile(r"error: encoding mismatch"), + "fails for encoding not supported by ICU", + ) + + pg_bin.command_fails_like( + [ + "initdb", + "--no-sync", + "--locale-provider", + "icu", + "--icu-locale", + "nonsense-nowhere", + str(test_datadir / "dataX"), + ], + re.compile( + r'error: locale "nonsense-nowhere" has unknown language "nonsense"' + ), + "fails for nonsense language", + ) + + pg_bin.command_fails_like( + [ + "initdb", + "--no-sync", + "--locale-provider", + "icu", + "--icu-locale", + "@colNumeric=lower", + str(test_datadir / "dataX"), + ], + re.compile( + r'could not open collator for locale "und-u-kn-lower": ' + r"U_ILLEGAL_ARGUMENT_ERROR" + ), + "fails for invalid collation argument", + ) + else: + pg_bin.command_fails( + [ + "initdb", + "--no-sync", + "--locale-provider", + "icu", + str(test_datadir / "data2"), + ], + "locale provider ICU fails since no ICU support", + ) + + +def test_locale_provider_builtin(pg_bin, test_datadir): + pg_bin.command_fails( + [ + "initdb", + "--no-sync", + "--locale-provider", + "builtin", + str(test_datadir / "data6"), + ], + "locale provider builtin fails without --locale", + ) + + pg_bin.command_ok( + [ + "initdb", + "--no-sync", + "--locale-provider", + "builtin", + "--locale", + "C", + str(test_datadir / "data7"), + ], + "locale provider builtin with --locale", + ) + + pg_bin.command_ok( + [ + "initdb", + "--no-sync", + "--locale-provider", + "builtin", + "--encoding", + "UTF-8", + "--lc-collate", + "C", + "--lc-ctype", + "C", + "--builtin-locale", + "C.UTF-8", + str(test_datadir / "data8"), + ], + "locale provider builtin with --encoding=UTF-8 --builtin-locale=C.UTF-8", + ) + + pg_bin.command_fails( + [ + "initdb", + "--no-sync", + "--locale-provider", + "builtin", + "--encoding", + "SQL_ASCII", + "--lc-collate", + "C", + "--lc-ctype", + "C", + "--builtin-locale", + "C.UTF-8", + str(test_datadir / "data9"), + ], + "locale provider builtin with --builtin-locale=C.UTF-8 fails for SQL_ASCII", + ) + + pg_bin.command_ok( + [ + "initdb", + "--no-sync", + "--locale-provider", + "builtin", + "--lc-ctype", + "C", + "--locale", + "C", + str(test_datadir / "data10"), + ], + "locale provider builtin with --lc-ctype", + ) + + pg_bin.command_fails( + [ + "initdb", + "--no-sync", + "--locale-provider", + "builtin", + "--icu-locale", + "en", + str(test_datadir / "dataX"), + ], + "fails for locale provider builtin with ICU locale", + ) + + pg_bin.command_fails( + [ + "initdb", + "--no-sync", + "--locale-provider", + "builtin", + "--icu-rules", + '""', + str(test_datadir / "dataX"), + ], + "fails for locale provider builtin with ICU rules", + ) + + +def test_invalid_provider_and_options(pg_bin, test_datadir): + pg_bin.command_fails( + [ + "initdb", + "--no-sync", + "--locale-provider", + "xyz", + str(test_datadir / "dataX"), + ], + "fails for invalid locale provider", + ) + + pg_bin.command_fails( + [ + "initdb", + "--no-sync", + "--locale-provider", + "libc", + "--icu-locale", + "en", + str(test_datadir / "dataX"), + ], + "fails for invalid option combination", + ) + + pg_bin.command_fails( + ["initdb", "--no-sync", "--set", "foo=bar", str(test_datadir / "dataX")], + "fails for invalid --set option", + ) + + +def test_set_case_insensitive(pg_bin, test_datadir): + """Multiple --set parameters are added case insensitively.""" + from pypg import util + + datay = str(test_datadir / "dataY") + pg_bin.command_ok( + [ + "initdb", + "--no-sync", + "--set", + "work_mem=128", + "--set", + "Work_Mem=256", + "--set", + "WORK_MEM=512", + datay, + ], + "multiple --set options with different case", + ) + + conf = util.slurp_file(os.path.join(datay, "postgresql.conf")) + assert not re.search( + r"^WORK_MEM = ", conf, re.MULTILINE + ), "WORK_MEM should not be configured" + assert not re.search( + r"^Work_Mem = ", conf, re.MULTILINE + ), "Work_Mem should not be configured" + assert re.search( + r"^work_mem = 512", conf, re.MULTILINE + ), "work_mem should be in config" + + +def test_no_data_checksums(pg_bin, test_datadir): + """Test the --no-data-checksums flag and that pg_checksums then fails.""" + datadir_nochecksums = str(test_datadir / "data_no_checksums") + + pg_bin.command_ok( + ["initdb", "--no-data-checksums", datadir_nochecksums], + "successful creation without data checksums", + ) + + # Control file should tell that data checksums are disabled. + pg_bin.command_like( + ["pg_controldata", datadir_nochecksums], + re.compile(r"Data page checksum version:.*0"), + "checksums are disabled in control file", + ) + + # pg_checksums fails with checksums disabled. + pg_bin.command_fails( + ["pg_checksums", "--pgdata", datadir_nochecksums], + "pg_checksums fails with data checksum disabled", + ) diff --git a/src/bin/pg_archivecleanup/meson.build b/src/bin/pg_archivecleanup/meson.build index 4527a3816b3..c1ecd225944 100644 --- a/src/bin/pg_archivecleanup/meson.build +++ b/src/bin/pg_archivecleanup/meson.build @@ -26,6 +26,11 @@ tests += { 't/010_pg_archivecleanup.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_010_pg_archivecleanup.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_archivecleanup/pyt/test_010_pg_archivecleanup.py b/src/bin/pg_archivecleanup/pyt/test_010_pg_archivecleanup.py new file mode 100644 index 00000000000..11c68c0d8c8 --- /dev/null +++ b/src/bin/pg_archivecleanup/pyt/test_010_pg_archivecleanup.py @@ -0,0 +1,140 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_archivecleanup, covering which WAL files it removes and retains.""" + +import os +import re + + +# WAL file patterns created before each sub-scenario. "present" tracks +# whether the file should still exist after running pg_archivecleanup. +WALFILES_VERBOSE = [ + {"name": "00000001000000370000000D", "present": False}, + {"name": "00000001000000370000000E", "present": True}, +] +WALFILES_WITH_GZ = [ + {"name": "00000001000000370000000C.gz", "present": False}, + {"name": "00000001000000370000000D", "present": False}, + {"name": "00000001000000370000000D.backup", "present": True}, + {"name": "00000001000000370000000E", "present": True}, + {"name": "00000001000000370000000F.partial", "present": True}, + {"name": "unrelated_file", "present": True}, +] +WALFILES_FOR_CLEAN_BACKUP_HISTORY = [ + {"name": "00000001000000370000000D", "present": False}, + {"name": "00000001000000370000000D.00000028.backup", "present": False}, + {"name": "00000001000000370000000E", "present": True}, + {"name": "00000001000000370000000F.partial", "present": True}, + {"name": "unrelated_file", "present": True}, +] + + +def _create_files(tempdir, walfiles): + for entry in walfiles: + with open(os.path.join(tempdir, entry["name"]), "w", encoding="utf-8") as fh: + fh.write("CONTENT") + + +def _remove_files(tempdir, walfiles): + for entry in walfiles: + path = os.path.join(tempdir, entry["name"]) + if os.path.exists(path): + os.unlink(path) + + +def test_pg_archivecleanup(pg_bin, tmp_path): + tempdir = str(tmp_path) + + pg_bin.program_help_ok("pg_archivecleanup") + pg_bin.program_version_ok("pg_archivecleanup") + pg_bin.program_options_handling_ok("pg_archivecleanup") + + pg_bin.command_fails_like( + ["pg_archivecleanup"], + r"must specify archive location", + "fails if archive location is not specified", + ) + pg_bin.command_fails_like( + ["pg_archivecleanup", tempdir], + r"must specify oldest kept WAL file", + "fails if oldest kept WAL file name is not specified", + ) + pg_bin.command_fails_like( + ["pg_archivecleanup", "notexist", "foo"], + r"archive location .* does not exist", + "fails if archive location does not exist", + ) + pg_bin.command_fails_like( + ["pg_archivecleanup", tempdir, "foo", "bar"], + r"too many command-line arguments", + "fails with too many command-line arguments", + ) + pg_bin.command_fails_like( + ["pg_archivecleanup", tempdir, "foo"], + r"invalid file name argument", + "fails with invalid restart file name", + ) + + # Dry run: nothing is physically removed, but logs show what would be. + _create_files(tempdir, WALFILES_VERBOSE) + res = pg_bin.result( + [ + "pg_archivecleanup", + "--debug", + "--dry-run", + tempdir, + "00000001000000370000000E", + ] + ) + assert res.returncode == 0, "pg_archivecleanup dry run: exit code 0" + for entry in WALFILES_VERBOSE: + pat = re.compile(re.escape(entry["name"]) + r".*would be removed") + if entry["present"]: + assert not pat.search(res.stderr), f"dry run for {entry['name']}: matches" + else: + assert pat.search(res.stderr), f"dry run for {entry['name']}: matches" + for entry in WALFILES_VERBOSE: + assert os.path.isfile( + os.path.join(tempdir, entry["name"]) + ), f"{entry['name']} not removed" + _remove_files(tempdir, WALFILES_VERBOSE) + + def run_check(testdata, oldestkeptwalfile, test_name, *options): + _create_files(tempdir, testdata) + pg_bin.command_ok( + ["pg_archivecleanup", *options, tempdir, oldestkeptwalfile], + f"{test_name}: runs", + ) + for entry in testdata: + path = os.path.join(tempdir, entry["name"]) + if entry["present"]: + assert os.path.isfile( + path + ), f"{test_name}:{entry['name']} was not cleaned up" + else: + assert not os.path.isfile( + path + ), f"{test_name}:{entry['name']} was cleaned up" + _remove_files(tempdir, testdata) + + run_check( + WALFILES_WITH_GZ, "00000001000000370000000E", "pg_archivecleanup", "-x.gz" + ) + run_check( + WALFILES_WITH_GZ, + "00000001000000370000000E.partial", + "pg_archivecleanup with .partial file", + "-x.gz", + ) + run_check( + WALFILES_WITH_GZ, + "00000001000000370000000E.00000020.backup", + "pg_archivecleanup with .backup file", + "-x.gz", + ) + run_check( + WALFILES_FOR_CLEAN_BACKUP_HISTORY, + "00000001000000370000000E", + "pg_archivecleanup with --clean-backup-history", + "-b", + ) diff --git a/src/bin/pg_config/meson.build b/src/bin/pg_config/meson.build index cbdfe8e5a4c..04ef59dfcec 100644 --- a/src/bin/pg_config/meson.build +++ b/src/bin/pg_config/meson.build @@ -26,6 +26,11 @@ tests += { 't/001_pg_config.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_pg_config.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_config/pyt/test_001_pg_config.py b/src/bin/pg_config/pyt/test_001_pg_config.py new file mode 100644 index 00000000000..cb5842e9253 --- /dev/null +++ b/src/bin/pg_config/pyt/test_001_pg_config.py @@ -0,0 +1,30 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_config option handling and its single/multiple-option output.""" + +import re + + +def test_pg_config(pg_bin): + pg_bin.program_help_ok("pg_config") + pg_bin.program_version_ok("pg_config") + pg_bin.program_options_handling_ok("pg_config") + + pg_bin.command_like( + ["pg_config", "--bindir"], re.compile(r"bin"), "pg_config single option" + ) + pg_bin.command_like( + ["pg_config", "--bindir", "--libdir"], + re.compile(r"bin.*\n.*lib"), + "pg_config two options", + ) + pg_bin.command_like( + ["pg_config", "--libdir", "--bindir"], + re.compile(r"lib.*\n.*bin"), + "pg_config two options different order", + ) + pg_bin.command_like( + ["pg_config"], + re.compile(r".*\n.*\n.*"), + "pg_config without options prints many lines", + ) diff --git a/src/bin/pg_controldata/meson.build b/src/bin/pg_controldata/meson.build index c587bb5bfd9..2e2a4100863 100644 --- a/src/bin/pg_controldata/meson.build +++ b/src/bin/pg_controldata/meson.build @@ -26,6 +26,11 @@ tests += { 't/001_pg_controldata.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_pg_controldata.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_controldata/pyt/test_001_pg_controldata.py b/src/bin/pg_controldata/pyt/test_001_pg_controldata.py new file mode 100644 index 00000000000..d6f145f8cae --- /dev/null +++ b/src/bin/pg_controldata/pyt/test_001_pg_controldata.py @@ -0,0 +1,47 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_controldata output and its handling of a corrupted pg_control.""" + +import os +import re + + +def test_pg_controldata(pg_bin, create_pg): + pg_bin.program_help_ok("pg_controldata") + pg_bin.program_version_ok("pg_controldata") + pg_bin.program_options_handling_ok("pg_controldata") + pg_bin.command_fails(["pg_controldata"], "pg_controldata without arguments fails") + pg_bin.command_fails( + ["pg_controldata", "nonexistent"], + "pg_controldata with nonexistent directory fails", + ) + + node = create_pg("main", start=False) + + pg_bin.command_like( + ["pg_controldata", node.data_dir], + re.compile(r"checkpoint"), + "pg_controldata produces output", + ) + + # Corrupt pg_control by overwriting everything after the first 16 bytes + # (the pg_control version number) with zeros, so we get a checksum + # mismatch rather than a version-number error. + pg_control = os.path.join(node.data_dir, "global", "pg_control") + size = os.path.getsize(pg_control) + with open(pg_control, "r+b") as fh: + fh.seek(16) + fh.write(b"\x00" * (size - 16)) + + pg_bin.command_checks_all( + ["pg_controldata", node.data_dir], + 0, + [re.compile(r".")], + [ + re.compile( + r"warning: calculated CRC checksum does not match value stored in control file" + ), + re.compile(r"warning: invalid WAL segment size"), + ], + "pg_controldata with corrupted pg_control", + ) diff --git a/src/bin/pg_ctl/meson.build b/src/bin/pg_ctl/meson.build index 69fa7a28427..3752e9d8d83 100644 --- a/src/bin/pg_ctl/meson.build +++ b/src/bin/pg_ctl/meson.build @@ -29,6 +29,14 @@ tests += { 't/004_logrotate.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_start_stop.py', + 'pyt/test_002_status.py', + 'pyt/test_003_promote.py', + 'pyt/test_004_logrotate.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_ctl/pyt/test_001_start_stop.py b/src/bin/pg_ctl/pyt/test_001_start_stop.py new file mode 100644 index 00000000000..26ac5e43803 --- /dev/null +++ b/src/bin/pg_ctl/pyt/test_001_start_stop.py @@ -0,0 +1,167 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_ctl start/stop/restart and resulting data-directory permissions.""" + +import os +import re +import stat +import time + +from pypg.util import WINDOWS_OS, short_tempdir + + +def _chmod_recursive(path, dir_mode, file_mode): + """Recursively chmod *path*, applying *dir_mode* to directories and + *file_mode* to regular files (symlinks are left untouched).""" + for root, dirs, files in os.walk(path): + os.chmod(root, dir_mode) + for d in dirs: + os.chmod(os.path.join(root, d), dir_mode) + for f in files: + full = os.path.join(root, f) + if not os.path.islink(full): + os.chmod(full, file_mode) + + +def _check_mode_recursive(path, dir_mode, file_mode): + """Check that every entry under *path* has the expected permissions. + + Returns True if all directories match *dir_mode* and all files match + *file_mode*. + """ + ok = True + for root, dirs, files in os.walk(path): + for name in [root] + [os.path.join(root, d) for d in dirs]: + actual = stat.S_IMODE(os.lstat(name).st_mode) + if actual != dir_mode: + print( + f"# Directory permissions check failed for {name}: " + f"expected {dir_mode:#o}, got {actual:#o}" + ) + ok = False + for f in files: + full = os.path.join(root, f) + if os.path.islink(full): + continue + actual = stat.S_IMODE(os.lstat(full).st_mode) + if actual != file_mode: + print( + f"# File permissions check failed for {full}: " + f"expected {file_mode:#o}, got {actual:#o}" + ) + ok = False + return ok + + +def test_start_stop(pg_bin, tmp_path): + """Exercise pg_ctl start/stop/restart and the resulting file permissions.""" + pg_bin.program_help_ok("pg_ctl") + pg_bin.program_version_ok("pg_ctl") + pg_bin.program_options_handling_ok("pg_ctl") + + pg_bin.command_exit_is( + ["pg_ctl", "start", "--pgdata", str(tmp_path / "nonexistent")], + 1, + "pg_ctl start with nonexistent directory", + ) + + data_dir = str(tmp_path / "data") + # Set up trust authentication by passing "-A trust" to initdb, which grants + # trust for the local unix socket connections this test uses. + pg_bin.command_ok( + [ + "pg_ctl", + "initdb", + "--pgdata", + data_dir, + "--options", + "--no-sync -A trust", + ], + "pg_ctl initdb", + ) + + # Use a short socket directory under /tmp to stay within the socket path + # length limit. + sockdir = short_tempdir() + try: + with open( + os.path.join(data_dir, "postgresql.conf"), "a", encoding="utf-8" + ) as conf: + conf.write("fsync = off\n") + conf.write("listen_addresses = ''\n") + # Forward slashes: backslashes in the postgresql.conf string value + # are mangled, so the socket directory would be wrong on Windows. + conf.write(f"unix_socket_directories = '{sockdir.replace(chr(92), '/')}'\n") + + log_file = str(tmp_path / "001_start_stop_server.log") + ctlcmd = ["pg_ctl", "start", "--pgdata", data_dir, "--log", log_file] + pg_bin.command_like( + ctlcmd, re.compile(r"done.*server started", re.S), "pg_ctl start" + ) + + # On Windows pg_ctl needs more than its ~2 second slop time to notice + # the already-running postmaster; without the wait the second start + # spuriously succeeds instead of failing. + if WINDOWS_OS: + time.sleep(3) + pg_bin.command_fails( + ["pg_ctl", "start", "--pgdata", data_dir], + "second pg_ctl start fails", + ) + pg_bin.command_ok( + ["pg_ctl", "stop", "--pgdata", data_dir], + "pg_ctl stop", + ) + pg_bin.command_fails( + ["pg_ctl", "stop", "--pgdata", data_dir], + "second pg_ctl stop fails", + ) + + # Log file for default permission test. + log_file = os.path.join(data_dir, "perm-test-600.log") + + pg_bin.command_ok( + ["pg_ctl", "restart", "--pgdata", data_dir, "--log", log_file], + "pg_ctl restart with server not running", + ) + + # Permissions on the log file should be default. Unix-style + # permissions are not supported on Windows, so skip the check there. + if not WINDOWS_OS: + assert os.path.isfile(log_file) + assert _check_mode_recursive(data_dir, 0o700, 0o600) + + # Log file for group access test. + log_file = os.path.join(data_dir, "perm-test-640.log") + + # Group access is not supported on Windows; skip that part there. + if not WINDOWS_OS: + pg_bin.command_ok( + ["pg_ctl", "stop", "--pgdata", data_dir], + "stop server before group permission test", + ) + + # Change the data dir mode so the log file will be created with + # group read privileges on the next start. + _chmod_recursive(data_dir, 0o750, 0o640) + + pg_bin.command_ok( + ["pg_ctl", "start", "--pgdata", data_dir, "--log", log_file], + "start server to check group permissions", + ) + + assert os.path.isfile(log_file) + assert _check_mode_recursive(data_dir, 0o750, 0o640) + + pg_bin.command_ok( + ["pg_ctl", "restart", "--pgdata", data_dir, "--log", log_file], + "pg_ctl restart with server running", + ) + + pg_bin.command_ok( + ["pg_ctl", "stop", "--pgdata", data_dir], + "stop server at end of test", + ) + finally: + # Make sure the server is down even if an assertion failed. + pg_bin.result(["pg_ctl", "stop", "--pgdata", data_dir, "-m", "immediate"]) diff --git a/src/bin/pg_ctl/pyt/test_002_status.py b/src/bin/pg_ctl/pyt/test_002_status.py new file mode 100644 index 00000000000..c0bd58a87f2 --- /dev/null +++ b/src/bin/pg_ctl/pyt/test_002_status.py @@ -0,0 +1,30 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_ctl status exit codes against missing, stopped, and running servers.""" + + +def test_status(pg_bin, create_pg, tmp_path): + """pg_ctl status reports the right exit code for missing/stopped/running.""" + pg_bin.command_exit_is( + ["pg_ctl", "status", "--pgdata", str(tmp_path / "nonexistent")], + 4, + "pg_ctl status with nonexistent directory", + ) + + node = create_pg("main", start=False) + + node.command_exit_is( + ["pg_ctl", "status", "--pgdata", node.data_dir], + 3, + "pg_ctl status with server not running", + ) + + node.start() + + node.command_exit_is( + ["pg_ctl", "status", "--pgdata", node.data_dir], + 0, + "pg_ctl status with server running", + ) + + node.stop() diff --git a/src/bin/pg_ctl/pyt/test_003_promote.py b/src/bin/pg_ctl/pyt/test_003_promote.py new file mode 100644 index 00000000000..f03a28ec9df --- /dev/null +++ b/src/bin/pg_ctl/pyt/test_003_promote.py @@ -0,0 +1,67 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_ctl promote, including failure cases and promoting a standby.""" + +import os + + +def test_003_promote(pg_bin, create_pg, tmp_path): + pg_bin.command_fails_like( + ["pg_ctl", "--pgdata", os.path.join(str(tmp_path), "nonexistent"), "promote"], + r"directory .* does not exist", + "pg_ctl promote with nonexistent directory", + ) + + node_primary = create_pg("primary", start=False, allows_streaming=True) + + node_primary.command_fails_like( + ["pg_ctl", "--pgdata", node_primary.data_dir, "promote"], + r"PID file .* does not exist", + "pg_ctl promote of not running instance fails", + ) + + node_primary.start() + + node_primary.command_fails_like( + ["pg_ctl", "--pgdata", node_primary.data_dir, "promote"], + r"not in standby mode", + "pg_ctl promote of primary instance fails", + ) + + node_standby = create_pg("standby", start=False) + node_primary.backup("my_backup") + node_standby.init_from_backup(node_primary, "my_backup", has_streaming=True) + node_standby.start() + + assert ( + node_standby.safe_sql("SELECT pg_is_in_recovery()") == "t" + ), "standby is in recovery" + + node_standby.command_ok( + ["pg_ctl", "--pgdata", node_standby.data_dir, "--no-wait", "promote"], + "pg_ctl --no-wait promote of standby runs", + ) + + assert node_standby.poll_query_until( + "SELECT NOT pg_is_in_recovery()" + ), "promoted standby is not in recovery" + + # same again with default wait option + node_standby = create_pg("standby2", start=False) + node_standby.init_from_backup(node_primary, "my_backup", has_streaming=True) + node_standby.start() + + assert ( + node_standby.safe_sql("SELECT pg_is_in_recovery()") == "t" + ), "standby is in recovery" + + node_standby.command_ok( + ["pg_ctl", "--pgdata", node_standby.data_dir, "promote"], + "pg_ctl promote of standby runs", + ) + + # no wait here + + assert ( + node_standby.safe_sql("SELECT pg_is_in_recovery()") == "f" + ), "promoted standby is not in recovery" diff --git a/src/bin/pg_ctl/pyt/test_004_logrotate.py b/src/bin/pg_ctl/pyt/test_004_logrotate.py new file mode 100644 index 00000000000..83808dd6843 --- /dev/null +++ b/src/bin/pg_ctl/pyt/test_004_logrotate.py @@ -0,0 +1,121 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_ctl logrotate and the resulting log file handling.""" + +import os +import re +import time + +from pypg.util import TIMEOUT_DEFAULT, slurp_file + + +def fetch_file_name(logfiles, fmt): + """Extract the file name of a *fmt* from the contents of current_logfiles.""" + filename = None + for line in logfiles.split("\n"): + m = re.search(rf"{fmt} (.*)$", line) + if m: + filename = m.group(1) + return filename + + +def check_log_pattern(fmt, logfiles, pattern, node): + """Check for a pattern in the logs associated to one format.""" + lfname = fetch_file_name(logfiles, fmt) + + max_attempts = 10 * TIMEOUT_DEFAULT + + logcontents = "" + for _ in range(max_attempts): + logcontents = slurp_file(os.path.join(node.data_dir, lfname)) + if re.search(pattern, logcontents): + break + time.sleep(0.1) + + assert re.search(pattern, logcontents), f"found expected log file content for {fmt}" + + # While we're at it, test pg_current_logfile() function + assert ( + node.safe_sql(f"SELECT pg_current_logfile('{fmt}')") == lfname + ), f"pg_current_logfile() gives correct answer with {fmt}" + + +def test_004_logrotate(create_pg): + # Set up node with logging collector + node = create_pg("primary", start=False) + node.append_conf( + """ +logging_collector = on +log_destination = 'stderr, csvlog, jsonlog' +# these ensure stability of test results: +log_rotation_age = 0 +lc_messages = 'C' +""" + ) + + node.start() + + # Verify that log output gets to the file + + node.sql("SELECT 1/0") + + # might need to retry if logging collector process is slow... + max_attempts = 10 * TIMEOUT_DEFAULT + + current_logfiles = None + for _ in range(max_attempts): + try: + current_logfiles = slurp_file( + os.path.join(node.data_dir, "current_logfiles") + ) + break + except OSError: + time.sleep(0.1) + assert current_logfiles is not None + + print(f"# current_logfiles = {current_logfiles}") + + assert re.search( + r"^stderr log/postgresql-.*log\n" + r"csvlog log/postgresql-.*csv\n" + r"jsonlog log/postgresql-.*json$", + current_logfiles, + ), "current_logfiles is sane" + + check_log_pattern("stderr", current_logfiles, "division by zero", node) + check_log_pattern("csvlog", current_logfiles, "division by zero", node) + check_log_pattern("jsonlog", current_logfiles, "division by zero", node) + + # Sleep 2 seconds and ask for log rotation; this should result in + # output into a different log file name. + time.sleep(2) + node.command_ok(["pg_ctl", "logrotate", "-D", node.data_dir]) + + # pg_ctl logrotate doesn't wait for rotation request to be completed. + # Allow a bit of time for it to happen. + new_current_logfiles = None + for _ in range(max_attempts): + new_current_logfiles = slurp_file( + os.path.join(node.data_dir, "current_logfiles") + ) + if new_current_logfiles != current_logfiles: + break + time.sleep(0.1) + + print(f"# now current_logfiles = {new_current_logfiles}") + + assert re.search( + r"^stderr log/postgresql-.*log\n" + r"csvlog log/postgresql-.*csv\n" + r"jsonlog log/postgresql-.*json$", + new_current_logfiles, + ), "new current_logfiles is sane" + + # Verify that log output gets to this file, too + node.sql("fee fi fo fum") + + check_log_pattern("stderr", new_current_logfiles, "syntax error", node) + check_log_pattern("csvlog", new_current_logfiles, "syntax error", node) + check_log_pattern("jsonlog", new_current_logfiles, "syntax error", node) + + node.stop() diff --git a/src/bin/pg_resetwal/meson.build b/src/bin/pg_resetwal/meson.build index c2607767b51..feccce1af30 100644 --- a/src/bin/pg_resetwal/meson.build +++ b/src/bin/pg_resetwal/meson.build @@ -27,6 +27,12 @@ tests += { 't/002_corrupted.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_corrupted.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_resetwal/pyt/test_001_basic.py b/src/bin/pg_resetwal/pyt/test_001_basic.py new file mode 100644 index 00000000000..8ea1b722013 --- /dev/null +++ b/src/bin/pg_resetwal/pyt/test_001_basic.py @@ -0,0 +1,331 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_resetwal option handling, dry-run output, and value resets.""" + +import os +import re +import stat + +from pypg.util import WINDOWS_OS + + +def _check_mode_recursive(path, dir_mode, file_mode): + """Check that every entry under *path* has the expected permissions. + + Returns True if all directories match *dir_mode* and all files match + *file_mode*. + """ + ok = True + for root, dirs, files in os.walk(path): + for name in [root] + [os.path.join(root, d) for d in dirs]: + actual = stat.S_IMODE(os.lstat(name).st_mode) + if actual != dir_mode: + print( + f"# Directory permissions check failed for {name}: " + f"expected {dir_mode:#o}, got {actual:#o}" + ) + ok = False + for f in files: + full = os.path.join(root, f) + if os.path.islink(full): + continue + actual = stat.S_IMODE(os.lstat(full).st_mode) + if actual != file_mode: + print( + f"# File permissions check failed for {full}: " + f"expected {file_mode:#o}, got {actual:#o}" + ) + ok = False + return ok + + +def _get_slru_files(data_dir, subdir): + files = sorted( + f + for f in os.listdir(os.path.join(data_dir, subdir)) + if re.search(r"[0-9A-F]+", f) + ) + return files + + +def test_pg_resetwal_basic(pg_bin, create_pg): + pg_bin.program_help_ok("pg_resetwal") + pg_bin.program_version_ok("pg_resetwal") + pg_bin.program_options_handling_ok("pg_resetwal") + + node = create_pg("main", start=False) + node.append_conf("track_commit_timestamp = on") + + pg_bin.command_like( + ["pg_resetwal", "-n", node.data_dir], + re.compile(r"checkpoint"), + "pg_resetwal -n produces output", + ) + + # Permissions on PGDATA should be default (unix-only; skipped on Windows). + if not WINDOWS_OS: + assert _check_mode_recursive( + node.data_dir, 0o700, 0o600 + ), "check PGDATA permissions" + + pg_bin.command_ok( + ["pg_resetwal", "--pgdata", node.data_dir], + "pg_resetwal runs", + ) + node.start() + assert node.safe_sql("SELECT 1;") == "1", "server running and working after reset" + + pg_bin.command_fails_like( + ["pg_resetwal", node.data_dir], + re.compile(r"lock file .* exists"), + "fails if server running", + ) + + node.stop("immediate") + pg_bin.command_fails_like( + ["pg_resetwal", node.data_dir], + re.compile(r"database server was not shut down cleanly"), + "does not run after immediate shutdown", + ) + pg_bin.command_ok( + ["pg_resetwal", "--force", node.data_dir], + "runs after immediate shutdown with force", + ) + node.start() + assert ( + node.safe_sql("SELECT 1;") == "1" + ), "server running and working after forced reset" + + node.stop() + + # check various command-line handling + + # Note: This test intends to check that a nonexistent data directory + # gives a reasonable error message. Because of the way the code is + # currently structured, you get an error about readings permissions, + # which is perhaps suboptimal, so feel free to update this test if + # this gets improved. + pg_bin.command_fails_like( + ["pg_resetwal", "foo"], + re.compile(r"error: could not read permissions of directory"), + "fails with nonexistent data directory", + ) + + pg_bin.command_fails_like( + ["pg_resetwal", "foo", "bar"], + re.compile(r"too many command-line arguments"), + "fails with too many command-line arguments", + ) + + # PGDATA set but not used by pg_resetwal (it requires the argument) + pg_bin.command_fails_like( + ["pg_resetwal"], + re.compile(r"no data directory specified"), + "fails with too few command-line arguments", + extra_env={"PGDATA": node.data_dir}, + ) + + # error cases + # -c + pg_bin.command_fails_like( + ["pg_resetwal", "-c", "foo", node.data_dir], + re.compile(r"error: invalid argument for option -c"), + "fails with incorrect -c option", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-c", "10,bar", node.data_dir], + re.compile(r"error: invalid argument for option -c"), + "fails with incorrect -c option part 2", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-c", "1,10", node.data_dir], + re.compile(r"greater than"), + "fails with -c ids value 1 part 1", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-c", "10,1", node.data_dir], + re.compile(r"greater than"), + "fails with -c value 1 part 2", + ) + # -e + pg_bin.command_fails_like( + ["pg_resetwal", "-e", "foo", node.data_dir], + re.compile(r"error: invalid argument for option -e"), + "fails with incorrect -e option", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-e", "-1", node.data_dir], + re.compile(r"error: invalid argument for option -e"), + "fails with -e value -1", + ) + # -l + pg_bin.command_fails_like( + ["pg_resetwal", "-l", "foo", node.data_dir], + re.compile(r"error: invalid argument for option -l"), + "fails with incorrect -l option", + ) + # -m + pg_bin.command_fails_like( + ["pg_resetwal", "-m", "foo", node.data_dir], + re.compile(r"error: invalid argument for option -m"), + "fails with incorrect -m option", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-m", "10,bar", node.data_dir], + re.compile(r"error: invalid argument for option -m"), + "fails with incorrect -m option part 2", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-m", "0,10", node.data_dir], + re.compile(r"must not be 0"), + "fails with -m value 0 in the first part", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-m", "10,0", node.data_dir], + re.compile(r"must not be 0"), + "fails with -m value 0 in the second part", + ) + # -o + pg_bin.command_fails_like( + ["pg_resetwal", "-o", "foo", node.data_dir], + re.compile(r"error: invalid argument for option -o"), + "fails with incorrect -o option", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-o", "0", node.data_dir], + re.compile(r"must not be 0"), + "fails with -o value 0", + ) + # -O + pg_bin.command_fails_like( + ["pg_resetwal", "-O", "foo", node.data_dir], + re.compile(r"error: invalid argument for option -O"), + "fails with incorrect -O option", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-O", "-1", node.data_dir], + re.compile(r"error: invalid argument for option -O"), + "fails with -O value -1", + ) + # --wal-segsize + pg_bin.command_fails_like( + ["pg_resetwal", "--wal-segsize", "foo", node.data_dir], + re.compile(r"error: invalid value"), + "fails with incorrect --wal-segsize option", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "--wal-segsize", "13", node.data_dir], + re.compile(r"must be a power"), + "fails with invalid --wal-segsize value", + ) + # -u + pg_bin.command_fails_like( + ["pg_resetwal", "-u", "foo", node.data_dir], + re.compile(r"error: invalid argument for option -u"), + "fails with incorrect -u option", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-u", "1", node.data_dir], + re.compile(r"must be greater than"), + "fails with -u value too small", + ) + # -x + pg_bin.command_fails_like( + ["pg_resetwal", "-x", "foo", node.data_dir], + re.compile(r"error: invalid argument for option -x"), + "fails with incorrect -x option", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-x", "1", node.data_dir], + re.compile(r"must be greater than"), + "fails with -x value too small", + ) + + # Check out of range values with -x. These are forbidden for all other + # 32-bit values too, but we use just -x to exercise the parsing. + pg_bin.command_fails_like( + ["pg_resetwal", "-x", "-1", node.data_dir], + re.compile(r"error: invalid argument for option -x"), + "fails with -x value -1", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-x", "-100", node.data_dir], + re.compile(r"error: invalid argument for option -x"), + "fails with negative -x value", + ) + pg_bin.command_fails_like( + ["pg_resetwal", "-x", "10000000000", node.data_dir], + re.compile(r"error: invalid argument for option -x"), + "fails with -x value too large", + ) + + # --char-signedness + pg_bin.command_fails_like( + ["pg_resetwal", "--char-signedness", "foo", node.data_dir], + re.compile(r"error: invalid argument for option --char-signedness"), + "fails with incorrect --char-signedness option", + ) + + # run with control override options + + out = pg_bin.result(["pg_resetwal", "--dry-run", node.data_dir]).stdout + m = re.search(r"^Database block size: *(\d+)$", out, re.MULTILINE) + assert m, "could not find Database block size in dry-run output" + blcksz = int(m.group(1)) + + cmd = ["pg_resetwal", "--pgdata", node.data_dir] + + # some not-so-critical hardcoded values + cmd += ["--epoch", "1"] + cmd += ["--next-wal-file", "00000001000000320000004B"] + cmd += ["--next-oid", "100000"] + cmd += ["--wal-segsize", "1"] + + # these use the guidance from the documentation + + data_dir = node.data_dir + + files = _get_slru_files(data_dir, "pg_commit_ts") + # XXX: Should there be a multiplier, similar to the other options? + # -c argument is "old,new" + cmd += [ + "--commit-timestamp-ids", + "%d,%d" + % (3 if int(files[0], 16) == 0 else int(files[0], 16), int(files[-1], 16)), + ] + + files = _get_slru_files(data_dir, "pg_multixact/offsets") + mult = 32 * blcksz // 8 + # --multixact-ids argument is "new,old". For the "old" part, the filename + # is coerced to a decimal number, multiplied by mult, then re-read as hex. + old_mxid = 1 if int(files[0], 16) == 0 else int(str(int(files[0]) * mult), 16) + cmd += [ + "--multixact-ids", + "%d,%d" % ((int(files[-1], 16) + 1) * mult, old_mxid), + ] + + files = _get_slru_files(data_dir, "pg_multixact/members") + mult = 32 * (blcksz // 20) * 4 + cmd += ["--multixact-offset", str((int(files[-1], 16) + 1) * mult)] + + files = _get_slru_files(data_dir, "pg_xact") + mult = 32 * blcksz * 4 + cmd += [ + "--oldest-transaction-id", + str(3 if int(files[0], 16) == 0 else int(files[0], 16) * mult), + "--next-transaction-id", + str((int(files[-1], 16) + 1) * mult), + ] + + pg_bin.command_ok( + cmd + ["--dry-run"], "runs with control override options, dry run" + ) + pg_bin.command_ok(cmd, "runs with control override options") + pg_bin.command_like( + ["pg_resetwal", "--dry-run", node.data_dir], + re.compile(r"^Latest checkpoint's NextOID: *100000$", re.MULTILINE), + "spot check that control changes were applied", + ) + + node.start() + assert True, "server started after reset" diff --git a/src/bin/pg_resetwal/pyt/test_002_corrupted.py b/src/bin/pg_resetwal/pyt/test_002_corrupted.py new file mode 100644 index 00000000000..64e539b0f72 --- /dev/null +++ b/src/bin/pg_resetwal/pyt/test_002_corrupted.py @@ -0,0 +1,67 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_resetwal handling of a corrupted pg_control file.""" + +import os +import re + + +def test_pg_resetwal_corrupted(pg_bin, create_pg): + """Tests for handling a corrupted pg_control.""" + node = create_pg("main", start=False) + + pg_control = os.path.join(node.data_dir, "global", "pg_control") + size = os.path.getsize(pg_control) + + # Read out the head of the file to get PG_CONTROL_VERSION in particular. + with open(pg_control, "rb") as fh: + data = fh.read(16) + assert len(data) == 16 + + # Fill pg_control with zeros + with open(pg_control, "wb") as fh: + fh.write(b"\x00" * size) + + pg_bin.command_checks_all( + ["pg_resetwal", "--dry-run", node.data_dir], + 0, + [re.compile(r"pg_control version number")], + [ + re.compile( + r"pg_resetwal: warning: pg_control exists but is broken or " + r"wrong version; ignoring it" + ) + ], + "processes corrupted pg_control all zeroes", + ) + + # Put in the previously saved header data. This uses a different code + # path internally, allowing us to process a zero WAL segment size. + with open(pg_control, "wb") as fh: + fh.write(data + b"\x00" * (size - 16)) + + pg_bin.command_checks_all( + ["pg_resetwal", "--dry-run", node.data_dir], + 0, + [re.compile(r"pg_control version number")], + [ + re.compile( + re.escape( + "pg_resetwal: warning: pg_control specifies invalid WAL " + "segment size (0 bytes); proceed with caution" + ) + ) + ], + "processes zero WAL segment size", + ) + + # now try to run it + pg_bin.command_fails_like( + ["pg_resetwal", node.data_dir], + re.compile(r"not proceeding because control file values were guessed"), + "does not run when control file values were guessed", + ) + pg_bin.command_ok( + ["pg_resetwal", "--force", node.data_dir], + "runs with force when control file values were guessed", + ) diff --git a/src/bin/pg_test_fsync/meson.build b/src/bin/pg_test_fsync/meson.build index f14793d665a..7a8a02143d2 100644 --- a/src/bin/pg_test_fsync/meson.build +++ b/src/bin/pg_test_fsync/meson.build @@ -26,6 +26,11 @@ tests += { 't/001_basic.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_test_fsync/pyt/test_001_basic.py b/src/bin/pg_test_fsync/pyt/test_001_basic.py new file mode 100644 index 00000000000..1c62c50ef2b --- /dev/null +++ b/src/bin/pg_test_fsync/pyt/test_001_basic.py @@ -0,0 +1,26 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_test_fsync option handling and argument validation.""" + +import re + + +def test_pg_test_fsync_basic(pg_bin): + # Basic checks + pg_bin.program_help_ok("pg_test_fsync") + pg_bin.program_version_ok("pg_test_fsync") + pg_bin.program_options_handling_ok("pg_test_fsync") + + # Invalid option combinations + pg_bin.command_fails_like( + ["pg_test_fsync", "--secs-per-test", "a"], + re.escape("pg_test_fsync: error: invalid argument for option --secs-per-test"), + "pg_test_fsync: invalid argument for option --secs-per-test", + ) + pg_bin.command_fails_like( + ["pg_test_fsync", "--secs-per-test", "0"], + re.escape( + "pg_test_fsync: error: --secs-per-test must be in range 1..4294967295" + ), + "pg_test_fsync: --secs-per-test must be in range", + ) diff --git a/src/bin/pg_test_timing/meson.build b/src/bin/pg_test_timing/meson.build index 89f31fa9529..a344762bee0 100644 --- a/src/bin/pg_test_timing/meson.build +++ b/src/bin/pg_test_timing/meson.build @@ -26,6 +26,11 @@ tests += { 't/001_basic.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_test_timing/pyt/test_001_basic.py b/src/bin/pg_test_timing/pyt/test_001_basic.py new file mode 100644 index 00000000000..1b4b9ae9bf4 --- /dev/null +++ b/src/bin/pg_test_timing/pyt/test_001_basic.py @@ -0,0 +1,46 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_test_timing option handling and argument validation.""" + +import re + + +def test_pg_test_timing_basic(pg_bin): + # Basic checks + pg_bin.program_help_ok("pg_test_timing") + pg_bin.program_version_ok("pg_test_timing") + pg_bin.program_options_handling_ok("pg_test_timing") + + # Invalid option combinations + pg_bin.command_fails_like( + ["pg_test_timing", "--duration", "a"], + re.escape("pg_test_timing: invalid argument for option --duration"), + "pg_test_timing: invalid argument for option --duration", + ) + pg_bin.command_fails_like( + ["pg_test_timing", "--duration", "0"], + re.escape("pg_test_timing: --duration must be in range 1..4294967295"), + "pg_test_timing: --duration must be in range", + ) + pg_bin.command_fails_like( + ["pg_test_timing", "--cutoff", "101"], + re.escape("pg_test_timing: --cutoff must be in range 0..100"), + "pg_test_timing: --cutoff must be in range", + ) + + # We can't check for specific output, but a short run should produce the + # expected section headings. Note the output in the log for the record. + pattern = re.compile( + re.escape("Testing timing overhead for 1 second.") + + r".*" + + re.escape("Histogram of timing durations:") + + r".*" + + re.escape("Observed timing durations up to 99.9900%:"), + re.DOTALL, + ) + res = pg_bin.command_like( + ["pg_test_timing", "--duration", "1"], + pattern, + "pg_test_timing: stdout passes sanity check", + ) + print(f"# pg_test_timing results:\n{res.stdout}") diff --git a/src/bin/pg_waldump/meson.build b/src/bin/pg_waldump/meson.build index 5296f21b82c..e58511c5d84 100644 --- a/src/bin/pg_waldump/meson.build +++ b/src/bin/pg_waldump/meson.build @@ -36,6 +36,12 @@ tests += { 't/002_save_fullpage.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_save_fullpage.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_waldump/pyt/test_001_basic.py b/src/bin/pg_waldump/pyt/test_001_basic.py new file mode 100644 index 00000000000..97111deb560 --- /dev/null +++ b/src/bin/pg_waldump/pyt/test_001_basic.py @@ -0,0 +1,550 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_waldump: option handling and decoding generated WAL.""" + +import os +import random +import re +import shutil +import struct +import subprocess + + +def _find_tar(): + """Locate a tar program, honoring the TAR environment variable.""" + return os.environ.get("TAR") or shutil.which("tar") or "" + + +def _tar_portability_options(tar): + """Return tar flags producing portable, reproducible archives across the + GNU, BSD, and OpenBSD tar variants.""" + if not tar: + return [] + + devnull = os.devnull + # GNU tar (Linux), BSD tar (FreeBSD, NetBSD, macOS, Windows) + rc = subprocess.run( + f"{tar} --format=ustar --owner=0 --group=0 -cf {devnull} {devnull} " + f"2>{devnull}", + shell=True, + check=False, + ).returncode + if rc == 0: + return ["--format=ustar", "--owner=0", "--group=0"] + # OpenBSD tar + rc = subprocess.run( + f"{tar} -F ustar -cf {devnull} {devnull} 2>{devnull}", + shell=True, + check=False, + ).returncode + if rc == 0: + return ["-F", "ustar"] + return [] + + +def check_pg_config(pg_config, define): + """Return True if *define* appears in the installed pg_config.h.""" + include_server = subprocess.run( + [pg_config, "--includedir-server"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + header = os.path.join(include_server, "pg_config.h") + with open(header, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + if define in line: + return True + return False + + +def _wal_path(node, walfile): + # Forward slashes: pg_waldump splits a WAL path on "/" to find the + # directory, so a Windows backslash path would not be located. + return "/".join([node.data_dir.replace("\\", "/"), "pg_wal", walfile]) + + +def _run_waldump(pg_bin, args): + """Run pg_waldump and return CommandResult.""" + return pg_bin.result(["pg_waldump", *args]) + + +def _test_pg_waldump_skip_bytes(pg_bin, path, startlsn, endlsn): + """Test that a message is shown if `from` wasn't a record start.""" + # Construct a new LSN that is one byte past the original start_lsn. + part1, part2 = startlsn.split("/") + lsn2 = int(part2, 16) + 1 + new_start = "%s/%X" % (part1, lsn2) + + res = _run_waldump( + pg_bin, + ["--start", new_start, "--end", endlsn, "--path", path], + ) + assert res.returncode == 0, "runs with start segment and start LSN specified" + assert re.search(r"first record is after", res.stderr), "info message printed" + + +def _test_pg_waldump(pg_bin, path, startlsn, endlsn, *opts): + """Run pg_waldump with options; return list of output lines.""" + res = _run_waldump( + pg_bin, + ["--start", startlsn, "--end", endlsn, "--path", path, *opts], + ) + label = " ".join(str(o) for o in opts) + assert res.returncode == 0, f"pg_waldump {label}: runs ok" + assert res.stderr == "", f"pg_waldump {label}: no stderr" + lines = res.stdout.split("\n") + if lines and lines[-1] == "": + lines.pop() + assert len(lines) > 0, f"pg_waldump {label}: some lines are output" + return lines + + +def _generate_archive(tar, tar_p_flags, archive, directory, compression_flags): + """Create a tar archive, shuffling the file order.""" + files = [e for e in os.listdir(directory) if e not in (".", "..")] + random.shuffle(files) + + # Run tar from within the WAL directory so member names are relative. + argv = [tar, *tar_p_flags, compression_flags, archive, *files] + proc = subprocess.run(argv, cwd=directory, check=False) + assert proc.returncode == 0, "tar archive created" + + +def test_pg_waldump_basic(pg_bin, create_pg, pg_config, tempdir_short): + tar = _find_tar() + tar_p_flags = _tar_portability_options(tar) + + pg_bin.program_help_ok("pg_waldump") + pg_bin.program_version_ok("pg_waldump") + pg_bin.program_options_handling_ok("pg_waldump") + + # wrong number of arguments + pg_bin.command_fails_like(["pg_waldump"], r"error: no arguments", "no arguments") + pg_bin.command_fails_like( + ["pg_waldump", "foo", "bar", "baz"], + r"error: too many command-line arguments", + "too many arguments", + ) + + # invalid option arguments + pg_bin.command_fails_like( + ["pg_waldump", "--block", "bad"], + r"error: invalid block number", + "invalid block number", + ) + pg_bin.command_fails_like( + ["pg_waldump", "--fork", "bad"], + r"error: invalid fork name", + "invalid fork name", + ) + pg_bin.command_fails_like( + ["pg_waldump", "--limit", "bad"], + r"error: invalid value", + "invalid limit", + ) + pg_bin.command_fails_like( + ["pg_waldump", "--relation", "bad"], + r"error: invalid relation", + "invalid relation specification", + ) + pg_bin.command_fails_like( + ["pg_waldump", "--rmgr", "bad"], + r"error: resource manager .* does not exist", + "invalid rmgr name", + ) + pg_bin.command_fails_like( + ["pg_waldump", "--start", "bad"], + r"error: invalid WAL location", + "invalid start LSN", + ) + pg_bin.command_fails_like( + ["pg_waldump", "--end", "bad"], + r"error: invalid WAL location", + "invalid end LSN", + ) + + # rmgr list: If you add one to the list, consider also adding a test + # case exercising the new rmgr below. + pg_bin.command_like( + ["pg_waldump", "--rmgr=list"], + re.compile( + r"""^XLOG +Transaction +Storage +CLOG +Database +Tablespace +MultiXact +RelMap +Standby +Heap2 +Heap +Btree +Hash +Gin +Gist +Sequence +SPGist +BRIN +CommitTs +ReplicationOrigin +Generic +LogicalMessage +XLOG2$""", + re.MULTILINE, + ), + "rmgr list", + ) + + node = create_pg("main", start=False) + node.append_conf( + """ +autovacuum = off +checkpoint_timeout = 1h + +# for standbydesc +archive_mode=on +archive_command='' + +# for XLOG_HEAP_TRUNCATE +wal_level=logical +""" + ) + node.start() + + start_lsn, start_walfile = node.safe_sql( + "SELECT pg_current_wal_insert_lsn(), " + "pg_walfile_name(pg_current_wal_insert_lsn())" + ).split("|") + + # heap, btree, hash, sequence and assorted DDL/DML. Statements that cannot + # run in a transaction block (VACUUM, VACUUM FULL, CREATE/DROP DATABASE) are + # issued in separate safe_sql calls, since the in-process libpq session + # wraps a multi-statement string in a single implicit transaction. + node.safe_sql( + """ +-- heap, btree, hash, sequence +CREATE TABLE t1 (a int GENERATED ALWAYS AS IDENTITY, b text); +CREATE INDEX i1a ON t1 USING btree (a); +CREATE INDEX i1b ON t1 USING hash (b); +INSERT INTO t1 VALUES (default, 'one'), (default, 'two'); +DELETE FROM t1 WHERE b = 'one'; +TRUNCATE t1; + +-- unlogged/init fork +CREATE UNLOGGED TABLE t2 (x int); +CREATE INDEX i2 ON t2 USING btree (x); +INSERT INTO t2 SELECT generate_series(1, 10); + +-- gin +CREATE TABLE gin_idx_tbl (id bigserial PRIMARY KEY, data jsonb); +CREATE INDEX gin_idx ON gin_idx_tbl USING gin (data); +INSERT INTO gin_idx_tbl + WITH random_json AS ( + SELECT json_object_agg(key, trunc(random() * 10)) as json_data + FROM unnest(array['a', 'b', 'c']) as u(key)) + SELECT generate_series(1,500), json_data FROM random_json; + +-- gist, spgist +CREATE TABLE gist_idx_tbl (p point); +CREATE INDEX gist_idx ON gist_idx_tbl USING gist (p); +CREATE INDEX spgist_idx ON gist_idx_tbl USING spgist (p); +INSERT INTO gist_idx_tbl (p) VALUES (point '(1, 1)'), (point '(3, 2)'), (point '(6, 3)'); + +-- brin +CREATE TABLE brin_idx_tbl (col1 int, col2 text, col3 text ); +CREATE INDEX brin_idx ON brin_idx_tbl USING brin (col1, col2, col3) WITH (autosummarize=on); +INSERT INTO brin_idx_tbl SELECT generate_series(1, 10000), 'dummy', 'dummy'; +UPDATE brin_idx_tbl SET col2 = 'updated' WHERE col1 BETWEEN 1 AND 5000; +SELECT brin_summarize_range('brin_idx', 0); +SELECT brin_desummarize_range('brin_idx', 0); +""" + ) + + # abort transaction (must be its own session block to ROLLBACK) + node.safe_sql( + """ +START TRANSACTION; +INSERT INTO t1 VALUES (default, 'three'); +ROLLBACK; +""" + ) + + node.safe_sql("VACUUM") + + # logical message + node.safe_sql("SELECT pg_logical_emit_message(true, 'foo', 'bar')") + + # relmap + node.safe_sql("VACUUM FULL pg_authid") + + # database + node.safe_sql("CREATE DATABASE d1") + node.safe_sql("DROP DATABASE d1") + + # Use a short location: on Windows the tablespace junction's target must be + # short, and forward slashes avoid a malformed junction. + tblspc_path = tempdir_short.replace("\\", "/") + node.safe_sql(f"CREATE TABLESPACE ts1 LOCATION '{tblspc_path}'") + node.safe_sql("DROP TABLESPACE ts1") + + # Test: Decode a continuation record (contrecord) that spans multiple WAL + # segments. + # + # Now consume all remaining room in the current WAL segment, leaving + # space enough only for the start of a largish record. + node.safe_sql( + """ +DO $$ +DECLARE + wal_segsize int := setting::int FROM pg_settings WHERE name = 'wal_segment_size'; + remain int; + iters int := 0; +BEGIN + LOOP + INSERT into t1(b) + select repeat(encode(sha256(g::text::bytea), 'hex'), (random() * 15 + 1)::int) + from generate_series(1, 10) g; + + remain := wal_segsize - (pg_current_wal_insert_lsn() - '0/0') % wal_segsize; + IF remain < 2 * setting::int from pg_settings where name = 'block_size' THEN + RAISE log 'exiting after % iterations, % bytes to end of WAL segment', iters, remain; + EXIT; + END IF; + iters := iters + 1; + END LOOP; +END +$$; +""" + ) + + contrecord_lsn = node.safe_sql("SELECT pg_current_wal_insert_lsn()") + # Generate contrecord record + node.safe_sql( + "SELECT pg_logical_emit_message(true, 'test 026', repeat('xyzxz', 123456))" + ) + + end_lsn, end_walfile = node.safe_sql( + "SELECT pg_current_wal_insert_lsn(), " + "pg_walfile_name(pg_current_wal_insert_lsn())" + ).split("|") + + default_ts_oid = node.safe_sql( + "SELECT oid FROM pg_tablespace WHERE spcname = 'pg_default'" + ) + postgres_db_oid = node.safe_sql( + "SELECT oid FROM pg_database WHERE datname = 'postgres'" + ) + rel_t1_oid = node.safe_sql("SELECT oid FROM pg_class WHERE relname = 't1'") + rel_i1a_oid = node.safe_sql("SELECT oid FROM pg_class WHERE relname = 'i1a'") + + data_dir = node.data_dir + node.stop() + + # various ways of specifying WAL range + pg_bin.command_fails_like( + ["pg_waldump", "foo", "bar"], + r'error: could not locate WAL file "foo"', + "start file not found", + ) + pg_bin.command_like( + ["pg_waldump", _wal_path(node, start_walfile)], + r".", + "runs with start segment specified", + ) + pg_bin.command_fails_like( + ["pg_waldump", _wal_path(node, start_walfile), "bar"], + r'error: could not open file "bar"', + "end file not found", + ) + pg_bin.command_like( + [ + "pg_waldump", + _wal_path(node, start_walfile), + _wal_path(node, end_walfile), + ], + r".", + "runs with start and end segment specified", + ) + pg_bin.command_like( + [ + "pg_waldump", + "--quiet", + "--path", + os.path.join(data_dir, "pg_wal") + "/", + start_walfile, + ], + re.compile(r"^$"), + "no output with --quiet option", + ) + + # Test that pg_waldump reports a detailed error message when dumping + # a WAL file with an invalid magic number (0000). + # + # The broken WAL file is created by copying a valid WAL file and + # overwriting its magic number with 0000. + broken_wal_dir = os.path.join(node.basedir, "broken_wal") + os.makedirs(broken_wal_dir, exist_ok=True) + # Forward slashes: pg_waldump locates a WAL file by splitting its path on + # "/", so a Windows backslash path would not be found. + broken_wal = broken_wal_dir.replace("\\", "/") + "/" + start_walfile + shutil.copy(_wal_path(node, start_walfile), broken_wal) + + with open(broken_wal, "r+b") as fh: + fh.seek(0) + fh.write(struct.pack("=H", 0)) + + pg_bin.command_fails_like( + ["pg_waldump", broken_wal], + re.compile(r"invalid magic number 0000", re.IGNORECASE), + "detailed error message shown for invalid WAL page magic", + ) + + tmp_dir = os.path.join(node.basedir, "archives") + os.makedirs(tmp_dir, exist_ok=True) + + have_libz = check_pg_config(pg_config, "#define HAVE_LIBZ 1") + + scenarios = [ + {"path": data_dir, "is_archive": False, "enabled": True}, + { + "path": os.path.join(tmp_dir, "pg_wal.tar"), + "compression_method": "none", + "compression_flags": "-cf", + "is_archive": True, + "enabled": True, + }, + { + "path": os.path.join(tmp_dir, "pg_wal.tar.gz"), + "compression_method": "gzip", + "compression_flags": "-czf", + "is_archive": True, + "enabled": have_libz, + }, + ] + + for scenario in scenarios: + # pg_waldump splits the path on "/", so feed it forward slashes. + path = scenario["path"].replace("\\", "/") + + if scenario["is_archive"] and (not tar): + # skip "tar command is not available" + continue + if scenario["is_archive"] and not scenario["enabled"]: + # skip " compression not supported by this build" + continue + + # create pg_wal archive + if scenario["is_archive"]: + _generate_archive( + tar, + tar_p_flags, + path, + os.path.join(data_dir, "pg_wal"), + scenario["compression_flags"], + ) + + pg_bin.command_fails_like( + ["pg_waldump", "--path", path], + r"error: no start WAL location given", + "path option requires start location", + ) + pg_bin.command_like( + [ + "pg_waldump", + "--path", + path, + "--start", + start_lsn, + "--end", + end_lsn, + ], + r".", + "runs with path option and start and end locations", + ) + pg_bin.command_fails_like( + ["pg_waldump", "--path", path, "--start", start_lsn], + r"error: error in WAL record at", + "falling off the end of the WAL results in an error", + ) + pg_bin.command_fails_like( + ["pg_waldump", "--quiet", "--path", path, "--start", start_lsn], + r"error: error in WAL record at", + "errors are shown with --quiet", + ) + + _test_pg_waldump_skip_bytes(pg_bin, path, start_lsn, end_lsn) + + lines = _test_pg_waldump(pg_bin, path, start_lsn, end_lsn) + assert ( + len([ln for ln in lines if not re.match(r"^rmgr: \w", ln)]) == 0 + ), "all output lines are rmgr lines" + + lines = _test_pg_waldump(pg_bin, path, contrecord_lsn, end_lsn) + assert ( + len([ln for ln in lines if not re.match(r"^rmgr: \w", ln)]) == 0 + ), "all output lines are rmgr lines" + + _test_pg_waldump_skip_bytes(pg_bin, path, contrecord_lsn, end_lsn) + + lines = _test_pg_waldump(pg_bin, path, start_lsn, end_lsn, "--limit", "6") + assert len(lines) == 6, "limit option observed" + + lines = _test_pg_waldump(pg_bin, path, start_lsn, end_lsn, "--fullpage") + assert ( + len([ln for ln in lines if not re.search(r"^rmgr:.*\bFPW\b", ln)]) == 0 + ), "all output lines are FPW" + + lines = _test_pg_waldump(pg_bin, path, start_lsn, end_lsn, "--stats") + assert re.search(r"WAL statistics", lines[0]), "statistics on stdout" + assert ( + len([ln for ln in lines if re.search(r"^rmgr:", ln)]) == 0 + ), "no rmgr lines output" + + lines = _test_pg_waldump(pg_bin, path, start_lsn, end_lsn, "--stats=record") + assert re.search(r"WAL statistics", lines[0]), "statistics on stdout" + assert ( + len([ln for ln in lines if re.search(r"^rmgr:", ln)]) == 0 + ), "no rmgr lines output" + + lines = _test_pg_waldump(pg_bin, path, start_lsn, end_lsn, "--rmgr", "Btree") + assert ( + len([ln for ln in lines if not re.search(r"^rmgr: Btree", ln)]) == 0 + ), "only Btree lines" + + lines = _test_pg_waldump(pg_bin, path, start_lsn, end_lsn, "--fork", "init") + assert ( + len([ln for ln in lines if not re.search(r"fork init", ln)]) == 0 + ), "only init fork lines" + + rel = f"{default_ts_oid}/{postgres_db_oid}/{rel_t1_oid}" + lines = _test_pg_waldump(pg_bin, path, start_lsn, end_lsn, "--relation", rel) + relre = re.compile( + r"rel %s/%s/%s" % (default_ts_oid, postgres_db_oid, rel_t1_oid) + ) + assert ( + len([ln for ln in lines if not relre.search(ln)]) == 0 + ), "only lines for selected relation" + + rel_i = f"{default_ts_oid}/{postgres_db_oid}/{rel_i1a_oid}" + lines = _test_pg_waldump( + pg_bin, + path, + start_lsn, + end_lsn, + "--relation", + rel_i, + "--block", + "1", + ) + assert ( + len([ln for ln in lines if not re.search(r"\bblk 1\b", ln)]) == 0 + ), "only lines for selected block" + + # Cleanup. + if scenario["is_archive"]: + try: + os.unlink(path) + except FileNotFoundError: + pass diff --git a/src/bin/pg_waldump/pyt/test_002_save_fullpage.py b/src/bin/pg_waldump/pyt/test_002_save_fullpage.py new file mode 100644 index 00000000000..8d062e7329d --- /dev/null +++ b/src/bin/pg_waldump/pyt/test_002_save_fullpage.py @@ -0,0 +1,120 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Tests for pg_waldump --save-fullpage, verifying saved full-page images.""" + +import glob +import os +import re +import struct + + +# This regexp will match filenames formatted as: +# TLI-LSNh-LSNl.TBLSPCOID.DBOID.NODEOID.dd_fork with the components being: +# - Timeline ID in hex format. +# - WAL LSN in hex format, as two 8-character numbers. +# - Tablespace OID (0 for global). +# - Database OID. +# - Relfilenode. +# - Block number. +# - Fork this block came from (vm, init, fsm, or main). +_FILE_RE = re.compile( + r"^[0-9A-F]{8}-([0-9A-F]{8})-([0-9A-F]{8})" + r"[.][0-9]+[.][0-9]+[.][0-9]+[.][0-9]+(?:_vm|_init|_fsm|_main)?$" +) + + +def _get_block_lsn(path, blocksize): + """Extract the LSN from the given block structure.""" + with open(path, "rb") as fh: + block = fh.read(blocksize) + assert len(block) == blocksize, "could not read block" + # unpack('LL', ...): two native unsigned longs. The page LSN is stored + # as two 32-bit halves (high, low). + lsn_hi, lsn_lo = struct.unpack("=LL", block[:8]) + + return ("%08X" % lsn_hi, "%08X" % lsn_lo) + + +def test_pg_waldump_save_fullpage(create_pg): + """Verify pg_waldump --save-fullpage emits well-formed block files.""" + node = create_pg("main", start=False) + node.append_conf( + """ +wal_level = 'replica' +max_wal_senders = 4 +""" + ) + node.start() + + # Generate data/WAL to examine that will have full pages in them. + # + # Note: the in-process libpq session runs a multi-statement string as a + # single implicit transaction, and CHECKPOINT cannot run in a transaction + # block. Issue the statements separately. + node.safe_sql( + "SELECT 'init' FROM pg_create_physical_replication_slot(" + "'regress_pg_waldump_slot', true, false)" + ) + node.safe_sql("CREATE TABLE test_table AS SELECT generate_series(1,100) a") + # Force FPWs on the next writes. + node.safe_sql("CHECKPOINT") + node.safe_sql("UPDATE test_table SET a = a + 1") + + walfile_name, blocksize = node.safe_sql( + "SELECT pg_walfile_name(pg_switch_wal()), current_setting('block_size')" + ).split("|") + blocksize = int(blocksize) + + # Get the relation node, etc for the new table + relation = node.safe_sql( + """SELECT format( + '%s/%s/%s', + CASE WHEN reltablespace = 0 THEN dattablespace ELSE reltablespace END, + pg_database.oid, + pg_relation_filenode(pg_class.oid)) + FROM pg_class, pg_database + WHERE relname = 'test_table' AND + datname = current_database()""" + ) + + # Forward slashes: pg_waldump splits a WAL path on "/" to find the + # directory, so a Windows backslash path would not be located. + walfile = "/".join([node.data_dir.replace("\\", "/"), "pg_wal", walfile_name]) + tmp_folder = str(node.basedir) + raw_dir = os.path.join(tmp_folder, "raw") + + assert os.path.isfile(walfile), "Got a WAL file" + + node.command_ok( + [ + "pg_waldump", + "--quiet", + "--save-fullpage", + raw_dir, + "--relation", + relation, + walfile, + ], + "pg_waldump with --save-fullpage runs", + ) + + file_count = 0 + + # Verify filename format matches --save-fullpage. + for fullpath in glob.glob(os.path.join(raw_dir, "*")): + filename = os.path.basename(fullpath) + + m = _FILE_RE.match(filename) + assert m is not None, f"verify filename format for file {filename}" + file_count += 1 + + hi_lsn_fn, lo_lsn_fn = m.group(1), m.group(2) + hi_lsn_bk, lo_lsn_bk = _get_block_lsn(fullpath, blocksize) + + # The LSN on the block comes before the file's LSN. + assert (hi_lsn_fn + lo_lsn_fn) >= (hi_lsn_bk + lo_lsn_bk), ( + f"LSN stored in the file {hi_lsn_fn}/{lo_lsn_fn} precedes the one " + f"stored in the block {hi_lsn_bk}/{lo_lsn_bk}" + ) + + assert file_count > 0, "verify that at least one block has been saved" diff --git a/src/bin/pg_walsummary/meson.build b/src/bin/pg_walsummary/meson.build index d012275402b..8674eee8a9f 100644 --- a/src/bin/pg_walsummary/meson.build +++ b/src/bin/pg_walsummary/meson.build @@ -26,7 +26,13 @@ tests += { 't/001_basic.pl', 't/002_blocks.pl', ], - } + }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_blocks.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_walsummary/pyt/test_001_basic.py b/src/bin/pg_walsummary/pyt/test_001_basic.py new file mode 100644 index 00000000000..e938f123627 --- /dev/null +++ b/src/bin/pg_walsummary/pyt/test_001_basic.py @@ -0,0 +1,18 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_walsummary option handling and argument validation.""" + +import re + + +def test_pg_walsummary_basic(pg_bin): + """pg_walsummary --help / --version / invalid-option handling.""" + pg_bin.program_help_ok("pg_walsummary") + pg_bin.program_version_ok("pg_walsummary") + pg_bin.program_options_handling_ok("pg_walsummary") + + pg_bin.command_fails_like( + ["pg_walsummary"], + re.compile(r"no input files specified"), + "input files must be specified", + ) diff --git a/src/bin/pg_walsummary/pyt/test_002_blocks.py b/src/bin/pg_walsummary/pyt/test_002_blocks.py new file mode 100644 index 00000000000..67806c71232 --- /dev/null +++ b/src/bin/pg_walsummary/pyt/test_002_blocks.py @@ -0,0 +1,124 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests that pg_walsummary reports the blocks recorded in WAL summaries.""" + +import os +import re + + +def test_pg_walsummary_blocks(pg_bin, create_pg): + """Generate WAL summaries and verify pg_walsummary reports modified blocks.""" + # Set up a new database instance. This test exercises neither archiving + # nor streaming; it only requires WAL summarization, which needs + # wal_level >= replica (the default). + node1 = create_pg("node1", start=False) + node1.append_conf("wal_level = replica") + node1.append_conf("summarize_wal = on") + node1.start() + + # Create a table and insert a few test rows into it. VACUUM FREEZE it so + # that autovacuum doesn't induce any future modifications unexpectedly. + # Then trigger a checkpoint. + # + # Note: unlike psql, the in-process libpq session runs a multi-statement + # string inside a single implicit transaction, and VACUUM cannot run in a + # transaction block. Issue VACUUM FREEZE as a separate command. + node1.safe_sql( + """ + CREATE TABLE mytable (a int, b text); + INSERT INTO mytable + SELECT + g, random()::text||random()::text||random()::text||random()::text + FROM + generate_series(1, 400) g; + """ + ) + node1.safe_sql("VACUUM FREEZE") + + # Record the current WAL insert LSN. + base_lsn = node1.safe_sql("SELECT pg_current_wal_insert_lsn()") + + # Now perform a CHECKPOINT. + node1.safe_sql("CHECKPOINT") + + # Wait for a new summary to show up, one that includes the inserts we just + # did. + result = node1.poll_query_until( + f""" + SELECT EXISTS ( + SELECT * from pg_available_wal_summaries() + WHERE end_lsn >= '{base_lsn}' + ) + """ + ) + assert result, "WAL summarization caught up after insert" + + # The WAL summarizer should have generated some IO statistics. + assert node1.poll_query_until( + "SELECT sum(reads) > 0 FROM pg_stat_io " + "WHERE backend_type = 'walsummarizer' AND object = 'wal'" + ), ( + "Timed out while waiting for WAL summarizer to generate statistics " + "for WAL reads" + ) + + # Find the highest LSN that is summarized on disk. + summarized_lsn = node1.safe_sql( + "SELECT MAX(end_lsn) AS summarized_lsn FROM pg_available_wal_summaries()" + ) + + # Update a row in the first block of the table and trigger a checkpoint. + node1.safe_sql( + "UPDATE mytable SET b = 'abcdefghijklmnopqrstuvwxyz' || b " + "|| '01234567890' WHERE a = 2" + ) + node1.safe_sql("CHECKPOINT") + + # Wait for a new summary to show up. + result = node1.poll_query_until( + f""" + SELECT EXISTS ( + SELECT * from pg_available_wal_summaries() + WHERE end_lsn > '{summarized_lsn}' + ) + """ + ) + assert result, "got new WAL summary after update" + + # Figure out the exact details for the new summary file. + details = node1.safe_sql( + f""" + SELECT tli, start_lsn, end_lsn from pg_available_wal_summaries() + WHERE end_lsn > '{summarized_lsn}' + """ + ) + lines = details.split("\n") + assert len(lines) == 1, "got exactly one new WAL summary" + tli, start_lsn, end_lsn = lines[0].split("|") + + # Reconstruct the full pathname for the WAL summary file. The backend + # names these files "%08X%08X%08X%08X%08X.summary" from the TLI and the + # high/low halves of the start and end LSNs (see OpenWalSummaryFile). + start_hi, start_lo = start_lsn.split("/") + end_hi, end_lo = end_lsn.split("/") + basename = "{:08X}{:08X}{:08X}{:08X}{:08X}.summary".format( + int(tli), + int(start_hi, 16), + int(start_lo, 16), + int(end_hi, 16), + int(end_lo, 16), + ) + filename = os.path.join(node1.data_dir, "pg_wal", "summaries", basename) + assert os.path.isfile(filename), "WAL summary file exists" + + # Run pg_walsummary on it. We expect exactly two blocks to be modified, + # block 0 and one other. + res = pg_bin.result(["pg_walsummary", "-i", filename]) + stdout = res.stdout + stderr = res.stderr + lines = stdout.rstrip("\n").split("\n") + assert re.search( + r"FORK main: block 0$", stdout, re.MULTILINE + ), "stdout shows block 0 modified" + assert stderr == "", "stderr is empty" + assert len(lines) == 2, "UPDATE modified 2 blocks" diff --git a/src/bin/scripts/meson.build b/src/bin/scripts/meson.build index c083ec38099..c9647d18786 100644 --- a/src/bin/scripts/meson.build +++ b/src/bin/scripts/meson.build @@ -83,6 +83,24 @@ tests += { 't/200_connstr.pl', ], }, + 'pytest': { + 'env': {'with_icu': icu.found() ? 'yes' : 'no'}, + 'tests': [ + 'pyt/test_010_clusterdb.py', + 'pyt/test_011_clusterdb_all.py', + 'pyt/test_020_createdb.py', + 'pyt/test_040_createuser.py', + 'pyt/test_050_dropdb.py', + 'pyt/test_070_dropuser.py', + 'pyt/test_080_pg_isready.py', + 'pyt/test_090_reindexdb.py', + 'pyt/test_091_reindexdb_all.py', + 'pyt/test_100_vacuumdb.py', + 'pyt/test_101_vacuumdb_all.py', + 'pyt/test_102_vacuumdb_stages.py', + 'pyt/test_200_connstr.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/scripts/pyt/test_010_clusterdb.py b/src/bin/scripts/pyt/test_010_clusterdb.py new file mode 100644 index 00000000000..4592075149b --- /dev/null +++ b/src/bin/scripts/pyt/test_010_clusterdb.py @@ -0,0 +1,47 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for clusterdb option handling, the SQL it issues, and error cases.""" + +import re + + +def test_clusterdb(pg_bin, create_pg): + pg_bin.program_help_ok("clusterdb") + pg_bin.program_version_ok("clusterdb") + pg_bin.program_options_handling_ok("clusterdb") + + node = create_pg("main", start=False) + # issues_sql_like needs the SQL logged on the server side. + node.append_conf( + "log_statement = 'all'\n" + "log_min_messages = 'debug1'\n" + "log_min_duration_statement = -1\n" + ) + node.start() + + node.issues_sql_like( + ["clusterdb"], + re.compile(r"statement: CLUSTER;"), + "SQL CLUSTER run", + ) + + node.command_fails_like( + ["clusterdb", "--table", "nonexistent"], + re.compile(r'relation "nonexistent" does not exist'), + "fails with nonexistent table", + ) + + node.safe_sql( + "CREATE TABLE test1 (a int); CREATE INDEX test1x ON test1 (a); " + "CLUSTER test1 USING test1x" + ) + node.issues_sql_like( + ["clusterdb", "--table", "test1"], + re.compile(r"statement: CLUSTER public\.test1;"), + "cluster specific table", + ) + + node.command_ok( + ["clusterdb", "--echo", "--verbose", "dbname=template1"], + "clusterdb with connection string", + ) diff --git a/src/bin/scripts/pyt/test_011_clusterdb_all.py b/src/bin/scripts/pyt/test_011_clusterdb_all.py new file mode 100644 index 00000000000..e414fed712d --- /dev/null +++ b/src/bin/scripts/pyt/test_011_clusterdb_all.py @@ -0,0 +1,56 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for clusterdb --all across multiple databases.""" + +import re + + +def test_clusterdb_all(create_pg): + node = create_pg("main", start=False) + node.append_conf( + "log_statement = 'all'\n" + "log_min_messages = 'debug1'\n" + "log_min_duration_statement = -1\n" + ) + node.start() + + # clusterdb -a is not compatible with -d. This relies on PGDATABASE to be + # set, something the test framework does (via the node's environment). + node.issues_sql_like( + ["clusterdb", "--all"], + re.compile(r"statement: CLUSTER.*statement: CLUSTER", re.S), + "cluster all databases", + ) + + node.safe_sql("CREATE DATABASE regression_invalid") + node.safe_sql( + "UPDATE pg_database SET datconnlimit = -2 " + "WHERE datname = 'regression_invalid'" + ) + node.command_ok( + ["clusterdb", "--all"], + "invalid database not targeted by clusterdb -a", + ) + + # Doesn't quite belong here, but don't want to waste time by creating an + # invalid database in the non-all clusterdb test as well. + node.command_fails_like( + ["clusterdb", "--dbname", "regression_invalid"], + re.compile(r'FATAL: cannot connect to invalid database "regression_invalid"'), + "clusterdb cannot target invalid database", + ) + + node.safe_sql( + "CREATE TABLE test1 (a int); CREATE INDEX test1x ON test1 (a); " + "CLUSTER test1 USING test1x" + ) + node.safe_sql( + "CREATE TABLE test1 (a int); CREATE INDEX test1x ON test1 (a); " + "CLUSTER test1 USING test1x", + dbname="template1", + ) + node.issues_sql_like( + ["clusterdb", "--all", "--table", "test1"], + re.compile(r"statement: CLUSTER public\.test1", re.S), + "cluster specific table in all databases", + ) diff --git a/src/bin/scripts/pyt/test_020_createdb.py b/src/bin/scripts/pyt/test_020_createdb.py new file mode 100644 index 00000000000..4ff1df964d3 --- /dev/null +++ b/src/bin/scripts/pyt/test_020_createdb.py @@ -0,0 +1,539 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for createdb option handling, templates, locales, and error cases.""" + +import os +import re + + +def _with_icu(node): + """Return True if this build has ICU support. + + Honor the with_icu environment variable (set by the build harness) if + present; otherwise detect ICU at runtime by checking for ICU-provider + collations in the catalog, which only exist when the server was compiled + with ICU. + """ + env = os.environ.get("with_icu") + if env is not None: + return env == "yes" + return ( + node.safe_sql("SELECT count(*) > 0 FROM pg_collation WHERE collprovider = 'i'") + == "t" + ) + + +def test_createdb(pg_bin, create_pg): + pg_bin.program_help_ok("createdb") + pg_bin.program_version_ok("createdb") + pg_bin.program_options_handling_ok("createdb") + + node = create_pg("main", start=False) + node.append_conf( + "log_statement = 'all'\n" + "log_min_messages = 'debug1'\n" + "log_min_duration_statement = -1\n" + ) + node.start() + + node.issues_sql_like( + ["createdb", "foobar1"], + re.compile(r"statement: CREATE DATABASE foobar1"), + "SQL CREATE DATABASE run", + ) + node.issues_sql_like( + [ + "createdb", + "--locale", + "C", + "--encoding", + "LATIN1", + "--template", + "template0", + "foobar2", + ], + re.compile(r"statement: CREATE DATABASE foobar2 ENCODING 'LATIN1'"), + "create database with encoding", + ) + + if _with_icu(node): + # This fails because template0 uses libc provider and has no ICU + # locale set. It would succeed if template0 used the icu provider. + node.command_fails( + [ + "createdb", + "--template", + "template0", + "--encoding", + "UTF8", + "--locale-provider", + "icu", + "foobar4", + ], + "create database with ICU fails without ICU locale specified", + ) + + node.issues_sql_like( + [ + "createdb", + "--template", + "template0", + "--encoding", + "UTF8", + "--locale-provider", + "icu", + "--locale", + "C", + "--icu-locale", + "en", + "foobar5", + ], + re.compile( + r"statement: CREATE DATABASE foobar5 .* " + r"LOCALE_PROVIDER icu ICU_LOCALE 'en'" + ), + "create database with ICU locale specified", + ) + + node.command_fails( + [ + "createdb", + "--template", + "template0", + "--encoding", + "UTF8", + "--locale-provider", + "icu", + "--icu-locale", + "@colNumeric=lower", + "foobarX", + ], + "fails for invalid ICU locale", + ) + + node.command_fails_like( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "icu", + "--encoding", + "SQL_ASCII", + "foobarX", + ], + re.compile( + r'ERROR: encoding "SQL_ASCII" is not supported with ICU provider' + ), + "fails for encoding not supported by ICU", + ) + + # additional node, which uses the icu provider + node2 = create_pg( + "icu", + start=False, + initdb_extra=["--locale-provider=icu", "--icu-locale=en"], + ) + node2.start() + + node2.command_ok( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "libc", + "foobar55", + ], + "create database with libc provider from template database " + "with icu provider", + ) + + node2.command_ok( + [ + "createdb", + "--template", + "template0", + "--icu-locale", + "en-US", + "foobar56", + ], + "create database with icu locale from template database " + "with icu provider", + ) + + node2.command_ok( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "icu", + "--locale", + "en", + "--lc-collate", + "C", + "--lc-ctype", + "C", + "foobar57", + ], + "create database with locale as ICU locale", + ) + else: + node.command_fails( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "icu", + "foobar4", + ], + "create database with ICU fails since no ICU support", + ) + + node.command_fails( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "builtin", + "tbuiltin1", + ], + 'create database with provider "builtin" fails without --locale', + ) + + node.command_ok( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "builtin", + "--locale", + "C", + "tbuiltin2", + ], + 'create database with provider "builtin" and locale "C"', + ) + + node.command_ok( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "builtin", + "--locale", + "C", + "--lc-collate", + "C", + "tbuiltin3", + ], + 'create database with provider "builtin" and LC_COLLATE=C', + ) + + node.command_ok( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "builtin", + "--locale", + "C", + "--lc-ctype", + "C", + "tbuiltin4", + ], + 'create database with provider "builtin" and LC_CTYPE=C', + ) + + node.command_ok( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "builtin", + "--lc-collate", + "C", + "--lc-ctype", + "C", + "--encoding", + "UTF-8", + "--builtin-locale", + "C.UTF8", + "tbuiltin5", + ], + "create database with --builtin-locale C.UTF-8 and -E UTF-8", + ) + + node.command_fails( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "builtin", + "--lc-collate", + "C", + "--lc-ctype", + "C", + "--encoding", + "LATIN1", + "--builtin-locale", + "C.UTF-8", + "tbuiltin6", + ], + "create database with --builtin-locale C.UTF-8 and -E LATIN1", + ) + + node.command_fails( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "builtin", + "--locale", + "C", + "--icu-locale", + "en", + "tbuiltin7", + ], + 'create database with provider "builtin" and ICU_LOCALE="en"', + ) + + node.command_fails( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "builtin", + "--locale", + "C", + "--icu-rules", + '""', + "tbuiltin8", + ], + 'create database with provider "builtin" and ICU_RULES=""', + ) + + node.command_fails( + [ + "createdb", + "--template", + "template1", + "--locale-provider", + "builtin", + "--locale", + "C", + "tbuiltin9", + ], + 'create database with provider "builtin" not matching template', + ) + + node.command_fails( + ["createdb", "foobar1"], + "fails if database already exists", + ) + + node.command_fails( + [ + "createdb", + "--template", + "template0", + "--locale-provider", + "xyz", + "foobarX", + ], + "fails for invalid locale provider", + ) + + node.command_fails_like( + ["createdb", "invalid \n dbname"], + re.compile(r"contains a newline or carriage return character"), + "fails if database name contains a newline character in name", + ) + + node.command_fails_like( + ["createdb", "invalid \r dbname"], + re.compile(r"contains a newline or carriage return character"), + "fails if database name contains a carriage return character in name", + ) + + # Check use of templates with shared dependencies copied from the template. + # Use fresh connections that are closed promptly: a lingering connection to + # foobar2 would block its later use as a CREATE DATABASE template. + with node.connect(dbname="foobar2") as foobar2: + foobar2.query( + "CREATE ROLE role_foobar;\n" + "CREATE TABLE tab_foobar (id int);\n" + "ALTER TABLE tab_foobar owner to role_foobar;\n" + "CREATE POLICY pol_foobar ON tab_foobar FOR ALL TO role_foobar;" + ) + node.issues_sql_like( + ["createdb", "--locale", "C", "--template", "foobar2", "foobar3"], + re.compile(r"statement: CREATE DATABASE foobar3 TEMPLATE foobar2 LOCALE 'C'"), + "create database with template", + ) + with node.connect(dbname="foobar3") as foobar3: + stdout = foobar3.query_safe( + "SELECT pg_describe_object(classid, objid, objsubid) AS obj,\n" + " pg_describe_object(refclassid, refobjid, 0) AS refobj\n" + " FROM pg_shdepend s JOIN pg_database d ON (d.oid = s.dbid)\n" + " WHERE d.datname = 'foobar3' ORDER BY obj;" + ) + assert re.search( + r"^policy pol_foobar on table tab_foobar\|role role_foobar\n" + r"table tab_foobar\|role role_foobar$", + stdout, + ), f"shared dependencies copied over to target database\n{stdout}" + + # Check quote handling with incorrect option values. + node.pg_bin.command_checks_all( + ["createdb", "--encoding", "foo'; SELECT '1", "foobar2"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r"^createdb: error: \"foo'; SELECT '1\" is not a valid " + r"encoding name", + re.S, + ) + ], + "createdb with incorrect --encoding", + ) + node.pg_bin.command_checks_all( + ["createdb", "--lc-collate", "foo'; SELECT '1", "foobar2"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r"^createdb: error: database creation failed: ERROR: invalid " + r"LC_COLLATE locale name" + r"|^createdb: error: database creation failed: ERROR: new " + r"collation \(foo'; SELECT '1\) is incompatible with the collation " + r"of the template database", + re.S, + ) + ], + "createdb with incorrect --lc-collate", + ) + node.pg_bin.command_checks_all( + ["createdb", "--lc-ctype", "foo'; SELECT '1", "foobar2"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r"^createdb: error: database creation failed: ERROR: invalid " + r"LC_CTYPE locale name" + r"|^createdb: error: database creation failed: ERROR: new " + r"LC_CTYPE \(foo'; SELECT '1\) is incompatible with the LC_CTYPE " + r"of the template database", + re.S, + ) + ], + "createdb with incorrect --lc-ctype", + ) + + node.pg_bin.command_checks_all( + ["createdb", "--strategy", "foo", "foobar2"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r"^createdb: error: database creation failed: ERROR: invalid " + r'create database strategy "foo"', + re.S, + ) + ], + "createdb with incorrect --strategy", + ) + + # Check database creation strategy + node.issues_sql_like( + [ + "createdb", + "--template", + "foobar2", + "--strategy", + "wal_log", + "foobar6", + ], + re.compile( + "statement: CREATE DATABASE foobar6 STRATEGY wal_log TEMPLATE foobar2" + ), + "create database with WAL_LOG strategy", + ) + + node.issues_sql_like( + [ + "createdb", + "--template", + "foobar2", + "--strategy", + "WAL_LOG", + "foobar6s", + ], + re.compile( + r'statement: CREATE DATABASE foobar6s STRATEGY "WAL_LOG" ' + r"TEMPLATE foobar2" + ), + "create database with WAL_LOG strategy", + ) + + node.issues_sql_like( + [ + "createdb", + "--template", + "foobar2", + "--strategy", + "file_copy", + "foobar7", + ], + re.compile( + r"statement: CREATE DATABASE foobar7 STRATEGY file_copy " + r"TEMPLATE foobar2" + ), + "create database with FILE_COPY strategy", + ) + + node.issues_sql_like( + [ + "createdb", + "--template", + "foobar2", + "--strategy", + "FILE_COPY", + "foobar7s", + ], + re.compile( + r'statement: CREATE DATABASE foobar7s STRATEGY "FILE_COPY" ' + r"TEMPLATE foobar2" + ), + "create database with FILE_COPY strategy", + ) + + # Create database owned by role_foobar. + node.issues_sql_like( + [ + "createdb", + "--template", + "foobar2", + "--owner", + "role_foobar", + "foobar8", + ], + re.compile( + "statement: CREATE DATABASE foobar8 OWNER role_foobar TEMPLATE foobar2" + ), + "create database with owner role_foobar", + ) + # query_safe raises on error, asserting the queries succeed. + with node.connect(dbname="foobar2") as foobar2: + foobar2.query_safe("DROP OWNED BY role_foobar;") + foobar2.query_safe("DROP DATABASE foobar8;") diff --git a/src/bin/scripts/pyt/test_040_createuser.py b/src/bin/scripts/pyt/test_040_createuser.py new file mode 100644 index 00000000000..8dfff398de4 --- /dev/null +++ b/src/bin/scripts/pyt/test_040_createuser.py @@ -0,0 +1,160 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for createuser option handling, the SQL it issues, and error cases.""" + +import re + + +def test_createuser(pg_bin, create_pg): + pg_bin.program_help_ok("createuser") + pg_bin.program_version_ok("createuser") + pg_bin.program_options_handling_ok("createuser") + + node = create_pg("main", start=False) + node.append_conf( + "log_statement = 'all'\n" + "log_min_messages = 'debug1'\n" + "log_min_duration_statement = -1\n" + ) + node.start() + + node.issues_sql_like( + ["createuser", "regress_user1"], + re.compile( + r"statement: CREATE ROLE regress_user1 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS;" + ), + "SQL CREATE USER run", + ) + node.issues_sql_like( + ["createuser", "--no-login", "regress_role1"], + re.compile( + r"statement: CREATE ROLE regress_role1 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT NOLOGIN NOREPLICATION NOBYPASSRLS;" + ), + "create a non-login role", + ) + node.issues_sql_like( + ["createuser", "--createrole", "regress user2"], + re.compile( + r'statement: CREATE ROLE "regress user2" NOSUPERUSER NOCREATEDB ' + r"CREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS;" + ), + "create a CREATEROLE user", + ) + node.issues_sql_like( + ["createuser", "--superuser", "regress_user3"], + re.compile( + r"statement: CREATE ROLE regress_user3 SUPERUSER CREATEDB " + r"CREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS;" + ), + "create a superuser", + ) + node.issues_sql_like( + [ + "createuser", + "--with-admin", + "regress_user1", + "--with-admin", + "regress user2", + "regress user #4", + ], + re.compile( + r'statement: CREATE ROLE "regress user #4" NOSUPERUSER NOCREATEDB ' + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS ADMIN " + r'regress_user1,"regress user2";' + ), + "add a role as a member with admin option of the newly created role", + ) + node.issues_sql_like( + [ + "createuser", + "REGRESS_USER5", + "--with-member", + "regress_user3", + "--with-member", + "regress user #4", + ], + re.compile( + r'statement: CREATE ROLE "REGRESS_USER5" NOSUPERUSER NOCREATEDB ' + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS ROLE " + r'regress_user3,"regress user #4";' + ), + "add a role as a member of the newly created role", + ) + node.issues_sql_like( + ["createuser", "--valid-until", "2029 12 31", "regress_user6"], + re.compile( + r"statement: CREATE ROLE regress_user6 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS " + r"VALID UNTIL '2029 12 31';" + ), + "create a role with a password expiration date", + ) + node.issues_sql_like( + ["createuser", "--bypassrls", "regress_user7"], + re.compile( + r"statement: CREATE ROLE regress_user7 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION BYPASSRLS;" + ), + "create a BYPASSRLS role", + ) + node.issues_sql_like( + ["createuser", "--no-bypassrls", "regress_user8"], + re.compile( + r"statement: CREATE ROLE regress_user8 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS;" + ), + "create a role without BYPASSRLS", + ) + node.issues_sql_like( + ["createuser", "--with-admin", "regress_user1", "regress_user9"], + re.compile( + r"statement: CREATE ROLE regress_user9 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS " + r"ADMIN regress_user1;" + ), + "--with-admin", + ) + node.issues_sql_like( + ["createuser", "--with-member", "regress_user1", "regress_user10"], + re.compile( + r"statement: CREATE ROLE regress_user10 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS " + r"ROLE regress_user1;" + ), + "--with-member", + ) + node.issues_sql_like( + ["createuser", "--role", "regress_user1", "regress_user11"], + re.compile( + r"statement: CREATE ROLE regress_user11 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS " + r"IN ROLE regress_user1;" + ), + "--role", + ) + node.issues_sql_like( + ["createuser", "regress_user12", "--member-of", "regress_user1"], + re.compile( + r"statement: CREATE ROLE regress_user12 NOSUPERUSER NOCREATEDB " + r"NOCREATEROLE INHERIT LOGIN NOREPLICATION NOBYPASSRLS " + r"IN ROLE regress_user1;" + ), + "--member-of", + ) + + node.command_fails( + ["createuser", "regress_user1"], + "fails if role already exists", + ) + node.command_fails( + [ + "createuser", + "regress_user1", + "--with-member", + "regress_user2", + "regress_user3", + ], + "fails for too many non-options", + ) diff --git a/src/bin/scripts/pyt/test_050_dropdb.py b/src/bin/scripts/pyt/test_050_dropdb.py new file mode 100644 index 00000000000..c44b1afe97f --- /dev/null +++ b/src/bin/scripts/pyt/test_050_dropdb.py @@ -0,0 +1,47 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for dropdb option handling, the SQL it issues, and error cases.""" + +import re + + +def test_dropdb(pg_bin, create_pg): + pg_bin.program_help_ok("dropdb") + pg_bin.program_version_ok("dropdb") + pg_bin.program_options_handling_ok("dropdb") + + node = create_pg("main") + # issues_sql_like needs the statements logged. + node.append_conf("log_statement = 'all'\nlog_min_duration_statement = -1") + node.restart() + + node.safe_sql("CREATE DATABASE foobar1") + node.issues_sql_like( + ["dropdb", "foobar1"], + re.compile(r"statement: DROP DATABASE foobar1"), + "SQL DROP DATABASE run", + ) + + node.safe_sql("CREATE DATABASE foobar2") + node.issues_sql_like( + ["dropdb", "--force", "foobar2"], + re.compile(r"statement: DROP DATABASE foobar2 WITH \(FORCE\);"), + "SQL DROP DATABASE (FORCE) run", + ) + + node.command_fails_like( + ["dropdb", "nonexistent"], + re.compile(r'database "nonexistent" does not exist'), + "fails with nonexistent database", + ) + + # check that invalid database can be dropped with dropdb + node.safe_sql("CREATE DATABASE regression_invalid") + node.safe_sql( + "UPDATE pg_database SET datconnlimit = -2 " + "WHERE datname = 'regression_invalid'" + ) + node.command_ok( + ["dropdb", "regression_invalid"], + "invalid database can be dropped", + ) diff --git a/src/bin/scripts/pyt/test_070_dropuser.py b/src/bin/scripts/pyt/test_070_dropuser.py new file mode 100644 index 00000000000..71b573cda53 --- /dev/null +++ b/src/bin/scripts/pyt/test_070_dropuser.py @@ -0,0 +1,29 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for dropuser option handling, the SQL it issues, and error cases.""" + +import re + + +def test_dropuser(pg_bin, create_pg): + pg_bin.program_help_ok("dropuser") + pg_bin.program_version_ok("dropuser") + pg_bin.program_options_handling_ok("dropuser") + + node = create_pg("main") + # issues_sql_like needs the statements logged. + node.append_conf("log_statement = 'all'\nlog_min_duration_statement = -1") + node.restart() + + node.safe_sql("CREATE ROLE regress_foobar1") + node.issues_sql_like( + ["dropuser", "regress_foobar1"], + re.compile(r"statement: DROP ROLE regress_foobar1"), + "SQL DROP ROLE run", + ) + + node.command_fails_like( + ["dropuser", "regress_nonexistent"], + re.compile(r'role "regress_nonexistent" does not exist'), + "fails with nonexistent user", + ) diff --git a/src/bin/scripts/pyt/test_080_pg_isready.py b/src/bin/scripts/pyt/test_080_pg_isready.py new file mode 100644 index 00000000000..54670d9fe6b --- /dev/null +++ b/src/bin/scripts/pyt/test_080_pg_isready.py @@ -0,0 +1,23 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_isready against stopped and running servers.""" + +from pypg.util import TIMEOUT_DEFAULT + + +def test_pg_isready(pg_bin, create_pg): + """pg_isready fails with no server, then succeeds once it is up.""" + pg_bin.program_help_ok("pg_isready") + pg_bin.program_version_ok("pg_isready") + pg_bin.program_options_handling_ok("pg_isready") + + node = create_pg("main", start=False) + + node.command_fails(["pg_isready"], "fails with no server running") + + node.start() + + node.command_ok( + ["pg_isready", "--timeout", TIMEOUT_DEFAULT], + "succeeds with server running", + ) diff --git a/src/bin/scripts/pyt/test_090_reindexdb.py b/src/bin/scripts/pyt/test_090_reindexdb.py new file mode 100644 index 00000000000..4a2c5e8ce9f --- /dev/null +++ b/src/bin/scripts/pyt/test_090_reindexdb.py @@ -0,0 +1,344 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for reindexdb option handling, the SQL it issues, and error cases.""" + +import os +import re + + +def test_reindexdb(pg_bin, create_pg, monkeypatch): + pg_bin.program_help_ok("reindexdb") + pg_bin.program_version_ok("reindexdb") + pg_bin.program_options_handling_ok("reindexdb") + + node = create_pg("main", start=False) + # issues_sql_like needs the statements logged. + node.append_conf("log_statement = 'all'\nlog_min_duration_statement = -1") + node.start() + + monkeypatch.setenv("PGOPTIONS", "--client-min-messages=WARNING") + + # Create a tablespace for testing. + tbspace_path = os.path.join(node.basedir, "regress_reindex_tbspace") + os.mkdir(tbspace_path) + tbspace_name = "reindex_tbspace" + node.safe_sql(f"CREATE TABLESPACE {tbspace_name} LOCATION '{tbspace_path}';") + + # Use text as data type to get a toast table. + node.safe_sql("CREATE TABLE test1 (a text); CREATE INDEX test1x ON test1 (a);") + # Collect toast table and index names of this relation, for later use. + toast_table = node.safe_sql( + "SELECT reltoastrelid::regclass FROM pg_class WHERE oid = 'test1'::regclass;" + ) + toast_index = node.safe_sql( + "SELECT indexrelid::regclass FROM pg_index " + f"WHERE indrelid = '{toast_table}'::regclass;" + ) + + # Set of SQL queries to cross-check the state of relfilenodes across + # REINDEX operations. A set of relfilenodes is saved from the catalogs + # and then compared with pg_class. + node.safe_sql( + "CREATE TABLE index_relfilenodes " + "(parent regclass, indname text, indoid oid, relfilenode oid);" + ) + # Save the relfilenode of a set of toast indexes, one from the catalog + # pg_constraint and one from the test table. + fetch_toast_relfilenodes = """SELECT b.oid::regclass, c.oid::regclass::text, c.oid, c.relfilenode + FROM pg_class a + JOIN pg_class b ON (a.oid = b.reltoastrelid) + JOIN pg_index i on (a.oid = i.indrelid) + JOIN pg_class c on (i.indexrelid = c.oid) + WHERE b.oid IN ('pg_constraint'::regclass, 'test1'::regclass)""" + # Same for relfilenodes of normal indexes. This saves the relfilenode + # from an index of pg_constraint, and from the index of the test table. + fetch_index_relfilenodes = """SELECT i.indrelid, a.oid::regclass::text, a.oid, a.relfilenode + FROM pg_class a + JOIN pg_index i ON (i.indexrelid = a.oid) + WHERE a.relname IN ('pg_constraint_oid_index', 'test1x')""" + save_relfilenodes = ( + f"INSERT INTO index_relfilenodes {fetch_toast_relfilenodes};" + + f"INSERT INTO index_relfilenodes {fetch_index_relfilenodes};" + ) + + # Query to compare a set of relfilenodes saved with the contents of + # pg_class. Note that this does not join using OIDs, as CONCURRENTLY + # would change them when reindexing. A filter is applied on the toast + # index names, even if this does not make a difference between the catalog + # and normal ones. The ordering based on the name is enough to ensure a + # fixed output, where the name of the parent table is included to provide + # more context. + compare_relfilenodes = r"""SELECT b.parent::regclass, + regexp_replace(b.indname::text, '(pg_toast.pg_toast_)\d+(_index)', '\1\2'), + CASE WHEN a.oid = b.indoid THEN 'OID is unchanged' + ELSE 'OID has changed' END, + CASE WHEN a.relfilenode = b.relfilenode THEN 'relfilenode is unchanged' + ELSE 'relfilenode has changed' END + FROM index_relfilenodes b + JOIN pg_class a ON b.indname::text = a.oid::regclass::text + ORDER BY b.parent::text, b.indname::text""" + + # Save the set of relfilenodes and compare them. + node.safe_sql(save_relfilenodes) + node.issues_sql_like( + ["reindexdb", "postgres"], + re.compile(r"statement: REINDEX DATABASE postgres;"), + "SQL REINDEX run", + ) + relnode_info = node.safe_sql(compare_relfilenodes) + assert relnode_info == ( + "pg_constraint|pg_constraint_oid_index|OID is unchanged|relfilenode is unchanged\n" + "pg_constraint|pg_toast.pg_toast__index|OID is unchanged|relfilenode is unchanged\n" + "test1|pg_toast.pg_toast__index|OID is unchanged|relfilenode has changed\n" + "test1|test1x|OID is unchanged|relfilenode has changed" + ), "relfilenode change after REINDEX DATABASE" + + # Re-save and run the second one. + node.safe_sql(f"TRUNCATE index_relfilenodes; {save_relfilenodes}") + node.issues_sql_like( + ["reindexdb", "--system", "postgres"], + re.compile(r"statement: REINDEX SYSTEM postgres;"), + "reindex system tables", + ) + relnode_info = node.safe_sql(compare_relfilenodes) + assert relnode_info == ( + "pg_constraint|pg_constraint_oid_index|OID is unchanged|relfilenode has changed\n" + "pg_constraint|pg_toast.pg_toast__index|OID is unchanged|relfilenode has changed\n" + "test1|pg_toast.pg_toast__index|OID is unchanged|relfilenode is unchanged\n" + "test1|test1x|OID is unchanged|relfilenode is unchanged" + ), "relfilenode change after REINDEX SYSTEM" + + node.issues_sql_like( + ["reindexdb", "--table", "test1", "postgres"], + re.compile(r"statement: REINDEX TABLE public\.test1;"), + "reindex specific table", + ) + node.issues_sql_like( + ["reindexdb", "--table", "test1", "--tablespace", tbspace_name, "postgres"], + re.compile( + rf"statement: REINDEX \(TABLESPACE {tbspace_name}\) TABLE public\.test1;" + ), + "reindex specific table on tablespace", + ) + node.issues_sql_like( + ["reindexdb", "--index", "test1x", "postgres"], + re.compile(r"statement: REINDEX INDEX public\.test1x;"), + "reindex specific index", + ) + node.issues_sql_like( + ["reindexdb", "--schema", "pg_catalog", "postgres"], + re.compile(r"statement: REINDEX SCHEMA pg_catalog;"), + "reindex specific schema", + ) + node.issues_sql_like( + ["reindexdb", "--verbose", "--table", "test1", "postgres"], + re.compile(r"statement: REINDEX \(VERBOSE\) TABLE public\.test1;"), + "reindex with verbose output", + ) + node.issues_sql_like( + [ + "reindexdb", + "--verbose", + "--table", + "test1", + "--tablespace", + tbspace_name, + "postgres", + ], + re.compile( + rf"statement: REINDEX \(VERBOSE, TABLESPACE {tbspace_name}\) TABLE public\.test1;" + ), + "reindex with verbose output and tablespace", + ) + + # Same with --concurrently. + # Save the state of the relations and compare them after the DATABASE + # rebuild. + node.safe_sql(f"TRUNCATE index_relfilenodes; {save_relfilenodes}") + node.issues_sql_like( + ["reindexdb", "--concurrently", "postgres"], + re.compile(r"statement: REINDEX DATABASE CONCURRENTLY postgres;"), + "SQL REINDEX CONCURRENTLY run", + ) + relnode_info = node.safe_sql(compare_relfilenodes) + assert relnode_info == ( + "pg_constraint|pg_constraint_oid_index|OID is unchanged|relfilenode is unchanged\n" + "pg_constraint|pg_toast.pg_toast__index|OID is unchanged|relfilenode is unchanged\n" + "test1|pg_toast.pg_toast__index|OID has changed|relfilenode has changed\n" + "test1|test1x|OID has changed|relfilenode has changed" + ), "OID change after REINDEX DATABASE CONCURRENTLY" + + node.issues_sql_like( + ["reindexdb", "--concurrently", "--table", "test1", "postgres"], + re.compile(r"statement: REINDEX TABLE CONCURRENTLY public\.test1;"), + "reindex specific table concurrently", + ) + node.issues_sql_like( + ["reindexdb", "--concurrently", "--index", "test1x", "postgres"], + re.compile(r"statement: REINDEX INDEX CONCURRENTLY public\.test1x;"), + "reindex specific index concurrently", + ) + node.issues_sql_like( + ["reindexdb", "--concurrently", "--schema", "public", "postgres"], + re.compile(r"statement: REINDEX SCHEMA CONCURRENTLY public;"), + "reindex specific schema concurrently", + ) + node.command_fails( + ["reindexdb", "--concurrently", "--system", "postgres"], + "reindex system tables concurrently", + ) + node.issues_sql_like( + ["reindexdb", "--concurrently", "--verbose", "--table", "test1", "postgres"], + re.compile(r"statement: REINDEX \(VERBOSE\) TABLE CONCURRENTLY public\.test1;"), + "reindex with verbose output concurrently", + ) + node.issues_sql_like( + [ + "reindexdb", + "--concurrently", + "--verbose", + "--table", + "test1", + "--tablespace", + tbspace_name, + "postgres", + ], + re.compile( + rf"statement: REINDEX \(VERBOSE, TABLESPACE {tbspace_name}\) TABLE CONCURRENTLY public\.test1;" + ), + "reindex concurrently with verbose output and tablespace", + ) + + # REINDEX TABLESPACE on toast indexes and tables fails. This is not + # part of the main regression test suite as these have unpredictable + # names, and CONCURRENTLY cannot be used in transaction blocks, preventing + # the use of TRY/CATCH blocks in a custom function to filter error + # messages. + node.pg_bin.command_checks_all( + ["reindexdb", "--table", toast_table, "--tablespace", tbspace_name, "postgres"], + 1, + [], + [re.compile(r"cannot move system relation")], + "reindex toast table with tablespace", + ) + node.pg_bin.command_checks_all( + [ + "reindexdb", + "--concurrently", + "--table", + toast_table, + "--tablespace", + tbspace_name, + "postgres", + ], + 1, + [], + [re.compile(r"cannot move system relation")], + "reindex toast table concurrently with tablespace", + ) + node.pg_bin.command_checks_all( + ["reindexdb", "--index", toast_index, "--tablespace", tbspace_name, "postgres"], + 1, + [], + [re.compile(r"cannot move system relation")], + "reindex toast index with tablespace", + ) + node.pg_bin.command_checks_all( + [ + "reindexdb", + "--concurrently", + "--index", + toast_index, + "--tablespace", + tbspace_name, + "postgres", + ], + 1, + [], + [re.compile(r"cannot move system relation")], + "reindex toast index concurrently with tablespace", + ) + + # connection strings + node.command_ok( + ["reindexdb", "--echo", "--table=pg_am", "dbname=template1"], + "reindexdb table with connection string", + ) + node.command_ok( + ["reindexdb", "--echo", "dbname=template1"], + "reindexdb database with connection string", + ) + node.command_ok( + ["reindexdb", "--echo", "--system", "dbname=template1"], + "reindexdb system with connection string", + ) + + # parallel processing + node.safe_sql( + """ + CREATE SCHEMA s1; + CREATE TABLE s1.t1(id integer); + CREATE INDEX ON s1.t1(id); + CREATE INDEX i1 ON s1.t1(id); + CREATE SCHEMA s2; + CREATE TABLE s2.t2(id integer); + CREATE INDEX ON s2.t2(id); + CREATE INDEX i2 ON s2.t2(id); + -- empty schema + CREATE SCHEMA s3; + """ + ) + + node.command_fails( + ["reindexdb", "--jobs", "2", "--system", "postgres"], + "parallel reindexdb cannot process system catalogs", + ) + node.command_ok( + [ + "reindexdb", + "--jobs", + "2", + "--index", + "s1.i1", + "--index", + "s2.i2", + "--index", + "s1.t1_id_idx", + "--index", + "s2.t2_id_idx", + "postgres", + ], + "parallel reindexdb for indices", + ) + # Note that the ordering of the commands is not stable, so the second + # command for s2.t2 is not checked after. + node.issues_sql_like( + ["reindexdb", "--jobs", "2", "--schema", "s1", "--schema", "s2", "postgres"], + re.compile(r"statement: REINDEX TABLE s1.t1;"), + "parallel reindexdb for schemas does a per-table REINDEX", + ) + node.command_ok( + ["reindexdb", "--jobs", "2", "--schema", "s3"], + "parallel reindexdb with empty schema", + ) + node.command_ok( + ["reindexdb", "--jobs", "2", "--concurrently", "--dbname", "postgres"], + "parallel reindexdb on database, concurrently", + ) + + # combinations of objects + node.issues_sql_like( + ["reindexdb", "--system", "--table", "test1", "postgres"], + re.compile(r"statement: REINDEX SYSTEM postgres;"), + "specify both --system and --table", + ) + node.issues_sql_like( + ["reindexdb", "--system", "--index", "test1x", "postgres"], + re.compile(r"statement: REINDEX INDEX public.test1x;"), + "specify both --system and --index", + ) + node.issues_sql_like( + ["reindexdb", "--system", "--schema", "pg_catalog", "postgres"], + re.compile(r"statement: REINDEX SCHEMA pg_catalog;"), + "specify both --system and --schema", + ) diff --git a/src/bin/scripts/pyt/test_091_reindexdb_all.py b/src/bin/scripts/pyt/test_091_reindexdb_all.py new file mode 100644 index 00000000000..32168780302 --- /dev/null +++ b/src/bin/scripts/pyt/test_091_reindexdb_all.py @@ -0,0 +1,66 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for reindexdb --all across multiple databases.""" + +import re + + +def test_reindexdb_all(create_pg, monkeypatch): + node = create_pg("main", start=False) + # issues_sql_like needs the statements logged. + node.append_conf("log_statement = 'all'\nlog_min_duration_statement = -1") + node.start() + + monkeypatch.setenv("PGOPTIONS", "--client-min-messages=WARNING") + + node.safe_sql("CREATE TABLE test1 (a int); CREATE INDEX test1x ON test1 (a);") + # Use a transient connection for template1 so it is not left connected + # when CREATE DATABASE (which copies template1) runs below. + sess = node.connect(dbname="template1") + try: + sess.query_safe("CREATE TABLE test1 (a int); CREATE INDEX test1x ON test1 (a);") + finally: + sess.close() + node.issues_sql_like( + ["reindexdb", "--all"], + re.compile(r"statement: REINDEX.*statement: REINDEX", re.S), + "reindex all databases", + ) + node.issues_sql_like( + ["reindexdb", "--all", "--system"], + re.compile(r"statement: REINDEX SYSTEM postgres", re.S), + "reindex system catalogs in all databases", + ) + node.issues_sql_like( + ["reindexdb", "--all", "--schema", "public"], + re.compile(r"statement: REINDEX SCHEMA public", re.S), + "reindex schema in all databases", + ) + node.issues_sql_like( + ["reindexdb", "--all", "--index", "test1x"], + re.compile(r"statement: REINDEX INDEX public\.test1x", re.S), + "reindex index in all databases", + ) + node.issues_sql_like( + ["reindexdb", "--all", "--table", "test1"], + re.compile(r"statement: REINDEX TABLE public\.test1", re.S), + "reindex table in all databases", + ) + + node.safe_sql("CREATE DATABASE regression_invalid") + node.safe_sql( + "UPDATE pg_database SET datconnlimit = -2 " + "WHERE datname = 'regression_invalid'" + ) + node.command_ok( + ["reindexdb", "--all"], + "invalid database not targeted by reindexdb --all", + ) + + # Doesn't quite belong here, but don't want to waste time by creating an + # invalid database in the non-all reindexdb test as well. + node.command_fails_like( + ["reindexdb", "--dbname", "regression_invalid"], + re.compile(r'FATAL: cannot connect to invalid database "regression_invalid"'), + "reindexdb cannot target invalid database", + ) diff --git a/src/bin/scripts/pyt/test_100_vacuumdb.py b/src/bin/scripts/pyt/test_100_vacuumdb.py new file mode 100644 index 00000000000..e29166e61af --- /dev/null +++ b/src/bin/scripts/pyt/test_100_vacuumdb.py @@ -0,0 +1,488 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for vacuumdb option handling, the SQL it issues, and error cases.""" + +import re + +import pytest + + +@pytest.fixture +def node(create_pg): + """A started server with statement logging enabled (for issues_sql_like).""" + n = create_pg("main", start=False) + n.append_conf("log_statement = 'all'\nlog_min_duration_statement = -1") + n.start() + return n + + +def test_program_help_version_options(pg_bin): + pg_bin.program_help_ok("vacuumdb") + pg_bin.program_version_ok("vacuumdb") + pg_bin.program_options_handling_ok("vacuumdb") + + +def test_vacuumdb_options(node): + node.issues_sql_like( + ["vacuumdb", "postgres"], + re.compile(r"statement: VACUUM.*;"), + "SQL VACUUM run", + ) + node.issues_sql_like( + ["vacuumdb", "-f", "postgres"], + re.compile(r"statement: VACUUM \(SKIP_DATABASE_STATS, FULL\).*;"), + "vacuumdb -f", + ) + node.issues_sql_like( + ["vacuumdb", "-F", "postgres"], + re.compile(r"statement: VACUUM \(SKIP_DATABASE_STATS, FREEZE\).*;"), + "vacuumdb -F", + ) + node.issues_sql_like( + ["vacuumdb", "-zj2", "postgres"], + re.compile(r"statement: VACUUM \(SKIP_DATABASE_STATS, ANALYZE\).*;"), + "vacuumdb -zj2", + ) + node.issues_sql_like( + ["vacuumdb", "-Z", "postgres"], + re.compile(r"statement: ANALYZE.*;"), + "vacuumdb -Z", + ) + node.issues_sql_like( + ["vacuumdb", "--disable-page-skipping", "postgres"], + re.compile( + r"statement: VACUUM \(DISABLE_PAGE_SKIPPING, SKIP_DATABASE_STATS\).*;" + ), + "vacuumdb --disable-page-skipping", + ) + node.issues_sql_like( + ["vacuumdb", "--skip-locked", "postgres"], + re.compile(r"statement: VACUUM \(SKIP_DATABASE_STATS, SKIP_LOCKED\).*;"), + "vacuumdb --skip-locked", + ) + node.issues_sql_like( + ["vacuumdb", "--skip-locked", "--analyze-only", "postgres"], + re.compile(r"statement: ANALYZE \(SKIP_LOCKED\).*;"), + "vacuumdb --skip-locked --analyze-only", + ) + node.command_fails( + ["vacuumdb", "--analyze-only", "--disable-page-skipping", "postgres"], + "--analyze-only and --disable-page-skipping specified together", + ) + node.issues_sql_like( + ["vacuumdb", "--no-index-cleanup", "postgres"], + re.compile( + r"statement: VACUUM \(INDEX_CLEANUP FALSE, SKIP_DATABASE_STATS\).*;" + ), + "vacuumdb --no-index-cleanup", + ) + node.command_fails( + ["vacuumdb", "--analyze-only", "--no-index-cleanup", "postgres"], + "--analyze-only and --no-index-cleanup specified together", + ) + node.issues_sql_like( + ["vacuumdb", "--no-truncate", "postgres"], + re.compile(r"statement: VACUUM \(TRUNCATE FALSE, SKIP_DATABASE_STATS\).*;"), + "vacuumdb --no-truncate", + ) + node.command_fails( + ["vacuumdb", "--analyze-only", "--no-truncate", "postgres"], + "--analyze-only and --no-truncate specified together", + ) + node.issues_sql_like( + ["vacuumdb", "--no-process-main", "postgres"], + re.compile(r"statement: VACUUM \(PROCESS_MAIN FALSE, SKIP_DATABASE_STATS\).*;"), + "vacuumdb --no-process-main", + ) + node.command_fails( + ["vacuumdb", "--analyze-only", "--no-process-main", "postgres"], + "--analyze-only and --no-process-main specified together", + ) + node.issues_sql_like( + ["vacuumdb", "--no-process-toast", "postgres"], + re.compile( + r"statement: VACUUM \(PROCESS_TOAST FALSE, SKIP_DATABASE_STATS\).*;" + ), + "vacuumdb --no-process-toast", + ) + node.command_fails( + ["vacuumdb", "--analyze-only", "--no-process-toast", "postgres"], + "--analyze-only and --no-process-toast specified together", + ) + node.issues_sql_like( + ["vacuumdb", "--parallel", "2", "postgres"], + re.compile(r"statement: VACUUM \(SKIP_DATABASE_STATS, PARALLEL 2\).*;"), + "vacuumdb -P 2", + ) + node.issues_sql_like( + ["vacuumdb", "--parallel", "0", "postgres"], + re.compile(r"statement: VACUUM \(SKIP_DATABASE_STATS, PARALLEL 0\).*;"), + "vacuumdb -P 0", + ) + node.command_ok( + ["vacuumdb", "-Z", "--table=pg_am", "dbname=template1"], + "vacuumdb with connection string", + ) + + node.command_fails( + ["vacuumdb", "-Zt", "pg_am;ABORT", "postgres"], + 'trailing command in "-t", without COLUMNS', + ) + + # Unwanted; better if it failed. + node.command_ok( + ["vacuumdb", "-Zt", "pg_am(amname);ABORT", "postgres"], + 'trailing command in "-t", with COLUMNS', + ) + + +def test_vacuumdb_tables_schemas(node): + node.safe_sql( + """ + CREATE TABLE "need""q(uot" (")x" text); + CREATE TABLE vactable (a int, b int); + CREATE VIEW vacview AS SELECT 1 as a; + + CREATE FUNCTION f0(int) RETURNS int LANGUAGE SQL AS 'SELECT $1 * $1'; + CREATE FUNCTION f1(int) RETURNS int LANGUAGE SQL AS 'SELECT f0($1)'; + CREATE TABLE funcidx (x int); + INSERT INTO funcidx VALUES (0),(1),(2),(3); + CREATE SCHEMA "Foo"; + CREATE TABLE "Foo".bar(id int); + CREATE SCHEMA "Bar"; + CREATE TABLE "Bar".baz(id int); +""" + ) + node.command_ok( + ["vacuumdb", "-Z", '--table="need""q(uot"(")x")', "postgres"], + "column list", + ) + + node.command_fails( + ["vacuumdb", "--analyze", "--table", "vactable(c)", "postgres"], + "incorrect column name with ANALYZE", + ) + node.command_fails( + ["vacuumdb", "--parallel", "-1", "postgres"], + "negative parallel degree", + ) + node.issues_sql_like( + ["vacuumdb", "--analyze", "--table", "vactable(a, b)", "postgres"], + re.compile( + r"statement: VACUUM \(SKIP_DATABASE_STATS, ANALYZE\) public.vactable\(a, b\);" + ), + "vacuumdb --analyze with complete column list", + ) + node.issues_sql_like( + ["vacuumdb", "--analyze-only", "--table", "vactable(b)", "postgres"], + re.compile(r"statement: ANALYZE public.vactable\(b\);"), + "vacuumdb --analyze-only with partial column list", + ) + node.pg_bin.command_checks_all( + ["vacuumdb", "--analyze", "--table", "vacview", "postgres"], + 0, + [re.compile(r'^.*vacuuming database "postgres"')], + [ + re.compile( + r"^WARNING.*cannot vacuum non-tables or special system tables", re.S + ) + ], + "vacuumdb with view", + ) + node.command_fails( + ["vacuumdb", "--table", "vactable", "--min-mxid-age", "0", "postgres"], + "vacuumdb --min-mxid-age with incorrect value", + ) + node.command_fails( + ["vacuumdb", "--table", "vactable", "--min-xid-age", "0", "postgres"], + "vacuumdb --min-xid-age with incorrect value", + ) + node.issues_sql_like( + ["vacuumdb", "--table", "vactable", "--min-mxid-age", "2147483000", "postgres"], + re.compile(r"GREATEST.*relminmxid.*2147483000"), + "vacuumdb --table --min-mxid-age", + ) + node.issues_sql_like( + ["vacuumdb", "--min-xid-age", "2147483001", "postgres"], + re.compile(r"GREATEST.*relfrozenxid.*2147483001"), + "vacuumdb --table --min-xid-age", + ) + node.issues_sql_like( + ["vacuumdb", "--schema", '"Foo"', "postgres"], + re.compile(r'VACUUM \(SKIP_DATABASE_STATS\) "Foo".bar'), + "vacuumdb --schema", + ) + node.issues_sql_unlike( + ["vacuumdb", "--schema", '"Foo"', "postgres", "--dry-run"], + re.compile(r'VACUUM \(SKIP_DATABASE_STATS\) "Foo".bar'), + "vacuumdb --dry-run", + ) + node.issues_sql_like( + ["vacuumdb", "--schema", '"Foo"', "--schema", '"Bar"', "postgres"], + re.compile( + r'VACUUM\ \(SKIP_DATABASE_STATS\)\ "Foo".bar' + r'.*VACUUM\ \(SKIP_DATABASE_STATS\)\ "Bar".baz', + re.S | re.X, + ), + "vacuumdb multiple --schema switches", + ) + node.issues_sql_like( + ["vacuumdb", "--exclude-schema", '"Foo"', "postgres"], + re.compile(r'^(?!.*VACUUM \(SKIP_DATABASE_STATS\) "Foo".bar).*$', re.S), + "vacuumdb --exclude-schema", + ) + node.issues_sql_like( + [ + "vacuumdb", + "--exclude-schema", + '"Foo"', + "--exclude-schema", + '"Bar"', + "postgres", + ], + re.compile( + r'^(?!.*VACUUM\ \(SKIP_DATABASE_STATS\)\ "Foo".bar' + r'| VACUUM\ \(SKIP_DATABASE_STATS\)\ "Bar".baz).*$', + re.S | re.X, + ), + "vacuumdb multiple --exclude-schema switches", + ) + node.command_fails_like( + [ + "vacuumdb", + "--exclude-schema", + "pg_catalog", + "--table", + "pg_class", + "postgres", + ], + re.compile( + r"cannot vacuum specific table\(s\) and exclude schema\(s\) at the same time" + ), + "cannot use options --exclude-schema and ---table at the same time", + ) + node.command_fails_like( + ["vacuumdb", "--schema", "pg_catalog", "--table", "pg_class", "postgres"], + re.compile( + r"cannot vacuum all tables in schema\(s\) and specific table\(s\) at the same time" + ), + "cannot use options --schema and ---table at the same time", + ) + node.command_fails_like( + ["vacuumdb", "--schema", "pg_catalog", "--exclude-schema", '"Foo"', "postgres"], + re.compile( + r"cannot vacuum all tables in schema\(s\) and exclude schema\(s\) at the same time" + ), + "cannot use options --schema and --exclude-schema at the same time", + ) + node.issues_sql_like( + ["vacuumdb", "--all", "--exclude-schema", "pg_catalog"], + re.compile(r"(?:(?!VACUUM \(SKIP_DATABASE_STATS\) pg_catalog.pg_class).)*"), + "vacuumdb --all --exclude-schema", + ) + node.issues_sql_like( + ["vacuumdb", "--all", "--schema", "pg_catalog"], + re.compile(r"VACUUM \(SKIP_DATABASE_STATS\) pg_catalog.pg_class"), + "vacuumdb --all ---schema", + ) + node.issues_sql_like( + ["vacuumdb", "--all", "--table", "pg_class"], + re.compile(r"VACUUM \(SKIP_DATABASE_STATS\) pg_catalog.pg_class"), + "vacuumdb --all --table", + ) + node.command_fails_like( + ["vacuumdb", "--all", "-d", "postgres"], + re.compile(r"cannot vacuum all databases and a specific one at the same time"), + "cannot use options --all and --dbname at the same time", + ) + node.command_fails_like( + ["vacuumdb", "--all", "postgres"], + re.compile(r"cannot vacuum all databases and a specific one at the same time"), + "cannot use option --all and a dbname as argument at the same time", + ) + + +def test_missing_stats_only(node): + node.safe_sql( + """ + CREATE TABLE regression_vacuumdb_test AS select generate_series(1, 10) a, generate_series(2, 11) b; + ALTER TABLE regression_vacuumdb_test ADD COLUMN c INT GENERATED ALWAYS AS (a + b); +""" + ) + node.issues_sql_unlike( + [ + "vacuumdb", + "--analyze-only", + "--dry-run", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only --dry-run", + ) + node.issues_sql_like( + [ + "vacuumdb", + "--analyze-only", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with missing stats", + ) + node.issues_sql_unlike( + [ + "vacuumdb", + "--analyze-only", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with no missing stats", + ) + + node.safe_sql( + "CREATE INDEX regression_vacuumdb_test_idx ON regression_vacuumdb_test (mod(a, 2));" + ) + node.issues_sql_like( + [ + "vacuumdb", + "--analyze-in-stages", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with missing index expression stats", + ) + node.issues_sql_unlike( + [ + "vacuumdb", + "--analyze-in-stages", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with no missing index expression stats", + ) + + node.safe_sql( + "CREATE STATISTICS regression_vacuumdb_test_stat ON a, b FROM regression_vacuumdb_test;" + ) + node.issues_sql_like( + [ + "vacuumdb", + "--analyze-only", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with missing extended stats", + ) + node.issues_sql_unlike( + [ + "vacuumdb", + "--analyze-only", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with no missing extended stats", + ) + + node.safe_sql( + "CREATE TABLE regression_vacuumdb_child (a INT) INHERITS (regression_vacuumdb_test);\n" + "INSERT INTO regression_vacuumdb_child VALUES (1, 2);\n" + "ANALYZE regression_vacuumdb_child;\n" + ) + node.issues_sql_like( + [ + "vacuumdb", + "--analyze-in-stages", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with missing inherited stats", + ) + node.issues_sql_unlike( + [ + "vacuumdb", + "--analyze-in-stages", + "--missing-stats-only", + "-t", + "regression_vacuumdb_test", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with no missing inherited stats", + ) + + node.safe_sql( + "CREATE TABLE regression_vacuumdb_parted (a INT) PARTITION BY LIST (a);\n" + "CREATE TABLE regression_vacuumdb_part1 PARTITION OF regression_vacuumdb_parted FOR VALUES IN (1);\n" + "INSERT INTO regression_vacuumdb_parted VALUES (1);\n" + "ANALYZE regression_vacuumdb_part1;\n" + ) + node.issues_sql_like( + [ + "vacuumdb", + "--analyze-only", + "--missing-stats-only", + "-t", + "regression_vacuumdb_parted", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with missing partition stats", + ) + node.issues_sql_unlike( + [ + "vacuumdb", + "--analyze-only", + "--missing-stats-only", + "-t", + "regression_vacuumdb_parted", + "postgres", + ], + re.compile(r"statement:\ ANALYZE", re.S | re.X), + "--missing-stats-only with no missing partition stats", + ) + + +def test_analyze_only_partitioned(node): + node.safe_sql( + "CREATE TABLE parent_table (a INT) PARTITION BY LIST (a);\n" + "CREATE TABLE child_table PARTITION OF parent_table FOR VALUES IN (1);\n" + "INSERT INTO parent_table VALUES (1);\n" + ) + node.issues_sql_like( + ["vacuumdb", "--analyze-only", "postgres"], + re.compile(r"statement: ANALYZE public.parent_table", re.S), + "--analyze-only updates statistics for partitioned tables", + ) + node.issues_sql_like( + ["vacuumdb", "--analyze-in-stages", "postgres"], + re.compile(r"statement: ANALYZE public.parent_table", re.S), + "--analyze-in-stages updates statistics for partitioned tables", + ) + node.issues_sql_unlike( + ["vacuumdb", "--analyze-only", "postgres"], + re.compile(r"statement:\ VACUUM", re.S | re.X), + "--analyze-only does not run vacuum", + ) diff --git a/src/bin/scripts/pyt/test_101_vacuumdb_all.py b/src/bin/scripts/pyt/test_101_vacuumdb_all.py new file mode 100644 index 00000000000..de5b2833ce3 --- /dev/null +++ b/src/bin/scripts/pyt/test_101_vacuumdb_all.py @@ -0,0 +1,42 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for vacuumdb --all across multiple databases.""" + +import re + +import pytest + + +@pytest.fixture +def node(create_pg): + n = create_pg("main", start=False) + n.append_conf("log_statement = 'all'\nlog_min_duration_statement = -1") + n.start() + return n + + +def test_vacuumdb_all(node): + node.issues_sql_like( + ["vacuumdb", "--all"], + re.compile(r"statement: VACUUM.*statement: VACUUM", re.S), + "vacuum all databases", + ) + + # CREATE DATABASE cannot run inside a transaction block with other + # statements, so issue it separately from the catalog UPDATE. + node.safe_sql("CREATE DATABASE regression_invalid;") + node.safe_sql( + "UPDATE pg_database SET datconnlimit = -2 WHERE datname = 'regression_invalid';" + ) + node.command_ok( + ["vacuumdb", "--all"], + "invalid database not targeted by vacuumdb -a", + ) + + # Doesn't quite belong here, but don't want to waste time by creating an + # invalid database in the non-all vacuumdb test as well. + node.command_fails_like( + ["vacuumdb", "--dbname", "regression_invalid"], + re.compile(r'FATAL: cannot connect to invalid database "regression_invalid"'), + "vacuumdb cannot target invalid database", + ) diff --git a/src/bin/scripts/pyt/test_102_vacuumdb_stages.py b/src/bin/scripts/pyt/test_102_vacuumdb_stages.py new file mode 100644 index 00000000000..2772abf3bb1 --- /dev/null +++ b/src/bin/scripts/pyt/test_102_vacuumdb_stages.py @@ -0,0 +1,53 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for vacuumdb --analyze-in-stages and the SQL it issues per stage.""" + +import re + +import pytest + + +@pytest.fixture +def node(create_pg): + n = create_pg("main", start=False) + n.append_conf("log_statement = 'all'\nlog_min_duration_statement = -1") + n.start() + return n + + +def test_analyze_in_stages(node): + node.issues_sql_like( + ["vacuumdb", "--analyze-in-stages", "postgres"], + re.compile( + r"statement:\ SET\ default_statistics_target=1;\ SET\ vacuum_cost_delay=0;" + r".*statement:\ ANALYZE" + r".*statement:\ SET\ default_statistics_target=10;\ RESET\ vacuum_cost_delay;" + r".*statement:\ ANALYZE" + r".*statement:\ RESET\ default_statistics_target;" + r".*statement:\ ANALYZE", + re.S | re.X, + ), + "analyze three times", + ) + + +def test_analyze_in_stages_all(node): + node.issues_sql_like( + ["vacuumdb", "--analyze-in-stages", "--all"], + re.compile( + r"statement:\ SET\ default_statistics_target=1;\ SET\ vacuum_cost_delay=0;" + r".*statement:\ ANALYZE" + r".*statement:\ SET\ default_statistics_target=1;\ SET\ vacuum_cost_delay=0;" + r".*statement:\ ANALYZE" + r".*statement:\ SET\ default_statistics_target=10;\ RESET\ vacuum_cost_delay;" + r".*statement:\ ANALYZE" + r".*statement:\ SET\ default_statistics_target=10;\ RESET\ vacuum_cost_delay;" + r".*statement:\ ANALYZE" + r".*statement:\ RESET\ default_statistics_target;" + r".*statement:\ ANALYZE" + r".*statement:\ RESET\ default_statistics_target;" + r".*statement:\ ANALYZE", + re.S | re.X, + ), + "analyze more than one database in stages", + ) diff --git a/src/bin/scripts/pyt/test_200_connstr.py b/src/bin/scripts/pyt/test_200_connstr.py new file mode 100644 index 00000000000..77eb0e04f56 --- /dev/null +++ b/src/bin/scripts/pyt/test_200_connstr.py @@ -0,0 +1,49 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests to check connection string handling in the utility programs.""" + + +def _generate_ascii_string(from_char, to_char): + """Build a string from the given inclusive range of byte values, mapping + each byte to the same Unicode code point (Latin-1 semantics).""" + return "".join(chr(i) for i in range(from_char, to_char + 1)) + + +def test_connstr_unusual_db_names(create_pg): + # We're going to use byte sequences that aren't valid UTF-8 strings. Use + # LATIN1, which accepts any byte and has a conversion from each byte to + # UTF-8. These are applied to the client programs via extra_env. + extra_env = {"LC_ALL": "C", "PGCLIENTENCODING": "LATIN1"} + + # Create database names covering the range of LATIN1 characters and + # run the utilities' --all options over them. + dbname1 = _generate_ascii_string(1, 63) # contains '=' + dbname2 = _generate_ascii_string(67, 129) # skip 64-66 to keep length to 62 + dbname3 = _generate_ascii_string(130, 192) + dbname4 = _generate_ascii_string(193, 255) + + node = create_pg( + "main", start=False, initdb_extra=["--locale=C", "--encoding=LATIN1"] + ) + node.start() + + pg_bin = node.pg_bin + for dbname in (dbname1, dbname2, dbname3, dbname4, "CamelCase"): + # run_log: run and log, ignoring the exit status. + pg_bin.result(["createdb", dbname], extra_env=extra_env) + + pg_bin.command_ok( + ["vacuumdb", "--all", "--echo", "--analyze-only"], + "vacuumdb --all with unusual database names", + extra_env=extra_env, + ) + pg_bin.command_ok( + ["reindexdb", "--all", "--echo"], + "reindexdb --all with unusual database names", + extra_env=extra_env, + ) + pg_bin.command_ok( + ["clusterdb", "--all", "--echo", "--verbose"], + "clusterdb --all with unusual database names", + extra_env=extra_env, + ) From 015f9fe13e86a349c99be4ee8ca32c10621e2ffa Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:07 -0400 Subject: [PATCH 07/18] python tests: pytest suites for the backup and verify tools pg_basebackup, pg_rewind, pg_verifybackup, pg_combinebackup, pg_checksums, pg_amcheck and amcheck. --- contrib/amcheck/meson.build | 10 + contrib/amcheck/pyt/test_001_verify_heapam.py | 216 +++ contrib/amcheck/pyt/test_002_cic.py | 94 ++ contrib/amcheck/pyt/test_003_cic_2pc.py | 235 ++++ .../pyt/test_004_verify_nbtree_unique.py | 246 ++++ contrib/amcheck/pyt/test_005_pitr.py | 91 ++ contrib/amcheck/pyt/test_006_verify_gin.py | 287 ++++ src/bin/pg_amcheck/meson.build | 9 + src/bin/pg_amcheck/pyt/test_001_basic.py | 10 + src/bin/pg_amcheck/pyt/test_002_nonesuch.py | 563 ++++++++ src/bin/pg_amcheck/pyt/test_003_check.py | 633 +++++++++ .../pg_amcheck/pyt/test_004_verify_heapam.py | 867 ++++++++++++ .../pg_amcheck/pyt/test_005_opclass_damage.py | 123 ++ src/bin/pg_basebackup/meson.build | 13 + .../pyt/test_010_pg_basebackup.py | 1243 +++++++++++++++++ .../pyt/test_011_in_place_tablespace.py | 45 + .../pyt/test_020_pg_receivewal.py | 484 +++++++ .../pyt/test_030_pg_recvlogical.py | 358 +++++ .../pyt/test_040_pg_createsubscriber.py | 865 ++++++++++++ src/bin/pg_checksums/meson.build | 6 + src/bin/pg_checksums/pyt/test_001_basic.py | 10 + src/bin/pg_checksums/pyt/test_002_actions.py | 279 ++++ src/bin/pg_combinebackup/meson.build | 15 + .../pg_combinebackup/pyt/test_001_basic.py | 25 + .../pyt/test_002_compare_backups.py | 337 +++++ .../pg_combinebackup/pyt/test_003_timeline.py | 139 ++ .../pg_combinebackup/pyt/test_004_manifest.py | 103 ++ .../pyt/test_005_integrity.py | 279 ++++ .../pyt/test_006_db_file_copy.py | 100 ++ .../pyt/test_007_wal_level_minimal.py | 77 + .../pg_combinebackup/pyt/test_008_promote.py | 122 ++ .../pyt/test_009_no_full_file.py | 84 ++ .../pg_combinebackup/pyt/test_010_hardlink.py | 168 +++ .../pyt/test_011_ib_truncation.py | 160 +++ src/bin/pg_rewind/meson.build | 15 + src/bin/pg_rewind/pyt/conftest.py | 363 +++++ src/bin/pg_rewind/pyt/test_001_basic.py | 249 ++++ src/bin/pg_rewind/pyt/test_002_databases.py | 106 ++ src/bin/pg_rewind/pyt/test_003_extrafiles.py | 130 ++ .../pg_rewind/pyt/test_004_pg_xlog_symlink.py | 76 + .../pg_rewind/pyt/test_005_same_timeline.py | 13 + src/bin/pg_rewind/pyt/test_006_options.py | 54 + .../pg_rewind/pyt/test_007_standby_source.py | 169 +++ .../pyt/test_008_min_recovery_point.py | 194 +++ .../pg_rewind/pyt/test_009_growing_files.py | 91 ++ .../pyt/test_010_keep_recycled_wals.py | 63 + src/bin/pg_rewind/pyt/test_011_wal_copy.py | 129 ++ src/bin/pg_verifybackup/meson.build | 18 + src/bin/pg_verifybackup/pyt/test_001_basic.py | 46 + .../pg_verifybackup/pyt/test_002_algorithm.py | 84 ++ .../pyt/test_003_corruption.py | 392 ++++++ .../pg_verifybackup/pyt/test_004_options.py | 160 +++ .../pyt/test_005_bad_manifest.py | 275 ++++ .../pg_verifybackup/pyt/test_006_encoding.py | 40 + src/bin/pg_verifybackup/pyt/test_007_wal.py | 121 ++ src/bin/pg_verifybackup/pyt/test_008_untar.py | 156 +++ .../pg_verifybackup/pyt/test_009_extract.py | 111 ++ .../pyt/test_010_client_untar.py | 166 +++ 58 files changed, 11517 insertions(+) create mode 100644 contrib/amcheck/pyt/test_001_verify_heapam.py create mode 100644 contrib/amcheck/pyt/test_002_cic.py create mode 100644 contrib/amcheck/pyt/test_003_cic_2pc.py create mode 100644 contrib/amcheck/pyt/test_004_verify_nbtree_unique.py create mode 100644 contrib/amcheck/pyt/test_005_pitr.py create mode 100644 contrib/amcheck/pyt/test_006_verify_gin.py create mode 100644 src/bin/pg_amcheck/pyt/test_001_basic.py create mode 100644 src/bin/pg_amcheck/pyt/test_002_nonesuch.py create mode 100644 src/bin/pg_amcheck/pyt/test_003_check.py create mode 100644 src/bin/pg_amcheck/pyt/test_004_verify_heapam.py create mode 100644 src/bin/pg_amcheck/pyt/test_005_opclass_damage.py create mode 100644 src/bin/pg_basebackup/pyt/test_010_pg_basebackup.py create mode 100644 src/bin/pg_basebackup/pyt/test_011_in_place_tablespace.py create mode 100644 src/bin/pg_basebackup/pyt/test_020_pg_receivewal.py create mode 100644 src/bin/pg_basebackup/pyt/test_030_pg_recvlogical.py create mode 100644 src/bin/pg_basebackup/pyt/test_040_pg_createsubscriber.py create mode 100644 src/bin/pg_checksums/pyt/test_001_basic.py create mode 100644 src/bin/pg_checksums/pyt/test_002_actions.py create mode 100644 src/bin/pg_combinebackup/pyt/test_001_basic.py create mode 100644 src/bin/pg_combinebackup/pyt/test_002_compare_backups.py create mode 100644 src/bin/pg_combinebackup/pyt/test_003_timeline.py create mode 100644 src/bin/pg_combinebackup/pyt/test_004_manifest.py create mode 100644 src/bin/pg_combinebackup/pyt/test_005_integrity.py create mode 100644 src/bin/pg_combinebackup/pyt/test_006_db_file_copy.py create mode 100644 src/bin/pg_combinebackup/pyt/test_007_wal_level_minimal.py create mode 100644 src/bin/pg_combinebackup/pyt/test_008_promote.py create mode 100644 src/bin/pg_combinebackup/pyt/test_009_no_full_file.py create mode 100644 src/bin/pg_combinebackup/pyt/test_010_hardlink.py create mode 100644 src/bin/pg_combinebackup/pyt/test_011_ib_truncation.py create mode 100644 src/bin/pg_rewind/pyt/conftest.py create mode 100644 src/bin/pg_rewind/pyt/test_001_basic.py create mode 100644 src/bin/pg_rewind/pyt/test_002_databases.py create mode 100644 src/bin/pg_rewind/pyt/test_003_extrafiles.py create mode 100644 src/bin/pg_rewind/pyt/test_004_pg_xlog_symlink.py create mode 100644 src/bin/pg_rewind/pyt/test_005_same_timeline.py create mode 100644 src/bin/pg_rewind/pyt/test_006_options.py create mode 100644 src/bin/pg_rewind/pyt/test_007_standby_source.py create mode 100644 src/bin/pg_rewind/pyt/test_008_min_recovery_point.py create mode 100644 src/bin/pg_rewind/pyt/test_009_growing_files.py create mode 100644 src/bin/pg_rewind/pyt/test_010_keep_recycled_wals.py create mode 100644 src/bin/pg_rewind/pyt/test_011_wal_copy.py create mode 100644 src/bin/pg_verifybackup/pyt/test_001_basic.py create mode 100644 src/bin/pg_verifybackup/pyt/test_002_algorithm.py create mode 100644 src/bin/pg_verifybackup/pyt/test_003_corruption.py create mode 100644 src/bin/pg_verifybackup/pyt/test_004_options.py create mode 100644 src/bin/pg_verifybackup/pyt/test_005_bad_manifest.py create mode 100644 src/bin/pg_verifybackup/pyt/test_006_encoding.py create mode 100644 src/bin/pg_verifybackup/pyt/test_007_wal.py create mode 100644 src/bin/pg_verifybackup/pyt/test_008_untar.py create mode 100644 src/bin/pg_verifybackup/pyt/test_009_extract.py create mode 100644 src/bin/pg_verifybackup/pyt/test_010_client_untar.py diff --git a/contrib/amcheck/meson.build b/contrib/amcheck/meson.build index d5137ef691d..9ff9a49067e 100644 --- a/contrib/amcheck/meson.build +++ b/contrib/amcheck/meson.build @@ -52,4 +52,14 @@ tests += { 't/006_verify_gin.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_verify_heapam.py', + 'pyt/test_002_cic.py', + 'pyt/test_003_cic_2pc.py', + 'pyt/test_004_verify_nbtree_unique.py', + 'pyt/test_005_pitr.py', + 'pyt/test_006_verify_gin.py', + ], + }, } diff --git a/contrib/amcheck/pyt/test_001_verify_heapam.py b/contrib/amcheck/pyt/test_001_verify_heapam.py new file mode 100644 index 00000000000..de8a1734381 --- /dev/null +++ b/contrib/amcheck/pyt/test_001_verify_heapam.py @@ -0,0 +1,216 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test amcheck's verify_heapam against deliberately corrupted heap pages.""" + +import os +import re +import struct + + +# Regexes matching the various line-pointer-corruption checks in +# verify_heapam.c, hit by corrupt_first_page on both little-endian and +# big-endian architectures. +_HEAP_CORRUPTION_RES = [ + re.compile( + r"line pointer redirection to item at offset \d+ " + r"precedes minimum offset \d+" + ), + re.compile( + r"line pointer redirection to item at offset \d+ exceeds maximum offset \d+" + ), + re.compile(r"line pointer to page offset \d+ is not maximally aligned"), + re.compile( + r"line pointer length \d+ is less than the minimum tuple header size \d+" + ), + re.compile( + r"line pointer to page offset \d+ with length \d+ " + r"ends beyond maximum page offset \d+" + ), +] + + +def relation_filepath(node, session, relname): + """Return the filesystem path for the named relation.""" + pgdata = node.data_dir + rel = session.query_oneval(f"SELECT pg_relation_filepath('{relname}')") + assert rel is not None, f"path not found for relation {relname}" + return os.path.join(pgdata, rel) + + +def fresh_test_table(session, relname): + """(Re)create and populate a test table of the given name.""" + return session.do( + f""" + DROP TABLE IF EXISTS {relname} CASCADE; + CREATE TABLE {relname} (a integer, b text); + ALTER TABLE {relname} SET (autovacuum_enabled=false); + ALTER TABLE {relname} ALTER b SET STORAGE external; + INSERT INTO {relname} (a, b) + (SELECT gs, repeat('b',gs*10) FROM generate_series(1,1000) gs); + BEGIN; + SAVEPOINT s1; + SELECT 1 FROM {relname} WHERE a = 42 FOR UPDATE; + UPDATE {relname} SET b = b WHERE a = 42; + RELEASE s1; + SAVEPOINT s1; + SELECT 1 FROM {relname} WHERE a = 42 FOR UPDATE; + UPDATE {relname} SET b = b WHERE a = 42; + COMMIT; + """ + ) + + +def fresh_test_sequence(session, seqname): + """Create a test sequence of the given name.""" + return session.do( + f""" + DROP SEQUENCE IF EXISTS {seqname} CASCADE; + CREATE SEQUENCE {seqname} + INCREMENT BY 13 + MINVALUE 17 + START WITH 23; + SELECT nextval('{seqname}'); + SELECT setval('{seqname}', currval('{seqname}') + nextval('{seqname}')); + """ + ) + + +def advance_test_sequence(session, seqname): + """Call SQL functions to increment the sequence.""" + return session.query_oneval(f"SELECT nextval('{seqname}')") + + +def set_test_sequence(session, seqname): + """Call SQL functions to set the sequence.""" + return session.query_oneval(f"SELECT setval('{seqname}', 102)") + + +def reset_test_sequence(session, seqname): + """Call SQL functions to reset the sequence.""" + return session.do(f"ALTER SEQUENCE {seqname} RESTART WITH 51") + + +def corrupt_first_page(node, session, relname): + """Stop the node, corrupt the first page of the relation, restart it.""" + relpath = relation_filepath(node, session, relname) + + session.close() + node.stop() + + with open(relpath, "r+b") as fh: + # Corrupt some line pointers. The values are chosen to hit the + # various line-pointer-corruption checks in verify_heapam.c + # on both little-endian and big-endian architectures. + fh.seek(32) + fh.write( + struct.pack( + "<6L", + 0xAAA15550, + 0xAAA0D550, + 0x00010000, + 0x00008000, + 0x0000800F, + 0x001E8000, + ) + ) + + node.start() + session.reconnect() + + +def detects_corruption(session, function, *res): + """Assert that verify_heapam output matches each corruption regex.""" + result = session.query_tuples(f"SELECT * FROM {function}") + for regex in res: + assert regex.search(result), f"expected /{regex.pattern}/ in:\n{result}" + + +def detects_heap_corruption(session, function): + """Assert verify_heapam reports the expected heap corruption messages.""" + detects_corruption(session, function, *_HEAP_CORRUPTION_RES) + + +def detects_no_corruption(session, function): + """Assert verify_heapam reports no corruption (empty output).""" + result = session.query_tuples(f"SELECT * FROM {function}") + assert result == "", f"expected no corruption, got:\n{result}" + + +def check_all_options_uncorrupted(session, relname): + """Check various options are stable and report no corruption. + + The relname *must* be an uncorrupted table, or this will fail. + """ + for stop in ("true", "false"): + for check_toast in ("true", "false"): + for skip in ("'none'", "'all-frozen'", "'all-visible'"): + for startblock in ("NULL", "0"): + for endblock in ("NULL", "0"): + opts = ( + f"on_error_stop := {stop}, " + f"check_toast := {check_toast}, " + f"skip := {skip}, " + f"startblock := {startblock}, " + f"endblock := {endblock}" + ) + detects_no_corruption( + session, f"verify_heapam('{relname}', {opts})" + ) + + +def test_001_verify_heapam(create_pg): + # + # Test set-up + # + node = create_pg("test", start=False, initdb_extra=["--no-data-checksums"]) + node.append_conf("autovacuum=off") + node.start() + session = node.session() + + session.do("CREATE EXTENSION amcheck") + + # + # Check a table with data loaded but no corruption, freezing, etc. + # + fresh_test_table(session, "test") + check_all_options_uncorrupted(session, "test") + + # + # Check a corrupt table + # + fresh_test_table(session, "test") + corrupt_first_page(node, session, "test") + detects_heap_corruption(session, "verify_heapam('test')") + detects_heap_corruption(session, "verify_heapam('test', skip := 'all-visible')") + detects_heap_corruption(session, "verify_heapam('test', skip := 'all-frozen')") + detects_heap_corruption(session, "verify_heapam('test', check_toast := false)") + detects_heap_corruption( + session, "verify_heapam('test', startblock := 0, endblock := 0)" + ) + + # + # Check a corrupt table with all-frozen data + # + fresh_test_table(session, "test") + session.do("VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) test") + detects_no_corruption(session, "verify_heapam('test')") + corrupt_first_page(node, session, "test") + detects_heap_corruption(session, "verify_heapam('test')") + detects_no_corruption(session, "verify_heapam('test', skip := 'all-frozen')") + + # + # Check a sequence with no corruption. The current implementation of + # sequences doesn't require its own test setup, since sequences are really + # just heap tables under-the-hood. To guard against future implementation + # changes made without remembering to update verify_heapam, we create and + # exercise a sequence, checking along the way that it passes corruption + # checks. + # + fresh_test_sequence(session, "test_seq") + check_all_options_uncorrupted(session, "test_seq") + advance_test_sequence(session, "test_seq") + check_all_options_uncorrupted(session, "test_seq") + set_test_sequence(session, "test_seq") + check_all_options_uncorrupted(session, "test_seq") + reset_test_sequence(session, "test_seq") + check_all_options_uncorrupted(session, "test_seq") diff --git a/contrib/amcheck/pyt/test_002_cic.py b/contrib/amcheck/pyt/test_002_cic.py new file mode 100644 index 00000000000..b09557b1e3f --- /dev/null +++ b/contrib/amcheck/pyt/test_002_cic.py @@ -0,0 +1,94 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test CREATE INDEX CONCURRENTLY with concurrent modifications.""" + +import os + +from pypg.util import TIMEOUT_DEFAULT + + +def test_002_cic(create_pg, tmp_path): + # + # Test set-up + # + node = create_pg("CIC_test", start=False) + node.append_conf("lock_timeout = " + str(1000 * TIMEOUT_DEFAULT) + "\n") + node.start() + node.safe_sql("CREATE EXTENSION amcheck") + node.safe_sql("CREATE TABLE tbl(i int, j jsonb)") + node.safe_sql("CREATE INDEX idx ON tbl(i)") + node.safe_sql("CREATE INDEX ginidx ON tbl USING gin(j)") + + # + # Stress CIC with pgbench. + # + # pgbench might try to launch more than one instance of the CIC + # transaction concurrently. That would deadlock, so use an advisory + # lock to ensure only one CIC runs at a time. + # + scripts = { + "002_pgbench_concurrent_transaction": ( + "BEGIN;\n" + 'INSERT INTO tbl VALUES(0, \'{"a":[["b",{"x":1}],' + '["b",{"x":2}]],"c":3}\');\n' + "COMMIT;\n" + ), + "002_pgbench_concurrent_transaction_savepoints": ( + "BEGIN;\n" + "SAVEPOINT s1;\n" + "INSERT INTO tbl VALUES(0, '[[14,2,3]]');\n" + "COMMIT;\n" + ), + "002_pgbench_concurrent_cic": ( + "SELECT pg_try_advisory_lock(42)::integer AS gotlock \\gset\n" + "\\if :gotlock\n" + "\tDROP INDEX CONCURRENTLY idx;\n" + "\tCREATE INDEX CONCURRENTLY idx ON tbl(i);\n" + "\tDROP INDEX CONCURRENTLY ginidx;\n" + "\tCREATE INDEX CONCURRENTLY ginidx ON tbl USING gin(j);\n" + "\tSELECT bt_index_check('idx',true);\n" + "\tSELECT gin_index_check('ginidx');\n" + "\tSELECT pg_advisory_unlock(42);\n" + "\\endif\n" + ), + } + # Files are ordered for determinism, matching _pgbench_make_files. + file_opts = [] + for fn in sorted(scripts): + path = os.path.join(str(tmp_path), fn) + with open(path, "w", encoding="utf-8") as fh: + fh.write(scripts[fn]) + file_opts += ["--file", path] + + node.command_checks_all( + ["pgbench", "--no-vacuum", "--client=5", "--transactions=100", *file_opts], + 0, + [r"actually processed"], + [r"^$"], + "concurrent INSERTs and CIC", + ) + + # Test bt_index_parent_check() with indexes created with + # CREATE INDEX CONCURRENTLY. + node.safe_sql("CREATE TABLE quebec(i int primary key)") + # Insert two rows into index + node.safe_sql("INSERT INTO quebec SELECT i FROM generate_series(1, 2) s(i);") + + # start background transaction + in_progress_h = node.connect("postgres") + in_progress_h.do("BEGIN", "SELECT pg_current_xact_id();") + + # delete one row from table, while background transaction is in progress + node.safe_sql("DELETE FROM quebec WHERE i = 1;") + # create index concurrently, which will skip the deleted row + node.safe_sql("CREATE INDEX CONCURRENTLY oscar ON quebec(i);") + + # check index using bt_index_parent_check + result = node.safe_sql( + "SELECT bt_index_parent_check('oscar', heapallindexed => true)" + ) + assert result == "", "bt_index_parent_check for CIC after removed row" + + in_progress_h.close() + + node.stop() diff --git a/contrib/amcheck/pyt/test_003_cic_2pc.py b/contrib/amcheck/pyt/test_003_cic_2pc.py new file mode 100644 index 00000000000..da7f0bdee46 --- /dev/null +++ b/contrib/amcheck/pyt/test_003_cic_2pc.py @@ -0,0 +1,235 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test CREATE INDEX CONCURRENTLY with concurrent prepared-xact modifications.""" + +import os + +from libpq import connect + + +def _pgbench(node, opts, expected_ret, stdout_res, stderr_res, msg, scripts, tmp_path): + """Run pgbench against this node, checking its exit code and output. + + Writes each named script in *scripts* (a dict of name -> SQL body) into a + temp file and runs pgbench with a -f for each, plus the split *opts*, + against this node's postgres database, then checks the exit code and the + stdout/stderr regex lists. + """ + args = [] + for name, content in scripts.items(): + fnam = os.path.join(str(tmp_path), name) + with open(fnam, "w", encoding="utf-8") as fh: + fh.write(content) + args += ["-f", fnam] + + cmd = ["pgbench"] + opts.split() + args + node.pg_bin.command_checks_all(cmd, expected_ret, stdout_res, stderr_res, msg) + + +def test_003_cic_2pc(create_pg, tmp_path): + # + # Test set-up + # + node = create_pg("CIC_2PC_test", start=False) + node.append_conf("max_prepared_transactions = 10") + # lock_timeout = 1000 * timeout_default (timeout_default defaults to 180s). + timeout_default = int(os.environ.get("PG_TEST_TIMEOUT_DEFAULT", "180")) + node.append_conf(f"lock_timeout = {1000 * timeout_default}") + node.start() + node.safe_sql("CREATE EXTENSION amcheck") + node.safe_sql("CREATE TABLE tbl(i int, j jsonb)") + + # + # Run 3 overlapping 2PC transactions with CIC + # + # We have two concurrent background sessions: main_h for INSERTs and cic_h + # for CIC. Also, we use a non-background (safe_sql) session for some + # COMMIT PREPARED statements. + # + + main_h = connect(node=node) + + main_h.do_async( + """ +BEGIN; +INSERT INTO tbl VALUES(0, '[[14,2,3]]'); +""" + ) + + cic_h = connect(node=node) + + cic_h.setnonblocking(1) + + cic_h.enterPipelineMode() + + cic_h.do_pipeline( + """ +CREATE INDEX CONCURRENTLY idx ON tbl(i) +""" + ) + + cic_h.pipelineSync() + + cic_h.do_pipeline( + """ +CREATE INDEX CONCURRENTLY ginidx ON tbl USING gin(j) +""" + ) + + cic_h.pipelineSync() + + main_h.wait_for_completion() + main_h.do_async( + """ +PREPARE TRANSACTION 'a'; +""" + ) + + main_h.wait_for_completion() + main_h.do_async( + """ +BEGIN; +INSERT INTO tbl VALUES(0, '[[14,2,3]]'); +""" + ) + + node.safe_sql("COMMIT PREPARED 'a';") + + main_h.wait_for_completion() + main_h.do_async( + """ +PREPARE TRANSACTION 'b'; +BEGIN; +INSERT INTO tbl VALUES(0, '"mary had a little lamb"'); +""" + ) + + # Our in-process safe_sql reuses a live connection, so without an explicit + # wait it could fire COMMIT PREPARED before the already-flushed async PREPARE + # lands. Wait for 'b' to become a visible prepared xact before committing it. + assert node.poll_query_until( + "SELECT count(*) = 1 FROM pg_prepared_xacts WHERE gid = 'b'" + ), "prepared transaction 'b' did not appear" + node.safe_sql("COMMIT PREPARED 'b';") + + main_h.wait_for_completion() + main_h.do("PREPARE TRANSACTION 'c';", "COMMIT PREPARED 'c';") + + main_h.close() + + # called twice out of an abundance of caution about pipeline mode + cic_h.wait_for_completion() + cic_h.wait_for_completion() + cic_h.close() + + result = node.safe_sql("SELECT bt_index_check('idx',true)") + assert result == "", "bt_index_check after overlapping 2PC" + + result = node.safe_sql("SELECT gin_index_check('ginidx')") + assert result == "", "gin_index_check after overlapping 2PC" + + # + # Server restart shall not change whether prepared xact blocks CIC + # + + node.safe_sql( + """ +BEGIN; +INSERT INTO tbl VALUES(0, '{"a":[["b",{"x":1}],["b",{"x":2}]],"c":3}'); +PREPARE TRANSACTION 'spans_restart'; +BEGIN; +CREATE TABLE unused (); +PREPARE TRANSACTION 'persists_forever'; +""" + ) + node.restart() + + reindex_h = connect(node=node) + reindex_h.do_async( + """ +DROP INDEX CONCURRENTLY idx; +CREATE INDEX CONCURRENTLY idx ON tbl(i); +DROP INDEX CONCURRENTLY ginidx; +CREATE INDEX CONCURRENTLY ginidx ON tbl USING gin(j); +""" + ) + + node.safe_sql("COMMIT PREPARED 'spans_restart'") + reindex_h.wait_for_completion() + reindex_h.close() + result = node.safe_sql("SELECT bt_index_check('idx',true)") + assert result == "", "bt_index_check after 2PC and restart" + result = node.safe_sql("SELECT gin_index_check('ginidx')") + assert result == "", "gin_index_check after 2PC and restart" + + # + # Stress CIC+2PC with pgbench + # + # pgbench might try to launch more than one instance of the CIC + # transaction concurrently. That would deadlock, so use an advisory + # lock to ensure only one CIC runs at a time. + + # Fix broken index first + node.safe_sql("REINDEX TABLE tbl;") + + # Run pgbench. + _pgbench( + node, + "--no-vacuum --client=5 --transactions=100", + 0, + [r"actually processed"], + [r"^$"], + "concurrent INSERTs w/ 2PC and CIC", + { + "003_pgbench_concurrent_2pc": """ + BEGIN; + INSERT INTO tbl VALUES(0,'null'); + PREPARE TRANSACTION 'c:client_id'; + COMMIT PREPARED 'c:client_id'; + """, + "003_pgbench_concurrent_2pc_savepoint": """ + BEGIN; + SAVEPOINT s1; + INSERT INTO tbl VALUES(0,'[false, "jnvaba", -76, 7, {"_": [1]}, 9]'); + PREPARE TRANSACTION 'c:client_id'; + COMMIT PREPARED 'c:client_id'; + """, + "003_pgbench_concurrent_cic": """ + SELECT pg_try_advisory_lock(42)::integer AS gotlock \\gset + \\if :gotlock + DROP INDEX CONCURRENTLY idx; + CREATE INDEX CONCURRENTLY idx ON tbl(i); + SELECT bt_index_check('idx',true); + SELECT pg_advisory_unlock(42); + \\endif + """, + "004_pgbench_concurrent_ric": """ + SELECT pg_try_advisory_lock(42)::integer AS gotlock \\gset + \\if :gotlock + REINDEX INDEX CONCURRENTLY idx; + SELECT bt_index_check('idx',true); + SELECT pg_advisory_unlock(42); + \\endif + """, + "005_pgbench_concurrent_cic": """ + SELECT pg_try_advisory_lock(42)::integer AS gotginlock \\gset + \\if :gotginlock + DROP INDEX CONCURRENTLY ginidx; + CREATE INDEX CONCURRENTLY ginidx ON tbl USING gin(j); + SELECT gin_index_check('ginidx'); + SELECT pg_advisory_unlock(42); + \\endif + """, + "006_pgbench_concurrent_ric": """ + SELECT pg_try_advisory_lock(42)::integer AS gotginlock \\gset + \\if :gotginlock + REINDEX INDEX CONCURRENTLY ginidx; + SELECT gin_index_check('ginidx'); + SELECT pg_advisory_unlock(42); + \\endif + """, + }, + tmp_path, + ) + + node.stop() diff --git a/contrib/amcheck/pyt/test_004_verify_nbtree_unique.py b/contrib/amcheck/pyt/test_004_verify_nbtree_unique.py new file mode 100644 index 00000000000..fde592a991d --- /dev/null +++ b/contrib/amcheck/pyt/test_004_verify_nbtree_unique.py @@ -0,0 +1,246 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Check btree validation behavior in the presence of breaking sort order changes.""" + +import re + + +def _check(node, index): + """Run bt_index_check on *index* in a fresh backend, returning ResultData. + + Using a fresh connection (a new backend) ensures amcheck sees catalog + changes (e.g. updates to pg_amproc) made by earlier statements. + """ + sess = node.connect() + try: + return sess.query(f"SELECT bt_index_check('{index}', true, true);") + finally: + sess.close() + + +def test_004_verify_nbtree_unique(create_pg): + node = create_pg("test", start=False) + node.append_conf("autovacuum = off") + node.start() + + # Create a custom operator class and an index which uses it. + node.safe_sql( + """ + CREATE EXTENSION amcheck; + + CREATE FUNCTION ok_cmp (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT + CASE WHEN $1 < $2 THEN -1 + WHEN $1 > $2 THEN 1 + ELSE 0 + END; + $$; + + --- + --- Check 1: uniqueness violation. + --- + CREATE FUNCTION ok_cmp1 (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT public.ok_cmp($1, $2); + $$; + + --- + --- Make values 768 and 769 look equal. + --- + CREATE FUNCTION bad_cmp1 (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT + CASE WHEN ($1 = 768 AND $2 = 769) OR + ($1 = 769 AND $2 = 768) THEN 0 + ELSE public.ok_cmp($1, $2) + END; + $$; + + --- + --- Check 2: uniqueness violation without deduplication. + --- + CREATE FUNCTION ok_cmp2 (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT public.ok_cmp($1, $2); + $$; + + CREATE FUNCTION bad_cmp2 (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT + CASE WHEN $1 = $2 AND $1 = 400 THEN -1 + ELSE public.ok_cmp($1, $2) + END; + $$; + + --- + --- Check 3: uniqueness violation with deduplication. + --- + CREATE FUNCTION ok_cmp3 (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT public.ok_cmp($1, $2); + $$; + + CREATE FUNCTION bad_cmp3 (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT public.bad_cmp2($1, $2); + $$; + + --- + --- Create data. + --- + CREATE TABLE bttest_unique1 (i int4); + INSERT INTO bttest_unique1 + (SELECT * FROM generate_series(1, 1024) gs); + + CREATE TABLE bttest_unique2 (i int4); + INSERT INTO bttest_unique2(i) + (SELECT * FROM generate_series(1, 400) gs); + INSERT INTO bttest_unique2 + (SELECT * FROM generate_series(400, 1024) gs); + + CREATE TABLE bttest_unique3 (i int4); + INSERT INTO bttest_unique3 + SELECT * FROM bttest_unique2; + + CREATE OPERATOR CLASS int4_custom_ops1 FOR TYPE int4 USING btree AS + OPERATOR 1 < (int4, int4), OPERATOR 2 <= (int4, int4), + OPERATOR 3 = (int4, int4), OPERATOR 4 >= (int4, int4), + OPERATOR 5 > (int4, int4), FUNCTION 1 ok_cmp1(int4, int4); + CREATE OPERATOR CLASS int4_custom_ops2 FOR TYPE int4 USING btree AS + OPERATOR 1 < (int4, int4), OPERATOR 2 <= (int4, int4), + OPERATOR 3 = (int4, int4), OPERATOR 4 >= (int4, int4), + OPERATOR 5 > (int4, int4), FUNCTION 1 bad_cmp2(int4, int4); + CREATE OPERATOR CLASS int4_custom_ops3 FOR TYPE int4 USING btree AS + OPERATOR 1 < (int4, int4), OPERATOR 2 <= (int4, int4), + OPERATOR 3 = (int4, int4), OPERATOR 4 >= (int4, int4), + OPERATOR 5 > (int4, int4), FUNCTION 1 bad_cmp3(int4, int4); + + CREATE UNIQUE INDEX bttest_unique_idx1 + ON bttest_unique1 + USING btree (i int4_custom_ops1) + WITH (deduplicate_items = off); + CREATE UNIQUE INDEX bttest_unique_idx2 + ON bttest_unique2 + USING btree (i int4_custom_ops2) + WITH (deduplicate_items = off); + CREATE UNIQUE INDEX bttest_unique_idx3 + ON bttest_unique3 + USING btree (i int4_custom_ops3) + WITH (deduplicate_items = on); +""" + ) + + # + # Test 1. + # - insert seq values + # - create unique index + # - break cmp function + # - amcheck finds the uniqueness violation + # + + # We have not yet broken the index, so we should get no corruption + result = node.safe_sql("SELECT bt_index_check('bttest_unique_idx1', true, true);") + assert result == "", "run amcheck on non-broken bttest_unique_idx1" + + # Change the operator class to use a function which considers certain + # different values to be equal. + node.safe_sql( + """ + UPDATE pg_catalog.pg_amproc SET + amproc = 'bad_cmp1'::regproc + WHERE amproc = 'ok_cmp1'::regproc; +""" + ) + + res = _check(node, "bttest_unique_idx1") + assert res.error_message is not None and re.search( + r'index uniqueness is violated for index "bttest_unique_idx1"', + res.error_message, + ), 'detected uniqueness violation for index "bttest_unique_idx1"' + + # + # Test 2. + # - break cmp function + # - insert seq values with duplicates + # - create unique index + # - make cmp function correct + # - amcheck finds the uniqueness violation + # + + # Due to bad cmp function we expect amcheck to detect item order violation, + # but no uniqueness violation. + res = _check(node, "bttest_unique_idx2") + assert res.error_message is not None and re.search( + r'item order invariant violated for index "bttest_unique_idx2"', + res.error_message, + ), 'detected item order invariant violation for index "bttest_unique_idx2"' + + node.safe_sql( + """ + UPDATE pg_catalog.pg_amproc SET + amproc = 'ok_cmp2'::regproc + WHERE amproc = 'bad_cmp2'::regproc; +""" + ) + + res = _check(node, "bttest_unique_idx2") + assert res.error_message is not None and re.search( + r'index uniqueness is violated for index "bttest_unique_idx2"', + res.error_message, + ), 'detected uniqueness violation for index "bttest_unique_idx2"' + + # + # Test 3. + # - same as Test 2, but with index deduplication + # + # Then uniqueness violation is detected between different posting list + # entries inside one index entry. + # + + # Due to bad cmp function we expect amcheck to detect item order violation, + # but no uniqueness violation. + res = _check(node, "bttest_unique_idx3") + assert res.error_message is not None and re.search( + r'item order invariant violated for index "bttest_unique_idx3"', + res.error_message, + ), 'detected item order invariant violation for index "bttest_unique_idx3"' + + # For unique index deduplication is possible only for same values, but + # with different visibility. + node.safe_sql( + """ + DELETE FROM bttest_unique3 WHERE 380 <= i AND i <= 420; + INSERT INTO bttest_unique3 (SELECT * FROM generate_series(380, 420)); + INSERT INTO bttest_unique3 VALUES (400); + DELETE FROM bttest_unique3 WHERE 380 <= i AND i <= 420; + INSERT INTO bttest_unique3 (SELECT * FROM generate_series(380, 420)); + INSERT INTO bttest_unique3 VALUES (400); + DELETE FROM bttest_unique3 WHERE 380 <= i AND i <= 420; + INSERT INTO bttest_unique3 (SELECT * FROM generate_series(380, 420)); + INSERT INTO bttest_unique3 VALUES (400); +""" + ) + + node.safe_sql( + """ + UPDATE pg_catalog.pg_amproc SET + amproc = 'ok_cmp3'::regproc + WHERE amproc = 'bad_cmp3'::regproc; +""" + ) + + res = _check(node, "bttest_unique_idx3") + assert res.error_message is not None and re.search( + r'index uniqueness is violated for index "bttest_unique_idx3"', + res.error_message, + ), 'detected uniqueness violation for index "bttest_unique_idx3"' + + node.stop() diff --git a/contrib/amcheck/pyt/test_005_pitr.py b/contrib/amcheck/pyt/test_005_pitr.py new file mode 100644 index 00000000000..d9e63035268 --- /dev/null +++ b/contrib/amcheck/pyt/test_005_pitr.py @@ -0,0 +1,91 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test integrity of intermediate states by PITR to those states.""" + +import re + + +def test_005_pitr(create_pg): + # origin node: generate WAL records of interest. + origin = create_pg("origin", start=False, has_archiving=True, allows_streaming=True) + origin.append_conf("autovacuum = off") + origin.start() + origin.backup("my_backup") + + # Create a table with each of 6 PK values spanning 1/4 of a block. Delete + # the first four, so one index leaf is eligible for deletion. Make a + # replication slot just so pg_walinspect will always have access to later + # WAL. + setup = """ +BEGIN; +CREATE EXTENSION amcheck; +CREATE EXTENSION pg_walinspect; +CREATE TABLE not_leftmost (c text STORAGE PLAIN); +INSERT INTO not_leftmost + SELECT repeat(n::text, database_block_size / 4) + FROM generate_series(1,6) t(n), pg_control_init(); +ALTER TABLE not_leftmost ADD CONSTRAINT not_leftmost_pk PRIMARY KEY (c); +DELETE FROM not_leftmost WHERE c ~ '^[1-4]'; +SELECT pg_create_physical_replication_slot('for_walinspect', true, false); +COMMIT; +""" + origin.safe_sql(setup) + before_vacuum_lsn = origin.safe_sql("SELECT pg_current_wal_lsn()") + + # VACUUM to delete the aforementioned leaf page. Force an XLogFlush() by + # dropping a permanent table. That way, the XLogReader infrastructure can + # always see VACUUM's records, even under synchronous_commit=off. Finally, + # find the LSN of that VACUUM's last UNLINK_PAGE record. The statements + # are run individually (as psql does when fed a script) so that VACUUM, + # which cannot run inside a transaction block, executes standalone. + sess = origin.session() + sess.do("SET synchronous_commit = off") + sess.do("VACUUM (VERBOSE, INDEX_CLEANUP ON) not_leftmost") + sess.do("CREATE TABLE XLogFlush ()") + sess.do("DROP TABLE XLogFlush") + unlink_lsn = sess.query_safe( + f""" +SELECT max(start_lsn) + FROM pg_get_wal_records_info('{before_vacuum_lsn}', 'FFFFFFFF/FFFFFFFF') + WHERE resource_manager = 'Btree' AND record_type = 'UNLINK_PAGE'""" + ) + origin.stop() + assert unlink_lsn, "did not find UNLINK_PAGE record" + + # replica node: amcheck at notable points in the WAL stream + replica = create_pg("replica", start=False) + replica.init_from_backup(origin, "my_backup", has_restoring=True) + replica.append_conf(f"recovery_target_lsn = '{unlink_lsn}'") + replica.append_conf("recovery_target_inclusive = off") + replica.append_conf("recovery_target_action = promote") + replica.start() + assert replica.poll_query_until( + "SELECT pg_is_in_recovery() = 'f'" + ), "Timed out while waiting for PITR promotion" + + # recovery done; run amcheck + debug = "SET client_min_messages = 'debug1'" + + # bt_index_parent_check should pass and report the interrupted page + # deletion via a debug message. Run on a dedicated session so the + # debug1 messages are captured through the notice processor (the + # in-process equivalent of psql's stderr). + sess = replica.connect() + try: + sess.do(debug) + sess.clear_stderr() + res = sess.query("SELECT bt_index_parent_check('not_leftmost_pk', true)") + stderr = sess.get_stderr() + print(stderr) + assert res.error_message is None, "bt_index_parent_check passes" + assert re.search( + r"interrupted page deletion detected", stderr + ), "bt_index_parent_check: interrupted page deletion detected" + + sess.clear_stderr() + res = sess.query("SELECT bt_index_check('not_leftmost_pk', true)") + stderr = sess.get_stderr() + print(stderr) + assert res.error_message is None, "bt_index_check passes" + finally: + sess.close() diff --git a/contrib/amcheck/pyt/test_006_verify_gin.py b/contrib/amcheck/pyt/test_006_verify_gin.py new file mode 100644 index 00000000000..9dab73469ed --- /dev/null +++ b/contrib/amcheck/pyt/test_006_verify_gin.py @@ -0,0 +1,287 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test amcheck's gin_index_check against corrupted GIN index pages.""" + +import os +import re +import struct + +# to get the split fast, we want tuples to be as large as possible, but +# the same time we don't want them to be toasted. +FILLER_SIZE = 1900 + + +def relation_filepath(node, relname): + """Return the filesystem path for the named relation.""" + pgdata = node.data_dir + rel = node.safe_sql(f"SELECT pg_relation_filepath('{relname}')") + assert rel, f"path not found for relation {relname}" + return os.path.join(pgdata, rel) + + +def string_replace_block(filename, find, replace, blkno, blksize): + """Substitute pattern 'find' with 'replace' within the block 'blkno'. + + *find* is a compiled regular expression (operating on bytes) and + *replace* is the bytes replacement (which may reference capture + groups using the standard re backreference syntax). + """ + offset = blkno * blksize + with open(filename, "r+b") as fh: + fh.seek(offset) + buffer = fh.read(blksize) + assert ( + len(buffer) == blksize + ), f"read only {len(buffer)} of {blksize} bytes from {filename}" + + buffer = find.sub(replace, buffer) + + fh.seek(offset) + fh.write(buffer) + + +def gin_check_error(node, indexname): + """Run gin_index_check and return its error message (or "").""" + res = node.sql(f"SELECT gin_index_check('{indexname}')") + return res.error_message or "" + + +def invalid_entry_order_leaf_page_test(node, blksize): + relname = "test" + indexname = "test_gin_idx" + + node.safe_sql( + f""" + DROP TABLE IF EXISTS {relname}; + CREATE TABLE {relname} (a text[]); + INSERT INTO {relname} (a) VALUES ('{{aaaaa,bbbbb}}'); + CREATE INDEX {indexname} ON {relname} USING gin (a); + """ + ) + relpath = relation_filepath(node, indexname) + + node.stop() + + blkno = 1 # root + + # produce wrong order by replacing aaaaa with ccccc + string_replace_block(relpath, re.compile(b"aaaaa"), b"ccccc", blkno, blksize) + + node.start() + + expected = ( + f'index "{indexname}" has wrong tuple order on entry tree page, ' + "block 1, offset 2, rightlink 4294967295" + ) + assert re.search(re.escape(expected), gin_check_error(node, indexname)) + + +def invalid_entry_order_inner_page_test(node, blksize): + relname = "test" + indexname = "test_gin_idx" + + # to break the order in the inner page we need at least 3 items + # (rightmost key in the inner level is not checked for the order) + # so fill table until we have 2 splits + node.safe_sql( + f""" + DROP TABLE IF EXISTS {relname}; + CREATE TABLE {relname} (a text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'pppppppppp' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'qqqqqqqqqq' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'rrrrrrrrrr' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'ssssssssss' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'tttttttttt' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'uuuuuuuuuu' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'vvvvvvvvvv' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'wwwwwwwwww' || random_string({FILLER_SIZE}) ||'}}')::text[]); + CREATE INDEX {indexname} ON {relname} USING gin (a); + """ + ) + relpath = relation_filepath(node, indexname) + + node.stop() + + blkno = 1 # root + + # we have rrrrrrrrr... and tttttttttt... as keys in the root, so produce + # wrong order by replacing rrrrrrrrrr.... + string_replace_block( + relpath, re.compile(b"rrrrrrrrrr"), b"zzzzzzzzzz", blkno, blksize + ) + + node.start() + + expected = ( + f'index "{indexname}" has wrong tuple order on entry tree page, ' + "block 1, offset 2, rightlink 4294967295" + ) + assert re.search(re.escape(expected), gin_check_error(node, indexname)) + + +def invalid_entry_columns_order_test(node, blksize): + relname = "test" + indexname = "test_gin_idx" + + node.safe_sql( + f""" + DROP TABLE IF EXISTS {relname}; + CREATE TABLE {relname} (a text[],b text[]); + INSERT INTO {relname} (a,b) VALUES ('{{aaa}}','{{bbb}}'); + CREATE INDEX {indexname} ON {relname} USING gin (a,b); + """ + ) + relpath = relation_filepath(node, indexname) + + node.stop() + + blkno = 1 # root + + # mess column numbers + # root items order before: (1,aaa), (2,bbb) + # root items order after: (2,aaa), (1,bbb) + attrno_1 = struct.pack("h", 1) + attrno_2 = struct.pack("h", 2) + + find = re.compile(re.escape(attrno_1) + b"(.)(aaa)", re.DOTALL) + replace = attrno_2 + rb"\1\2" + string_replace_block(relpath, find, replace, blkno, blksize) + + find = re.compile(re.escape(attrno_2) + b"(.)(bbb)", re.DOTALL) + replace = attrno_1 + rb"\1\2" + string_replace_block(relpath, find, replace, blkno, blksize) + + node.start() + + expected = ( + f'index "{indexname}" has wrong tuple order on entry tree page, ' + "block 1, offset 2, rightlink 4294967295" + ) + assert re.search(re.escape(expected), gin_check_error(node, indexname)) + + +def inconsistent_with_parent_key__parent_key_corrupted_test(node, blksize): + relname = "test" + indexname = "test_gin_idx" + + # fill the table until we have a split + node.safe_sql( + f""" + DROP TABLE IF EXISTS {relname}; + CREATE TABLE {relname} (a text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'llllllllll' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'mmmmmmmmmm' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'nnnnnnnnnn' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'xxxxxxxxxx' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'yyyyyyyyyy' || random_string({FILLER_SIZE}) ||'}}')::text[]); + CREATE INDEX {indexname} ON {relname} USING gin (a); + """ + ) + relpath = relation_filepath(node, indexname) + + node.stop() + + blkno = 1 # root + + # we have nnnnnnnnnn... as parent key in the root, so replace it with + # something smaller then child's keys + string_replace_block( + relpath, re.compile(b"nnnnnnnnnn"), b"aaaaaaaaaa", blkno, blksize + ) + + node.start() + + expected = f'index "{indexname}" has inconsistent records on page 3 offset 3' + assert re.search(re.escape(expected), gin_check_error(node, indexname)) + + +def inconsistent_with_parent_key__child_key_corrupted_test(node, blksize): + relname = "test" + indexname = "test_gin_idx" + + # fill the table until we have a split + node.safe_sql( + f""" + DROP TABLE IF EXISTS {relname}; + CREATE TABLE {relname} (a text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'llllllllll' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'mmmmmmmmmm' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'nnnnnnnnnn' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'xxxxxxxxxx' || random_string({FILLER_SIZE}) ||'}}')::text[]); + INSERT INTO {relname} (a) VALUES (('{{' || 'yyyyyyyyyy' || random_string({FILLER_SIZE}) ||'}}')::text[]); + CREATE INDEX {indexname} ON {relname} USING gin (a); + """ + ) + relpath = relation_filepath(node, indexname) + + node.stop() + + blkno = 3 # leaf + + # we have nnnnnnnnnn... as parent key in the root, so replace child key + # with something bigger + string_replace_block( + relpath, re.compile(b"nnnnnnnnnn"), b"pppppppppp", blkno, blksize + ) + + node.start() + + expected = f'index "{indexname}" has inconsistent records on page 3 offset 3' + assert re.search(re.escape(expected), gin_check_error(node, indexname)) + + +def inconsistent_with_parent_key__parent_key_corrupted_posting_tree_test(node, blksize): + relname = "test" + indexname = "test_gin_idx" + + node.safe_sql( + f""" + DROP TABLE IF EXISTS {relname}; + CREATE TABLE {relname} (a text[]); + INSERT INTO {relname} (a) select ('{{aaaaa}}') from generate_series(1,10000); + CREATE INDEX {indexname} ON {relname} USING gin (a); + """ + ) + relpath = relation_filepath(node, indexname) + + node.stop() + + blkno = 2 # posting tree root + + # we have a posting tree for 'aaaaa' key with the root at 2nd block + # and two leaf pages 3 and 4. replace 4th page's high key with (1,1) + # so that there are tid's in leaf page that are larger then the new high key. + find = re.compile(re.escape(struct.pack("HHH", 0, 4, 0)) + b"....", re.DOTALL) + replace = struct.pack("HHHHH", 0, 4, 0, 1, 1) + string_replace_block(relpath, find, replace, blkno, blksize) + + node.start() + + expected = ( + f'index "{indexname}": tid exceeds parent\'s high key in ' + "postingTree leaf on block 4" + ) + assert re.search(re.escape(expected), gin_check_error(node, indexname)) + + +def test_006_verify_gin(create_pg): + # Test set-up + node = create_pg("test", start=False, initdb_extra=["--no-data-checksums"]) + node.append_conf("autovacuum=off") + node.start() + blksize = int(node.safe_sql("SHOW block_size;")) + node.safe_sql("CREATE EXTENSION amcheck") + node.safe_sql( + """ + CREATE OR REPLACE FUNCTION random_string( INT ) RETURNS text AS $$ + SELECT string_agg(substring('0123456789abcdefghijklmnopqrstuvwxyz', ceil(random() * 36)::integer, 1), '') from generate_series(1, $1); + $$ LANGUAGE SQL;""" + ) + + # Tests + invalid_entry_order_leaf_page_test(node, blksize) + invalid_entry_order_inner_page_test(node, blksize) + invalid_entry_columns_order_test(node, blksize) + inconsistent_with_parent_key__parent_key_corrupted_test(node, blksize) + inconsistent_with_parent_key__child_key_corrupted_test(node, blksize) + inconsistent_with_parent_key__parent_key_corrupted_posting_tree_test(node, blksize) diff --git a/src/bin/pg_amcheck/meson.build b/src/bin/pg_amcheck/meson.build index 592cef74ecb..335d2c7851d 100644 --- a/src/bin/pg_amcheck/meson.build +++ b/src/bin/pg_amcheck/meson.build @@ -30,6 +30,15 @@ tests += { 't/005_opclass_damage.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_nonesuch.py', + 'pyt/test_003_check.py', + 'pyt/test_004_verify_heapam.py', + 'pyt/test_005_opclass_damage.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_amcheck/pyt/test_001_basic.py b/src/bin/pg_amcheck/pyt/test_001_basic.py new file mode 100644 index 00000000000..0cc68f00f2d --- /dev/null +++ b/src/bin/pg_amcheck/pyt/test_001_basic.py @@ -0,0 +1,10 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_amcheck option handling and argument validation.""" + + +def test_001_basic(pg_bin): + """pg_amcheck --help / --version / invalid-option handling.""" + pg_bin.program_help_ok("pg_amcheck") + pg_bin.program_version_ok("pg_amcheck") + pg_bin.program_options_handling_ok("pg_amcheck") diff --git a/src/bin/pg_amcheck/pyt/test_002_nonesuch.py b/src/bin/pg_amcheck/pyt/test_002_nonesuch.py new file mode 100644 index 00000000000..fc5cc1aad0e --- /dev/null +++ b/src/bin/pg_amcheck/pyt/test_002_nonesuch.py @@ -0,0 +1,563 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_amcheck error handling with nonexistent roles, databases, and objects.""" + +import re + + +def test_nonesuch(create_pg): + # Test set-up. With trust auth the role-does-not-exist error is raised + # when connecting as no_such_user, so no extra initdb args are needed here. + node = create_pg("test") + + # Load the amcheck extension, upon which pg_amcheck depends + node.safe_sql("CREATE EXTENSION amcheck") + + ######################################### + # Test non-existent databases + + # Failing to connect to the initial database is an error. + node.command_checks_all( + ["pg_amcheck", "qqq"], + 1, + [re.compile(r"^$")], + [re.compile(r'FATAL: database "qqq" does not exist')], + "checking a non-existent database", + ) + + # Failing to resolve a database pattern is an error by default. + node.command_checks_all( + ["pg_amcheck", "--database", "qqq", "--database", "postgres"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: error: no connectable databases to check matching "qqq"' + ) + ], + "checking an unresolvable database pattern", + ) + + # But only a warning under --no-strict-names + node.command_checks_all( + [ + "pg_amcheck", + "--no-strict-names", + "--database", + "qqq", + "--database", + "postgres", + ], + 0, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "qqq"' + ) + ], + "checking an unresolvable database pattern under --no-strict-names", + ) + + # Check that a substring of an existent database name does not get interpreted + # as a matching pattern. + node.command_checks_all( + ["pg_amcheck", "--database", "post", "--database", "postgres"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: error: no connectable databases to check matching "post"' + ) + ], + "checking an unresolvable database pattern (substring of existent database)", + ) + + # Check that a superstring of an existent database name does not get interpreted + # as a matching pattern. + node.command_checks_all( + ["pg_amcheck", "--database", "postgresql", "--database", "postgres"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: error: no connectable databases to check matching "postgresql"' + ) + ], + "checking an unresolvable database pattern (superstring of existent database)", + ) + + ######################################### + # Test connecting with a non-existent user + + # Failing to connect to the initial database due to bad username is an error. + node.command_checks_all( + ["pg_amcheck", "--username", "no_such_user", "postgres"], + 1, + [re.compile(r"^$")], + [re.compile(r'role "no_such_user" does not exist')], + "checking with a non-existent user", + ) + + ######################################### + # Test checking databases without amcheck installed + + # Attempting to check a database by name where amcheck is not installed should + # raise a warning. If all databases are skipped, having no relations to check + # raises an error. + node.command_checks_all( + ["pg_amcheck", "template1"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: warning: skipping database "template1": amcheck is not installed' + ), + re.compile(r"pg_amcheck: error: no relations to check"), + ], + "checking a database by name without amcheck installed, no other databases", + ) + + # Again, but this time with another database to check, so no error is raised. + node.command_checks_all( + ["pg_amcheck", "--database", "template1", "--database", "postgres"], + 0, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: warning: skipping database "template1": amcheck is not installed' + ) + ], + "checking a database by name without amcheck installed, with other databases", + ) + + # Again, but by way of checking all databases + node.command_checks_all( + ["pg_amcheck", "--all"], + 0, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: warning: skipping database "template1": amcheck is not installed' + ) + ], + "checking a database by pattern without amcheck installed, with other databases", + ) + + ######################################### + # Test unreasonable patterns + + # Check three-part unreasonable pattern that has zero-length names + node.command_checks_all( + ["pg_amcheck", "--database", "postgres", "--table", ".."], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: error: no connectable databases to check matching "\.\."' + ) + ], + 'checking table pattern ".."', + ) + + # Again, but with non-trivial schema and relation parts + node.command_checks_all( + ["pg_amcheck", "--database", "postgres", "--table", ".foo.bar"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: error: no connectable databases to check matching "\.foo\.bar"' + ) + ], + 'checking table pattern ".foo.bar"', + ) + + # Check two-part unreasonable pattern that has zero-length names + node.command_checks_all( + ["pg_amcheck", "--database", "postgres", "--table", "."], + 1, + [re.compile(r"^$")], + [re.compile(r'pg_amcheck: error: no heap tables to check matching "\."')], + 'checking table pattern "."', + ) + + # Check that a multipart database name is rejected + node.command_checks_all( + ["pg_amcheck", "--database", "localhost.postgres"], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper qualified name \(too many dotted names\): localhost\.postgres" + ) + ], + "multipart database patterns are rejected", + ) + + # Check that a three-part schema name is rejected + node.command_checks_all( + ["pg_amcheck", "--schema", "localhost.postgres.pg_catalog"], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper qualified name \(too many dotted names\): localhost\.postgres\.pg_catalog" + ) + ], + "three part schema patterns are rejected", + ) + + # Check that a four-part table name is rejected + node.command_checks_all( + ["pg_amcheck", "--table", "localhost.postgres.pg_catalog.pg_class"], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper relation name \(too many dotted names\): localhost\.postgres\.pg_catalog\.pg_class" + ) + ], + "four part table patterns are rejected", + ) + + # Check that too many dotted names still draws an error under --no-strict-names + # That flag means that it is ok for the object to be missing, not that it is ok + # for the object name to be ungrammatical + node.command_checks_all( + [ + "pg_amcheck", + "--no-strict-names", + "--table", + "this.is.a.really.long.dotted.string", + ], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper relation name \(too many dotted names\): this\.is\.a\.really\.long\.dotted\.string" + ) + ], + "ungrammatical table names still draw errors under --no-strict-names", + ) + node.command_checks_all( + [ + "pg_amcheck", + "--no-strict-names", + "--schema", + "postgres.long.dotted.string", + ], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper qualified name \(too many dotted names\): postgres\.long\.dotted\.string" + ) + ], + "ungrammatical schema names still draw errors under --no-strict-names", + ) + node.command_checks_all( + [ + "pg_amcheck", + "--no-strict-names", + "--database", + "postgres.long.dotted.string", + ], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper qualified name \(too many dotted names\): postgres\.long\.dotted\.string" + ) + ], + "ungrammatical database names still draw errors under --no-strict-names", + ) + + # Likewise for exclusion patterns + node.command_checks_all( + ["pg_amcheck", "--no-strict-names", "--exclude-table", "a.b.c.d"], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper relation name \(too many dotted names\): a\.b\.c\.d" + ) + ], + "ungrammatical table exclusions still draw errors under --no-strict-names", + ) + node.command_checks_all( + ["pg_amcheck", "--no-strict-names", "--exclude-schema", "a.b.c"], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper qualified name \(too many dotted names\): a\.b\.c" + ) + ], + "ungrammatical schema exclusions still draw errors under --no-strict-names", + ) + node.command_checks_all( + ["pg_amcheck", "--no-strict-names", "--exclude-database", "a.b"], + 2, + [re.compile(r"^$")], + [ + re.compile( + r"pg_amcheck: error: improper qualified name \(too many dotted names\): a\.b" + ) + ], + "ungrammatical database exclusions still draw errors under --no-strict-names", + ) + + ######################################### + # Test checking non-existent databases, schemas, tables, and indexes + + # Use --no-strict-names and a single existent table so we only get warnings + # about the failed pattern matches + node.command_checks_all( + [ + "pg_amcheck", + "--no-strict-names", + "--table", + "no_such_table", + "--table", + "no*such*table", + "--index", + "no_such_index", + "--index", + "no*such*index", + "--relation", + "no_such_relation", + "--relation", + "no*such*relation", + "--database", + "no_such_database", + "--database", + "no*such*database", + "--relation", + "none.none", + "--relation", + "none.none.none", + "--relation", + "postgres.none.none", + "--relation", + "postgres.pg_catalog.none", + "--relation", + "postgres.none.pg_class", + "--table", + "postgres.pg_catalog.pg_class", # This exists + ], + 0, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: warning: no heap tables to check matching "no_such_table"' + ), + re.compile( + r'pg_amcheck: warning: no heap tables to check matching "no\*such\*table"' + ), + re.compile( + r'pg_amcheck: warning: no btree indexes to check matching "no_such_index"' + ), + re.compile( + r'pg_amcheck: warning: no btree indexes to check matching "no\*such\*index"' + ), + re.compile( + r'pg_amcheck: warning: no relations to check matching "no_such_relation"' + ), + re.compile( + r'pg_amcheck: warning: no relations to check matching "no\*such\*relation"' + ), + re.compile( + r'pg_amcheck: warning: no heap tables to check matching "no\*such\*table"' + ), + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "no_such_database"' + ), + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "no\*such\*database"' + ), + re.compile( + r'pg_amcheck: warning: no relations to check matching "none\.none"' + ), + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "none\.none\.none"' + ), + re.compile( + r'pg_amcheck: warning: no relations to check matching "postgres\.none\.none"' + ), + re.compile( + r'pg_amcheck: warning: no relations to check matching "postgres\.pg_catalog\.none"' + ), + re.compile( + r'pg_amcheck: warning: no relations to check matching "postgres\.none\.pg_class"' + ), + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "no_such_database"' + ), + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "no\*such\*database"' + ), + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "none\.none\.none"' + ), + ], + "many unmatched patterns and one matched pattern under --no-strict-names", + ) + + ######################################### + # Test that an invalid / partially dropped database won't be targeted + + # CREATE DATABASE cannot run inside a transaction block, so issue each + # statement separately (the in-process Session wraps multi-statement input + # in a transaction). + node.safe_sql("CREATE DATABASE regression_invalid") + node.safe_sql( + "UPDATE pg_database SET datconnlimit = -2 WHERE datname = 'regression_invalid'" + ) + + node.command_checks_all( + ["pg_amcheck", "--database", "regression_invalid"], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: error: no connectable databases to check matching "regression_invalid"' + ), + ], + "checking handling of invalid database", + ) + + node.command_checks_all( + [ + "pg_amcheck", + "--database", + "postgres", + "--table", + "regression_invalid.public.foo", + ], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: error: no connectable databases to check matching "regression_invalid.public.foo"' + ), + ], + "checking handling of object in invalid database", + ) + + ######################################### + # Test checking otherwise existent objects but in databases where they do not exist + + node.safe_sql( + """ + CREATE TABLE public.foo (f integer); + CREATE INDEX foo_idx ON foo(f); + """ + ) + node.safe_sql("CREATE DATABASE another_db") + + node.command_checks_all( + [ + "pg_amcheck", + "--database", + "postgres", + "--no-strict-names", + "--table", + "template1.public.foo", + "--table", + "another_db.public.foo", + "--table", + "no_such_database.public.foo", + "--index", + "template1.public.foo_idx", + "--index", + "another_db.public.foo_idx", + "--index", + "no_such_database.public.foo_idx", + ], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: warning: skipping database "template1": amcheck is not installed' + ), + re.compile( + r'pg_amcheck: warning: no heap tables to check matching "template1\.public\.foo"' + ), + re.compile( + r'pg_amcheck: warning: no heap tables to check matching "another_db\.public\.foo"' + ), + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "no_such_database\.public\.foo"' + ), + re.compile( + r'pg_amcheck: warning: no btree indexes to check matching "template1\.public\.foo_idx"' + ), + re.compile( + r'pg_amcheck: warning: no btree indexes to check matching "another_db\.public\.foo_idx"' + ), + re.compile( + r'pg_amcheck: warning: no connectable databases to check matching "no_such_database\.public\.foo_idx"' + ), + re.compile(r"pg_amcheck: error: no relations to check"), + ], + "checking otherwise existent objects in the wrong databases", + ) + + ######################################### + # Test schema exclusion patterns + + # Check with only schema exclusion patterns + node.command_checks_all( + [ + "pg_amcheck", + "--all", + "--no-strict-names", + "--exclude-schema", + "public", + "--exclude-schema", + "pg_catalog", + "--exclude-schema", + "pg_toast", + "--exclude-schema", + "information_schema", + ], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: warning: skipping database "template1": amcheck is not installed' + ), + re.compile(r"pg_amcheck: error: no relations to check"), + ], + "schema exclusion patterns exclude all relations", + ) + + # Check with schema exclusion patterns overriding relation and schema inclusion patterns + node.command_checks_all( + [ + "pg_amcheck", + "--all", + "--no-strict-names", + "--schema", + "public", + "--schema", + "pg_catalog", + "--schema", + "pg_toast", + "--schema", + "information_schema", + "--table", + "pg_catalog.pg_class", + "--exclude-schema", + "*", + ], + 1, + [re.compile(r"^$")], + [ + re.compile( + r'pg_amcheck: warning: skipping database "template1": amcheck is not installed' + ), + re.compile(r"pg_amcheck: error: no relations to check"), + ], + "schema exclusion pattern overrides all inclusion patterns", + ) diff --git a/src/bin/pg_amcheck/pyt/test_003_check.py b/src/bin/pg_amcheck/pyt/test_003_check.py new file mode 100644 index 00000000000..6c747796121 --- /dev/null +++ b/src/bin/pg_amcheck/pyt/test_003_check.py @@ -0,0 +1,633 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_amcheck detection of corruption across schemas, tables, and indexes.""" + +import os +import struct + + +# State accumulated by the plan_to_* helpers. Keys are absolute filesystem +# paths. +corrupt_page = {} +remove_relation = {} + + +def relation_filepath(node, dbname, relname): + """Return the filesystem path for the named relation. + + Assumes the test node is running. + """ + pgdata = node.data_dir + rel = node.safe_sql(f"SELECT pg_relation_filepath('{relname}')", dbname) + assert rel is not None and rel != "", f"path not found for relation {relname}" + return os.path.join(pgdata, rel) + + +def relation_toast(node, dbname, relname): + """Return the name of the toast relation associated with the named relation. + + Assumes the test node is running. + """ + return node.safe_sql( + f""" + SELECT c.reltoastrelid::regclass + FROM pg_catalog.pg_class c + WHERE c.oid = '{relname}'::regclass + AND c.reltoastrelid != 0 + """, + dbname, + ) + + +def plan_to_corrupt_first_page(node, dbname, relname): + """Plan to corrupt the first page of the given relation.""" + relpath = relation_filepath(node, dbname, relname) + corrupt_page[relpath] = 1 + + +def plan_to_remove_relation_file(node, dbname, relname): + """Plan to corrupt the given relation by removing its file.""" + relpath = relation_filepath(node, dbname, relname) + remove_relation[relpath] = 1 + + +def plan_to_remove_toast_file(node, dbname, relname): + """Plan to remove the toast file (if any) of the given relation.""" + toastname = relation_toast(node, dbname, relname) + if toastname: + plan_to_remove_relation_file(node, dbname, toastname) + + +def corrupt_first_page(relpath): + """Corrupt the first page of the given file path.""" + with open(relpath, "r+b") as fh: + # Corrupt some line pointers. The values are chosen to hit the + # various line-pointer-corruption checks in verify_heapam.c + # on both little-endian and big-endian architectures. + fh.seek(32) + fh.write( + struct.pack( + "<7L", + 0xAAA15550, + 0xAAA0D550, + 0x00010000, + 0x00008000, + 0x0000800F, + 0x001E8000, + 0xFFFFFFFF, + ) + ) + + +def perform_all_corruptions(node): + """Stop the node, perform all planned corruptions, and start again.""" + node.stop() + for relpath in corrupt_page: + corrupt_first_page(relpath) + for relpath in remove_relation: + os.unlink(relpath) + node.start() + + +def test_003_check(create_pg): + # Reset the planned-corruption state (module globals) for this run. + corrupt_page.clear() + remove_relation.clear() + + # Test set-up + node = create_pg("test", start=False, initdb_extra=["--no-data-checksums"]) + node.append_conf("autovacuum=off") + node.start() + + for dbname in ("db1", "db2", "db3"): + # Create the database + node.safe_sql(f"CREATE DATABASE {dbname}") + + # Load the amcheck extension, upon which pg_amcheck depends. Put the + # extension in an unexpected location to test that pg_amcheck finds it + # correctly. Create tables with names that look like pg_catalog names + # to check that pg_amcheck does not get confused by them. Create + # functions in schema public that look like amcheck functions to check + # that pg_amcheck does not use them. + node.safe_sql( + """ + CREATE SCHEMA amcheck_schema; + CREATE EXTENSION amcheck WITH SCHEMA amcheck_schema; + CREATE TABLE amcheck_schema.pg_database (junk text); + CREATE TABLE amcheck_schema.pg_namespace (junk text); + CREATE TABLE amcheck_schema.pg_class (junk text); + CREATE TABLE amcheck_schema.pg_operator (junk text); + CREATE TABLE amcheck_schema.pg_proc (junk text); + CREATE TABLE amcheck_schema.pg_tablespace (junk text); + + CREATE FUNCTION public.bt_index_check(index regclass, + heapallindexed boolean default false) + RETURNS VOID AS $$ + BEGIN + RAISE EXCEPTION 'Invoked wrong bt_index_check!'; + END; + $$ LANGUAGE plpgsql; + + CREATE FUNCTION public.bt_index_parent_check(index regclass, + heapallindexed boolean default false, + rootdescend boolean default false) + RETURNS VOID AS $$ + BEGIN + RAISE EXCEPTION 'Invoked wrong bt_index_parent_check!'; + END; + $$ LANGUAGE plpgsql; + + CREATE FUNCTION public.verify_heapam(relation regclass, + on_error_stop boolean default false, + check_toast boolean default false, + skip text default 'none', + startblock bigint default null, + endblock bigint default null, + blkno OUT bigint, + offnum OUT integer, + attnum OUT integer, + msg OUT text) + RETURNS SETOF record AS $$ + BEGIN + RAISE EXCEPTION 'Invoked wrong verify_heapam!'; + END; + $$ LANGUAGE plpgsql; + """, + dbname, + ) + + # Create schemas, tables and indexes in five separate schemas. The + # schemas are all identical to start, but we will corrupt them + # differently later. + for schema in ("s1", "s2", "s3", "s4", "s5"): + node.safe_sql( + f""" + CREATE SCHEMA {schema}; + CREATE SEQUENCE {schema}.seq1; + CREATE SEQUENCE {schema}.seq2; + CREATE TABLE {schema}.t1 ( + i INTEGER, + b BOX, + ia int4[], + ir int4range, + t TEXT + ); + CREATE TABLE {schema}.t2 ( + i INTEGER, + b BOX, + ia int4[], + ir int4range, + t TEXT + ); + CREATE VIEW {schema}.t2_view AS ( + SELECT i*2, t FROM {schema}.t2 + ); + ALTER TABLE {schema}.t2 + ALTER COLUMN t + SET STORAGE EXTERNAL; + + INSERT INTO {schema}.t1 (i, b, ia, ir, t) + (SELECT gs::INTEGER AS i, + box(point(gs,gs+5),point(gs*2,gs*3)) AS b, + array[gs, gs + 1]::int4[] AS ia, + int4range(gs, gs+100) AS ir, + repeat('foo', gs) AS t + FROM generate_series(1,10000,3000) AS gs); + + INSERT INTO {schema}.t2 (i, b, ia, ir, t) + (SELECT gs::INTEGER AS i, + box(point(gs,gs+5),point(gs*2,gs*3)) AS b, + array[gs, gs + 1]::int4[] AS ia, + int4range(gs, gs+100) AS ir, + repeat('foo', gs) AS t + FROM generate_series(1,10000,3000) AS gs); + + CREATE MATERIALIZED VIEW {schema}.t1_mv AS SELECT * FROM {schema}.t1; + CREATE MATERIALIZED VIEW {schema}.t2_mv AS SELECT * FROM {schema}.t2; + + create table {schema}.p1 (a int, b int) PARTITION BY list (a); + create table {schema}.p2 (a int, b int) PARTITION BY list (a); + + create table {schema}.p1_1 partition of {schema}.p1 for values in (1, 2, 3); + create table {schema}.p1_2 partition of {schema}.p1 for values in (4, 5, 6); + create table {schema}.p2_1 partition of {schema}.p2 for values in (1, 2, 3); + create table {schema}.p2_2 partition of {schema}.p2 for values in (4, 5, 6); + + CREATE INDEX t1_btree ON {schema}.t1 USING BTREE (i); + CREATE INDEX t2_btree ON {schema}.t2 USING BTREE (i); + + CREATE INDEX t1_hash ON {schema}.t1 USING HASH (i); + CREATE INDEX t2_hash ON {schema}.t2 USING HASH (i); + + CREATE INDEX t1_brin ON {schema}.t1 USING BRIN (i); + CREATE INDEX t2_brin ON {schema}.t2 USING BRIN (i); + + CREATE INDEX t1_gist ON {schema}.t1 USING GIST (b); + CREATE INDEX t2_gist ON {schema}.t2 USING GIST (b); + + CREATE INDEX t1_gin ON {schema}.t1 USING GIN (ia); + CREATE INDEX t2_gin ON {schema}.t2 USING GIN (ia); + + CREATE INDEX t1_spgist ON {schema}.t1 USING SPGIST (ir); + CREATE INDEX t2_spgist ON {schema}.t2 USING SPGIST (ir); + + CREATE UNIQUE INDEX t1_btree_unique ON {schema}.t1 USING BTREE (i); + CREATE UNIQUE INDEX t2_btree_unique ON {schema}.t2 USING BTREE (i); + """, + dbname, + ) + + # Database 'db1' corruptions + # + + # Corrupt indexes in schema "s1" + plan_to_remove_relation_file(node, "db1", "s1.t1_btree") + plan_to_corrupt_first_page(node, "db1", "s1.t2_btree") + + # Corrupt tables in schema "s2" + plan_to_remove_relation_file(node, "db1", "s2.t1") + plan_to_corrupt_first_page(node, "db1", "s2.t2") + + # Corrupt tables, partitions, matviews, and btrees in schema "s3" + plan_to_remove_relation_file(node, "db1", "s3.t1") + plan_to_corrupt_first_page(node, "db1", "s3.t2") + + plan_to_remove_relation_file(node, "db1", "s3.t1_mv") + plan_to_remove_relation_file(node, "db1", "s3.p1_1") + + plan_to_corrupt_first_page(node, "db1", "s3.t2_mv") + plan_to_corrupt_first_page(node, "db1", "s3.p2_1") + + plan_to_remove_relation_file(node, "db1", "s3.t1_btree") + plan_to_corrupt_first_page(node, "db1", "s3.t2_btree") + + # Corrupt toast table, partitions, and materialized views in schema "s4" + plan_to_remove_toast_file(node, "db1", "s4.t2") + + # Corrupt all other object types in schema "s5". We don't have amcheck + # support for these types, but we check that their corruption does not + # trigger any errors in pg_amcheck + plan_to_remove_relation_file(node, "db1", "s5.seq1") + plan_to_remove_relation_file(node, "db1", "s5.t1_hash") + plan_to_remove_relation_file(node, "db1", "s5.t1_gist") + plan_to_remove_relation_file(node, "db1", "s5.t1_gin") + plan_to_remove_relation_file(node, "db1", "s5.t1_brin") + plan_to_remove_relation_file(node, "db1", "s5.t1_spgist") + + plan_to_corrupt_first_page(node, "db1", "s5.seq2") + plan_to_corrupt_first_page(node, "db1", "s5.t2_hash") + plan_to_corrupt_first_page(node, "db1", "s5.t2_gist") + plan_to_corrupt_first_page(node, "db1", "s5.t2_gin") + plan_to_corrupt_first_page(node, "db1", "s5.t2_brin") + plan_to_corrupt_first_page(node, "db1", "s5.t2_spgist") + + # Database 'db2' corruptions + # + plan_to_remove_relation_file(node, "db2", "s1.t1") + plan_to_remove_relation_file(node, "db2", "s1.t1_btree") + + # Leave 'db3' uncorrupted + # + + # Standard first arguments to the command_* helpers. The node's command + # helpers already target this server's socket via PGHOST/PGPORT, so no + # explicit --port is required. + cmd = ["pg_amcheck"] + + # Regular expressions to match various expected output + no_output_re = r"^$" + line_pointer_corruption_re = r"line pointer" + missing_file_re = r'could not open file ".*": No such file or directory' + index_missing_relation_fork_re = r'index ".*" lacks a main relation fork' + + # We have created test databases with tables populated with data, but have + # not yet corrupted anything. As such, we expect no corruption and verify + # that none is reported. + node.command_checks_all( + cmd + ["--database", "db1", "--database", "db2", "--database", "db3"], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck prior to corruption", + ) + + # Perform the corruptions we planned above using only a single database + # restart. + perform_all_corruptions(node) + + # Checking databases with amcheck installed and corrupt relations, + # pg_amcheck command itself should return exit status = 2, because tables + # and indexes are corrupt, not exit status = 1, which would mean the + # pg_amcheck command itself failed. Corruption messages should go to + # stdout, and nothing to stderr. + node.command_checks_all( + cmd + ["db1"], + 2, + [ + index_missing_relation_fork_re, + line_pointer_corruption_re, + missing_file_re, + ], + [no_output_re], + "pg_amcheck all schemas, tables and indexes in database db1", + ) + + node.command_checks_all( + cmd + ["--database", "db1", "--database", "db2", "--database", "db3"], + 2, + [ + index_missing_relation_fork_re, + line_pointer_corruption_re, + missing_file_re, + ], + [no_output_re], + "pg_amcheck all schemas, tables and indexes in databases db1, db2, and db3", + ) + + # Scans of indexes in s1 should detect the specific corruption that we + # created above. For missing relation forks, we know what the error + # message looks like. For corrupted index pages, the error might vary + # depending on how the page was formatted on disk, including variations due + # to alignment differences between platforms, so we accept any non-empty + # error message. + # + # If we don't limit the check to databases with amcheck installed, we + # expect complaint on stderr, but otherwise stderr should be quiet. + node.command_checks_all( + cmd + ["--all", "--schema", "s1", "--index", "t1_btree"], + 2, + [index_missing_relation_fork_re], + [ + r'pg_amcheck: warning: skipping database "postgres": ' + r"amcheck is not installed" + ], + "pg_amcheck index s1.t1_btree reports missing main relation fork", + ) + + node.command_checks_all( + cmd + ["--database", "db1", "--schema", "s1", "--index", "t2_btree"], + 2, + [r".+"], # Any non-empty error message is acceptable + [no_output_re], + "pg_amcheck index s1.s2 reports index corruption", + ) + + # Checking db1.s1 with indexes excluded should show no corruptions because + # we did not corrupt any tables in db1.s1. Verify that both stdout and + # stderr are quiet. + node.command_checks_all( + cmd + ["--table", "s1.*", "--no-dependent-indexes", "db1"], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck of db1.s1 excluding indexes", + ) + + # Checking db2.s1 should show table corruptions if indexes are excluded + node.command_checks_all( + cmd + ["--table", "s1.*", "--no-dependent-indexes", "db2"], + 2, + [missing_file_re], + [no_output_re], + "pg_amcheck of db2.s1 excluding indexes", + ) + + # In schema db1.s3, the tables and indexes are both corrupt. We should see + # corruption messages on stdout, and nothing on stderr. + node.command_checks_all( + cmd + ["--schema", "s3", "db1"], + 2, + [ + index_missing_relation_fork_re, + line_pointer_corruption_re, + missing_file_re, + ], + [no_output_re], + "pg_amcheck schema s3 reports table and index errors", + ) + + # In schema db1.s4, only toast tables are corrupt. Check that under + # default options the toast corruption is reported, but when excluding + # toast we get no error reports. + node.command_checks_all( + cmd + ["--schema", "s4", "db1"], + 2, + [missing_file_re], + [no_output_re], + "pg_amcheck in schema s4 reports toast corruption", + ) + + node.command_checks_all( + cmd + + [ + "--no-dependent-toast", + "--exclude-toast-pointers", + "--schema", + "s4", + "db1", + ], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck in schema s4 excluding toast reports no corruption", + ) + + # Check that no corruption is reported in schema db1.s5 + node.command_checks_all( + cmd + ["--schema", "s5", "db1"], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck over schema s5 reports no corruption", + ) + + # In schema db1.s1, only indexes are corrupt. Verify that when we exclude + # the indexes, no corruption is reported about the schema. + node.command_checks_all( + cmd + + [ + "--schema", + "s1", + "--exclude-index", + "t1_btree", + "--exclude-index", + "t2_btree", + "db1", + ], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck over schema s1 with corrupt indexes excluded reports no corruption", + ) + + # In schema db1.s1, only indexes are corrupt. Verify that when we provide + # only table inclusions, and disable index expansion, no corruption is + # reported about the schema. + node.command_checks_all( + cmd + ["--table", "s1.*", "--no-dependent-indexes", "db1"], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck over schema s1 with all indexes excluded reports no corruption", + ) + + # In schema db1.s2, only tables are corrupt. Verify that when we exclude + # those tables that no corruption is reported. + node.command_checks_all( + cmd + + [ + "--schema", + "s2", + "--exclude-table", + "t1", + "--exclude-table", + "t2", + "db1", + ], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck over schema s2 with corrupt tables excluded reports no corruption", + ) + + # Check errors about bad block range command line arguments. We use schema + # s5 to avoid getting messages about corrupt tables or indexes. + node.command_fails_like( + cmd + ["--schema", "s5", "--startblock", "junk", "db1"], + r"invalid start block", + "pg_amcheck rejects garbage startblock", + ) + + node.command_fails_like( + cmd + ["--schema", "s5", "--endblock", "1234junk", "db1"], + r"invalid end block", + "pg_amcheck rejects garbage endblock", + ) + + node.command_fails_like( + cmd + ["--schema", "s5", "--startblock", "5", "--endblock", "4", "db1"], + r"end block precedes start block", + "pg_amcheck rejects invalid block range", + ) + + # Check bt_index_parent_check alternates. We don't create any index + # corruption that would behave differently under these modes, so just smoke + # test that the arguments are handled sensibly. + node.command_checks_all( + cmd + ["--schema", "s1", "--index", "t1_btree", "--parent-check", "db1"], + 2, + [index_missing_relation_fork_re], + [no_output_re], + "pg_amcheck smoke test --parent-check", + ) + + node.command_checks_all( + cmd + + [ + "--schema", + "s1", + "--index", + "t1_btree", + "--heapallindexed", + "--rootdescend", + "db1", + ], + 2, + [index_missing_relation_fork_re], + [no_output_re], + "pg_amcheck smoke test --heapallindexed --rootdescend", + ) + + node.command_checks_all( + cmd + + [ + "--database", + "db1", + "--database", + "db2", + "--database", + "db3", + "--exclude-schema", + "s*", + ], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck excluding all corrupt schemas", + ) + + node.command_checks_all( + cmd + + [ + "--schema", + "s1", + "--index", + "t1_btree", + "--parent-check", + "--checkunique", + "db1", + ], + 2, + [index_missing_relation_fork_re], + [no_output_re], + "pg_amcheck smoke test --parent-check --checkunique", + ) + + node.command_checks_all( + cmd + + [ + "--schema", + "s1", + "--index", + "t1_btree", + "--heapallindexed", + "--rootdescend", + "--checkunique", + "db1", + ], + 2, + [index_missing_relation_fork_re], + [no_output_re], + "pg_amcheck smoke test --heapallindexed --rootdescend --checkunique", + ) + + node.command_checks_all( + cmd + + [ + "--checkunique", + "--database", + "db1", + "--database", + "db2", + "--database", + "db3", + "--exclude-schema", + "s*", + ], + 0, + [no_output_re], + [no_output_re], + "pg_amcheck excluding all corrupt schemas with --checkunique option", + ) + + # + # Smoke test for checkunique option for not supported versions. + # + node.safe_sql( + """ + DROP EXTENSION amcheck; + CREATE EXTENSION amcheck WITH SCHEMA amcheck_schema VERSION '1.3' ; + """, + "db3", + ) + + node.command_checks_all( + cmd + ["--checkunique", "db3"], + 0, + [no_output_re], + [ + r"pg_amcheck: warning: option --checkunique is not supported " + r"by amcheck version 1.3" + ], + "pg_amcheck smoke test --checkunique", + ) diff --git a/src/bin/pg_amcheck/pyt/test_004_verify_heapam.py b/src/bin/pg_amcheck/pyt/test_004_verify_heapam.py new file mode 100644 index 00000000000..3b19e6173c3 --- /dev/null +++ b/src/bin/pg_amcheck/pyt/test_004_verify_heapam.py @@ -0,0 +1,867 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests that pg_amcheck (verify_heapam) detects specific heap page corruption.""" + +import os +import re +import struct + +# This regression test demonstrates that the pg_amcheck binary correctly +# identifies specific kinds of corruption within pages. To test this, we need +# a mechanism to create corrupt pages with predictable, repeatable corruption. +# The postgres backend cannot be expected to help us with this, as its design +# is not consistent with the goal of intentionally corrupting pages. +# +# Instead, we create a table to corrupt, and with careful consideration of how +# postgresql lays out heap pages, we seek to offsets within the page and +# overwrite deliberately chosen bytes with specific values calculated to +# corrupt the page in expected ways. We then verify that pg_amcheck reports +# the corruption, and that it runs without crashing. Note that the backend +# cannot simply be started to run queries against the corrupt table, as the +# backend will crash, at least for some of the corruption types we generate. +# +# Autovacuum potentially touching the table in the background makes the exact +# behavior of this test harder to reason about. We turn it off to keep things +# simpler. We use a "belt and suspenders" approach, turning it off for the +# system generally in postgresql.conf, and turning it off specifically for the +# test table. +# +# This test depends on the table being written to the heap file exactly as we +# expect it to be, so we take care to arrange the columns of the table, and +# insert rows of the table, that give predictable sizes and locations within +# the table page. +# +# The HeapTupleHeaderData has 23 bytes of fixed size fields before the variable +# length t_bits[] array. We have exactly 3 columns in the table, so natts = 3, +# t_bits is 1 byte long, and t_hoff = MAXALIGN(23 + 1) = 24. +# +# We're not too fussy about which datatypes we use for the test, but we do care +# about some specific properties. We'd like to test both fixed size and +# varlena types. We'd like some varlena data inline and some toasted. And +# we'd like the layout of the table such that the datums land at predictable +# offsets within the tuple. We choose a structure without padding on all +# supported architectures: +# +# a BIGINT +# b TEXT +# c TEXT +# +# We always insert a 7-ascii character string into field 'b', which with a +# 1-byte varlena header gives an 8 byte inline value. We always insert a long +# text string in field 'c', long enough to force toast storage. +# +# We choose to read and write binary copies of our table's tuples, using the +# struct module. The layout diagram below uses a shorthand notation in which: +# +# l = "signed 32-bit Long", +# L = "Unsigned 32-bit Long", +# S = "Unsigned 16-bit Short", +# C = "Unsigned 8-bit Octet", +# +# Each tuple in our table has a layout as follows: +# +# xx xx xx xx t_xmin: xxxx offset = 0 L +# xx xx xx xx t_xmax: xxxx offset = 4 L +# xx xx xx xx t_field3: xxxx offset = 8 L +# xx xx bi_hi: xx offset = 12 S +# xx xx bi_lo: xx offset = 14 S +# xx xx ip_posid: xx offset = 16 S +# xx xx t_infomask2: xx offset = 18 S +# xx xx t_infomask: xx offset = 20 S +# xx t_hoff: x offset = 22 C +# xx t_bits: x offset = 23 C +# xx xx xx xx xx xx xx xx 'a': xxxxxxxx offset = 24 LL +# xx xx xx xx xx xx xx xx 'b': xxxxxxxx offset = 32 CCCCCCCC +# xx xx xx xx xx xx xx xx 'c': xxxxxxxx offset = 40 CCllLL +# xx xx xx xx xx xx xx xx : xxxxxxxx ...continued +# xx xx : xx ...continued +# +# We could choose to read and write columns 'b' and 'c' in other ways, but +# it is convenient enough to do it this way. We define packing code +# constants here, where they can be compared easily against the layout. +# +# The struct format string uses native byte order ("=") so that endianness +# matches the platform under test, and "=" suppresses alignment padding. + +HEAPTUPLE_PACK_CODE = "=LLLHHHHHBBLLBBBBBBBBBBllLL" +HEAPTUPLE_PACK_LENGTH = 58 # Total size + +# The field names corresponding (in order) to the HEAPTUPLE_PACK_CODE entries. +_TUP_FIELDS = [ + "t_xmin", + "t_xmax", + "t_field3", + "bi_hi", + "bi_lo", + "ip_posid", + "t_infomask2", + "t_infomask", + "t_hoff", + "t_bits", + "a_1", + "a_2", + "b_header", + "b_body1", + "b_body2", + "b_body3", + "b_body4", + "b_body5", + "b_body6", + "b_body7", + "c_va_header", + "c_va_vartag", + "c_va_rawsize", + "c_va_extinfo", + "c_va_valueid", + "c_va_toastrelid", +] + + +def read_tuple(fh, offset): + """Read a tuple of our table from a heap page. + + Takes an open file handle to the heap file, and the offset of the tuple. + + Rather than returning the binary data from the file, unpacks the data into + a dict with named fields. These fields exactly match the ones understood + by write_tuple(), below. + """ + fh.seek(offset, 0) + buffer = fh.read(HEAPTUPLE_PACK_LENGTH) + assert len(buffer) == HEAPTUPLE_PACK_LENGTH, "read failed" + values = struct.unpack(HEAPTUPLE_PACK_CODE, buffer) + tup = dict(zip(_TUP_FIELDS, values)) + # Stitch together the text for column 'b' + tup["b"] = "".join(chr(tup["b_body%d" % i]) for i in range(1, 8)) + return tup + + +def write_tuple(fh, offset, tup): + """Write a tuple of our table to a heap page. + + Takes an open file handle to the heap file, the offset of the tuple, and a + dict with the tuple values, as returned by read_tuple(). Writes the tuple + fields from the dict into the heap file. + + The purpose of this function is to write a tuple back to disk with some + subset of fields modified. The function does no error checking. Use + cautiously. + """ + buffer = struct.pack( + HEAPTUPLE_PACK_CODE, + tup["t_xmin"], + tup["t_xmax"], + tup["t_field3"], + tup["bi_hi"], + tup["bi_lo"], + tup["ip_posid"], + tup["t_infomask2"], + tup["t_infomask"], + tup["t_hoff"], + tup["t_bits"], + tup["a_1"], + tup["a_2"], + tup["b_header"], + tup["b_body1"], + tup["b_body2"], + tup["b_body3"], + tup["b_body4"], + tup["b_body5"], + tup["b_body6"], + tup["b_body7"], + tup["c_va_header"], + tup["c_va_vartag"], + tup["c_va_rawsize"], + tup["c_va_extinfo"], + tup["c_va_valueid"], + tup["c_va_toastrelid"], + ) + fh.seek(offset, 0) + fh.write(buffer) + + +# Some #define constants from access/htup_details.h for use while corrupting. +HEAP_HASNULL = 0x0001 +HEAP_XMAX_LOCK_ONLY = 0x0080 +HEAP_XMIN_COMMITTED = 0x0100 +HEAP_XMIN_INVALID = 0x0200 +HEAP_XMAX_COMMITTED = 0x0400 +HEAP_XMAX_INVALID = 0x0800 +HEAP_NATTS_MASK = 0x07FF +HEAP_XMAX_IS_MULTI = 0x1000 +HEAP_KEYS_UPDATED = 0x2000 +HEAP_HOT_UPDATED = 0x4000 +HEAP_ONLY_TUPLE = 0x8000 +HEAP_UPDATED = 0x2000 + +# 16-bit fields must wrap when we OR/clear bits (struct "H" requires 0..65535). +_U16_MASK = 0xFFFF + + +def header(blkno=None, offnum=None, attnum=None): + """Generate a regex string matching the header we expect verify_heapam() + to return given which fields we expect to be non-null.""" + if attnum is not None: + return ( + r'heap table "postgres\.public\.test", block %d, offset %d, ' + r"attribute %d:\s+" % (blkno, offnum, attnum) + ) + if offnum is not None: + return r'heap table "postgres\.public\.test", block %d, offset %d:\s+' % ( + blkno, + offnum, + ) + if blkno is not None: + return r'heap table "postgres\.public\.test", block %d:\s+' % blkno + return r'heap table "postgres\.public\.test":\s+' + + +def test_004_verify_heapam(create_pg): + # Set up the node. Once we create and corrupt the table, + # autovacuum workers visiting the table could crash the backend. + # Disable autovacuum so that won't happen. + node = create_pg("test", initdb_extra=["--no-data-checksums"]) + node.append_conf("autovacuum=off") + node.append_conf("max_prepared_transactions=10") + + # The node is already started; reload the configuration we appended. + node.restart() + + # Start the node and load the extensions. We depend on both + # amcheck and pageinspect for this test. + port = node.port + pgdata = node.data_dir + session = node.session() + session.do("CREATE EXTENSION amcheck") + session.do("CREATE EXTENSION pageinspect") + + # Get a non-zero datfrozenxid + session.do("VACUUM FREEZE") + + # Create the test table with precisely the schema that our corruption + # function expects. + session.do( + """ + CREATE TABLE public.test (a BIGINT, b TEXT, c TEXT); + ALTER TABLE public.test SET (autovacuum_enabled=false); + ALTER TABLE public.test ALTER COLUMN c SET STORAGE EXTERNAL; + CREATE INDEX test_idx ON public.test(a, b); + """ + ) + + # We want (0 < datfrozenxid < test.relfrozenxid). To achieve this, we + # freeze an otherwise unused table, public.junk, prior to inserting data + # and freezing public.test + session.do( + """ + CREATE TABLE public.junk AS SELECT 'junk'::TEXT AS junk_column; + ALTER TABLE public.junk SET (autovacuum_enabled=false); + """, + "VACUUM FREEZE public.junk", + ) + + rel = session.query_oneval("SELECT pg_relation_filepath('public.test')") + relpath = os.path.join(pgdata, rel) + + # Initial setup for the public.test table. + # ROWCOUNT is the total number of rows that we expect to insert into the + # page. ROWCOUNT_BASIC is the number of those rows that are related to + # basic tuple validation, rather than update chain validation. + ROWCOUNT = 44 + ROWCOUNT_BASIC = 16 + + # First insert data needed for tests unrelated to update chain validation. + # Then freeze the page. These tuples are at offset numbers 1 to 16. + session.do( + """ + INSERT INTO public.test (a, b, c) + SELECT + x'DEADF9F9DEADF9F9'::bigint, + 'abcdefg', + repeat('w', 10000) + FROM generate_series(1, %d); + """ + % ROWCOUNT_BASIC, + "VACUUM FREEZE public.test", + ) + + # Create some simple HOT update chains for line pointer validation. After + # the page is HOT pruned, we'll have two redirects line pointers each + # pointing to a tuple. We'll then change the second redirect to point to + # the same tuple as the first one and verify that we can detect corruption. + session.do( + """ + INSERT INTO public.test (a, b, c) + VALUES ( x'DEADF9F9DEADF9F9'::bigint, 'abcdefg', + generate_series(1,2)); -- offset numbers 17 and 18 + UPDATE public.test SET c = 'a' WHERE c = '1'; -- offset number 19 + UPDATE public.test SET c = 'a' WHERE c = '2'; -- offset number 20 + """ + ) + + # Create some more HOT update chains. + session.do( + """ + INSERT INTO public.test (a, b, c) + VALUES ( x'DEADF9F9DEADF9F9'::bigint, 'abcdefg', + generate_series(3,6)); -- offset numbers 21 through 24 + UPDATE public.test SET c = 'a' WHERE c = '3'; -- offset number 25 + UPDATE public.test SET c = 'a' WHERE c = '4'; -- offset number 26 + """ + ) + + # Negative test case of HOT-pruning with aborted tuple. + session.do( + """ + BEGIN; + UPDATE public.test SET c = 'a' WHERE c = '5'; -- offset number 27 + ABORT; + """, + "VACUUM FREEZE public.test;", + ) + + # Next update on any tuple will be stored at the same place of tuple + # inserted by aborted transaction. This should not cause the table to + # appear corrupt. + session.do( + """ + BEGIN; + UPDATE public.test SET c = 'a' WHERE c = '6'; -- offset number 27 again + COMMIT; + """, + "VACUUM FREEZE public.test;", + ) + + # Data for HOT chain validation, so not calling VACUUM FREEZE. + session.do( + """ + BEGIN; + INSERT INTO public.test (a, b, c) + VALUES ( x'DEADF9F9DEADF9F9'::bigint, 'abcdefg', + generate_series(7,15)); -- offset numbers 28 to 36 + UPDATE public.test SET c = 'a' WHERE c = '7'; -- offset number 37 + UPDATE public.test SET c = 'a' WHERE c = '10'; -- offset number 38 + UPDATE public.test SET c = 'a' WHERE c = '11'; -- offset number 39 + UPDATE public.test SET c = 'a' WHERE c = '12'; -- offset number 40 + UPDATE public.test SET c = 'a' WHERE c = '13'; -- offset number 41 + UPDATE public.test SET c = 'a' WHERE c = '14'; -- offset number 42 + UPDATE public.test SET c = 'a' WHERE c = '15'; -- offset number 43 + COMMIT; + """ + ) + + # Need one aborted transaction to test corruption in HOT chains. + session.do( + """ + BEGIN; + UPDATE public.test SET c = 'a' WHERE c = '9'; -- offset number 44 + ABORT; + """ + ) + + # Need one in-progress transaction to test few corruption in HOT chains. + # We are creating PREPARE TRANSACTION here as these will not be aborted + # even if we stop the node. + session.do( + """ + BEGIN; + PREPARE TRANSACTION 'in_progress_tx'; + """ + ) + in_progress_xid = session.query_oneval( + """ + SELECT transaction FROM pg_prepared_xacts; + """ + ) + + relfrozenxid = session.query_oneval( + "select relfrozenxid from pg_class where relname = 'test'" + ) + datfrozenxid = session.query_oneval( + "select datfrozenxid from pg_database where datname = 'postgres'" + ) + + relfrozenxid = int(relfrozenxid) + datfrozenxid = int(datfrozenxid) + + # Sanity check that our 'test' table has a relfrozenxid newer than the + # datfrozenxid for the database, and that the datfrozenxid is greater than + # the first normal xid. We rely on these invariants in some of our tests. + assert not (datfrozenxid <= 3 or datfrozenxid >= relfrozenxid), ( + "Xid thresholds not as expected: got datfrozenxid = %d, " + "relfrozenxid = %d" % (datfrozenxid, relfrozenxid) + ) + + # Find where each of the tuples is located on the page. If a particular + # line pointer is a redirect rather than a tuple, we record the offset as + # -1. + lp_off_res = session.query( + """ + SELECT CASE WHEN lp_flags = 2 THEN -1 ELSE lp_off END + FROM heap_page_items(get_raw_page('test', 'main', 0)) + """ + ) + lp_off = [int(row[0]) for row in lp_off_res.rows] + + assert len(lp_off) == ROWCOUNT, "row offset counts mismatch" + + # Sanity check that our 'test' table on disk layout matches expectations. + # If this is not so, we will have to skip the test until somebody updates + # the test to work on this platform. + session.close() + node.stop() + + ENDIANNESS = None + with open(relpath, "r+b") as file: + for tupidx in range(ROWCOUNT): + offset = lp_off[tupidx] + if offset == -1: + continue # ignore redirect line pointers + tup = read_tuple(file, offset) + + # Sanity-check that the data appears on the page where we expect. + a_1 = tup["a_1"] + a_2 = tup["a_2"] + b = tup["b"] + assert a_1 == 0xDEADF9F9 and a_2 == 0xDEADF9F9 and b == "abcdefg", ( + "Page layout of index %d differs from our expectations: " + 'expected (%x, %x, "%s"), got (%x, %x, "%s")' + % ( + tupidx, + 0xDEADF9F9, + 0xDEADF9F9, + "abcdefg", + a_1, + a_2, + re.sub( + r"(\W)", + lambda m: "\\x%02x" % ord(m.group(1)), + b, + ), + ) + ) + + # Determine endianness of current platform from the 1-byte varlena + # header + ENDIANNESS = "little" if tup["b_header"] == 0x11 else "big" + + node.start() + + # Ok, Xids and page layout look ok. We can run corruption tests. + + # Check that pg_amcheck runs against the uncorrupted table without error. + node.command_ok( + ["pg_amcheck", "--port", str(port), "postgres"], + "pg_amcheck test table, prior to corruption", + ) + + # Check that pg_amcheck runs against the uncorrupted table and index + # without error. + node.command_ok( + ["pg_amcheck", "--port", str(port), "postgres"], + "pg_amcheck test table and index, prior to corruption", + ) + + node.stop() + + # Saved values used to corrupt later tuples relative to earlier ones. + pred_xmax = None + pred_posid = None + aborted_xid = None + + # Corrupt the tuples, one type of corruption per tuple. Some types of + # corruption cause verify_heapam to skip to the next tuple without + # performing any remaining checks, so we can't exercise the system properly + # if we focus all our corruption on a single tuple. + expected = [] + with open(relpath, "r+b") as file: + for tupidx in range(ROWCOUNT): + offnum = tupidx + 1 # offnum is 1-based, not zero-based + offset = lp_off[tupidx] + hdr = header(0, offnum) + + # Read tuple, if there is one. + tup = None if offset == -1 else read_tuple(file, offset) + + if offnum == 1: + # Corruptly set xmin < relfrozenxid + xmin = relfrozenxid - 1 + tup["t_xmin"] = xmin + tup["t_infomask"] &= ~HEAP_XMIN_COMMITTED & _U16_MASK + tup["t_infomask"] &= ~HEAP_XMIN_INVALID & _U16_MASK + + # Expected corruption report + expected.append( + re.compile( + hdr + r"xmin %d precedes relation freeze threshold 0:\d+" % xmin + ) + ) + elif offnum == 2: + # Corruptly set xmin < datfrozenxid + xmin = 3 + tup["t_xmin"] = xmin + tup["t_infomask"] &= ~HEAP_XMIN_COMMITTED & _U16_MASK + tup["t_infomask"] &= ~HEAP_XMIN_INVALID & _U16_MASK + + expected.append( + re.compile( + hdr + + r"xmin %d precedes oldest valid transaction ID 0:\d+" % xmin + ) + ) + elif offnum == 3: + # Corruptly set xmin < datfrozenxid, further back, noting + # circularity of xid comparison. + xmin = 4026531839 + tup["t_xmin"] = xmin + tup["t_infomask"] &= ~HEAP_XMIN_COMMITTED & _U16_MASK + tup["t_infomask"] &= ~HEAP_XMIN_INVALID & _U16_MASK + + expected.append( + re.compile( + hdr + + r"xmin %d precedes oldest valid transaction ID 0:\d+" % xmin + ) + ) + elif offnum == 4: + # Corruptly set xmax < relminmxid; + xmax = 4026531839 + tup["t_xmax"] = xmax + tup["t_infomask"] &= ~HEAP_XMAX_INVALID & _U16_MASK + + expected.append( + re.compile( + hdr + + r"xmax %d precedes oldest valid transaction ID 0:\d+" % xmax + ) + ) + elif offnum == 5: + # Corrupt the tuple t_hoff, but keep it aligned properly + tup["t_hoff"] += 128 + + expected.append( + re.compile( + hdr + r"data begins at offset 152 beyond the tuple length 58" + ) + ) + expected.append( + re.compile( + hdr + r"tuple data should begin at byte 24, but actually " + r"begins at byte 152 \(3 attributes, no nulls\)" + ) + ) + elif offnum == 6: + # Corrupt the tuple t_hoff, wrong alignment + tup["t_hoff"] += 3 + + expected.append( + re.compile( + hdr + r"tuple data should begin at byte 24, but actually " + r"begins at byte 27 \(3 attributes, no nulls\)" + ) + ) + elif offnum == 7: + # Corrupt the tuple t_hoff, underflow but correct alignment + tup["t_hoff"] -= 8 + + expected.append( + re.compile( + hdr + r"tuple data should begin at byte 24, but actually " + r"begins at byte 16 \(3 attributes, no nulls\)" + ) + ) + elif offnum == 8: + # Corrupt the tuple t_hoff, underflow and wrong alignment + tup["t_hoff"] -= 3 + + expected.append( + re.compile( + hdr + r"tuple data should begin at byte 24, but actually " + r"begins at byte 21 \(3 attributes, no nulls\)" + ) + ) + elif offnum == 9: + # Corrupt the tuple to look like it has lots of attributes, not + # just 3 + tup["t_infomask2"] |= HEAP_NATTS_MASK + + expected.append( + re.compile( + hdr + r"number of attributes 2047 exceeds maximum 3 " + r"expected for table" + ) + ) + elif offnum == 10: + # Corrupt the tuple to look like it has lots of attributes, + # some of them null. This falsely creates the impression that + # the t_bits array is longer than just one byte, but t_hoff + # still says otherwise. + tup["t_infomask"] |= HEAP_HASNULL + tup["t_infomask2"] |= HEAP_NATTS_MASK + tup["t_bits"] = 0xAA + + expected.append( + re.compile( + hdr + r"tuple data should begin at byte 280, but actually " + r"begins at byte 24 \(2047 attributes, has nulls\)" + ) + ) + elif offnum == 11: + # Same as above, but this time t_hoff plays along + tup["t_infomask"] |= HEAP_HASNULL + tup["t_infomask2"] |= HEAP_NATTS_MASK & 0x40 + tup["t_bits"] = 0xAA + tup["t_hoff"] = 32 + + expected.append( + re.compile( + hdr + r"number of attributes 67 exceeds maximum 3 " + r"expected for table" + ) + ) + elif offnum == 12: + # Overwrite column 'b' 1-byte varlena header and initial + # characters to look like a long 4-byte varlena + # + # On little endian machines, bytes ending in two zero bits + # (xxxxxx00 bytes) are 4-byte length word, aligned, + # uncompressed data (up to 1G). We set the high six bits to + # 111111 and the lower two bits to 00, then the next three bytes + # with 0xFF using 0xFCFFFFFF. + # + # On big endian machines, bytes starting in two zero bits + # (00xxxxxx bytes) are 4-byte length word, aligned, + # uncompressed data (up to 1G). We set the low six bits to + # 111111 and the high two bits to 00, then the next three bytes + # with 0xFF using 0x3FFFFFFF. + tup["b_header"] = 0xFC if ENDIANNESS == "little" else 0x3F + tup["b_body1"] = 0xFF + tup["b_body2"] = 0xFF + tup["b_body3"] = 0xFF + + hdr = header(0, offnum, 1) + expected.append( + re.compile( + hdr + r"attribute with length \d+ ends at offset \d+ " + r"beyond total tuple length \d+" + ) + ) + elif offnum == 13: + # Corrupt the bits in column 'c' toast pointer + tup["c_va_valueid"] = 0xFFFFFFFF + + hdr = header(0, offnum, 2) + expected.append( + re.compile(hdr + r"toast value \d+ not found in toast table") + ) + elif offnum == 14: + # Set both HEAP_XMAX_COMMITTED and HEAP_XMAX_IS_MULTI + tup["t_infomask"] |= HEAP_XMAX_COMMITTED + tup["t_infomask"] |= HEAP_XMAX_IS_MULTI + tup["t_xmax"] = 4 + + expected.append( + re.compile( + hdr + r"multitransaction ID 4 equals or exceeds next valid " + r"multitransaction ID 1" + ) + ) + elif offnum == 15: + # Set both HEAP_XMAX_COMMITTED and HEAP_XMAX_IS_MULTI + tup["t_infomask"] |= HEAP_XMAX_COMMITTED + tup["t_infomask"] |= HEAP_XMAX_IS_MULTI + tup["t_xmax"] = 4000000000 + + expected.append( + re.compile( + hdr + r"multitransaction ID 4000000000 precedes relation " + r"minimum multitransaction ID threshold 1" + ) + ) + elif offnum == 16: # Last offnum must equal ROWCOUNT + # Corruptly set xmin > next_xid to be in the future. + xmin = 123456 + tup["t_xmin"] = xmin + tup["t_infomask"] &= ~HEAP_XMIN_COMMITTED & _U16_MASK + tup["t_infomask"] &= ~HEAP_XMIN_INVALID & _U16_MASK + + expected.append( + re.compile( + hdr + r"xmin %d equals or exceeds next valid transaction " + r"ID 0:\d+" % xmin + ) + ) + elif offnum == 17: + # at offnum 19 we will unset HEAP_ONLY_TUPLE flag + assert tup is None, "offnum %d should be a redirect" % offnum + expected.append( + re.compile( + hdr + r"redirected line pointer points to a non-heap-only " + r"tuple at offset \d+" + ) + ) + elif offnum == 18: + # rewrite line pointer with lp_off = 17, lp_flags = 2, + # lp_len = 0. + assert tup is None, "offnum %d should be a redirect" % offnum + file.seek(92, 0) + file.write( + struct.pack( + "=L", + 0x00010011 if ENDIANNESS == "little" else 0x00230000, + ) + ) + expected.append( + re.compile( + hdr + r"redirected line pointer points to another " + r"redirected line pointer at offset \d+" + ) + ) + elif offnum == 19: + # unset HEAP_ONLY_TUPLE flag, so that update chain validation + # will complain about offset 17 + tup["t_infomask2"] &= ~HEAP_ONLY_TUPLE & _U16_MASK + elif offnum == 22: + # rewrite line pointer with lp.off = 25, lp_flags = 2, + # lp_len = 0 + file.seek(108, 0) + file.write( + struct.pack( + "=L", + 0x00010019 if ENDIANNESS == "little" else 0x00330000, + ) + ) + expected.append( + re.compile( + hdr + r"redirect line pointer points to offset \d+, but " + r"offset \d+ also points there" + ) + ) + elif offnum == 28: + tup["t_infomask2"] &= ~HEAP_HOT_UPDATED & _U16_MASK + expected.append( + re.compile( + hdr + r"non-heap-only update produced a heap-only tuple at " + r"offset \d+" + ) + ) + + # Save these values so we can insert them into the tuple at + # offnum 29. + pred_xmax = tup["t_xmax"] + pred_posid = tup["ip_posid"] + elif offnum == 29: + # Copy these values from the tuple at offset 28. + tup["t_xmax"] = pred_xmax + tup["ip_posid"] = pred_posid + expected.append( + re.compile( + hdr + r"tuple points to new version at offset \d+, but " + r"offset \d+ also points there" + ) + ) + elif offnum == 30: + # Save xid, so we can insert into into tuple at offset 31. + aborted_xid = tup["t_xmax"] + elif offnum == 31: + # Set xmin to xmax of tuple at offset 30. + tup["t_xmin"] = aborted_xid + tup["t_infomask"] &= ~HEAP_XMIN_COMMITTED & _U16_MASK + expected.append( + re.compile( + hdr + r"tuple with aborted xmin \d+ was updated to produce " + r"a tuple at offset \d+ with committed xmin \d+" + ) + ) + elif offnum == 32: + tup["t_infomask2"] |= HEAP_ONLY_TUPLE + expected.append( + re.compile( + hdr + r"tuple is root of chain but is marked as heap-only " + r"tuple" + ) + ) + expected.append( + re.compile( + hdr + r"tuple is heap only, but not the result of an update" + ) + ) + elif offnum == 33: + # Tuple at offset 40 is the successor of this one; we'll corrupt + # it to be non-heap-only. + expected.append( + re.compile( + hdr + r"heap-only update produced a non-heap only tuple at " + r"offset \d+" + ) + ) + elif offnum == 34: + tup["t_xmax"] = 0 + expected.append( + re.compile(hdr + r"tuple has been HOT updated, but xmax is 0") + ) + elif offnum == 35: + tup["t_xmin"] = int(in_progress_xid) + tup["t_infomask"] &= ~HEAP_XMIN_COMMITTED & _U16_MASK + expected.append( + re.compile( + hdr + r"tuple with in-progress xmin \d+ was updated to " + r"produce a tuple at offset \d+ with committed xmin \d+" + ) + ) + elif offnum == 36: + # Tuple at offset 43 is the successor of this one; we'll corrupt + # it to have xmin = in_progress_xid. By setting the xmax of this + # tuple to the same value, we make it look like an update chain + # with an in-progress XID following a committed one. + tup["t_xmin"] = aborted_xid + tup["t_xmax"] = int(in_progress_xid) + tup["t_infomask"] &= ~HEAP_XMIN_COMMITTED & _U16_MASK + expected.append( + re.compile( + hdr + r"tuple with aborted xmin \d+ was updated to produce " + r"a tuple at offset \d+ with in-progress xmin \d+" + ) + ) + elif offnum == 40: + # Tuple at offset 33 is the predecessor of this one; the error + # will be reported there. + tup["t_infomask2"] &= ~HEAP_ONLY_TUPLE & _U16_MASK + elif offnum == 43: + # Tuple at offset 36 is the predecessor of this one; the error + # will be reported there. + tup["t_xmin"] = int(in_progress_xid) + tup["t_infomask"] &= ~HEAP_XMIN_COMMITTED & _U16_MASK + else: + # The tests for update chain validation end up creating a bunch + # of tuples that aren't corrupted in any way e.g. because only + # one of the two tuples in the update chain needs to be + # corrupted for the test, or because one update chain is being + # made to erroneously point into the middle of another that has + # nothing wrong with it. In all such cases we need not write + # the tuple back to the file. + continue + + if tup is not None: + write_tuple(file, offset, tup) + + node.start() + session = node.session() + + # Run pg_amcheck against the corrupt table with epoch=0, comparing actual + # corruption messages against the expected messages + node.command_checks_all( + ["pg_amcheck", "--no-dependent-indexes", "--port", str(port), "postgres"], + 2, + expected, + [], + "Expected corruption message output", + ) + session.do( + """ + COMMIT PREPARED 'in_progress_tx'; + """ + ) + + session.close() + node.stop() diff --git a/src/bin/pg_amcheck/pyt/test_005_opclass_damage.py b/src/bin/pg_amcheck/pyt/test_005_opclass_damage.py new file mode 100644 index 00000000000..48ec80caec1 --- /dev/null +++ b/src/bin/pg_amcheck/pyt/test_005_opclass_damage.py @@ -0,0 +1,123 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests btree validation behavior in the presence of breaking sort order changes.""" + + +def test_005_opclass_damage(create_pg): + node = create_pg("test") + + # Create a custom operator class and an index which uses it. + node.safe_sql( + """ + CREATE EXTENSION amcheck; + + CREATE FUNCTION int4_asc_cmp (a int4, b int4) RETURNS int LANGUAGE sql AS $$ + SELECT CASE WHEN $1 = $2 THEN 0 WHEN $1 > $2 THEN 1 ELSE -1 END; $$; + + CREATE FUNCTION ok_cmp (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT + CASE WHEN $1 < $2 THEN -1 + WHEN $1 > $2 THEN 1 + ELSE 0 + END; + $$; + + CREATE OPERATOR CLASS int4_fickle_ops FOR TYPE int4 USING btree AS + OPERATOR 1 < (int4, int4), OPERATOR 2 <= (int4, int4), + OPERATOR 3 = (int4, int4), OPERATOR 4 >= (int4, int4), + OPERATOR 5 > (int4, int4), FUNCTION 1 int4_asc_cmp(int4, int4); + + CREATE OPERATOR CLASS int4_unique_ops FOR TYPE int4 USING btree AS + OPERATOR 1 < (int4, int4), OPERATOR 2 <= (int4, int4), + OPERATOR 3 = (int4, int4), OPERATOR 4 >= (int4, int4), + OPERATOR 5 > (int4, int4), FUNCTION 1 ok_cmp(int4, int4); + + CREATE TABLE int4tbl (i int4); + INSERT INTO int4tbl (SELECT * FROM generate_series(1,1000) gs); + CREATE INDEX fickleidx ON int4tbl USING btree (i int4_fickle_ops); + CREATE UNIQUE INDEX bttest_unique_idx + ON int4tbl + USING btree (i int4_unique_ops) + WITH (deduplicate_items = off); +""" + ) + + # We have not yet broken the index, so we should get no corruption + node.command_like( + ["pg_amcheck", "--port", str(node.port), "postgres"], + r"^$", + "pg_amcheck all schemas, tables and indexes reports no corruption", + ) + + # Change the operator class to use a function which sorts in a different + # order to corrupt the btree index + node.safe_sql( + """ + CREATE FUNCTION int4_desc_cmp (int4, int4) RETURNS int LANGUAGE sql AS $$ + SELECT CASE WHEN $1 = $2 THEN 0 WHEN $1 > $2 THEN -1 ELSE 1 END; $$; + UPDATE pg_catalog.pg_amproc + SET amproc = 'int4_desc_cmp'::regproc + WHERE amproc = 'int4_asc_cmp'::regproc +""" + ) + + # Index corruption should now be reported + node.command_checks_all( + ["pg_amcheck", "--port", str(node.port), "postgres"], + 2, + [r'item order invariant violated for index "fickleidx"'], + [], + "pg_amcheck all schemas, tables and indexes reports fickleidx corruption", + ) + + # + # Check unique constraints + # + + # Repair broken opclass for check unique tests. + node.safe_sql( + """ + UPDATE pg_catalog.pg_amproc + SET amproc = 'int4_asc_cmp'::regproc + WHERE amproc = 'int4_desc_cmp'::regproc +""" + ) + + # We should get no corruptions + node.command_like( + ["pg_amcheck", "--checkunique", "--port", str(node.port), "postgres"], + r"^$", + "pg_amcheck all schemas, tables and indexes reports no corruption", + ) + + # Break opclass for check unique tests. + node.safe_sql( + """ + CREATE FUNCTION bad_cmp (int4, int4) + RETURNS int LANGUAGE sql AS + $$ + SELECT + CASE WHEN ($1 = 768 AND $2 = 769) OR + ($1 = 769 AND $2 = 768) THEN 0 + WHEN $1 < $2 THEN -1 + WHEN $1 > $2 THEN 1 + ELSE 0 + END; + $$; + + UPDATE pg_catalog.pg_amproc + SET amproc = 'bad_cmp'::regproc + WHERE amproc = 'ok_cmp'::regproc +""" + ) + + # Unique index corruption should now be reported + node.command_checks_all( + ["pg_amcheck", "--checkunique", "--port", str(node.port), "postgres"], + 2, + [r'index uniqueness is violated for index "bttest_unique_idx"'], + [], + "pg_amcheck all schemas, tables and indexes reports bttest_unique_idx corruption", + ) diff --git a/src/bin/pg_basebackup/meson.build b/src/bin/pg_basebackup/meson.build index d70ce5786a2..657c9441c77 100644 --- a/src/bin/pg_basebackup/meson.build +++ b/src/bin/pg_basebackup/meson.build @@ -105,6 +105,19 @@ tests += { 't/040_pg_createsubscriber.pl', ], }, + 'pytest': { + 'env': {'GZIP_PROGRAM': gzip.found() ? gzip.full_path() : '', + 'TAR': tar.found() ? tar.full_path() : '', + 'LZ4': program_lz4.found() ? program_lz4.full_path() : '', + }, + 'tests': [ + 'pyt/test_010_pg_basebackup.py', + 'pyt/test_011_in_place_tablespace.py', + 'pyt/test_020_pg_receivewal.py', + 'pyt/test_030_pg_recvlogical.py', + 'pyt/test_040_pg_createsubscriber.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_basebackup/pyt/test_010_pg_basebackup.py b/src/bin/pg_basebackup/pyt/test_010_pg_basebackup.py new file mode 100644 index 00000000000..a988cf6657b --- /dev/null +++ b/src/bin/pg_basebackup/pyt/test_010_pg_basebackup.py @@ -0,0 +1,1243 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_basebackup across its many options and output formats.""" + +import glob +import os +import re +import stat +import subprocess + +import pytest + +from pypg.util import ( + TIMEOUT_DEFAULT, + WINDOWS_OS, + append_to_file, + dir_symlink, + short_tempdir, + slurp_file, +) + +# pg_basebackup invocation defaults: keep test times reasonable. Used as the +# leading elements of the argument list passed to the node command_* helpers. +PG_BASEBACKUP_DEFS = ["pg_basebackup", "--no-sync", "-cfast"] + +# Some tests depend on optional build features (e.g. compression libraries). +# We probe the installed pg_config.h at runtime for the corresponding HAVE_* +# defines and skip those tests when the feature is absent. + + +def _have_pg_config_define(define): + """Return True if pg_config.h contains the given #define line.""" + try: + out = subprocess.run( + ["pg_config", "--includedir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return False + header = os.path.join(out, "pg_config.h") + try: + with open(header, encoding="utf-8", errors="replace") as fh: + return define in fh.read() + except OSError: + return False + + +HAVE_LIBZ = _have_pg_config_define("#define HAVE_LIBZ 1") + + +def _slurp_dir(path): + """Return directory entries, including '.' and '..'.""" + return [".", ".."] + os.listdir(path) + + +def check_mode_recursive(path, dir_mode, file_mode): + """Recursively verify directory and file permission bits. + + Symlinks are ignored. Returns True if all modes match. + """ + ok = True + for root, _dirs, files in os.walk(path): + st = os.lstat(root) + if stat.S_IMODE(st.st_mode) != dir_mode: + print( + f"mode of directory {root} is {oct(stat.S_IMODE(st.st_mode))}, " + f"expected {oct(dir_mode)}" + ) + ok = False + for name in files: + fp = os.path.join(root, name) + if os.path.islink(fp): + continue + st = os.lstat(fp) + if stat.S_IMODE(st.st_mode) != file_mode: + print( + f"mode of file {fp} is {oct(stat.S_IMODE(st.st_mode))}, " + f"expected {oct(file_mode)}" + ) + ok = False + return ok + + +def chmod_recursive(path, dir_mode, file_mode): + """Recursively chmod *path* applying *dir_mode* and *file_mode*.""" + os.chmod(path, dir_mode) + for root, dirs, files in os.walk(path): + for name in dirs: + os.chmod(os.path.join(root, name), dir_mode) + for name in files: + fp = os.path.join(root, name) + if not os.path.islink(fp): + os.chmod(fp, file_mode) + + +def test_010_pg_basebackup(create_pg, pg_bin, tmp_path): + pg_bin.program_help_ok("pg_basebackup") + pg_bin.program_version_ok("pg_basebackup") + pg_bin.program_options_handling_ok("pg_basebackup") + + tempdir = str(tmp_path / "tempdir") + os.mkdir(tempdir) + + # Set umask so test directories and files are created with default + # permissions (pg_basebackup creates the target dir honoring the umask). + old_umask = os.umask(0o077) + try: + _run_body(create_pg, tempdir) + finally: + os.umask(old_umask) + + +def _run_body(create_pg, tempdir): + # Initialize node without replication settings. The framework's + # init() (without allows_streaming) leaves the compiled-in defaults + # (wal_level=replica, max_wal_senders=10), so explicitly write + # wal_level=minimal / max_wal_senders=0 here to make the "fails because of + # WAL configuration" check below meaningful. (The default trust pg_hba + # already permits the backupuser role, so no extra role setup is needed.) + node = create_pg("main", start=False, initdb_extra=["--data-checksums"]) + node.append_conf( + "\n".join( + [ + "wal_level = minimal", + "max_wal_senders = 0", + "", + ] + ) + ) + node.start() + pgdata = node.data_dir + + node.command_fails( + ["pg_basebackup"], "pg_basebackup needs target directory specified" + ) + + # Sanity checks for options. + node.command_fails_like( + ["pg_basebackup", "--pgdata", f"{tempdir}/backup", "--compress", "none:1"], + r'compression algorithm "none" does not accept a compression level', + 'failure if method "none" specified with compression level', + ) + node.command_fails_like( + ["pg_basebackup", "--pgdata", f"{tempdir}/backup", "--compress", "none+"], + r'unrecognized compression algorithm: "none\+"', + "failure on incorrect separator to define compression level", + ) + + # Write a file with a non-UTF8 name to test backup of such files. Some + # filesystems (macOS, and some Windows ANSI code pages) reject this + # filename, in which case we quietly proceed without this bit of coverage. + os.makedirs(f"{tempdir}/pgdata", exist_ok=True) + try: + badname = os.path.join(tempdir.encode(), b"pgdata", b"FOO\xe0\xe0\xe0BAR") + with open(badname, "ab") as fh: + fh.write(b"test backup of file with non-UTF8 name\n") + except (OSError, UnicodeDecodeError): + # OSError: the filesystem rejected the name. UnicodeDecodeError: + # Windows cannot map the non-UTF8 bytes to a wide-char path at all -- + # on some builds even joining the byte path raises this, so the join + # is inside the try too. + pass + + # set_replication_conf / reload: the default trust pg_hba already permits + # local replication, so no pg_hba change is needed for unix sockets. + node.reload() + + node.command_fails( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backup"], + "pg_basebackup fails because of WAL configuration", + ) + + assert not os.path.isdir(f"{tempdir}/backup"), "backup directory was cleaned up" + + # Create a backup directory that is not empty so the next command will + # fail but leave the data directory behind. + os.mkdir(f"{tempdir}/backup") + append_to_file(f"{tempdir}/backup/dir-not-empty.txt", "Some data") + + node.command_fails( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backup", "-n"], + "failing run with no-clean option", + ) + + assert os.path.isdir( + f"{tempdir}/backup" + ), "backup directory was created and left behind" + _rmtree(f"{tempdir}/backup") + + node.append_conf( + "\n".join( + [ + "max_replication_slots = 10", + "max_wal_senders = 10", + "wal_level = replica", + ] + ) + ) + node.restart() + + # Now that we have a server that supports replication commands, test + # whether certain invalid compression commands fail on the client side + # with client-side compression and on the server side with server-side + # compression. + if not HAVE_LIBZ: + pytest.skip("postgres was not built with ZLIB support") + else: + client_fails = "pg_basebackup: error: " + server_fails = "pg_basebackup: error: could not initiate base backup: ERROR: " + compression_failure_tests = [ + [ + "extrasquishy", + 'unrecognized compression algorithm: "extrasquishy"', + "failure on invalid compression algorithm", + ], + [ + "gzip:", + "invalid compression specification: found empty string where a " + "compression option was expected", + "failure on empty compression options list", + ], + [ + "gzip:thunk", + "invalid compression specification: unrecognized compression " + 'option: "thunk"', + "failure on unknown compression option", + ], + [ + "gzip:level", + 'invalid compression specification: compression option "level" ' + "requires a value", + "failure on missing compression level", + ], + [ + "gzip:level=", + "invalid compression specification: value for compression option " + '"level" must be an integer', + "failure on empty compression level", + ], + [ + "gzip:level=high", + "invalid compression specification: value for compression option " + '"level" must be an integer', + "failure on non-numeric compression level", + ], + [ + "gzip:level=236", + 'invalid compression specification: compression algorithm "gzip" ' + "expects a compression level between 1 and 9", + "failure on out-of-range compression level", + ], + [ + "gzip:level=9,", + "invalid compression specification: found empty string where a " + "compression option was expected", + "failure on extra, empty compression option", + ], + [ + "gzip:workers=3", + 'invalid compression specification: compression algorithm "gzip" ' + "does not accept a worker count", + "failure on worker count for gzip", + ], + [ + "gzip:long", + 'invalid compression specification: compression algorithm "gzip" ' + "does not support long-distance mode", + "failure on long mode for gzip", + ], + ] + for spec, errmsg, label in compression_failure_tests: + cfail = re.escape(client_fails + errmsg) + sfail = re.escape(server_fails + errmsg) + node.command_fails_like( + ["pg_basebackup", "--pgdata", f"{tempdir}/backup", "--compress", spec], + cfail, + "client " + label, + ) + node.command_fails_like( + [ + "pg_basebackup", + "--pgdata", + f"{tempdir}/backup", + "--compress", + "server-" + spec, + ], + sfail, + "server " + label, + ) + + # Write some files to test that they are not copied. + for filename in ( + "backup_label", + "tablespace_map", + "postgresql.auto.conf.tmp", + "current_logfiles.tmp", + "global/pg_internal.init.123", + ): + append_to_file(os.path.join(pgdata, filename), "DONOTCOPY") + + # Test that macOS system files are skipped. Only test on non-macOS + # systems since creating incorrect .DS_Store files on macOS may have + # unintended side effects. + import sys + + if sys.platform != "darwin": + append_to_file(os.path.join(pgdata, ".DS_Store"), "DONOTCOPY") + + # Connect to a database to create global/pg_internal.init. If this is + # removed the test below would return a false positive. + node.safe_sql("SELECT 1;") + + # Create an unlogged table to test that forks other than init are not + # copied. + node.safe_sql("CREATE UNLOGGED TABLE base_unlogged (id int)") + + base_unlogged_path = node.safe_sql("select pg_relation_filepath('base_unlogged')") + + # Make sure main and init forks exist. + assert os.path.isfile( + os.path.join(pgdata, base_unlogged_path + "_init") + ), "unlogged init fork in base" + assert os.path.isfile( + os.path.join(pgdata, base_unlogged_path) + ), "unlogged main fork in base" + + # Create files that look like temporary relations to ensure they are + # ignored. + postgres_oid = node.safe_sql( + "select oid from pg_database where datname = 'postgres'" + ) + + temp_relation_files = [ + "t999_999", + "t9999_999.1", + "t999_9999_vm", + "t99999_99999_vm.1", + ] + for filename in temp_relation_files: + append_to_file( + os.path.join(pgdata, "base", postgres_oid, filename), "TEMP_RELATION" + ) + + # Run base backup. + node.command_ok( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backup", "--wal-method", "none"], + "pg_basebackup runs", + ) + assert os.path.isfile(f"{tempdir}/backup/PG_VERSION"), "backup was created" + assert os.path.isfile( + f"{tempdir}/backup/backup_manifest" + ), "backup manifest included" + + # Permissions on backup should be default (unix-only; skipped on Windows). + if not WINDOWS_OS: + assert check_mode_recursive( + f"{tempdir}/backup", 0o700, 0o600 + ), "check backup dir permissions" + + # Only archive_status and summaries directories should be copied in + # pg_wal/. + assert sorted(_slurp_dir(f"{tempdir}/backup/pg_wal/")) == sorted( + [".", "..", "archive_status", "summaries"] + ), "no WAL files copied" + + # Contents of these directories should not be copied. + for dirname in ( + "pg_dynshmem", + "pg_notify", + "pg_replslot", + "pg_serial", + "pg_snapshots", + "pg_stat_tmp", + "pg_subtrans", + ): + assert sorted(_slurp_dir(f"{tempdir}/backup/{dirname}/")) == sorted( + [".", ".."] + ), f"contents of {dirname}/ not copied" + + # These files should not be copied. + for filename in ( + "postgresql.auto.conf.tmp", + "postmaster.opts", + "postmaster.pid", + "tablespace_map", + "current_logfiles.tmp", + "global/pg_internal.init", + "global/pg_internal.init.123", + ): + assert not os.path.isfile( + f"{tempdir}/backup/{filename}" + ), f"{filename} not copied" + + # We only test .DS_Store files being skipped on non-macOS systems. + if sys.platform != "darwin": + assert not os.path.isfile(f"{tempdir}/backup/.DS_Store"), ".DS_Store not copied" + + # Unlogged relation forks other than init should not be copied. + assert os.path.isfile( + f"{tempdir}/backup/{base_unlogged_path}_init" + ), "unlogged init fork in backup" + assert not os.path.isfile( + f"{tempdir}/backup/{base_unlogged_path}" + ), "unlogged main fork not in backup" + + # Temp relations should not be copied. + for filename in temp_relation_files: + assert not os.path.isfile( + f"{tempdir}/backup/base/{postgres_oid}/{filename}" + ), f"base/{postgres_oid}/{filename} not copied" + + # Make sure existing backup_label was ignored. + assert ( + slurp_file(f"{tempdir}/backup/backup_label") != "DONOTCOPY" + ), "existing backup_label not copied" + _rmtree(f"{tempdir}/backup") + + # Now delete the bogus backup_label file since it will interfere with + # startup. + os.unlink(os.path.join(pgdata, "backup_label")) + + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup2", + "--no-manifest", + "--waldir", + f"{tempdir}/xlog2", + ], + "separate xlog directory", + ) + assert os.path.isfile(f"{tempdir}/backup2/PG_VERSION"), "backup was created" + assert not os.path.isfile( + f"{tempdir}/backup2/backup_manifest" + ), "manifest was suppressed" + assert os.path.isdir(f"{tempdir}/xlog2/"), "xlog directory was created" + _rmtree(f"{tempdir}/backup2") + _rmtree(f"{tempdir}/xlog2") + + node.command_ok( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/tarbackup", "--format", "tar"], + "tar format", + ) + assert os.path.isfile(f"{tempdir}/tarbackup/base.tar"), "backup tar was created" + _rmtree(f"{tempdir}/tarbackup") + + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_foo", + "--format", + "plain", + "--tablespace-mapping", + "=/foo", + ], + r"invalid tablespace mapping format", + "--tablespace-mapping with empty old directory fails", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_foo", + "--format", + "plain", + "--tablespace-mapping", + "/foo=", + ], + r"invalid tablespace mapping format", + "--tablespace-mapping with empty new directory fails", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_foo", + "--format", + "plain", + "--tablespace-mapping", + "/foo=/bar=/baz", + ], + r'multiple "=" signs in tablespace mapping', + "--tablespace-mapping with multiple = fails", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_foo", + "--format", + "plain", + "--tablespace-mapping", + "foo=/bar", + ], + r"old directory is not an absolute path in tablespace mapping", + "--tablespace-mapping with old directory not absolute fails", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_foo", + "--format", + "plain", + "--tablespace-mapping", + "/foo=bar", + ], + r"new directory is not an absolute path in tablespace mapping", + "--tablespace-mapping with new directory not absolute fails", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_foo", + "--format", + "plain", + "--tablespace-mapping", + "foo", + ], + r"invalid tablespace mapping format", + "--tablespace-mapping with invalid format fails", + ) + + superlongname = "superlongname_" + ("x" * 100) + # Tar format doesn't support filenames longer than 100 bytes. + superlongpath = os.path.join(pgdata, superlongname) + with open(superlongpath, "w", encoding="utf-8"): + pass + node.command_fails( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/tarbackup_l1", "--format", "tar"], + "pg_basebackup tar with long name fails", + ) + os.unlink(superlongpath) + + # The following tests are for symlinks. + + # Move pg_replslot out of $pgdata and create a symlink to it. + node.stop() + + # Set umask so test directories and files are created with group + # permissions. + os.umask(0o027) + + # Enable group permissions on PGDATA. + chmod_recursive(pgdata, 0o750, 0o640) + + # Create a temporary directory in a short system location (for short + # tablespace path names, to stay under the tar 99-char limit). + sys_tempdir = short_tempdir() + + # pg_replslot should be empty. Remove and recreate it under sys_tempdir + # before symlinking, to avoid moving across drives. + os.rmdir(os.path.join(pgdata, "pg_replslot")) + os.mkdir(os.path.join(sys_tempdir, "pg_replslot")) + dir_symlink( + os.path.join(sys_tempdir, "pg_replslot"), os.path.join(pgdata, "pg_replslot") + ) + + node.start() + + # Test backup of a tablespace using tar format. Symlink the system + # located tempdir to our physical temp location so we can use shorter + # names for the tablespace directories. + real_sys_tempdir = os.path.join(sys_tempdir, "tempdir") + dir_symlink(tempdir, real_sys_tempdir) + + os.mkdir(f"{tempdir}/tblspc1") + real_ts_dir = f"{real_sys_tempdir}/tblspc1" + node.safe_sql(f"CREATE TABLESPACE tblspc1 LOCATION '{real_ts_dir}';") + node.safe_sql( + "CREATE TABLE test1 (a int) TABLESPACE tblspc1;" + "INSERT INTO test1 VALUES (1234);" + ) + node.backup("tarbackup2", backup_options=["--format", "tar"]) + # empty test1, just so that it's different from the to-be-restored data + node.safe_sql("TRUNCATE TABLE test1;") + + # basic checks on the output + backupdir = os.path.join(node.backup_dir, "tarbackup2") + assert os.path.isfile(f"{backupdir}/base.tar"), "backup tar was created" + assert os.path.isfile(f"{backupdir}/pg_wal.tar"), "WAL tar was created" + tblspc_tars = glob.glob(f"{backupdir}/[0-9]*.tar") + assert len(tblspc_tars) == 1, "one tablespace tar was created" + + # Try to verify the tar-format backup by restoring it. + # + # FRAMEWORK GAP: PostgresServer.init_from_backup only supports plain-format + # backups (tar_program / tablespace_map variants are explicitly + # unsupported by this framework), so the restore-and-query sub-check is + # skipped here. + + # Create an unlogged table to test that forks other than init are not + # copied. + node.safe_sql("CREATE UNLOGGED TABLE tblspc1_unlogged (id int) TABLESPACE tblspc1;") + + tblspc1_unlogged_path = node.safe_sql( + "select pg_relation_filepath('tblspc1_unlogged')" + ) + + # Make sure main and init forks exist. + assert os.path.isfile( + os.path.join(pgdata, tblspc1_unlogged_path + "_init") + ), "unlogged init fork in tablespace" + assert os.path.isfile( + os.path.join(pgdata, tblspc1_unlogged_path) + ), "unlogged main fork in tablespace" + + # Create files that look like temporary relations to ensure they are + # ignored in a tablespace. + temp_relation_files = ["t888_888", "t888888_888888_vm.1"] + test1_path = node.safe_sql("select pg_relation_filepath('test1')") + tblspc1_id = os.path.basename(os.path.dirname(os.path.dirname(test1_path))) + + for filename in temp_relation_files: + append_to_file( + f"{real_sys_tempdir}/tblspc1/{tblspc1_id}/{postgres_oid}/{filename}", + "TEMP_RELATION", + ) + + node.command_fails( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backup1", "--format", "plain"], + "plain format with tablespaces fails without tablespace mapping", + ) + + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup1", + "--format", + "plain", + "--tablespace-mapping", + f"{real_ts_dir}={tempdir}/tbackup/tblspc1", + ], + "plain format with tablespaces succeeds with tablespace mapping", + ) + assert os.path.isdir(f"{tempdir}/tbackup/tblspc1"), "tablespace was relocated" + + # Check the tablespace symlink was updated (unix only; junctions on + # Windows are not reported as symlinks by os.path.islink, and group-access + # permission checks do not apply there). + if not WINDOWS_OS: + found = False + for entry in os.listdir(os.path.join(pgdata, "pg_tblspc")): + link = f"{tempdir}/backup1/pg_tblspc/{entry}" + if ( + os.path.islink(link) + and os.readlink(link) == f"{tempdir}/tbackup/tblspc1" + ): + found = True + break + assert found, "tablespace symlink was updated" + + # Group access should be enabled on all backup files. + assert check_mode_recursive( + f"{tempdir}/backup1", 0o750, 0o640 + ), "check backup dir permissions" + + # Unlogged relation forks other than init should not be copied. + m = re.search(r"[^/]*/[^/]*/[^/]*$", tblspc1_unlogged_path) + tblspc1_unlogged_backup_path = m.group(0) + + assert os.path.isfile( + f"{tempdir}/tbackup/tblspc1/{tblspc1_unlogged_backup_path}_init" + ), "unlogged init fork in tablespace backup" + assert not os.path.isfile( + f"{tempdir}/tbackup/tblspc1/{tblspc1_unlogged_backup_path}" + ), "unlogged main fork not in tablespace backup" + + # Temp relations should not be copied. + for filename in temp_relation_files: + assert not os.path.isfile( + f"{tempdir}/tbackup/tblspc1/{tblspc1_id}/{postgres_oid}/{filename}" + ), f"[tblspc1]/{postgres_oid}/{filename} not copied" + + # Also remove temp relation files or tablespace drop will fail. + filepath = f"{real_sys_tempdir}/tblspc1/{tblspc1_id}/{postgres_oid}/{filename}" + os.unlink(filepath) + + assert os.path.isdir( + f"{tempdir}/backup1/pg_replslot" + ), "pg_replslot symlink copied as directory" + _rmtree(f"{tempdir}/backup1") + + os.mkdir(f"{tempdir}/tbl=spc2") + real_ts_dir = f"{real_sys_tempdir}/tbl=spc2" + node.safe_sql("DROP TABLE test1;") + node.safe_sql("DROP TABLE tblspc1_unlogged;") + node.safe_sql("DROP TABLESPACE tblspc1;") + node.safe_sql(f"CREATE TABLESPACE tblspc2 LOCATION '{real_ts_dir}';") + # Escape '=' in the old directory for the --tablespace-mapping argument. + real_ts_dir_escaped = real_ts_dir.replace("=", "\\=") + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup3", + "--format", + "plain", + "--tablespace-mapping", + f"{real_ts_dir_escaped}={tempdir}/tbackup/tbl\\=spc2", + ], + "mapping tablespace with = sign in path", + ) + assert os.path.isdir( + f"{tempdir}/tbackup/tbl=spc2" + ), "tablespace with = sign was relocated" + node.safe_sql("DROP TABLESPACE tblspc2;") + _rmtree(f"{tempdir}/backup3") + + os.mkdir(f"{tempdir}/{superlongname}") + real_ts_dir = f"{real_sys_tempdir}/{superlongname}" + node.safe_sql(f"CREATE TABLESPACE tblspc3 LOCATION '{real_ts_dir}';") + node.command_ok( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/tarbackup_l3", "--format", "tar"], + "pg_basebackup tar with long symlink target", + ) + node.safe_sql("DROP TABLESPACE tblspc3;") + _rmtree(f"{tempdir}/tarbackup_l3") + + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backupR", "--write-recovery-conf"], + "pg_basebackup --write-recovery-conf runs", + ) + assert os.path.isfile( + f"{tempdir}/backupR/postgresql.auto.conf" + ), "postgresql.auto.conf exists" + assert os.path.isfile( + f"{tempdir}/backupR/standby.signal" + ), "standby.signal was created" + recovery_conf = slurp_file(f"{tempdir}/backupR/postgresql.auto.conf") + _rmtree(f"{tempdir}/backupR") + + port = node.port + assert re.search( + rf"^primary_conninfo = '.*port={port}.*'\n", recovery_conf, re.M + ), "postgresql.auto.conf sets primary_conninfo" + + node.command_ok( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backupxd"], + "pg_basebackup runs in default xlog mode", + ) + assert any( + re.match(r"^[0-9A-F]{24}$", f) for f in _slurp_dir(f"{tempdir}/backupxd/pg_wal") + ), "WAL files copied" + _rmtree(f"{tempdir}/backupxd") + + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backupxf", "--wal-method", "fetch"], + "pg_basebackup --wal-method fetch runs", + ) + assert any( + re.match(r"^[0-9A-F]{24}$", f) for f in _slurp_dir(f"{tempdir}/backupxf/pg_wal") + ), "WAL files copied" + _rmtree(f"{tempdir}/backupxf") + + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backupxs", "--wal-method", "stream"], + "pg_basebackup --wal-method stream runs", + ) + assert any( + re.match(r"^[0-9A-F]{24}$", f) for f in _slurp_dir(f"{tempdir}/backupxs/pg_wal") + ), "WAL files copied" + _rmtree(f"{tempdir}/backupxs") + + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backupxst", + "--wal-method", + "stream", + "--format", + "tar", + ], + "pg_basebackup --wal-method stream runs in tar mode", + ) + assert os.path.isfile(f"{tempdir}/backupxst/pg_wal.tar"), "tar file was created" + _rmtree(f"{tempdir}/backupxst") + + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backupnoslot", + "--wal-method", + "stream", + "--no-slot", + ], + "pg_basebackup --wal-method stream runs with --no-slot", + ) + _rmtree(f"{tempdir}/backupnoslot") + + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backupxf", "--wal-method", "fetch"], + "pg_basebackup --wal-method fetch runs", + ) + + node.command_fails_like( + PG_BASEBACKUP_DEFS + ["--target", "blackhole"], + r"WAL cannot be streamed when a backup target is specified", + "backup target requires --wal-method", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + ["--target", "blackhole", "--wal-method", "stream"], + r"WAL cannot be streamed when a backup target is specified", + "backup target requires --wal-method other than --wal-method stream", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + ["--target", "bogus", "--wal-method", "none"], + r"unrecognized target", + "backup target unrecognized", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--target", + "blackhole", + "--wal-method", + "none", + "--pgdata", + f"{tempdir}/blackhole", + ], + r"cannot specify both output directory and backup target", + "backup target and output directory", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + + ["--target", "blackhole", "--wal-method", "none", "--format", "tar"], + r"cannot specify both format and backup target", + "backup target and format", + ) + node.command_ok( + PG_BASEBACKUP_DEFS + ["--target", "blackhole", "--wal-method", "none"], + "backup target blackhole", + ) + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--target", f"server:{tempdir}/backuponserver", "--wal-method", "none"], + "backup target server", + ) + assert os.path.isfile( + f"{tempdir}/backuponserver/base.tar" + ), "backup tar was created" + _rmtree(f"{tempdir}/backuponserver") + + node.command_ok( + ["createuser", "--replication", "--role=pg_write_server_files", "backupuser"], + "create backup user", + ) + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--username", + "backupuser", + "--target", + f"server:{tempdir}/backuponserver", + "--wal-method", + "none", + ], + "backup target server", + ) + assert os.path.isfile( + f"{tempdir}/backuponserver/base.tar" + ), "backup tar was created as non-superuser" + _rmtree(f"{tempdir}/backuponserver") + + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backupxs_sl_fail", + "--wal-method", + "stream", + "--slot", + "slot0", + ], + r'replication slot "slot0" does not exist', + "pg_basebackup fails with nonexistent replication slot", + ) + + node.command_fails_like( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backupxs_slot", "--create-slot"], + r"--create-slot needs a slot to be specified using --slot", + "pg_basebackup --create-slot fails without slot name", + ) + + node.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backupxs_slot", + "--create-slot", + "--slot", + "slot0", + "--no-slot", + ], + r"--no-slot cannot be used with slot name", + "pg_basebackup fails with --create-slot --slot --no-slot", + ) + node.command_fails_like( + PG_BASEBACKUP_DEFS + + ["--target", "blackhole", "--pgdata", f"{tempdir}/blackhole"], + r"cannot specify both output directory and backup target", + "backup target and output directory", + ) + + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backuptr/co", "--wal-method", "none"], + "pg_basebackup --wal-method fetch runs", + ) + + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backupxs_slot", "--create-slot", "--slot", "slot0"], + "pg_basebackup --create-slot runs", + ) + _rmtree(f"{tempdir}/backupxs_slot") + + assert ( + node.safe_sql( + "SELECT slot_name FROM pg_replication_slots WHERE slot_name = 'slot0'" + ) + == "slot0" + ), "replication slot was created" + assert ( + node.safe_sql( + "SELECT restart_lsn FROM pg_replication_slots WHERE slot_name = 'slot0'" + ) + != "" + ), "restart LSN of new slot is not null" + + node.command_fails_like( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backupxs_slot1", "--create-slot", "--slot", "slot0"], + r'replication slot "slot0" already exists', + "pg_basebackup fails with --create-slot --slot and a previously " + "existing slot", + ) + + node.safe_sql("SELECT * FROM pg_create_physical_replication_slot('slot1')") + lsn = node.safe_sql( + "SELECT restart_lsn FROM pg_replication_slots WHERE slot_name = 'slot1'" + ) + assert lsn == "", "restart LSN of new slot is null" + node.command_fails( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/fail", "--slot", "slot1", "--wal-method", "none"], + "pg_basebackup with replication slot fails without WAL streaming", + ) + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backupxs_sl", + "--wal-method", + "stream", + "--slot", + "slot1", + ], + "pg_basebackup --wal-method stream with replication slot runs", + ) + lsn = node.safe_sql( + "SELECT restart_lsn FROM pg_replication_slots WHERE slot_name = 'slot1'" + ) + assert re.match(r"^0/[0-9A-Z]{7,8}$", lsn), "restart LSN of slot has advanced" + _rmtree(f"{tempdir}/backupxs_sl") + + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backupxs_sl_R", + "--wal-method", + "stream", + "--slot", + "slot1", + "--write-recovery-conf", + ], + "pg_basebackup with replication slot and --write-recovery-conf runs", + ) + assert re.search( + r"^primary_slot_name = 'slot1'\n", + slurp_file(f"{tempdir}/backupxs_sl_R/postgresql.auto.conf"), + re.M, + ), "recovery conf file sets primary_slot_name" + + checksum = node.safe_sql("SHOW data_checksums;") + assert checksum == "on", "checksums are enabled" + _rmtree(f"{tempdir}/backupxs_sl_R") + + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_dbname_R", + "--wal-method", + "stream", + "--dbname", + "dbname=db1", + "--write-recovery-conf", + ], + "pg_basebackup with dbname and --write-recovery-conf runs", + ) + assert re.search( + r"dbname=db1", + slurp_file(f"{tempdir}/backup_dbname_R/postgresql.auto.conf"), + re.M, + ), "recovery conf file sets dbname" + _rmtree(f"{tempdir}/backup_dbname_R") + + # create tables to corrupt and get their relfilenodes + file_corrupt1 = node.safe_sql( + "CREATE TABLE corrupt1 AS SELECT a FROM generate_series(1,10000) AS a; " + "ALTER TABLE corrupt1 SET (autovacuum_enabled=false); " + "SELECT pg_relation_filepath('corrupt1')" + ) + file_corrupt2 = node.safe_sql( + "CREATE TABLE corrupt2 AS SELECT b FROM generate_series(1,2) AS b; " + "ALTER TABLE corrupt2 SET (autovacuum_enabled=false); " + "SELECT pg_relation_filepath('corrupt2')" + ) + + # get block size for corruption steps + block_size = int(node.safe_sql("SHOW block_size;")) + + # induce corruption + node.stop() + node.corrupt_page_checksum(file_corrupt1, 0) + node.start() + + node.command_checks_all( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backup_corrupt"], + 1, + [r"^$"], + [r"(?s)^WARNING.*checksum verification failed"], + "pg_basebackup reports checksum mismatch", + ) + _rmtree(f"{tempdir}/backup_corrupt") + + # induce further corruption in 5 more blocks + node.stop() + for i in range(1, 6): + node.corrupt_page_checksum(file_corrupt1, i * block_size) + node.start() + + node.command_checks_all( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backup_corrupt2"], + 1, + [r"^$"], + [r"(?s)^WARNING.*further.*failures.*will.not.be.reported"], + "pg_basebackup does not report more than 5 checksum mismatches", + ) + _rmtree(f"{tempdir}/backup_corrupt2") + + # induce corruption in a second file + node.stop() + node.corrupt_page_checksum(file_corrupt2, 0) + node.start() + + node.command_checks_all( + PG_BASEBACKUP_DEFS + ["--pgdata", f"{tempdir}/backup_corrupt3"], + 1, + [r"^$"], + [r"(?s)^WARNING.*7 total checksum verification failures"], + "pg_basebackup correctly report the total number of checksum mismatches", + ) + _rmtree(f"{tempdir}/backup_corrupt3") + + # do not verify checksums, should return ok + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backup_corrupt4", "--no-verify-checksums"], + "pg_basebackup with -k does not report checksum mismatch", + ) + _rmtree(f"{tempdir}/backup_corrupt4") + + node.safe_sql("DROP TABLE corrupt1;") + node.safe_sql("DROP TABLE corrupt2;") + + print("# Testing pg_basebackup with compression methods") + + # Check ZLIB compression if available. + if not HAVE_LIBZ: + print("# postgres was not built with ZLIB support; skipping compression tests") + else: + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_gzip", + "--compress", + "1", + "--format", + "t", + ], + "pg_basebackup with --compress", + ) + node.command_ok( + PG_BASEBACKUP_DEFS + + ["--pgdata", f"{tempdir}/backup_gzip2", "--gzip", "--format", "t"], + "pg_basebackup with --gzip", + ) + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/backup_gzip3", + "--compress", + "gzip:1", + "--format", + "t", + ], + "pg_basebackup with --compress=gzip:1", + ) + + # Verify that the stored files are generated with their expected names. + zlib_files = glob.glob(f"{tempdir}/backup_gzip/*.tar.gz") + assert ( + len(zlib_files) == 2 + ), "two files created with --compress=NUM (base.tar.gz and pg_wal.tar.gz)" + zlib_files2 = glob.glob(f"{tempdir}/backup_gzip2/*.tar.gz") + assert ( + len(zlib_files2) == 2 + ), "two files created with --gzip (base.tar.gz and pg_wal.tar.gz)" + zlib_files3 = glob.glob(f"{tempdir}/backup_gzip3/*.tar.gz") + assert ( + len(zlib_files3) == 2 + ), "two files created with --compress=gzip:NUM (base.tar.gz and pg_wal.tar.gz)" + + # Check the integrity of the files generated using Python's gzip + # module (equivalent to a "gzip --test" check). + import gzip as gzipmod + + for gzpath in zlib_files + zlib_files2 + zlib_files3: + with gzipmod.open(gzpath, "rb") as gf: + while gf.read(1024 * 1024): + pass + _rmtree(f"{tempdir}/backup_gzip") + _rmtree(f"{tempdir}/backup_gzip2") + _rmtree(f"{tempdir}/backup_gzip3") + + # Test background stream process terminating before the basebackup has + # finished; the main process should exit gracefully with an error message + # on stderr. To reduce timing risk we throttle the base backup. + node.safe_sql("CREATE TABLE t AS SELECT a FROM generate_series(1,10000) AS a;") + + # Set a distinguishing application_name so we can find the walsender. + app_name = "test_010_pg_basebackup" + connstr = node.connstr("postgres") + f" application_name={app_name}" + with subprocess.Popen( + [ + os.path.join(node.bindir, "pg_basebackup"), + "--no-sync", + "-cfast", + "--wal-method=stream", + "--pgdata", + f"{tempdir}/sigchld", + "--max-rate", + "32", + "--dbname", + connstr, + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) as sigchld_bb: + + assert node.poll_query_until( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE " + f"application_name = '{app_name}' AND wait_event = 'WalSenderMain' " + "AND backend_type = 'walsender' AND query ~ 'START_REPLICATION'" + ), "Walsender killed" + + try: + _, stderr = sigchld_bb.communicate(timeout=TIMEOUT_DEFAULT) + except subprocess.TimeoutExpired: + sigchld_bb.kill() + _, stderr = sigchld_bb.communicate() + assert re.search( + r"background process terminated unexpectedly", stderr + ), "background process exit message" + + # Test that we can back up an in-place tablespace. CREATE TABLESPACE + # can't run in a transaction block and the GUC must be set on the same + # connection, so set the GUC and create the tablespace as separate + # statements on one persistent session. + with node.connect() as ts_sess: + ts_sess.query_safe("SET allow_in_place_tablespaces = on") + ts_sess.query_safe("CREATE TABLESPACE tblspc2 LOCATION ''") + node.safe_sql( + "CREATE TABLE test2 (a int) TABLESPACE tblspc2;" + "INSERT INTO test2 VALUES (1234);" + ) + tblspc_oid = node.safe_sql( + "SELECT oid FROM pg_tablespace WHERE spcname = 'tblspc2';" + ) + node.backup("backup3") + node.safe_sql("DROP TABLE test2;") + node.safe_sql("DROP TABLESPACE tblspc2;") + + # check that the in-place tablespace exists in the backup + backupdir = os.path.join(node.backup_dir, "backup3") + dst_tblspc = glob.glob(f"{backupdir}/pg_tblspc/{tblspc_oid}/PG_*") + assert len(dst_tblspc) == 1, "tblspc directory copied" + + # Can't take backup with referring manifest of different cluster. + # + # Set up another new database instance. Since create_pg always runs a + # fresh initdb, it gets a different system ID. + node2 = create_pg("node2", start=False, has_archiving=True, allows_streaming=True) + node2.append_conf("summarize_wal = on") + node2.start() + + node2.command_fails_like( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + f"{tempdir}/diff_sysid", + "--incremental", + f"{backupdir}/backup_manifest", + ], + r"system identifier in backup manifest is .*, but database system " + r"identifier is", + "pg_basebackup fails with different database system manifest", + ) + + +def _rmtree(path): + import shutil + + shutil.rmtree(path, ignore_errors=True) diff --git a/src/bin/pg_basebackup/pyt/test_011_in_place_tablespace.py b/src/bin/pg_basebackup/pyt/test_011_in_place_tablespace.py new file mode 100644 index 00000000000..06562c75ab0 --- /dev/null +++ b/src/bin/pg_basebackup/pyt/test_011_in_place_tablespace.py @@ -0,0 +1,45 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that pg_basebackup correctly handles in-place tablespaces.""" + +import glob +import os + +# For nearly all pg_basebackup invocations some options should be specified, +# to keep test times reasonable. +PG_BASEBACKUP_DEFS = ["pg_basebackup", "--no-sync", "--checkpoint", "fast"] + + +def test_011_in_place_tablespace(create_pg, tmp_path): + tempdir = str(tmp_path) + + # Set up an instance. + node = create_pg("main", allows_streaming=True) + + # Create an in-place tablespace. CREATE TABLESPACE can't run in a + # transaction block and the GUC must be set on the same connection, so set + # the GUC and create the tablespace as separate statements on one + # persistent session. + with node.connect() as ts_sess: + ts_sess.query_safe("SET allow_in_place_tablespaces = on") + ts_sess.query_safe("CREATE TABLESPACE inplace LOCATION ''") + + # Back it up. + backupdir = os.path.join(tempdir, "backup") + node.command_ok( + PG_BASEBACKUP_DEFS + + [ + "--pgdata", + backupdir, + "--format", + "tar", + "--wal-method", + "none", + ], + "pg_basebackup runs", + ) + + # Make sure we got base.tar and one tablespace. + assert os.path.isfile(os.path.join(backupdir, "base.tar")), "backup tar was created" + tblspc_tars = glob.glob(os.path.join(backupdir, "[0-9]*.tar")) + assert len(tblspc_tars) == 1, "one tablespace tar was created" diff --git a/src/bin/pg_basebackup/pyt/test_020_pg_receivewal.py b/src/bin/pg_basebackup/pyt/test_020_pg_receivewal.py new file mode 100644 index 00000000000..2909c5fb937 --- /dev/null +++ b/src/bin/pg_basebackup/pyt/test_020_pg_receivewal.py @@ -0,0 +1,484 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_receivewal, including compression, replication slots, and +permission handling. +""" + +import glob +import os +import stat + +from pypg.util import WINDOWS_OS +import subprocess + +import pytest + +from libpq import Session + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _pg_config_value(pg_config, option): + return subprocess.run( + [pg_config, option], stdout=subprocess.PIPE, text=True, check=True + ).stdout.strip() + + +def check_pg_config(pg_config, define): + """Return True if *define* appears in the installed pg_config.h.""" + include_server = _pg_config_value(pg_config, "--includedir-server") + header = os.path.join(include_server, "pg_config.h") + with open(header, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + if define in line: + return True + return False + + +def _slot(node, slot_name): + """Return a dict of pg_replication_slots columns for *slot_name*. + + Missing values come back as empty strings. + """ + fields = [ + "plugin", + "slot_type", + "datoid", + "database", + "active", + "active_pid", + "xmin", + "catalog_xmin", + "restart_lsn", + ] + row = node.safe_sql( + "SELECT " + ", ".join(fields) + " FROM pg_catalog.pg_replication_slots " + f"WHERE slot_name = '{slot_name}'" + ) + values = row.split("|") if row != "" else [""] * len(fields) + return dict(zip(fields, values)) + + +def _check_mode_recursive(directory, expected_dir_mode, expected_file_mode): + """Recursively verify file/dir permission bits under *directory*.""" + for root, dirs, files in os.walk(directory): + for name in [*dirs, *files]: + path = os.path.join(root, name) + expected = expected_dir_mode if os.path.isdir(path) else expected_file_mode + actual = stat.S_IMODE(os.lstat(path).st_mode) + if actual != expected: + print(f"mode of {path} is {actual:#o} but expected {expected:#o}") + return False + return True + + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- + + +def test_020_pg_receivewal(pg_bin, pg_config, create_pg): + # Set umask so test directories and files are created with default + # permissions. + os.umask(0o077) + + pg_bin.program_help_ok("pg_receivewal") + pg_bin.program_version_ok("pg_receivewal") + pg_bin.program_options_handling_ok("pg_receivewal") + + primary = create_pg( + "primary", + start=False, + allows_streaming=True, + initdb_extra=["--wal-segsize=1"], + ) + primary.start() + + stream_dir = os.path.join(primary.basedir, "archive_wal") + os.mkdir(stream_dir) + + # Sanity checks for command line options. + primary.command_fails( + ["pg_receivewal"], "pg_receivewal needs target directory specified" + ) + primary.command_fails( + [ + "pg_receivewal", + "--directory", + stream_dir, + "--create-slot", + "--drop-slot", + ], + "failure if both --create-slot and --drop-slot specified", + ) + primary.command_fails( + ["pg_receivewal", "--directory", stream_dir, "--create-slot"], + "failure if --create-slot specified without --slot", + ) + primary.command_fails( + [ + "pg_receivewal", + "--directory", + stream_dir, + "--synchronous", + "--no-sync", + ], + "failure if --synchronous specified with --no-sync", + ) + primary.command_fails_like( + [ + "pg_receivewal", + "--directory", + stream_dir, + "--compress", + "none:1", + ], + r"pg_receivewal: error: invalid compression specification: " + r'compression algorithm "none" does not accept a compression level', + "failure if --compress none:N (where N > 0)", + ) + + # Slot creation and drop + slot_name = "test" + primary.command_ok( + ["pg_receivewal", "--slot", slot_name, "--create-slot"], + "creating a replication slot", + ) + slot = _slot(primary, slot_name) + assert slot["slot_type"] == "physical", "physical replication slot was created" + assert slot["restart_lsn"] == "", "restart LSN of new slot is null" + primary.command_ok( + ["pg_receivewal", "--slot", slot_name, "--drop-slot"], + "dropping a replication slot", + ) + assert _slot(primary, slot_name)["slot_type"] == "", "replication slot was removed" + + # Generate some WAL. Use --synchronous at the same time to add more + # code coverage. Switch to the next segment first so that subsequent + # restarts of pg_receivewal will see this segment as full.. + primary.safe_sql("CREATE TABLE test_table(x integer PRIMARY KEY);") + primary.safe_sql("SELECT pg_switch_wal();") + nextlsn = primary.safe_sql("SELECT pg_current_wal_insert_lsn();") + primary.safe_sql("INSERT INTO test_table VALUES (1);") + + # Stream up to the given position. This is necessary to have a fixed + # started point for the next commands done in this test, with or without + # compression involved. + primary.command_ok( + [ + "pg_receivewal", + "--directory", + stream_dir, + "--verbose", + "--endpos", + nextlsn, + "--synchronous", + "--no-loop", + ], + "streaming some WAL with --synchronous", + ) + + # Verify that one partial file was generated and keep track of it + partial_wals = glob.glob(os.path.join(stream_dir, "*.partial")) + assert len(partial_wals) == 1, "one partial WAL segment was created" + + print("# Testing pg_receivewal with compression methods") + + # Check ZLIB compression if available. + if not check_pg_config(pg_config, "#define HAVE_LIBZ 1"): + pytest.skip("postgres was not built with ZLIB support") + else: + # Generate more WAL worth one completed, compressed, segment. + primary.safe_sql("SELECT pg_switch_wal();") + nextlsn = primary.safe_sql("SELECT pg_current_wal_insert_lsn();") + primary.safe_sql("INSERT INTO test_table VALUES (2);") + + primary.command_ok( + [ + "pg_receivewal", + "--directory", + stream_dir, + "--verbose", + "--endpos", + nextlsn, + "--compress", + "gzip:1", + "--no-loop", + ], + "streaming some WAL using ZLIB compression", + ) + + # Verify that the stored files are generated with their expected + # names. + zlib_wals = glob.glob(os.path.join(stream_dir, "*.gz")) + assert len(zlib_wals) == 1, "one WAL segment compressed with ZLIB was created" + zlib_partial_wals = glob.glob(os.path.join(stream_dir, "*.gz.partial")) + assert ( + len(zlib_partial_wals) == 1 + ), "one partial WAL segment compressed with ZLIB was created" + + # Verify that the start streaming position is computed correctly by + # comparing it with the partial file generated previously. The name + # of the previous partial, now-completed WAL segment is updated, + # keeping its base number. + completed = partial_wals[0][: -len(".partial")] + ".gz" + assert zlib_wals[0] == completed, "one partial WAL segment is now completed" + # Update the list of partial wals with the current one. + partial_wals = zlib_partial_wals + + # Check the integrity of the completed segment, if gzip is a command + # available. + gzip = os.environ.get("GZIP_PROGRAM") + if not gzip: + print("# program gzip is not found in your system") + else: + gzip_is_valid = subprocess.run( + [gzip, "--test", *zlib_wals], check=False + ).returncode + assert ( + gzip_is_valid == 0 + ), "gzip verified the integrity of compressed WAL segments" + + # Check LZ4 compression if available + if not check_pg_config(pg_config, "#define USE_LZ4 1"): + pytest.skip("postgres was not built with LZ4 support") + else: + # Generate more WAL including one completed, compressed segment. + primary.safe_sql("SELECT pg_switch_wal();") + nextlsn = primary.safe_sql("SELECT pg_current_wal_insert_lsn();") + primary.safe_sql("INSERT INTO test_table VALUES (3);") + + # Stream up to the given position. + primary.command_ok( + [ + "pg_receivewal", + "--directory", + stream_dir, + "--verbose", + "--endpos", + nextlsn, + "--no-loop", + "--compress", + "lz4", + ], + "streaming some WAL using --compress=lz4", + ) + + # Verify that the stored files are generated with their expected + # names. + lz4_wals = glob.glob(os.path.join(stream_dir, "*.lz4")) + assert len(lz4_wals) == 1, "one WAL segment compressed with LZ4 was created" + lz4_partial_wals = glob.glob(os.path.join(stream_dir, "*.lz4.partial")) + assert ( + len(lz4_partial_wals) == 1 + ), "one partial WAL segment compressed with LZ4 was created" + + # Verify that the start streaming position is computed correctly by + # comparing it with the partial file generated previously. The name + # of the previous partial, now-completed WAL segment is updated, + # keeping its base number. + base = partial_wals[0] + if base.endswith(".gz.partial"): + base = base[: -len(".gz.partial")] + elif base.endswith(".partial"): + base = base[: -len(".partial")] + completed = base + ".lz4" + assert lz4_wals[0] == completed, "one partial WAL segment is now completed" + # Update the list of partial wals with the current one. + partial_wals = lz4_partial_wals + + # Check the integrity of the completed segment, if LZ4 is an available + # command. + lz4 = os.environ.get("LZ4") + if not lz4: + print("# program lz4 is not found in your system") + else: + lz4_is_valid = subprocess.run( + [lz4, "-t", *lz4_wals], check=False + ).returncode + assert ( + lz4_is_valid == 0 + ), "lz4 verified the integrity of compressed WAL segments" + + # Verify that the start streaming position is computed and that the value + # is correct regardless of whether any compression is available. + primary.safe_sql("SELECT pg_switch_wal();") + nextlsn = primary.safe_sql("SELECT pg_current_wal_insert_lsn();") + primary.safe_sql("INSERT INTO test_table VALUES (4);") + primary.command_ok( + [ + "pg_receivewal", + "--directory", + stream_dir, + "--verbose", + "--endpos", + nextlsn, + "--no-loop", + ], + "streaming some WAL", + ) + + # Strip an optional (.gz|.lz4) plus .partial suffix to get the name of the + # completed segment. + completed = partial_wals[0] + for suffix in (".gz.partial", ".lz4.partial", ".partial"): + if completed.endswith(suffix): + completed = completed[: -len(suffix)] + break + assert os.path.exists( + completed + ), "check that previously partial WAL is now complete" + + # Permissions on WAL files should be default (unix-only; skipped on Windows). + if not WINDOWS_OS: + assert _check_mode_recursive( + stream_dir, 0o700, 0o600 + ), "check stream dir permissions" + + print("# Testing pg_receivewal with slot as starting streaming point") + + # When using a replication slot, archiving should be resumed from the + # slot's restart LSN. Use a new archive location and new slot for this + # test. + slot_dir = os.path.join(primary.basedir, "slot_wal") + os.mkdir(slot_dir) + slot_name = "archive_slot" + + # Setup the slot, reserving WAL at creation (corresponding to the + # last redo LSN here, actually, so use a checkpoint to reduce the + # number of segments archived). + primary.safe_sql("checkpoint;") + primary.safe_sql( + f"SELECT pg_create_physical_replication_slot('{slot_name}', true);" + ) + + # Get the segment name associated with the slot's restart LSN, that should + # be archived. + walfile_streamed = primary.safe_sql( + "SELECT pg_walfile_name(restart_lsn) " + "FROM pg_replication_slots " + f"WHERE slot_name = '{slot_name}';" + ) + + # Switch to a new segment, to make sure that the segment retained by the + # slot is still streamed. This may not be necessary, but play it safe. + primary.safe_sql("INSERT INTO test_table VALUES (5);") + primary.safe_sql("SELECT pg_switch_wal();") + nextlsn = primary.safe_sql("SELECT pg_current_wal_insert_lsn();") + + # Add a bit more data to accelerate the end of the next pg_receivewal + # commands. + primary.safe_sql("INSERT INTO test_table VALUES (6);") + + # Check case where the slot does not exist. + primary.command_fails_like( + [ + "pg_receivewal", + "--directory", + slot_dir, + "--slot", + "nonexistentslot", + "--no-loop", + "--no-sync", + "--verbose", + "--endpos", + nextlsn, + ], + 'pg_receivewal: error: replication slot "nonexistentslot" does not exist', + "pg_receivewal fails with non-existing slot", + ) + primary.command_ok( + [ + "pg_receivewal", + "--directory", + slot_dir, + "--slot", + slot_name, + "--no-loop", + "--no-sync", + "--verbose", + "--endpos", + nextlsn, + ], + "WAL streamed from the slot's restart_lsn", + ) + assert os.path.exists( + os.path.join(slot_dir, walfile_streamed) + ), "WAL from the slot's restart_lsn has been archived" + + # Test timeline switch using a replication slot, requiring a promoted + # standby. + backup_name = "basebackup" + primary.backup(backup_name) + standby = create_pg("standby", start=False) + standby.init_from_backup(primary, backup_name, has_streaming=True) + standby.start() + + # Create a replication slot on this new standby + archive_slot = "archive_slot" + with Session( + connstr=standby.connstr() + " replication=database", + libdir=standby.libdir, + ) as sess: + res = sess.query( + f"CREATE_REPLICATION_SLOT {archive_slot} PHYSICAL (RESERVE_WAL)" + ) + assert res.error_message is None, res.error_message + # Wait for standby catchup + primary.wait_for_catchup(standby) + # Get a walfilename from before the promotion to make sure it is archived + # after promotion + standby_slot = _slot(standby, archive_slot) + replication_slot_lsn = standby_slot["restart_lsn"] + + # pg_walfile_name() is not supported while in recovery, so use the primary + # to build the segment name. Both nodes are on the same timeline, so this + # produces a segment name with the timeline we are switching from. + walfile_before_promotion = primary.safe_sql( + f"SELECT pg_walfile_name('{replication_slot_lsn}');" + ) + # Everything is setup, promote the standby to trigger a timeline switch. + standby.promote() + + # Force a segment switch to make sure at least one full WAL is archived + # on the new timeline. + walfile_after_promotion = standby.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_insert_lsn());" + ) + standby.safe_sql("INSERT INTO test_table VALUES (7);") + standby.safe_sql("SELECT pg_switch_wal();") + nextlsn = standby.safe_sql("SELECT pg_current_wal_insert_lsn();") + # This speeds up the operation. + standby.safe_sql("INSERT INTO test_table VALUES (8);") + + # Now try to resume from the slot after the promotion. + timeline_dir = os.path.join(primary.basedir, "timeline_wal") + os.mkdir(timeline_dir) + + standby.command_ok( + [ + "pg_receivewal", + "--directory", + timeline_dir, + "--verbose", + "--endpos", + nextlsn, + "--slot", + archive_slot, + "--no-sync", + "--no-loop", + ], + "Stream some wal after promoting, resuming from the slot's position", + ) + assert os.path.exists( + os.path.join(timeline_dir, walfile_before_promotion) + ), f"WAL segment {walfile_before_promotion} archived after timeline jump" + assert os.path.exists( + os.path.join(timeline_dir, walfile_after_promotion) + ), f"WAL segment {walfile_after_promotion} archived after timeline jump" + assert os.path.exists( + os.path.join(timeline_dir, "00000002.history") + ), "timeline history file archived after timeline jump" diff --git a/src/bin/pg_basebackup/pyt/test_030_pg_recvlogical.py b/src/bin/pg_basebackup/pyt/test_030_pg_recvlogical.py new file mode 100644 index 00000000000..ce5c67574c4 --- /dev/null +++ b/src/bin/pg_basebackup/pyt/test_030_pg_recvlogical.py @@ -0,0 +1,358 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_recvlogical, the command-line client for logical decoding.""" + +import os +import re +import signal +import subprocess + +from pypg.util import WINDOWS_OS, poll_until, slurp_file + + +def _wait_for_file(path, pattern, offset=0): + """Wait until *pattern* matches the contents of *path* at/after *offset*. + + Returns the file length when matched; raises on timeout. + """ + regex = re.compile(pattern) + + def _found(): + if not os.path.exists(path): + return False + return regex.search(slurp_file(path, offset)) is not None + + if not poll_until(_found): + raise TimeoutError(f"timed out waiting for {pattern!r} in {path}") + return os.path.getsize(path) + + +def _chmod_recursive(directory, dir_mode, file_mode): + """Recursively chmod a tree: *dir_mode* for dirs, *file_mode* for files.""" + os.chmod(directory, dir_mode) + for root, dirs, files in os.walk(directory): + for name in dirs: + os.chmod(os.path.join(root, name), dir_mode) + for name in files: + os.chmod(os.path.join(root, name), file_mode) + + +def _slot_restart_lsn(node, slot_name): + return node.safe_sql( + "SELECT restart_lsn FROM pg_replication_slots " + f"WHERE slot_name = '{slot_name}'" + ) + + +def test_030_pg_recvlogical(create_pg, pg_bin): + pg_bin.program_help_ok("pg_recvlogical") + pg_bin.program_version_ok("pg_recvlogical") + pg_bin.program_options_handling_ok("pg_recvlogical") + + # Initialize node without replication settings + node = create_pg("main", start=False, allows_streaming=True, has_archiving=True) + node.append_conf( + "\n".join( + [ + "wal_level = 'logical'", + "max_replication_slots = 4", + "max_wal_senders = 4", + "log_min_messages = 'debug1'", + "log_error_verbosity = verbose", + "max_prepared_transactions = 10", + ] + ) + ) + node.start() + + connstr = node.connstr("postgres") + + node.command_fails(["pg_recvlogical"], "pg_recvlogical needs a slot name") + node.command_fails( + ["pg_recvlogical", "--slot", "test"], "pg_recvlogical needs a database" + ) + node.command_fails( + ["pg_recvlogical", "--slot", "test", "--dbname", "postgres"], + "pg_recvlogical needs an action", + ) + node.command_fails( + [ + "pg_recvlogical", + "--slot", + "test", + "--dbname", + connstr, + "--start", + ], + "no destination file", + ) + + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "test", + "--dbname", + connstr, + "--create-slot", + ], + "slot created", + ) + + assert _slot_restart_lsn(node, "test") != "", "restart lsn is defined for new slot" + + node.safe_sql("CREATE TABLE test_table(x integer)") + node.safe_sql( + "INSERT INTO test_table(x) SELECT y FROM generate_series(1, 10) a(y);" + ) + nextlsn = node.safe_sql("SELECT pg_current_wal_insert_lsn()").strip() + + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "test", + "--dbname", + connstr, + "--start", + "--endpos", + nextlsn, + "--no-loop", + "--file", + "-", + ], + "replayed a transaction", + ) + + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "test", + "--dbname", + connstr, + "--drop-slot", + ], + "slot dropped", + ) + + # test with two-phase option enabled + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "test", + "--dbname", + connstr, + "--create-slot", + "--two-phase", + ], + "slot with two-phase created", + ) + + assert _slot_restart_lsn(node, "test") != "", "restart lsn is defined for new slot" + + node.safe_sql( + "BEGIN; INSERT INTO test_table values (11); PREPARE TRANSACTION 'test'" + ) + node.safe_sql("COMMIT PREPARED 'test'") + nextlsn = node.safe_sql("SELECT pg_current_wal_insert_lsn()").strip() + + node.command_fails( + [ + "pg_recvlogical", + "--slot", + "test", + "--dbname", + connstr, + "--start", + "--endpos", + nextlsn, + "--enable-two-phase", + "--no-loop", + "--file", + "-", + ], + "incorrect usage", + ) + + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "test", + "--dbname", + connstr, + "--start", + "--endpos", + nextlsn, + "--no-loop", + "--file", + "-", + ], + "replayed a two-phase transaction", + ) + + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "test", + "--drop-slot", + ], + "drop could work without dbname", + ) + + # test with failover option enabled + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "test", + "--dbname", + connstr, + "--create-slot", + "--enable-failover", + ], + "slot with failover created", + ) + + result = node.safe_sql( + "SELECT failover FROM pg_catalog.pg_replication_slots " + "WHERE slot_name = 'test'" + ) + assert result == "t", "failover is enabled for the new slot" + + # Test that when pg_recvlogical reconnects, it does not write duplicate + # records to the output file + outfile = os.path.join(node.basedir, "reconnect.out") + + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "reconnect_test", + "--dbname", + connstr, + "--create-slot", + ], + "slot created for reconnection test", + ) + + # Insert the first record for this test + node.safe_sql("INSERT INTO test_table VALUES (1)") + + pg_recvlogical_cmd = [ + os.path.join(node.bindir, "pg_recvlogical"), + "--slot", + "reconnect_test", + "--dbname", + connstr, + "--start", + "--file", + outfile, + "--fsync-interval", + "1", + "--status-interval", + "100", + "--verbose", + ] + + # This test targets non-Windows platforms only, using signals to terminate + # pg_recvlogical. + with subprocess.Popen( + pg_recvlogical_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) as recv: + try: + # Wait for pg_recvlogical to receive and write the first INSERT + first_ins = _wait_for_file(outfile, r"INSERT") + + # Terminate the walsender to force pg_recvlogical to reconnect + backend_pid = node.safe_sql( + "SELECT active_pid FROM pg_replication_slots " + "WHERE slot_name = 'reconnect_test'" + ) + node.safe_sql(f"SELECT pg_terminate_backend({backend_pid})") + + # Wait for pg_recvlogical to reconnect + assert node.poll_query_until( + "SELECT active_pid IS NOT NULL AND active_pid != " + f"{backend_pid} FROM pg_replication_slots " + "WHERE slot_name = 'reconnect_test'" + ), "Timed out while waiting for pg_recvlogical to reconnect" + + # Insert the second record for this test + node.safe_sql("INSERT INTO test_table VALUES (2)") + + # Wait for pg_recvlogical to receive and write the second INSERT + _wait_for_file(outfile, r"INSERT", first_ins) + + # Terminate pg_recvlogical by sending a TERM signal. + recv.send_signal(signal.SIGTERM) + finally: + recv.wait() + + outfiledata = slurp_file(outfile) + count = len(re.findall(r"INSERT", outfiledata)) + assert count == 2, "pg_recvlogical has received and written two INSERTs" + + # Check that pg_recvlogical derives output file permissions from the source + # cluster. (unix-style permissions; Windows is out of scope for this port.) + + # The cluster was initialized without group access, so pg_recvlogical + # should create the output file as 0600 (-rw-------). + if not WINDOWS_OS: + mode = oct(os.stat(outfile).st_mode & 0o7777) + assert mode == oct( + 0o600 + ), "pg_recvlogical output file has no group permissions (0600)" + + # Enable group access on the source cluster and its files, then restart + # so pg_recvlogical observes the updated source cluster permissions. + node.stop() + _chmod_recursive(node.data_dir, 0o750, 0o640) + node.start() + + outfile = os.path.join(node.basedir, "group_access.out") + pg_recvlogical_cmd = [ + os.path.join(node.bindir, "pg_recvlogical"), + "--slot", + "reconnect_test", + "--dbname", + connstr, + "--start", + "--file", + outfile, + "--fsync-interval", + "1", + ] + + with subprocess.Popen( + pg_recvlogical_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) as recv: + try: + node.safe_sql("INSERT INTO test_table VALUES (3)") + _wait_for_file(outfile, r"INSERT") + recv.send_signal(signal.SIGTERM) + finally: + recv.wait() + + # With group access enabled on the source cluster, pg_recvlogical should + # create the output file as 0640 (-rw-r-----). + if not WINDOWS_OS: + mode = oct(os.stat(outfile).st_mode & 0o7777) + assert mode == oct( + 0o640 + ), "pg_recvlogical output file respects group permissions (0640)" + + node.command_ok( + [ + "pg_recvlogical", + "--slot", + "reconnect_test", + "--dbname", + connstr, + "--drop-slot", + ], + "reconnect_test slot dropped", + ) diff --git a/src/bin/pg_basebackup/pyt/test_040_pg_createsubscriber.py b/src/bin/pg_basebackup/pyt/test_040_pg_createsubscriber.py new file mode 100644 index 00000000000..0bbdf4c1b18 --- /dev/null +++ b/src/bin/pg_basebackup/pyt/test_040_pg_createsubscriber.py @@ -0,0 +1,865 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test pg_createsubscriber, converting a standby server into a subscriber.""" + +import glob +import os +import re + +from libpq import Session + + +# pg_createsubscriber is the tool under test. It is invoked through the +# command_* helpers / PgBin (running the binary is allowed); SQL is run +# in-process via libpq Sessions. + + +def _connstr(node, dbname=None): + """Build a connection string for *node*. + + Returns ``port=N host=H`` when *dbname* is None, otherwise appends a + properly-escaped ``dbname='...'`` (only backslashes and single quotes need + escaping). + """ + base = f"port={node.port} host={node.host}" + if dbname is None: + return base + escaped = dbname.replace("\\", "\\\\").replace("'", "\\'") + return f"{base} dbname='{escaped}'" + + +# The framework's PostgresServer.connstr() / safe_sql() do not escape the +# database name, which breaks for the exotic ASCII database name (db1) used +# below. Open a fresh, properly-escaped libpq Session per call so no +# connection lingers across stop(). +def _safe_sql(node, dbname, query): + """safe_sql against a database whose name may contain special characters.""" + sess = Session(connstr=_connstr(node, dbname), libdir=node.libdir) + try: + return sess.query_safe(query) + finally: + sess.close() + + +def _generate_db(node, prefix, from_char, to_char, suffix): + """Generate a database with a name made of a range of ASCII characters.""" + dbname = prefix + for i in range(from_char, to_char + 1): + if i in (7, 10, 13): # skip BEL, LF, and CR + continue + dbname += chr(i) + dbname += suffix + + node.command_ok( + ["createdb", dbname], + f"created database with ASCII characters from {from_char} to {to_char}", + ) + return dbname + + +def _comment_out_conf(node, key): + """Remove a setting from postgresql.conf by commenting it out.""" + path = os.path.join(node.data_dir, "postgresql.conf") + with open(path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + out = [ln for ln in lines if not re.match(rf"\s*{re.escape(key)}\s*=", ln)] + with open(path, "w", encoding="utf-8") as fh: + fh.writelines(out) + + +def test_040_pg_createsubscriber(pg_bin, create_pg, tmp_path): + datadir = str(tmp_path / "datadir") + os.makedirs(datadir, exist_ok=True) + logdir = str(tmp_path / "logdir") + os.makedirs(logdir, exist_ok=True) + + pg_bin.program_help_ok("pg_createsubscriber") + pg_bin.program_version_ok("pg_createsubscriber") + pg_bin.program_options_handling_ok("pg_createsubscriber") + + # + # Test mandatory options + pg_bin.command_fails( + ["pg_createsubscriber"], "no subscriber data directory specified" + ) + pg_bin.command_fails( + ["pg_createsubscriber", "--pgdata", datadir], + "no publisher connection string specified", + ) + pg_bin.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--pgdata", + datadir, + "--publisher-server", + "port=5432", + ], + "no database name specified", + ) + pg_bin.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--pgdata", + datadir, + "--publisher-server", + "port=5432", + "--database", + "pg1", + "--database", + "pg1", + ], + "duplicate database name", + ) + pg_bin.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--pgdata", + datadir, + "--publisher-server", + "port=5432", + "--publication", + "foo1", + "--publication", + "foo1", + "--database", + "pg1", + "--database", + "pg2", + ], + "duplicate publication name", + ) + pg_bin.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--pgdata", + datadir, + "--publisher-server", + "port=5432", + "--publication", + "foo1", + "--database", + "pg1", + "--database", + "pg2", + ], + "wrong number of publication names", + ) + pg_bin.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--pgdata", + datadir, + "--publisher-server", + "port=5432", + "--publication", + "foo1", + "--publication", + "foo2", + "--subscription", + "bar1", + "--database", + "pg1", + "--database", + "pg2", + ], + "wrong number of subscription names", + ) + pg_bin.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--pgdata", + datadir, + "--publisher-server", + "port=5432", + "--publication", + "foo1", + "--publication", + "foo2", + "--subscription", + "bar1", + "--subscription", + "bar2", + "--replication-slot", + "baz1", + "--database", + "pg1", + "--database", + "pg2", + ], + "wrong number of replication slot names", + ) + + # Set up node P as primary + node_p = create_pg("node_p", start=False, allows_streaming="logical") + pconnstr = _connstr(node_p) + # Disable autovacuum to avoid generating xid during stats update as + # otherwise the new XID could then be replicated to standby at some random + # point making slots at primary lag behind standby during slot sync. + node_p.append_conf("autovacuum = off") + node_p.start() + + # Set up node F as about-to-fail node + # Force it to initialize a new cluster instead of copying a previously + # initdb'd cluster. New cluster has a different system identifier so we can + # test if the target cluster is a copy of the source cluster. (create_pg + # always runs a fresh initdb, so node_f naturally gets a distinct system + # identifier.) + node_f = create_pg("node_f", start=False, allows_streaming="logical") + + # On node P + # - create databases + # - create test tables + # - insert a row + # - create a physical replication slot + db1 = _generate_db(node_p, 'regression\\"\\', 1, 45, '\\\\"\\\\\\') + db2 = _generate_db(node_p, "regression", 46, 90, "") + + _safe_sql(node_p, db1, "CREATE TABLE tbl1 (a text)") + _safe_sql(node_p, db1, "INSERT INTO tbl1 VALUES('first row')") + _safe_sql(node_p, db2, "CREATE TABLE tbl2 (a text)") + slotname = "physical_slot" + _safe_sql( + node_p, + db2, + f"SELECT pg_create_physical_replication_slot('{slotname}')", + ) + + # Set up node S as standby linking to node P + node_p.backup("backup_1") + node_s = create_pg("node_s", start=False, allows_streaming="logical") + node_s.init_from_backup(node_p, "backup_1", has_streaming=True) + node_s.append_conf( + f""" +primary_slot_name = '{slotname}' +primary_conninfo = '{pconnstr} dbname=postgres' +hot_standby_feedback = on +""" + ) + sconnstr = _connstr(node_s) + node_s.set_standby_mode() + node_s.start() + + # Set up node T as standby linking to node P then promote it + node_t = create_pg("node_t", start=False, allows_streaming="logical") + node_t.init_from_backup(node_p, "backup_1", has_streaming=True) + node_t.set_standby_mode() + node_t.start() + node_t.promote() + node_t.stop() + + # Run pg_createsubscriber on a promoted server + node_t.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--pgdata", + node_t.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_t.host, + "--subscriber-port", + node_t.port, + "--database", + db1, + "--database", + db2, + ], + "target server is not in recovery", + ) + + # Run pg_createsubscriber when standby is running + node_s.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--database", + db1, + "--database", + db2, + ], + "standby is up and running", + ) + + # Run pg_createsubscriber on about-to-fail node F + node_f.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--pgdata", + node_f.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_f.host, + "--subscriber-port", + node_f.port, + "--database", + db1, + "--database", + db2, + ], + "subscriber data directory is not a copy of the source database cluster", + ) + + # Set up node C as standby linking to node S + node_s.backup("backup_2") + node_c = create_pg("node_c", start=False, allows_streaming="logical") + node_c.init_from_backup(node_s, "backup_2", has_streaming=True) + _comment_out_conf(node_c, "primary_slot_name") + node_c.set_standby_mode() + + # Run pg_createsubscriber on node C (P -> S -> C) + node_c.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--pgdata", + node_c.data_dir, + "--publisher-server", + _connstr(node_s, db1), + "--socketdir", + node_c.host, + "--subscriber-port", + node_c.port, + "--database", + db1, + "--database", + db2, + ], + "primary server is in recovery", + ) + + # Check some unmet conditions on node P + node_p.append_conf( + """ +max_replication_slots = 1 +max_wal_senders = 1 +max_worker_processes = 2 +""" + ) + node_p.restart() + node_s.stop() + node_s.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--database", + db1, + "--database", + db2, + ], + "primary contains unmet conditions on node P", + ) + # Restore default settings here but only apply it after testing standby. + # Some standby settings should not be a lower setting than on the primary. + node_p.append_conf( + """ +max_replication_slots = 10 +max_wal_senders = 10 +max_worker_processes = 8 +""" + ) + + # Check some unmet conditions on node S + node_s.append_conf( + """ +max_active_replication_origins = 1 +max_logical_replication_workers = 1 +max_worker_processes = 2 +""" + ) + node_s.command_fails( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--database", + db1, + "--database", + db2, + ], + "standby contains unmet conditions on node S", + ) + node_s.append_conf( + """ +max_active_replication_origins = 10 +max_logical_replication_workers = 4 +max_worker_processes = 8 +""" + ) + # Restore default settings on both servers + node_p.restart() + + # Create failover slot to test its removal + fslotname = "failover_slot" + _safe_sql( + node_p, + db1, + f"SELECT pg_create_logical_replication_slot('{fslotname}', 'pgoutput', false, false, true)", + ) + node_s.start() + # Wait for the standby to catch up so that the standby is not lagging behind + # the failover slot. + node_p.wait_for_replay_catchup(node_s) + node_s.safe_sql("SELECT pg_sync_replication_slots()") + result = node_s.safe_sql( + "SELECT slot_name FROM pg_replication_slots " + f"WHERE slot_name = '{fslotname}' AND synced AND NOT temporary" + ) + assert result == "failover_slot", "failover slot is synced" + + # Insert another row on node P and wait node S to catch up. We + # intentionally performed this insert after syncing logical slot as + # otherwise the local slot's (created during synchronization of slot) xmin + # on standby could be ahead of the remote slot leading to failure in + # synchronization. + _safe_sql(node_p, db1, "INSERT INTO tbl1 VALUES('second row')") + node_p.wait_for_replay_catchup(node_s) + + # Create subscription to test its removal + dummy_sub = "regress_sub_dummy" + _safe_sql( + node_p, + db1, + f"CREATE SUBSCRIPTION {dummy_sub} CONNECTION 'dbname=dummy' " + "PUBLICATION pub_dummy WITH (connect=false)", + ) + node_p.wait_for_replay_catchup(node_s) + + # Create user-defined publications, wait for streaming replication to sync + # them to the standby, then verify that '--clean' removes them. + _safe_sql( + node_p, + db1, + "CREATE PUBLICATION test_pub1 FOR ALL TABLES;" + "CREATE PUBLICATION test_pub2 FOR ALL TABLES;", + ) + + node_p.wait_for_replay_catchup(node_s) + + assert ( + _safe_sql(node_s, db1, "SELECT COUNT(*) FROM pg_publication") == "2" + ), "two pre-existing publications on subscriber" + + node_s.stop() + + # dry run mode on node S + node_s.command_ok( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--recovery-timeout", + "180", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--publication", + "pub1", + "--publication", + "pub2", + "--subscription", + "sub1", + "--subscription", + "sub2", + "--database", + db1, + "--database", + db2, + "--logdir", + logdir, + ], + "run pg_createsubscriber --dry-run on node S", + ) + + # Check that the log files were created + server_log_files = glob.glob(f"{logdir}/*/pg_createsubscriber_server.log") + assert len(server_log_files) == 1, "pg_createsubscriber_server.log file was created" + server_log_file_size = os.path.getsize(server_log_files[0]) + assert server_log_file_size != 0, "pg_createsubscriber_server.log file not empty" + with open(server_log_files[0], "r", encoding="utf-8", errors="replace") as fh: + server_log = fh.read() + assert re.search( + r"consistent recovery state reached", server_log + ), "server reached consistent recovery state" + + internal_log_files = glob.glob(f"{logdir}/*/pg_createsubscriber_internal.log") + assert ( + len(internal_log_files) == 1 + ), "pg_createsubscriber_internal.log file was created" + internal_log_file_size = os.path.getsize(internal_log_files[0]) + assert ( + internal_log_file_size != 0 + ), "pg_createsubscriber_internal.log file not empty" + with open(internal_log_files[0], "r", encoding="utf-8", errors="replace") as fh: + internal_log = fh.read() + assert re.search( + r"target server reached the consistent state", internal_log + ), "log shows consistent state reached" + + # Check if node S is still a standby + node_s.start() + assert ( + node_s.safe_sql("SELECT pg_catalog.pg_is_in_recovery()") == "t" + ), "standby is in recovery" + node_s.stop() + + # pg_createsubscriber can run without --databases option + node_s.command_ok( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--replication-slot", + "replslot1", + ], + "run pg_createsubscriber without --databases", + ) + + # run pg_createsubscriber with '--database' and '--all' without '--dry-run' + # and verify the failure + node_s.command_fails_like( + [ + "pg_createsubscriber", + "--verbose", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--database", + db1, + "--all", + ], + r"options --database and -a/--all cannot be used together", + "fail if --database is used with --all", + ) + + # run pg_createsubscriber with '--publication' and '--all' and verify the + # failure + node_s.command_fails_like( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--all", + "--publication", + "pub1", + ], + r"options --publication and -a/--all cannot be used together", + "fail if --publication is used with --all", + ) + + # run pg_createsubscriber with '--all' option + res = node_s.pg_bin.command_ok( + [ + "pg_createsubscriber", + "--verbose", + "--dry-run", + "--recovery-timeout", + "180", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--all", + ], + "run pg_createsubscriber with --all", + ) + stderr = res.stderr + + # Verify that the required logical replication objects are output. + # The expected count 3 refers to postgres, db1 and db2 databases. + assert ( + len(re.findall(r"would create publication", stderr)) == 3 + ), "verify publications are created for all databases" + assert ( + len(re.findall(r"would create the replication slot", stderr)) == 3 + ), "verify replication slots are created for all databases" + assert ( + len(re.findall(r"would create subscription", stderr)) == 3 + ), "verify subscriptions are created for all databases" + + # Create a user-defined publication, and a table that is not a member of + # that publication. + _safe_sql( + node_p, + db1, + "CREATE PUBLICATION test_pub3 FOR TABLE tbl1;" + "CREATE TABLE not_replicated (a int);", + ) + + # Run pg_createsubscriber on node S. --verbose is used twice to show more + # information. + # + # Test two phase and clean options. Use pre-existing publication. + node_s.command_ok( + [ + "pg_createsubscriber", + "--verbose", + "--verbose", + "--recovery-timeout", + "180", + "--pgdata", + node_s.data_dir, + "--publisher-server", + _connstr(node_p, db1), + "--socketdir", + node_s.host, + "--subscriber-port", + node_s.port, + "--publication", + "test_pub3", + "--publication", + "pub2", + "--replication-slot", + "replslot1", + "--replication-slot", + "replslot2", + "--database", + db1, + "--database", + db2, + "--enable-two-phase", + "--clean", + "publications", + ], + "run pg_createsubscriber on node S", + ) + + # Check that included file is renamed after success. + node_s_datadir = node_s.data_dir + assert os.path.isfile( + os.path.join(node_s_datadir, "pg_createsubscriber.conf.disabled") + ), "pg_createsubscriber.conf.disabled exists in node S" + + # Confirm the physical replication slot has been removed + result = _safe_sql( + node_p, + db1, + f"SELECT count(*) FROM pg_replication_slots WHERE slot_name = '{slotname}'", + ) + assert ( + result == "0" + ), "the physical replication slot used as primary_slot_name has been removed" + + # Insert rows on P + _safe_sql(node_p, db1, "INSERT INTO tbl1 VALUES('third row')") + _safe_sql(node_p, db2, "INSERT INTO tbl2 VALUES('row 1')") + _safe_sql(node_p, db1, "INSERT INTO not_replicated VALUES(0)") + + # Start subscriber + node_s.start() + + # Confirm publications are removed from the subscriber node + assert ( + _safe_sql(node_s, db1, "SELECT COUNT(*) FROM pg_publication") == "0" + ), "all publications were removed from db1" + assert ( + _safe_sql(node_s, db2, "SELECT COUNT(*) FROM pg_publication") == "0" + ), "all publications were removed from db2" + + # Verify that all subtwophase states are pending or enabled, e.g. there are + # no subscriptions where subtwophase is disabled ('d') + assert ( + node_s.safe_sql( + "SELECT count(1) = 0 FROM pg_subscription WHERE subtwophasestate = 'd'" + ) + == "t" + ), "subscriptions are created with the two-phase option enabled" + + # Confirm the pre-existing subscription has been removed + result = node_s.safe_sql( + f"SELECT count(*) FROM pg_subscription WHERE subname = '{dummy_sub}'" + ) + assert result == "0", "pre-existing subscription was dropped" + + # Get subscription names + result = node_s.safe_sql( + "SELECT subname FROM pg_subscription WHERE subname ~ '^pg_createsubscriber_'" + ) + subnames = result.split("\n") + + # Wait subscriber to catch up + node_s.wait_for_subscription_sync(node_p, subnames[0]) + node_s.wait_for_subscription_sync(node_p, subnames[1]) + + # Confirm the failover slot has been removed + result = _safe_sql( + node_s, + db1, + f"SELECT count(*) FROM pg_replication_slots WHERE slot_name = '{fslotname}'", + ) + assert result == "0", "failover slot was removed" + + # Check result in database db1 + result = _safe_sql(node_s, db1, "SELECT * FROM tbl1") + assert ( + result == "first row\nsecond row\nthird row" + ), "logical replication works in database db1" + result = _safe_sql(node_s, db1, "SELECT * FROM not_replicated") + assert result == "", "table is not replicated in database db1" + + # Check result in database db2 + result = _safe_sql(node_s, db2, "SELECT * FROM tbl2") + assert result == "row 1", "logical replication works in database db2" + + # Different system identifier? + sysid_p = node_p.safe_sql("SELECT system_identifier FROM pg_control_system()") + sysid_s = node_s.safe_sql("SELECT system_identifier FROM pg_control_system()") + assert sysid_p != sysid_s, "system identifier was changed" + + # Verify that pub2 was created in db2 + assert ( + _safe_sql( + node_p, db2, "SELECT COUNT(*) FROM pg_publication WHERE pubname = 'pub2'" + ) + == "1" + ), "publication pub2 was created in db2" + + # Get subscription and publication names + result = node_s.safe_sql( + "SELECT subname, subpublications FROM pg_subscription " + "WHERE subname ~ '^pg_createsubscriber_' " + "ORDER BY subpublications;" + ) + # re.VERBOSE (free-spacing) mode is used below, so the literal whitespace + # and indentation between the two lines is insignificant; only the \n and + # the escaped tokens match. + assert re.search( + r"""^pg_createsubscriber_\d+_[0-9a-f]+ \|\{pub2\}\n + pg_createsubscriber_\d+_[0-9a-f]+ \|\{test_pub3\}$""", + result, + re.VERBOSE, + ), "subscription and publication names are ok" + + # Verify that the correct publications are being used + result = node_s.safe_sql( + "SELECT d.datname, s.subpublications " + "FROM pg_subscription s " + "JOIN pg_database d ON d.oid = s.subdbid " + "WHERE subname ~ '^pg_createsubscriber_' " + "ORDER BY s.subdbid" + ) + assert ( + result == f"{db1}|{{test_pub3}}\n{db2}|{{pub2}}" + ), "subscriptions use the correct publications" + + # Verify that node K, set as a standby, is able to start correctly without + # the recovery configuration written by pg_createsubscriber interfering. + # This node is created from node S, where pg_createsubscriber has been run. + + # Create a physical standby from the promoted subscriber + node_s.safe_sql(f"SELECT pg_create_physical_replication_slot('{slotname}');") + + # Create backup from promoted subscriber + node_s.backup("backup_3") + + # Initialize new physical standby + node_k = create_pg("node_k", start=False, allows_streaming="logical") + node_k.init_from_backup(node_s, "backup_3", has_streaming=True) + + node_k_datadir = node_k.data_dir + assert os.path.isfile( + os.path.join(node_k_datadir, "pg_createsubscriber.conf.disabled") + ), "pg_createsubscriber.conf.disabled exists in node K" + + # Configure the new standby + node_k.append_conf( + f""" +primary_slot_name = '{slotname}' +primary_conninfo = '{sconnstr} dbname=postgres' +hot_standby_feedback = on +""" + ) + + node_k.set_standby_mode() + node_k_name = node_s.name + node_k.command_ok( + [ + "pg_ctl", + "--wait", + "--pgdata", + node_k.data_dir, + "--log", + node_k.logfile, + "--options", + f"--cluster-name={node_k_name}", + "start", + ], + "node K has started", + ) + + # Note that this uses a direct pg_ctl command rather than a teardown(), + # because node_k.stop() would not work due to the node's postmaster PID not + # being tracked, something that is set within node_k.start(). + node_k.pg_bin.result(["pg_ctl", "stop", "--pgdata", node_k.data_dir]) + + # clean up: explicit teardown of the nodes (also handled by the fixture). + node_p.teardown() + node_s.teardown() + node_t.teardown() + node_f.teardown() diff --git a/src/bin/pg_checksums/meson.build b/src/bin/pg_checksums/meson.build index 7b2401cb31b..b01cad6634a 100644 --- a/src/bin/pg_checksums/meson.build +++ b/src/bin/pg_checksums/meson.build @@ -28,6 +28,12 @@ tests += { 't/002_actions.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_actions.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_checksums/pyt/test_001_basic.py b/src/bin/pg_checksums/pyt/test_001_basic.py new file mode 100644 index 00000000000..cb39d4db12e --- /dev/null +++ b/src/bin/pg_checksums/pyt/test_001_basic.py @@ -0,0 +1,10 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_checksums option handling and argument validation.""" + + +def test_pg_checksums_basic(pg_bin): + """pg_checksums --help / --version / invalid-option handling.""" + pg_bin.program_help_ok("pg_checksums") + pg_bin.program_version_ok("pg_checksums") + pg_bin.program_options_handling_ok("pg_checksums") diff --git a/src/bin/pg_checksums/pyt/test_002_actions.py b/src/bin/pg_checksums/pyt/test_002_actions.py new file mode 100644 index 00000000000..14fcce7eeea --- /dev/null +++ b/src/bin/pg_checksums/pyt/test_002_actions.py @@ -0,0 +1,279 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_checksums enable/disable/verify actions and corruption detection.""" + +import os +import re +import sys + +from pypg.util import append_to_file + + +def _check_relation_corruption(node, pg_bin, table, tablespace): + """Create and check a table with corrupted checksums on a tablespace. + + Stops and starts the node multiple times, leaving it started at the end. + """ + pgdata = node.data_dir + + # Create table and discover its filesystem location. + node.safe_sql( + f"CREATE TABLE {table} AS SELECT a FROM generate_series(1,10000) AS a;" + f"ALTER TABLE {table} SET (autovacuum_enabled=false);" + ) + node.safe_sql(f"ALTER TABLE {table} SET TABLESPACE {tablespace};") + + file_corrupted = node.safe_sql(f"SELECT pg_relation_filepath('{table}');") + relfilenode_corrupted = node.safe_sql( + f"SELECT relfilenode FROM pg_class WHERE relname = '{table}';" + ) + + node.stop() + + # Checksums are correct for single relfilenode as the table is not + # corrupted yet. + pg_bin.command_ok( + [ + "pg_checksums", + "--check", + "--pgdata", + pgdata, + "--filenode", + relfilenode_corrupted, + ], + f"succeeds for single relfilenode on tablespace {tablespace} with offline cluster", + ) + + # Time to create some corruption + node.corrupt_page_checksum(file_corrupted, 0) + + # Checksum checks on single relfilenode fail + pg_bin.command_checks_all( + [ + "pg_checksums", + "--check", + "--pgdata", + pgdata, + "--filenode", + relfilenode_corrupted, + ], + 1, + [re.compile(r"Bad checksums:.*1")], + [re.compile(r"checksum verification failed")], + f"fails with corrupted data for single relfilenode on tablespace {tablespace}", + ) + + # Global checksum checks fail as well + pg_bin.command_checks_all( + ["pg_checksums", "--check", "--pgdata", pgdata], + 1, + [re.compile(r"Bad checksums:.*1")], + [re.compile(r"checksum verification failed")], + f"fails with corrupted data on tablespace {tablespace}", + ) + + # Drop corrupted table again and make sure there is no more corruption. + node.start() + node.safe_sql(f"DROP TABLE {table};") + node.stop() + pg_bin.command_ok( + ["pg_checksums", "--check", "--pgdata", pgdata], + f"succeeds again after table drop on tablespace {tablespace}", + ) + + node.start() + + +def _fail_corrupt(node, pg_bin, file): + """Check that pg_checksums detects a correctly-named file with bad data.""" + pgdata = node.data_dir + file_name = os.path.join(pgdata, "global", file) + append_to_file(file_name, "foo") + + pg_bin.command_checks_all( + ["pg_checksums", "--check", "--pgdata", pgdata], + 1, + [re.compile(r"^$")], + [re.compile(r"could not read block 0 in file.*" + re.escape(file) + r"\":")], + f"fails for corrupted data in {file}", + ) + + os.unlink(file_name) + + +def test_pg_checksums_actions(create_pg, pg_bin): + """Basic sanity checks supported by pg_checksums on an initialized cluster.""" + # initdb with checksums disabled. + node = create_pg("main", start=False, initdb_extra=["--no-data-checksums"]) + pgdata = node.data_dir + + # Control file should know that checksums are disabled. + pg_bin.command_like( + ["pg_controldata", pgdata], + re.compile(r"Data page checksum version:.*0"), + "checksums disabled in control file", + ) + + # These are correct but empty files, so they should pass through. + for empty in ( + "99999", + "99999.123", + "99999_fsm", + "99999_init", + "99999_vm", + "99999_init.123", + "99999_fsm.123", + "99999_vm.123", + ): + append_to_file(os.path.join(pgdata, "global", empty), "") + + # These are temporary files and folders with dummy contents, which + # should be ignored by the scan. + append_to_file(os.path.join(pgdata, "global", "pgsql_tmp_123"), "foo") + os.mkdir(os.path.join(pgdata, "global", "pgsql_tmp")) + append_to_file(os.path.join(pgdata, "global", "pgsql_tmp", "1.1"), "foo") + append_to_file(os.path.join(pgdata, "global", "pg_internal.init"), "foo") + append_to_file(os.path.join(pgdata, "global", "pg_internal.init.123"), "foo") + + # These are non-postgres macOS files, which should be ignored by the scan. + # Only perform this test on non-macOS systems though as creating incorrect + # system files may have side effects on macOS. + if sys.platform != "darwin": + append_to_file(os.path.join(pgdata, "global", ".DS_Store"), "foo") + + # Enable checksums. + pg_bin.command_ok( + ["pg_checksums", "--enable", "--no-sync", "--pgdata", pgdata], + "checksums successfully enabled in cluster", + ) + + # Successive attempt to enable checksums fails. + pg_bin.command_fails( + ["pg_checksums", "--enable", "--no-sync", "--pgdata", pgdata], + "enabling checksums fails if already enabled", + ) + + # Control file should know that checksums are enabled. + pg_bin.command_like( + ["pg_controldata", pgdata], + re.compile(r"Data page checksum version:.*1"), + "checksums enabled in control file", + ) + + # Disable checksums again. Flush result here as that should be cheap. + pg_bin.command_ok( + ["pg_checksums", "--disable", "--pgdata", pgdata], + "checksums successfully disabled in cluster", + ) + + # Successive attempt to disable checksums fails. + pg_bin.command_fails( + ["pg_checksums", "--disable", "--no-sync", "--pgdata", pgdata], + "disabling checksums fails if already disabled", + ) + + # Control file should know that checksums are disabled. + pg_bin.command_like( + ["pg_controldata", pgdata], + re.compile(r"Data page checksum version:.*0"), + "checksums disabled in control file", + ) + + # Enable checksums again for follow-up tests. + pg_bin.command_ok( + ["pg_checksums", "--enable", "--no-sync", "--pgdata", pgdata], + "checksums successfully enabled in cluster", + ) + + # Control file should know that checksums are enabled. + pg_bin.command_like( + ["pg_controldata", pgdata], + re.compile(r"Data page checksum version:.*1"), + "checksums enabled in control file", + ) + + # Checksums pass on a newly-created cluster + pg_bin.command_ok( + ["pg_checksums", "--check", "--pgdata", pgdata], + "succeeds with offline cluster", + ) + + # Checksums are verified if no other arguments are specified + pg_bin.command_ok( + ["pg_checksums", "--pgdata", pgdata], + "verifies checksums as default action", + ) + + # Specific relation files cannot be requested when action is --disable + # or --enable. + pg_bin.command_fails( + ["pg_checksums", "--disable", "--filenode", "1234", "--pgdata", pgdata], + "fails when relfilenodes are requested and action is --disable", + ) + pg_bin.command_fails( + ["pg_checksums", "--enable", "--filenode", "1234", "--pgdata", pgdata], + "fails when relfilenodes are requested and action is --enable", + ) + + # Test postgres -C for an offline cluster. + # Run-time GUCs are safe to query here. Note that a lock file is created, + # then removed, leading to an extra LOG entry showing in stderr. This uses + # log_min_messages=fatal to remove any noise. This test uses a startup + # wrapped with pg_ctl to allow the case where this runs under a privileged + # account on Windows. + pg_bin.command_checks_all( + [ + "pg_ctl", + "start", + "--silent", + "--pgdata", + pgdata, + "-o", + "-C data_checksums -c log_min_messages=fatal", + ], + 1, + [re.compile(r"^on$", re.MULTILINE)], + [re.compile(r"could not start server")], + "data_checksums=on is reported on an offline cluster", + ) + + # Checks cannot happen with an online cluster + node.start() + pg_bin.command_fails( + ["pg_checksums", "--check", "--pgdata", pgdata], + "fails with online cluster", + ) + + # Check corruption of table on default tablespace. + _check_relation_corruption(node, pg_bin, "corrupt1", "pg_default") + + # Create tablespace to check corruptions in a non-default tablespace. + basedir = node.basedir + tablespace_dir = os.path.join(basedir, "ts_corrupt_dir") + os.mkdir(tablespace_dir) + node.safe_sql(f"CREATE TABLESPACE ts_corrupt LOCATION '{tablespace_dir}';") + _check_relation_corruption(node, pg_bin, "corrupt2", "ts_corrupt") + + # Stop instance for the follow-up checks. + node.stop() + + # Create a fake tablespace location that should not be scanned + # when verifying checksums. + os.mkdir(os.path.join(tablespace_dir, "PG_99_999999991")) + append_to_file(os.path.join(tablespace_dir, "PG_99_999999991", "foo"), "123") + pg_bin.command_ok( + ["pg_checksums", "--check", "--pgdata", pgdata], + "succeeds with foreign tablespace", + ) + + # Authorized relation files filled with corrupted data cause the + # checksum checks to fail. Make sure to use file names different + # than the previous ones. + _fail_corrupt(node, pg_bin, "99990") + _fail_corrupt(node, pg_bin, "99990.123") + _fail_corrupt(node, pg_bin, "99990_fsm") + _fail_corrupt(node, pg_bin, "99990_init") + _fail_corrupt(node, pg_bin, "99990_vm") + _fail_corrupt(node, pg_bin, "99990_init.123") + _fail_corrupt(node, pg_bin, "99990_fsm.123") + _fail_corrupt(node, pg_bin, "99990_vm.123") diff --git a/src/bin/pg_combinebackup/meson.build b/src/bin/pg_combinebackup/meson.build index a35b86f3f59..2e0d3b257cd 100644 --- a/src/bin/pg_combinebackup/meson.build +++ b/src/bin/pg_combinebackup/meson.build @@ -40,6 +40,21 @@ tests += { 't/010_hardlink.pl', 't/011_ib_truncation.pl', ], + }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_compare_backups.py', + 'pyt/test_003_timeline.py', + 'pyt/test_004_manifest.py', + 'pyt/test_005_integrity.py', + 'pyt/test_006_db_file_copy.py', + 'pyt/test_007_wal_level_minimal.py', + 'pyt/test_008_promote.py', + 'pyt/test_009_no_full_file.py', + 'pyt/test_010_hardlink.py', + 'pyt/test_011_ib_truncation.py', + ], } } diff --git a/src/bin/pg_combinebackup/pyt/test_001_basic.py b/src/bin/pg_combinebackup/pyt/test_001_basic.py new file mode 100644 index 00000000000..91dbd690030 --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_001_basic.py @@ -0,0 +1,25 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_combinebackup option handling.""" + +import os + + +def test_001_basic(pg_bin, tmp_path): + tempdir = str(tmp_path / "tempdir") + os.mkdir(tempdir) + + pg_bin.program_help_ok("pg_combinebackup") + pg_bin.program_version_ok("pg_combinebackup") + pg_bin.program_options_handling_ok("pg_combinebackup") + + pg_bin.command_fails_like( + ["pg_combinebackup"], + r"no input directories specified", + "input directories must be specified", + ) + pg_bin.command_fails_like( + ["pg_combinebackup", tempdir], + r"no output directory specified", + "output directory must be specified", + ) diff --git a/src/bin/pg_combinebackup/pyt/test_002_compare_backups.py b/src/bin/pg_combinebackup/pyt/test_002_compare_backups.py new file mode 100644 index 00000000000..819f324f960 --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_002_compare_backups.py @@ -0,0 +1,337 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Take a full backup and an incremental backup (with a user-defined +tablespace), combine the incremental with the full using pg_combinebackup, +then perform PITR to the same LSN from both the full backup and the combined +backup. A logical dump (pg_dumpall) of each restored server must match, which +demonstrates that the combined backup reconstructs the same database state as +the original full backup. + +FRAMEWORK NOTES: + + * The framework's init_from_backup does not support combining with a prior + backup or remapping tablespaces. This test therefore drives + pg_combinebackup directly and performs the data-directory copy plus + tablespace symlink relocation itself (see _restore_node). + + * --copy/--clone/--link is chosen via the PG_TEST_PG_COMBINEBACKUP_MODE + environment variable (default --copy). +""" + +import os +import re +import shutil + +from pypg.util import dir_symlink, remove_dir_symlink + + +def _restore_node(node, backup_path, ts_oid, ts_dest): + """Bring a node's data dir up from a plain-format backup at *backup_path*. + + Copies the backup tree into the node's data directory, relocates the + user-defined tablespace into *ts_dest* and repoints the pg_tblspc/ + symlink at it, + and writes the minimal connection configuration. Recovery configuration + (restore_command, recovery_target_*) is appended separately by the caller. + """ + data_path = node.data_dir + if os.path.isdir(data_path): + shutil.rmtree(data_path) + shutil.copytree(backup_path, data_path, symlinks=True) + os.chmod(data_path, 0o700) + + # Relocate the tablespace: copy its contents to ts_dest and repoint the + # pg_tblspc/ symlink. In a plain-format backup the symlink points at + # wherever the backup relocated the tablespace; we move it under this + # node's own area so the two restored nodes don't collide. + link = os.path.join(data_path, "pg_tblspc", ts_oid) + if os.path.islink(link): + # POSIX (and a Windows symlink): the link still points at the backup's + # tablespace location; copy it out and repoint the link. + src = os.path.realpath(link) + shutil.copytree(src, ts_dest, symlinks=True) + remove_dir_symlink(link) + else: + # Windows: shutil.copytree does not preserve the junction, so the + # tablespace contents were copied into pg_tblspc/ as a real + # directory. Move that directory to its destination. + shutil.move(link, ts_dest) + dir_symlink(ts_dest, link) + + node.append_conf( + "\n".join( + [ + "", + f"port = {node.port}", + "listen_addresses = ''", + f"unix_socket_directories = '{node.host}'", + "", + ] + ) + ) + + +def test_002_compare_backups(create_pg, tempdir_short): + # Use a short tempdir: the tablespace symlinks below are written into a + # base backup's tar stream, whose target length is limited. + tempdir = tempdir_short + + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_COMBINEBACKUP_MODE") or "--copy" + print(f"# testing using mode {mode}") + + # Set up a new database instance. + primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + primary.append_conf("summarize_wal = on") + primary.start() + tsprimary = os.path.join(tempdir, "ts") + os.mkdir(tsprimary) + + # Create some test tables, each containing one row of data, plus a whole + # extra database. CREATE DATABASE and CREATE TABLESPACE cannot run inside + # a transaction block, so issue them as separate statements (the + # in-process libpq session groups a multi-statement string into one + # implicit transaction, unlike psql). + primary.safe_sql( + """ +CREATE TABLE will_change (a int, b text); +INSERT INTO will_change VALUES (1, 'initial test row'); +CREATE TABLE will_grow (a int, b text); +INSERT INTO will_grow VALUES (1, 'initial test row'); +CREATE TABLE will_shrink (a int, b text); +INSERT INTO will_shrink VALUES (1, 'initial test row'); +CREATE TABLE will_get_vacuumed (a int, b text); +INSERT INTO will_get_vacuumed VALUES (1, 'initial test row'); +CREATE TABLE will_get_dropped (a int, b text); +INSERT INTO will_get_dropped VALUES (1, 'initial test row'); +CREATE TABLE will_get_rewritten (a int, b text); +INSERT INTO will_get_rewritten VALUES (1, 'initial test row'); +""" + ) + primary.safe_sql("CREATE DATABASE db_will_get_dropped;") + primary.safe_sql(f"CREATE TABLESPACE ts1 LOCATION '{tsprimary}';") + primary.safe_sql( + """ +CREATE TABLE will_not_change_in_ts (a int, b text) TABLESPACE ts1; +INSERT INTO will_not_change_in_ts VALUES (1, 'initial test row'); +CREATE TABLE will_change_in_ts (a int, b text) TABLESPACE ts1; +INSERT INTO will_change_in_ts VALUES (1, 'initial test row'); +CREATE TABLE will_get_dropped_in_ts (a int, b text); +INSERT INTO will_get_dropped_in_ts VALUES (1, 'initial test row'); +""" + ) + + # Read list of tablespace OIDs. There should be just one. + tsoids = [ + e + for e in os.listdir(os.path.join(primary.data_dir, "pg_tblspc")) + if re.match(r"^\d+", e) + ] + assert len(tsoids) == 1, "exactly one user-defined tablespace" + tsoid = tsoids[0] + + # Take a full backup. + backup1path = os.path.join(primary.backup_dir, "backup1") + tsbackup1path = os.path.join(tempdir, "ts1backup") + os.mkdir(tsbackup1path) + primary.command_ok( + [ + "pg_basebackup", + "--no-sync", + "--pgdata", + backup1path, + "--checkpoint", + "fast", + "--tablespace-mapping", + f"{tsprimary}={tsbackup1path}", + ], + "full backup", + ) + + # Now make some database changes. VACUUM and the DROP/CREATE DATABASE + # statements cannot run inside a transaction block, so issue the + # transaction-incompatible statements separately. + primary.safe_sql( + """ +UPDATE will_change SET b = 'modified value' WHERE a = 1; +UPDATE will_change_in_ts SET b = 'modified value' WHERE a = 1; +INSERT INTO will_grow + SELECT g, 'additional row' FROM generate_series(2, 5000) g; +TRUNCATE will_shrink; +DROP TABLE will_get_dropped; +DROP TABLE will_get_dropped_in_ts; +CREATE TABLE newly_created (a int, b text); +INSERT INTO newly_created VALUES (1, 'row for new table'); +CREATE TABLE newly_created_in_ts (a int, b text) TABLESPACE ts1; +INSERT INTO newly_created_in_ts VALUES (1, 'row for new table'); +""" + ) + primary.safe_sql("VACUUM will_get_vacuumed;") + primary.safe_sql("VACUUM FULL will_get_rewritten;") + primary.safe_sql("DROP DATABASE db_will_get_dropped;") + primary.safe_sql("CREATE DATABASE db_newly_created;") + + # Take an incremental backup. + backup2path = os.path.join(primary.backup_dir, "backup2") + tsbackup2path = os.path.join(tempdir, "tsbackup2") + os.mkdir(tsbackup2path) + primary.command_ok( + [ + "pg_basebackup", + "--no-sync", + "--pgdata", + backup2path, + "--checkpoint", + "fast", + "--tablespace-mapping", + f"{tsprimary}={tsbackup2path}", + "--incremental", + os.path.join(backup1path, "backup_manifest"), + ], + "incremental backup", + ) + + # Find an LSN to which either backup can be recovered. + lsn = primary.safe_sql("SELECT pg_current_wal_lsn();") + + # Make sure that the WAL segment containing that LSN has been archived. + # PostgreSQL won't issue two consecutive XLOG_SWITCH records, and the + # backup just issued one, so call txid_current() to generate some WAL + # activity before calling pg_switch_wal(). + primary.safe_sql("SELECT txid_current();") + primary.safe_sql("SELECT pg_switch_wal()") + + # Now wait for the LSN we chose above to be archived. + archive_wait_query = ( + f"SELECT pg_walfile_name('{lsn}') <= last_archived_wal " + "FROM pg_stat_archiver;" + ) + assert primary.poll_query_until( + archive_wait_query + ), "Timed out while waiting for WAL segment to be archived" + + # Perform PITR from the full backup. Disable archive_mode so that the + # archive doesn't find out about the new timeline; that way, the later PITR + # below will choose the same timeline. + tspitr1path = os.path.join(tempdir, "tspitr1") + pitr1 = create_pg("pitr1", start=False) + _restore_node(pitr1, backup1path, tsoid, tspitr1path) + pitr1.enable_restoring(primary, standby=True) + pitr1.append_conf( + f""" +recovery_target_lsn = '{lsn}' +recovery_target_action = 'promote' +archive_mode = 'off' +""" + ) + pitr1.start() + + # Perform PITR to the same LSN from the incremental backup. Use the same + # basic configuration as before. First combine the incremental backup + # (backup2) with its prior full backup (backup1) using pg_combinebackup, + # relocating the tablespace. + tspitr2path = os.path.join(tempdir, "tspitr2") + combinedpath = os.path.join(primary.backup_dir, "combined") + tscombinedpath = os.path.join(tempdir, "tscombined") + pitr2 = create_pg("pitr2", start=False) + pitr2.command_ok( + [ + "pg_combinebackup", + backup1path, + backup2path, + "--output", + combinedpath, + "--tablespace-mapping", + f"{tsbackup2path}={tscombinedpath}", + mode, + ], + "combine full and incremental backup", + ) + _restore_node(pitr2, combinedpath, tsoid, tspitr2path) + pitr2.enable_restoring(primary, standby=True) + pitr2.append_conf( + f""" +recovery_target_lsn = '{lsn}' +recovery_target_action = 'promote' +archive_mode = 'off' +""" + ) + pitr2.start() + + # Wait until both servers exit recovery. + assert pitr1.poll_query_until( + "SELECT NOT pg_is_in_recovery();" + ), f"Timed out while waiting apply to reach LSN {lsn}" + assert pitr2.poll_query_until( + "SELECT NOT pg_is_in_recovery();" + ), f"Timed out while waiting apply to reach LSN {lsn}" + + # Perform a logical dump of each server, and check that they match. + # It would be much nicer if we could physically compare the data files, but + # that doesn't really work. The contents of the page hole aren't guaranteed + # to be identical, and there can be other discrepancies as well. + # + # NB: We're just using the primary's backup directory for scratch space + # here. This could equally well be any other directory we wanted to pick. + backupdir = primary.backup_dir + dump1 = os.path.join(backupdir, "pitr1.dump") + dump2 = os.path.join(backupdir, "pitr2.dump") + pitr1.command_ok( + [ + "pg_dumpall", + "--restrict-key", + "test", + "--no-sync", + "--no-unlogged-table-data", + "--file", + dump1, + "--dbname", + pitr1.connstr("postgres"), + ], + "dump from PITR 1", + ) + pitr2.command_ok( + [ + "pg_dumpall", + "--restrict-key", + "test", + "--no-sync", + "--no-unlogged-table-data", + "--file", + dump2, + "--dbname", + pitr2.connstr("postgres"), + ], + "dump from PITR 2", + ) + + # Compare the two dumps, there should be no differences other than + # the tablespace paths. + _compare_dumps(dump1, dump2, "contents of dumps match for both PITRs") + + +def _compare_dumps(dump1, dump2, msg): + """Compare two pg_dumpall files, normalizing the tablespace location path. + + Lines of the form + "CREATE TABLESPACE ... LOCATION ...tspitr[12]" have their trailing 1/2 + folded to N before comparison so the per-node tablespace paths don't cause + a spurious difference. + """ + + def _norm(line): + return re.sub( + r"(create tablespace .* location .*\btspitr)[12]", + r"\1N", + line, + flags=re.IGNORECASE, + ) + + with open(dump1, encoding="utf-8", errors="replace") as fh: + lines1 = [_norm(line) for line in fh] + with open(dump2, encoding="utf-8", errors="replace") as fh: + lines2 = [_norm(line) for line in fh] + + assert lines1 == lines2, msg diff --git a/src/bin/pg_combinebackup/pyt/test_003_timeline.py b/src/bin/pg_combinebackup/pyt/test_003_timeline.py new file mode 100644 index 00000000000..fa6d815e14b --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_003_timeline.py @@ -0,0 +1,139 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""This test aims to validate that restoring an incremental backup works +properly even when the reference backup is on a different timeline. +""" + +import os +import shutil + + +def _combine_backup(node, root_node, prior_backups, final_backup, combine_mode=None): + """Combine *prior_backups* + *final_backup* into *node*'s data dir. + + Runs ``pg_combinebackup`` with the prior backup paths and the final + (incremental) backup path, writing the reconstructed cluster into *node*'s + data directory, then writes this node's port/socket configuration so it can + start. + + The framework's PostgresServer.init_from_backup does not support + incremental/combine restores, so this helper drives pg_combinebackup + directly instead. + """ + data_path = node.data_dir + # create_pg already ran initdb into data_dir; pg_combinebackup requires a + # fresh output directory, so remove it first. + if os.path.isdir(data_path): + shutil.rmtree(data_path) + + prior_paths = [os.path.join(root_node.backup_dir, name) for name in prior_backups] + final_path = os.path.join(root_node.backup_dir, final_backup) + + combineargs = ["pg_combinebackup", "--debug"] + if combine_mode is not None: + combineargs.append(combine_mode) + combineargs += prior_paths + [final_path, "--output", data_path] + node.command_ok(combineargs, "combine backup for node " + node.name) + + # Mirror init_from_backup's base configuration for this node. + node.append_conf( + "\n".join( + [ + "", + f"port = {node.port}", + "listen_addresses = ''", + f"unix_socket_directories = '{node.host}'", + "", + ] + ) + ) + + +def test_003_timeline(create_pg, tmp_path): + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_COMBINEBACKUP_MODE") or "--copy" + + print(f"# testing using mode {mode}") + + # Set up a new database instance. + node1 = create_pg("node1", start=False, has_archiving=True, allows_streaming=True) + node1.append_conf("summarize_wal = on") + node1.start() + + # Create a table and insert a test row into it. + node1.safe_sql( + "CREATE TABLE mytable (a int, b text);\n" + "INSERT INTO mytable VALUES (1, 'aardvark');\n" + ) + + # Take a full backup. + backup1path = os.path.join(node1.backup_dir, "backup1") + node1.command_ok( + ["pg_basebackup", "--pgdata", backup1path, "--no-sync", "--checkpoint", "fast"], + "full backup from node1", + ) + + # Insert a second row on the original node. + node1.safe_sql("INSERT INTO mytable VALUES (2, 'beetle');\n") + + # Now take an incremental backup. + backup2path = os.path.join(node1.backup_dir, "backup2") + node1.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup2path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + os.path.join(backup1path, "backup_manifest"), + ], + "incremental backup from node1", + ) + + # Restore the incremental backup and use it to create a new node. + node2 = create_pg("node2", start=False) + _combine_backup(node2, node1, ["backup1"], "backup2") + node2.start() + + # Insert rows on both nodes. + node1.safe_sql("INSERT INTO mytable VALUES (3, 'crab');\n") + node2.safe_sql("INSERT INTO mytable VALUES (4, 'dingo');\n") + + # Take another incremental backup, from node2, based on backup2 from node1. + backup3path = os.path.join(node1.backup_dir, "backup3") + node2.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup3path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + os.path.join(backup2path, "backup_manifest"), + ], + "incremental backup from node2", + ) + + # Restore the incremental backup and use it to create a new node. + node3 = create_pg("node3", start=False) + _combine_backup(node3, node1, ["backup1", "backup2"], "backup3", combine_mode=mode) + node3.start() + + # Let's insert one more row. + node3.safe_sql("INSERT INTO mytable VALUES (5, 'elephant');\n") + + # Now check that we have the expected rows. + result = node3.safe_sql( + "select string_agg(a::text, ':'), string_agg(b, ':') from mytable;\n" + ) + assert result == "1:2:4:5|aardvark:beetle:dingo:elephant" + + # Let's also verify all the backups. + for backup_name in ("backup1", "backup2", "backup3"): + node1.command_ok( + ["pg_verifybackup", os.path.join(node1.backup_dir, backup_name)], + f"verify backup {backup_name}", + ) diff --git a/src/bin/pg_combinebackup/pyt/test_004_manifest.py b/src/bin/pg_combinebackup/pyt/test_004_manifest.py new file mode 100644 index 00000000000..6a5a9533a80 --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_004_manifest.py @@ -0,0 +1,103 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""This test aims to validate that pg_combinebackup works in the degenerate +case where it is invoked on a single full backup and that it can produce +a new, valid manifest when it does. Secondarily, it checks that +pg_combinebackup does not produce a manifest when run with --no-manifest. +""" + +import os +import re + +from pypg.util import slurp_file + +# Can be changed to test the other modes. +MODE = os.environ.get("PG_TEST_PG_COMBINEBACKUP_MODE") or "--copy" + + +def _combine_and_test_one_backup( + node, original_backup_path, backup_name, failure_pattern, *extra_options +): + """Process the backup with pg_combinebackup using various manifest options.""" + revised_backup_path = os.path.join(node.backup_dir, backup_name) + node.command_ok( + [ + "pg_combinebackup", + original_backup_path, + "--output", + revised_backup_path, + "--no-sync", + *extra_options, + ], + f"pg_combinebackup with {' '.join(extra_options)}", + ) + if failure_pattern is not None: + node.command_fails_like( + ["pg_verifybackup", revised_backup_path], + failure_pattern, + f"unable to verify backup {backup_name}", + ) + else: + node.command_ok( + ["pg_verifybackup", revised_backup_path], f"verify backup {backup_name}" + ) + + +def test_004_manifest(create_pg): + print(f"# testing using mode {MODE}") + + # Set up a new database instance. + node = create_pg("node", has_archiving=True, allows_streaming=True) + + # Take a full backup. + original_backup_path = os.path.join(node.backup_dir, "original") + node.command_ok( + [ + "pg_basebackup", + "--pgdata", + original_backup_path, + "--no-sync", + "--checkpoint", + "fast", + ], + "full backup", + ) + + # Verify the full backup. + node.command_ok(["pg_verifybackup", original_backup_path], "verify original backup") + + _combine_and_test_one_backup( + node, + original_backup_path, + "nomanifest", + r"could not open file.*backup_manifest", + "--no-manifest", + ) + _combine_and_test_one_backup( + node, original_backup_path, "csum_none", None, "--manifest-checksums=NONE", MODE + ) + _combine_and_test_one_backup( + node, + original_backup_path, + "csum_sha224", + None, + "--manifest-checksums=SHA224", + MODE, + ) + + # Verify that SHA224 is mentioned in the SHA224 manifest lots of times. + sha224_manifest = slurp_file( + os.path.join(node.backup_dir, "csum_sha224", "backup_manifest") + ) + sha224_count = len(re.findall("SHA224", sha224_manifest, re.M | re.I)) + assert sha224_count > 100, "SHA224 is mentioned many times in SHA224 manifest" + + # Verify that Checksum-Algorithm is not mentioned in the no-checksum + # manifest. + nocsum_manifest = slurp_file( + os.path.join(node.backup_dir, "csum_none", "backup_manifest") + ) + nocsum_count = len(re.findall("Checksum-Algorithm", nocsum_manifest, re.M | re.I)) + assert ( + nocsum_count == 0 + ), "Checksum-Algorithm is not mentioned in no-checksum manifest" diff --git a/src/bin/pg_combinebackup/pyt/test_005_integrity.py b/src/bin/pg_combinebackup/pyt/test_005_integrity.py new file mode 100644 index 00000000000..052eeacf67c --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_005_integrity.py @@ -0,0 +1,279 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""This test aims to validate that an incremental backup can be combined +with a valid prior backup and that it cannot be combined with an invalid +prior backup. + +FRAMEWORK NOTES: + + * The test uses two clusters (node1, node2). Every create_pg() runs a fresh + initdb into its own data directory, so the two nodes already have different + system identifiers. + + * All backups are taken into node1's backup directory, including those taken + from node2. +""" + +import os +import shutil + + +def test_005_integrity(create_pg): + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_COMBINEBACKUP_MODE") or "--copy" + print(f"# testing using mode {mode}") + + # Set up a new database instance. + node1 = create_pg("node1", start=False, has_archiving=True, allows_streaming=True) + node1.append_conf("summarize_wal = on") + node1.start() + + # Create a file called INCREMENTAL.config in the root directory of the + # first database instance. We only recognize INCREMENTAL.${original_name} + # files under base and global and in tablespace directories, so this + # shouldn't cause anything to fail. + strangely_named_config_file = os.path.join(node1.data_dir, "INCREMENTAL.config") + with open(strangely_named_config_file, "w", encoding="utf-8"): + pass + + # Set up another new database instance. A separate cluster is created + # with a different system ID. + node2 = create_pg("node2", start=False, has_archiving=True, allows_streaming=True) + node2.append_conf("summarize_wal = on") + node2.start() + + # Take a full backup from node1. + backup1path = os.path.join(node1.backup_dir, "backup1") + node1.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup1path, + "--no-sync", + "--checkpoint", + "fast", + ], + "full backup from node1", + ) + + # Now take an incremental backup. + backup2path = os.path.join(node1.backup_dir, "backup2") + node1.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup2path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + os.path.join(backup1path, "backup_manifest"), + ], + "incremental backup from node1", + ) + + # Now take another incremental backup. + backup3path = os.path.join(node1.backup_dir, "backup3") + node1.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup3path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + os.path.join(backup2path, "backup_manifest"), + ], + "another incremental backup from node1", + ) + + # Take a full backup from node2. + backupother1path = os.path.join(node1.backup_dir, "backupother1") + node2.command_ok( + [ + "pg_basebackup", + "--pgdata", + backupother1path, + "--no-sync", + "--checkpoint", + "fast", + ], + "full backup from node2", + ) + + # Take an incremental backup from node2. + backupother2path = os.path.join(node1.backup_dir, "backupother2") + node2.command_ok( + [ + "pg_basebackup", + "--pgdata", + backupother2path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + os.path.join(backupother1path, "backup_manifest"), + ], + "incremental backup from node2", + ) + + # Result directory. + resultpath = os.path.join(node1.backup_dir, "result") + + # Can't combine 2 full backups. + node1.command_fails_like( + [ + "pg_combinebackup", + backup1path, + backup1path, + "--output", + resultpath, + mode, + ], + r"is a full backup, but only the first backup should be a full backup", + "can't combine full backups", + ) + + # Can't combine 2 incremental backups. + node1.command_fails_like( + [ + "pg_combinebackup", + backup2path, + backup2path, + "--output", + resultpath, + mode, + ], + r"is an incremental backup, but the first backup should be a full backup", + "can't combine full backups", + ) + + # Can't combine full backup with an incremental backup from a different + # system. + node1.command_fails_like( + [ + "pg_combinebackup", + backup1path, + backupother2path, + "--output", + resultpath, + mode, + ], + r"expected system identifier.*but found", + "can't combine backups from different nodes", + ) + + # Can't combine when different manifest system identifier + os.rename( + os.path.join(backup2path, "backup_manifest"), + os.path.join(backup2path, "backup_manifest.orig"), + ) + shutil.copy( + os.path.join(backupother2path, "backup_manifest"), + os.path.join(backup2path, "backup_manifest"), + ) + node1.command_fails_like( + [ + "pg_combinebackup", + backup1path, + backup2path, + backup3path, + "--output", + resultpath, + mode, + ], + r" manifest system identifier is .*, but control file has ", + "can't combine backups with different manifest system identifier ", + ) + # Restore the backup state + os.replace( + os.path.join(backup2path, "backup_manifest.orig"), + os.path.join(backup2path, "backup_manifest"), + ) + + # Can't omit a required backup. + node1.command_fails_like( + [ + "pg_combinebackup", + backup1path, + backup3path, + "--output", + resultpath, + mode, + ], + r"starts at LSN.*but expected", + "can't omit a required backup", + ) + + # Can't combine backups in the wrong order. + node1.command_fails_like( + [ + "pg_combinebackup", + backup1path, + backup3path, + backup2path, + "--output", + resultpath, + mode, + ], + r"starts at LSN.*but expected", + "can't combine backups in the wrong order", + ) + + # Can combine 3 backups that match up properly. + node1.command_ok( + [ + "pg_combinebackup", + backup1path, + backup2path, + backup3path, + "--output", + resultpath, + mode, + ], + "can combine 3 matching backups", + ) + shutil.rmtree(resultpath) + + # Can combine full backup with first incremental. + synthetic12path = os.path.join(node1.backup_dir, "synthetic12") + node1.command_ok( + [ + "pg_combinebackup", + backup1path, + backup2path, + "--output", + synthetic12path, + mode, + ], + "can combine 2 matching backups", + ) + + # Can combine result of previous step with second incremental. + node1.command_ok( + [ + "pg_combinebackup", + synthetic12path, + backup3path, + "--output", + resultpath, + mode, + ], + "can combine synthetic backup with later incremental", + ) + shutil.rmtree(resultpath) + + # Can't combine result of 1+2 with 2. + node1.command_fails_like( + [ + "pg_combinebackup", + synthetic12path, + backup2path, + "--output", + resultpath, + mode, + ], + r"starts at LSN.*but expected", + "can't combine synthetic backup with included incremental", + ) diff --git a/src/bin/pg_combinebackup/pyt/test_006_db_file_copy.py b/src/bin/pg_combinebackup/pyt/test_006_db_file_copy.py new file mode 100644 index 00000000000..880f6c5c15b --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_006_db_file_copy.py @@ -0,0 +1,100 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Verify that an incremental backup correctly copies whole database files when +needed. When a database is dropped and recreated (with the same OID) between +the full and incremental backups, pg_combinebackup must take the whole new +database file rather than try to apply an incremental delta, so the restored +cluster reflects the recreated (empty) database. +""" + +import os +import shutil + +from libpq import ExecStatusType + + +def test_006_db_file_copy(create_pg, tmp_path): + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_COMBINEBACKUP_MODE") or "--copy" + print(f"# testing using mode {mode}") + + # Set up a new database instance. + primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + primary.append_conf("summarize_wal = on") + primary.start() + + # Initial setup. + primary.safe_sql("CREATE DATABASE lakh OID = 100000 STRATEGY = FILE_COPY") + primary.safe_sql("CREATE TABLE t1 (a int)", dbname="lakh") + + # Take a full backup. + backup1path = os.path.join(primary.backup_dir, "backup1") + primary.command_ok( + ["pg_basebackup", "--pgdata", backup1path, "--no-sync", "--checkpoint", "fast"], + "full backup", + ) + + # Now make some database changes. DROP/CREATE DATABASE cannot run inside + # a transaction block, so issue them as separate top-level statements (the + # in-process Session wraps a multi-statement string in one transaction). + primary.safe_sql("DROP DATABASE lakh;") + primary.safe_sql("CREATE DATABASE lakh OID = 100000 STRATEGY = FILE_COPY") + + # Take an incremental backup. + backup2path = os.path.join(primary.backup_dir, "backup2") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup2path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + os.path.join(backup1path, "backup_manifest"), + ], + "incremental backup", + ) + + # Recover the incremental backup. + # + # The framework's init_from_backup does not support incremental combine, so + # run pg_combinebackup directly to produce the combined data directory, + # then build a verification node on top of it (as test_010 does for its + # restore-and-query checks). + restore = create_pg("restore", start=False) + combined = restore.data_dir + shutil.rmtree(combined, ignore_errors=True) + restore.command_ok( + ["pg_combinebackup", backup1path, backup2path, "--output", combined, mode], + "combine backups", + ) + + # init() already wrote our connection settings (port, socket dir) to the + # original data dir's postgresql.conf, which pg_combinebackup overwrote. + # Append them again to the combined data dir before starting. + restore.append_conf( + "\n".join( + [ + "", + f"port = {restore.port}", + "listen_addresses = ''", + f"unix_socket_directories = '{restore.host}'", + "", + ] + ) + ) + restore.start() + + # Query the DB. The table created before the full backup must be gone, + # because the database was dropped and recreated between backups. + res = restore.sql("SELECT * FROM t1", dbname="lakh") + assert ( + res.status == ExecStatusType.PGRES_FATAL_ERROR + ), "SELECT * FROM t1: query should fail" + assert res.psqlout == "", "SELECT * FROM t1: no stdout" + assert 'relation "t1" does not exist' in ( + res.error_message or "" + ), "SELECT * FROM t1: stderr missing table" diff --git a/src/bin/pg_combinebackup/pyt/test_007_wal_level_minimal.py b/src/bin/pg_combinebackup/pyt/test_007_wal_level_minimal.py new file mode 100644 index 00000000000..087a2a36a8b --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_007_wal_level_minimal.py @@ -0,0 +1,77 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""This test aims to validate that taking an incremental backup fails when +wal_level has been changed to minimal between the full backup and the +attempted incremental backup. With wal_level=minimal, WAL summarization is +disabled, so the summaries required to compute the incremental are missing. +""" + +import os + + +def test_007_wal_level_minimal(create_pg): + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_COMBINEBACKUP_MODE") or "--copy" + print(f"# testing using mode {mode}") + + # Set up a new database instance. + node1 = create_pg("node1", start=False, allows_streaming=True) + node1.append_conf( + "\n".join( + [ + "summarize_wal = on", + "wal_keep_size = '1GB'", + "", + ] + ) + ) + node1.start() + + # Create a table and insert a test row into it. + node1.safe_sql( + "CREATE TABLE mytable (a int, b text);\n" + "INSERT INTO mytable VALUES (1, 'finch');" + ) + + # Take a full backup. + backup1path = os.path.join(node1.backup_dir, "backup1") + node1.command_ok( + ["pg_basebackup", "--pgdata", backup1path, "--no-sync", "--checkpoint", "fast"], + "full backup", + ) + + # Switch to wal_level=minimal, which also requires max_wal_senders=0 and + # summarize_wal=off + # Each ALTER SYSTEM must run as its own top-level statement (the + # in-process Session wraps a multi-statement string in one transaction, + # and ALTER SYSTEM cannot run inside a transaction block). + node1.safe_sql("ALTER SYSTEM SET wal_level = minimal;") + node1.safe_sql("ALTER SYSTEM SET max_wal_senders = 0;") + node1.safe_sql("ALTER SYSTEM SET summarize_wal = off;") + node1.restart() + + # Insert a second row on the original node. + node1.safe_sql("INSERT INTO mytable VALUES (2, 'gerbil');") + + # Revert configuration changes + node1.safe_sql("ALTER SYSTEM RESET wal_level;") + node1.safe_sql("ALTER SYSTEM RESET max_wal_senders;") + node1.safe_sql("ALTER SYSTEM RESET summarize_wal;") + node1.restart() + + # Now take an incremental backup. + backup2path = os.path.join(node1.backup_dir, "backup2") + node1.command_fails_like( + [ + "pg_basebackup", + "--pgdata", + backup2path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + os.path.join(backup1path, "backup_manifest"), + ], + r"(?s)WAL summaries are required on timeline 1 from.*are incomplete", + "incremental backup fails", + ) diff --git a/src/bin/pg_combinebackup/pyt/test_008_promote.py b/src/bin/pg_combinebackup/pyt/test_008_promote.py new file mode 100644 index 00000000000..e606911bd42 --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_008_promote.py @@ -0,0 +1,122 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test whether WAL summaries are complete such that incremental backup +can be performed after promoting a standby at an arbitrary LSN. +""" + +import os +import shutil + + +def _combine_backup(node, root_node, prior_backups, final_backup, combine_mode=None): + """Combine *prior_backups* + *final_backup* into *node*'s data dir. + + Runs ``pg_combinebackup`` with the prior backup paths and the final + (incremental) backup path, writing the reconstructed cluster into *node*'s + data directory, then writes this node's port/socket configuration so it can + start. + + The framework's PostgresServer.init_from_backup does not support + incremental/combine restores, so this helper drives pg_combinebackup + directly instead. + """ + data_path = node.data_dir + # create_pg already ran initdb into data_dir; pg_combinebackup requires a + # fresh output directory, so remove it first. + if os.path.isdir(data_path): + shutil.rmtree(data_path) + + prior_paths = [os.path.join(root_node.backup_dir, name) for name in prior_backups] + final_path = os.path.join(root_node.backup_dir, final_backup) + + combineargs = ["pg_combinebackup", "--debug"] + if combine_mode is not None: + combineargs.append(combine_mode) + combineargs += prior_paths + [final_path, "--output", data_path] + node.command_ok(combineargs, "combine backup for node " + node.name) + + # Mirror init_from_backup's base configuration for this node. + node.append_conf( + "\n".join( + [ + "", + f"port = {node.port}", + "listen_addresses = ''", + f"unix_socket_directories = '{node.host}'", + "", + ] + ) + ) + + +def test_008_promote(create_pg): + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_COMBINEBACKUP_MODE") or "--copy" + + print(f"# testing using mode {mode}") + + # Set up a new database instance. + node1 = create_pg("node1", start=False, has_archiving=True, allows_streaming=True) + node1.append_conf("summarize_wal = on") + node1.append_conf("log_min_messages = debug1") + node1.start() + + # Create a table and insert a test row into it. + node1.safe_sql( + "CREATE TABLE mytable (a int, b text);\n" + "INSERT INTO mytable VALUES (1, 'avocado');\n" + ) + + # Take a full backup. + backup1path = os.path.join(node1.backup_dir, "backup1") + node1.command_ok( + ["pg_basebackup", "--pgdata", backup1path, "--no-sync", "--checkpoint", "fast"], + "full backup from node1", + ) + + # Checkpoint and record LSN after. + node1.safe_sql("CHECKPOINT") + lsn = node1.safe_sql("SELECT pg_current_wal_insert_lsn()") + + # Insert a second row on the original node. + node1.safe_sql("INSERT INTO mytable VALUES (2, 'beetle');\n") + + # Now create a second node. We want this to stream from the first node and + # then stop recovery at some arbitrary LSN, not just when it hits the end + # of WAL, so use a recovery target. + node2 = create_pg("node2", start=False) + node2.init_from_backup(node1, "backup1", has_streaming=True) + node2.append_conf( + f"recovery_target_lsn = '{lsn}'\n" "recovery_target_action = 'pause'\n" + ) + node2.start() + + # Wait until recovery pauses, then promote. + node2.poll_query_until("SELECT pg_get_wal_replay_pause_state() = 'paused';") + node2.safe_sql("SELECT pg_promote()") + + # Once promotion occurs, insert a second row on the new node. + node2.poll_query_until("SELECT pg_is_in_recovery() = 'f';") + node2.safe_sql("INSERT INTO mytable VALUES (2, 'blackberry');\n") + + # Now take an incremental backup. If WAL summarization didn't follow the + # timeline change correctly, something should break at this point. + backup2path = os.path.join(node1.backup_dir, "backup2") + node2.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup2path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + os.path.join(backup1path, "backup_manifest"), + ], + "incremental backup from node2", + ) + + # Restore the incremental backup and use it to create a new node. + node3 = create_pg("node3", start=False) + _combine_backup(node3, node1, ["backup1"], "backup2") + node3.start() diff --git a/src/bin/pg_combinebackup/pyt/test_009_no_full_file.py b/src/bin/pg_combinebackup/pyt/test_009_no_full_file.py new file mode 100644 index 00000000000..ca532ccd323 --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_009_no_full_file.py @@ -0,0 +1,84 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that pg_combinebackup fails cleanly when an incremental file has no +corresponding full file in the prior backup. +""" + +import os +import shutil + + +def test_009_no_full_file(create_pg, tmp_path): + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_COMBINEBACKUP_MODE") or "--copy" + + print(f"# testing using mode {mode}") + + # Set up a new database instance. + primary = create_pg("primary", has_archiving=True, allows_streaming=True) + primary.append_conf("summarize_wal = on") + primary.restart() + + # Take a full backup. + backup1path = str(tmp_path / "backup1") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup1path, + "--no-sync", + "--checkpoint", + "fast", + ], + "full backup", + ) + + # Take an incremental backup. + backup2path = str(tmp_path / "backup2") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup2path, + "--no-sync", + "--checkpoint", + "fast", + "--incremental", + backup1path + "/backup_manifest", + ], + "incremental backup", + ) + + # Find an incremental file in the incremental backup for which there is a + # full file in the full backup. When we find one, replace the full file + # with an incremental file. + filelist = [ + f for f in os.listdir(f"{backup2path}/base/1") if f.startswith("INCREMENTAL.") + ] + success = 0 + for iname in filelist: + name = iname[len("INCREMENTAL.") :] + + if os.path.isfile(f"{backup1path}/base/1/{name}"): + shutil.copy( + f"{backup2path}/base/1/{iname}", f"{backup1path}/base/1/{iname}" + ) + os.unlink(f"{backup1path}/base/1/{name}") + success = 1 + break + + assert success, "found a file to replace" + + # pg_combinebackup should fail. + outpath = str(tmp_path / "out") + primary.command_fails_like( + [ + "pg_combinebackup", + backup1path, + backup2path, + "--output", + outpath, + ], + r"full backup contains unexpected incremental file", + "pg_combinebackup fails", + ) diff --git a/src/bin/pg_combinebackup/pyt/test_010_hardlink.py b/src/bin/pg_combinebackup/pyt/test_010_hardlink.py new file mode 100644 index 00000000000..047b9f7e108 --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_010_hardlink.py @@ -0,0 +1,168 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Validate that hard links are created as expected in the output directory when +running pg_combinebackup in --link mode. Take a full backup and an +incremental backup, combine them with pg_combinebackup --link, and check that +data files which did not change between the two backups are hard-linked +(st_nlink == 2), while the last segment of a table that did change has a single +link. + +FRAMEWORK NOTE: + + * The framework's init_from_backup does not support incremental combine, so + (as test_006 does) run pg_combinebackup directly into the restore node's + data directory. +""" + +import os +import shutil + + +def _get_hard_link_count(path): + """Return the number of hard links of the file at *path*.""" + return os.stat(path).st_nlink + + +def _check_data_file(data_file, last_segment_nlinks): + """Check the hard link counts of all segments of a data file. + + Given the path to the first segment of a data file, inspect its parent + directory to find all the segments of that data file. All segments should + contain 2 hard links, except the last one, which should match + *last_segment_nlinks*. + """ + data_file_segments = [data_file] + + # Start checking for additional segments. + segment_number = 1 + while True: + next_segment = f"{data_file}.{segment_number}" + if os.path.isfile(next_segment): + data_file_segments.append(next_segment) + segment_number += 1 + else: + break + + # All segments of the given data file should contain 2 hard links, except + # for the last one, which should match the given number of links. + last_segment = data_file_segments.pop() + + for segment in data_file_segments: + nlink_count = _get_hard_link_count(segment) + assert nlink_count == 2, f"File '{segment}' has 2 hard links" + + nlink_count = _get_hard_link_count(last_segment) + assert ( + nlink_count == last_segment_nlinks + ), f"File '{last_segment}' has {last_segment_nlinks} hard link(s)" + + +def test_010_hardlink(create_pg): + # Set up a new database instance. + primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + primary.append_conf("summarize_wal = on") + # We disable autovacuum to prevent "something else" to modify our test + # tables. + primary.append_conf("autovacuum = off") + primary.start() + + # Create a couple of tables (~264KB each). + # Note: Cirrus CI runs some tests with a very small segment size, so, in + # that environment, a single table of 264KB would have both a segment with + # a link count of 1 and also one with a link count of 2. But in a normal + # installation, segment size is 1GB. Therefore, we use 2 different tables + # here: for test_1, all segments (or the only one) will have two hard + # links; for test_2, the last segment (or the only one) will have 1 hard + # link, and any others will have 2. + query = """ +CREATE TABLE test_{0} AS + SELECT x.id::bigint, + repeat('a', 1600) AS value + FROM generate_series(1, 100) AS x(id); +""" + + primary.safe_sql(query.format("1")) + primary.safe_sql(query.format("2")) + + # Fetch information about the data files. + path_query = """ +SELECT pg_relation_filepath(oid) +FROM pg_class +WHERE relname = 'test_{0}'; +""" + + test_1_path = primary.safe_sql(path_query.format("1")) + print(f"# test_1 path is {test_1_path}") + + test_2_path = primary.safe_sql(path_query.format("2")) + print(f"# test_2 path is {test_2_path}") + + # Take a full backup. + backup1path = os.path.join(primary.backup_dir, "backup1") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup1path, + "--no-sync", + "--checkpoint", + "fast", + "--wal-method", + "none", + ], + "full backup", + ) + + # Perform an insert that touches a page of the last segment of the data + # file of table test_2. + primary.safe_sql("INSERT INTO test_2 (id, value) VALUES (101, repeat('a', 1600));") + + # Take an incremental backup. + backup2path = os.path.join(primary.backup_dir, "backup2") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup2path, + "--no-sync", + "--checkpoint", + "fast", + "--wal-method", + "none", + "--incremental", + os.path.join(backup1path, "backup_manifest"), + ], + "incremental backup", + ) + + # Restore the incremental backup and use it to create a new node. + # + # Run pg_combinebackup --link directly into the restore node's data + # directory. + restore = create_pg("restore", start=False) + combined = restore.data_dir + if os.path.isdir(combined): + shutil.rmtree(combined) + restore.command_ok( + [ + "pg_combinebackup", + backup1path, + backup2path, + "--output", + combined, + "--link", + ], + "combine backups with --link", + ) + + # Ensure files have the expected count of hard links. We expect all data + # files from test_1 to contain 2 hard links, because they were not touched + # between the full and incremental backups, and the last data file of table + # test_2 to contain a single hard link because of changes in its last page. + test_1_full_path = os.path.join(restore.data_dir, test_1_path) + _check_data_file(test_1_full_path, 2) + + test_2_full_path = os.path.join(restore.data_dir, test_2_path) + _check_data_file(test_2_full_path, 1) diff --git a/src/bin/pg_combinebackup/pyt/test_011_ib_truncation.py b/src/bin/pg_combinebackup/pyt/test_011_ib_truncation.py new file mode 100644 index 00000000000..9ae6828ad35 --- /dev/null +++ b/src/bin/pg_combinebackup/pyt/test_011_ib_truncation.py @@ -0,0 +1,160 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""This test aims to validate two things: (1) that the calculated truncation +block never exceeds the segment size and (2) that the correct limit block +length is calculated for the VM fork. + +The test exercises incremental-backup handling of relation truncation: a +relation shrinks (via DELETE + VACUUM TRUNCATE) between the full and the +incremental backup. The full and incremental backups are then combined and +the result verified on a started node. + +FRAMEWORK NOTES: + + * The framework's init_from_backup does not support incremental combine, so + run pg_combinebackup directly into the verification node's data_dir, then + build a verification node on top of it (as test_002 / test_006 do). +""" + +import os +import shutil + + +def test_011_ib_truncation(create_pg, tmp_path): + # Initialize primary node + primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + primary.append_conf("summarize_wal = on") + primary.start() + + # Backup locations + backup_path = primary.backup_dir + full_backup = os.path.join(backup_path, "full") + + # To avoid using up lots of disk space in the CI/buildfarm environment, + # this test will only find the issue when run with a small RELSEG_SIZE. As + # of this writing, one of the CI runs is configured using + # --with-segsize-blocks=6, and we aim to have this test check for the issue + # only in that configuration. + target_blocks = 6 + block_size = int(primary.safe_sql("SELECT current_setting('block_size')::int;")) + + # We'll have two blocks more than the target number of blocks (one will + # survive the subsequent truncation). + target_rows = int(target_blocks + 2) + rows_after_truncation = int(target_rows - 1) + + # Create a test table. STORAGE PLAIN prevents compression and TOASTing of + # repetitive data, ensuring predictable row sizes. + primary.safe_sql( + """ + CREATE TABLE t ( + id int, + data text STORAGE PLAIN + ) WITH (autovacuum_enabled = false); +""" + ) + + # The tuple size should be enough to prevent two tuples from being on the + # same page. Since the template string has a length of 32 bytes, it's + # enough to repeat it (block_size / (2*32)) times. + primary.safe_sql( + "INSERT INTO t\n" + " SELECT i,\n" + " repeat('0123456789ABCDEF0123456789ABCDEF', " + f"({block_size} / (2*32)))\n" + f" FROM generate_series(1, {target_rows}) i;" + ) + + # Make sure hint bits are set. + primary.safe_sql("VACUUM t;") + + # Verify that the relation is as large as was desired. + t_blocks = int( + primary.safe_sql( + "SELECT pg_relation_size('t') / current_setting('block_size')::int;" + ) + ) + assert t_blocks > target_blocks, "target block size exceeded" + + # Take a full base backup + primary.backup("full") + + # Delete rows at the logical end of the table, creating removable pages. + primary.safe_sql(f"DELETE FROM t WHERE id > ({rows_after_truncation});") + + # VACUUM the table. TRUNCATE is enabled by default, and is just mentioned + # here for emphasis. + primary.safe_sql("VACUUM (TRUNCATE) t;") + + # Verify expected length after truncation. + t_blocks = int( + primary.safe_sql( + "SELECT pg_relation_size('t') / current_setting('block_size')::int;" + ) + ) + assert t_blocks == rows_after_truncation, "post-truncation row count as expected" + assert t_blocks > target_blocks, "post-truncation block count as expected" + + # Take an incremental backup based on the full backup manifest + primary.backup( + "incr", + backup_options=["--incremental", os.path.join(full_backup, "backup_manifest")], + ) + + # We used to have a bug where the wrong limit block was calculated for the + # VM fork, so verify that the WAL summary records the correct VM fork + # truncation limit. We can't just check whether the restored VM fork is + # the right size on disk, because it's so small that the incremental + # backup code will send the entire file. + relfilenode = primary.safe_sql("SELECT pg_relation_filenode('t');") + vm_limits = primary.safe_sql( + "SELECT string_agg(relblocknumber::text, ',')\n" + " FROM pg_available_wal_summaries() s,\n" + " pg_wal_summary_contents(s.tli, s.start_lsn, s.end_lsn) c\n" + f" WHERE c.relfilenode = {relfilenode}\n" + " AND c.relforknumber = 2\n" + " AND c.is_limit_block;" + ) + assert vm_limits == "1", "WAL summary has correct VM fork truncation limit" + + # Combine full and incremental backups. Before the fix, this failed + # because the INCREMENTAL file header contained an incorrect + # truncation_block_length value. + # + # Run pg_combinebackup directly into the verification node's data_dir. + restored = create_pg("node2", start=False) + combined = restored.data_dir + if os.path.isdir(combined): + shutil.rmtree(combined) + incr_backup = os.path.join(backup_path, "incr") + restored.command_ok( + ["pg_combinebackup", full_backup, incr_backup, "--output", combined], + "combine full and incremental backup", + ) + + # init() already wrote our connection settings (port, socket dir) to the + # original data dir's postgresql.conf, which pg_combinebackup overwrote. + # Append them again to the combined data dir before starting. + restored.append_conf( + "\n".join( + [ + "", + f"port = {restored.port}", + "listen_addresses = ''", + f"unix_socket_directories = '{restored.host}'", + "", + ] + ) + ) + restored.start() + + # Check that the restored table contains the correct number of rows + restored_count = restored.safe_sql("SELECT count(*) FROM t;") + assert ( + int(restored_count) == rows_after_truncation + ), "Restored backup has correct row count" + + primary.stop() + restored.stop() diff --git a/src/bin/pg_rewind/meson.build b/src/bin/pg_rewind/meson.build index 52a6ab0a515..e310783d185 100644 --- a/src/bin/pg_rewind/meson.build +++ b/src/bin/pg_rewind/meson.build @@ -47,6 +47,21 @@ tests += { 't/011_wal_copy.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_databases.py', + 'pyt/test_003_extrafiles.py', + 'pyt/test_004_pg_xlog_symlink.py', + 'pyt/test_005_same_timeline.py', + 'pyt/test_006_options.py', + 'pyt/test_007_standby_source.py', + 'pyt/test_008_min_recovery_point.py', + 'pyt/test_009_growing_files.py', + 'pyt/test_010_keep_recycled_wals.py', + 'pyt/test_011_wal_copy.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_rewind/pyt/conftest.py b/src/bin/pg_rewind/pyt/conftest.py new file mode 100644 index 00000000000..9abea2e1ac3 --- /dev/null +++ b/src/bin/pg_rewind/pyt/conftest.py @@ -0,0 +1,363 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Pytest harness for the pg_rewind tests. + +Each test consists of a cycle where a new cluster is first created with +initdb, and a streaming replication standby is set up to follow the primary. +Then the primary is shut down and the standby is promoted, and finally +pg_rewind is used to rewind the old primary, using the standby as the source. + +A test uses the ``rewind`` fixture, which yields a :class:`RewindTest` +object. Its methods should be called in this sequence: + +1. ``setup_cluster`` - creates a PostgreSQL cluster that runs as the primary + +2. ``start_primary`` - starts the primary server + +3. ``create_standby`` - runs pg_basebackup to initialize a standby server, + and sets it up to follow the primary. + +4. ``promote_standby`` - runs "pg_ctl promote" to promote the standby server. + The old primary keeps running. + +5. ``run_pg_rewind`` - stops the old primary (if it's still running) and runs + pg_rewind to synchronize it with the now-promoted standby server. + +6. ``clean_rewind_test`` - stops both servers used in the test, if they're + still running (also handled automatically by the create_pg fixture + teardown). + +The helpers ``primary_psql`` and ``standby_psql`` run SQL against the primary +and standby servers, respectively, using the in-process libpq Session (never +forking psql). ``check_query`` runs a query against the primary and checks +its output against the expected text. +""" + +import os +import shutil + +import pytest + +from pypg.command import PgBin + + +class RewindTest: + """Driver object exposed to tests through the ``rewind`` fixture.""" + + def __init__(self, create_pg, bindir): + self._create_pg = create_pg + # pg_rewind / pg_ctl etc. are invoked with explicit data dirs and + # connection strings, so a plain PgBin (no node env) is sufficient. + self._pg_bin = PgBin(bindir) + self.node_primary = None + self.node_standby = None + # Whether the cluster was initialized with group access (initdb -g), + # which changes the expected data-directory file permissions. + self._group_access = False + + # -- psql helpers -------------------------------------------------------- + + def primary_psql(self, sql, dbname="postgres"): + """Run *sql* against the primary; return trimmed text output.""" + return self.node_primary.safe_sql(sql, dbname=dbname) + + def standby_psql(self, sql, dbname="postgres"): + """Run *sql* against the standby; return trimmed text output.""" + return self.node_standby.safe_sql(sql, dbname=dbname) + + def check_query(self, query, expected, test_name): + """Run *query* against the primary and assert the output matches. + + The output is fetched in-process (libpq) with no formatting, which is + the equivalent of psql --no-align --tuples-only. *expected* is the + text expected, with a trailing newline per row. + """ + result = self.node_primary.sql(query) + # Reproduce psql -At output: each row's columns joined by '|', rows + # joined by newlines, with a trailing newline. + lines = [] + for row in result.rows: + lines.append("|".join("" if v is None else str(v) for v in row)) + stdout = "".join(line + "\n" for line in lines) + assert stdout == expected, ( + f"{test_name}: query result matches\n" + f"got:\n{stdout!r}\nexpected:\n{expected!r}" + ) + + # -- cluster lifecycle --------------------------------------------------- + + def setup_cluster(self, extra_name=None, extra=None): + """Create the primary node; data checksums are on by default. + + *extra_name* differentiates clusters; *extra* is a list of extra + arguments for initdb. + """ + name = "primary" + (f"_{extra_name}" if extra_name else "") + + # Initialize primary. Under the trust auth this framework uses, the + # rewind_user role just needs to exist, which start_primary's SQL + # ensures. + self._group_access = bool(extra) and ( + "-g" in extra or "--allow-group-access" in extra + ) + # Files the test itself writes into PGDATA (standby.signal, + # postgresql.auto.conf, ...) must match the cluster's group-access + # mode for the permission checks: 0027 -> 0640/0750 with group access, + # 0077 -> 0600/0700 without. (The server sets its own umask from the + # data directory, but test-written files honor the process umask.) + os.umask(0o027 if self._group_access else 0o077) + self.node_primary = self._create_pg( + name, + start=False, + allows_streaming=True, + initdb_extra=extra, + ) + + # Set wal_keep_size to prevent WAL segment recycling after enforced + # checkpoints in the tests. + self.node_primary.append_conf( + "\nwal_keep_size = 320MB\nallow_in_place_tablespaces = on\n" + ) + + def start_primary(self): + """Start the primary and create the minimal-privilege rewind role.""" + self.node_primary.start() + + # Create custom role which is used to run pg_rewind, and adjust its + # permissions to the minimum necessary. + self.node_primary.safe_sql( + """ + CREATE ROLE rewind_user LOGIN; + GRANT EXECUTE ON function pg_catalog.pg_ls_dir(text, boolean, boolean) + TO rewind_user; + GRANT EXECUTE ON function pg_catalog.pg_stat_file(text, boolean) + TO rewind_user; + GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text) + TO rewind_user; + GRANT EXECUTE ON function pg_catalog.pg_read_binary_file(text, bigint, bigint, boolean) + TO rewind_user;""" + ) + + def create_standby(self, extra_name=None): + """pg_basebackup-initialize a standby that follows the primary.""" + name = "standby" + (f"_{extra_name}" if extra_name else "") + self.node_standby = self._create_pg(name, start=False) + + self.node_primary.backup("my_backup") + self.node_standby.init_from_backup(self.node_primary, "my_backup") + + # Build primary_conninfo without nested single quotes (the value is + # itself single-quoted in postgresql.conf). application_name is set to + # the standby's node name so wait_for_catchup can locate it in + # pg_stat_replication. + connstr_primary = ( + f"host={self.node_primary.host} port={self.node_primary.port} " + f"dbname=postgres application_name={self.node_standby.name}" + ) + self.node_standby.append_conf(f"\nprimary_conninfo='{connstr_primary}'\n") + self.node_standby.set_standby_mode() + + # Start standby + self.node_standby.start() + + # The standby may have WAL to apply before it matches the primary. + # That is fine, because no test examines the standby before promotion. + + def promote_standby(self): + """Wait for the standby to catch up, then promote it.""" + # Wait for the standby to receive and write all WAL. + self.node_primary.wait_for_catchup(self.node_standby, "write") + + # Now promote standby (the caller then diverges the two servers). + self.node_standby.promote() + + def run_pg_rewind(self, test_mode): + """Stop the old primary and rewind it onto the promoted standby. + + *test_mode* is one of 'local', 'remote' or 'archive'. + """ + primary_pgdata = self.node_primary.data_dir + standby_pgdata = self.node_standby.data_dir + + # Append the rewind-specific role to the connection string. + standby_connstr = self.node_standby.connstr("postgres") + " user=rewind_user" + + # A scratch directory to stash the primary's postgresql.conf, which + # would otherwise be overwritten during the rewind. + tmp_folder = os.path.join(self.node_primary.basedir, "rewind_tmp") + os.makedirs(tmp_folder, exist_ok=True) + + if test_mode == "archive": + # pg_rewind is tested with --restore-target-wal by moving all + # WAL files to a secondary location. This leads to a failure in + # ensureCleanShutdown(), forcing the use of --no-ensure-shutdown, + # so stop the primary gracefully here. + self.node_primary.stop() + else: + # Stop the primary and be ready to perform the rewind. The + # cluster needs recovery to finish once, and pg_rewind makes sure + # that it happens automatically. + self.node_primary.stop("immediate") + + # Keep a temporary postgresql.conf for primary node or it would be + # overwritten during the rewind. + saved_conf = os.path.join(tmp_folder, "primary-postgresql.conf.tmp") + shutil.copy(os.path.join(primary_pgdata, "postgresql.conf"), saved_conf) + + # Now run pg_rewind + if test_mode == "local": + # Do rewind using a local pgdata as source. Stop the standby + # (source) first, as pg_rewind requires a cleanly stopped source. + self.node_standby.stop() + self._pg_bin.command_ok( + [ + "pg_rewind", + "--debug", + "--source-pgdata", + standby_pgdata, + "--target-pgdata", + primary_pgdata, + "--no-sync", + "--config-file", + saved_conf, + ], + "pg_rewind local", + ) + elif test_mode == "remote": + # Do rewind using a remote connection as source, generating + # recovery configuration automatically. + self._pg_bin.command_ok( + [ + "pg_rewind", + "--debug", + "--source-server", + standby_connstr, + "--target-pgdata", + primary_pgdata, + "--no-sync", + "--write-recovery-conf", + "--config-file", + saved_conf, + ], + "pg_rewind remote", + ) + + # Check that pg_rewind with dbname and --write-recovery-conf wrote + # the dbname in the generated primary_conninfo value. + with open( + os.path.join(primary_pgdata, "postgresql.auto.conf"), + encoding="utf-8", + ) as fh: + auto_conf = fh.read() + assert "dbname=postgres" in auto_conf, "recovery conf file sets dbname" + + # Check that standby.signal is here as recovery configuration was + # requested. + assert os.path.exists( + os.path.join(primary_pgdata, "standby.signal") + ), "standby.signal created after pg_rewind" + + # Now, when pg_rewind apparently succeeded with minimal + # permissions, add REPLICATION privilege. So we could test that + # the new standby is able to connect to the new primary with the + # generated config. + self.node_standby.safe_sql("ALTER ROLE rewind_user WITH REPLICATION;") + elif test_mode == "archive": + # Do rewind using a local pgdata as source and a specified + # directory with the target WAL archive. The old primary has to + # be stopped at this point (done above). + + # Remove the existing archive directory and move all WAL segments + # from the old primary to the archives. These will be used by + # pg_rewind. + archive_dir = self.node_primary.archive_dir + pg_wal = os.path.join(primary_pgdata, "pg_wal") + if os.path.isdir(archive_dir): + shutil.rmtree(archive_dir) + shutil.copytree(pg_wal, archive_dir, symlinks=True) + + # Fast way to remove entire directory content. + shutil.rmtree(pg_wal) + os.mkdir(pg_wal) + + # Make sure that directories have the right umask as this is + # required by a follow-up check on permissions. + os.chmod(archive_dir, 0o700) + os.chmod(pg_wal, 0o700) + + # Add an appropriate restore_command to the target cluster (from + # the primary's own archive dir, in non-standby recovery mode). + self.node_primary.enable_restoring(self.node_primary, standby=False) + + # Stop the new primary (source) and be ready to perform the rewind. + self.node_standby.stop() + + # Note the use of --no-ensure-shutdown here. WAL files are gone + # in this mode and the primary has been stopped gracefully + # already. --config-file reuses the original postgresql.conf as + # restore_command has been enabled above. + self._pg_bin.command_ok( + [ + "pg_rewind", + "--debug", + "--source-pgdata", + standby_pgdata, + "--target-pgdata", + primary_pgdata, + "--no-sync", + "--no-ensure-shutdown", + "--restore-target-wal", + "--config-file", + os.path.join(primary_pgdata, "postgresql.conf"), + ], + "pg_rewind archive", + ) + else: + raise ValueError("Incorrect test mode specified") + + # Now move back postgresql.conf with old settings. + shutil.move(saved_conf, os.path.join(primary_pgdata, "postgresql.conf")) + # Restore the right permissions on the moved-back file: 0640 with + # group access (initdb -g), 0600 otherwise. + os.chmod( + os.path.join(primary_pgdata, "postgresql.conf"), + 0o640 if self._group_access else 0o600, + ) + + # Plug-in the rewound node to the now-promoted standby node. + if test_mode != "remote": + self.node_primary.append_conf( + "\nprimary_conninfo='host={host} port={port}'\n".format( + host=self.node_standby.host, + port=self.node_standby.port, + ) + ) + self.node_primary.set_standby_mode() + + # Restart the primary to check that the rewind went correctly. + self.node_primary.start() + + def clean_rewind_test(self): + """Stop both servers, if they're still running.""" + if self.node_primary is not None: + self.node_primary.teardown() + if self.node_standby is not None: + self.node_standby.teardown() + + +@pytest.fixture +def rewind(create_pg, bindir): + """Yield a :class:`RewindTest` driver; tear down both nodes afterwards. + + Set umask(0077) so that files the test (and the server) create in PGDATA + keep the default 0600/0700 permissions that 001_basic's + check_mode_recursive() asserts on. This covers files such as + standby.signal and postgresql.auto.conf. + """ + old_umask = os.umask(0o077) + try: + rt = RewindTest(create_pg, bindir) + yield rt + rt.clean_rewind_test() + finally: + os.umask(old_umask) diff --git a/src/bin/pg_rewind/pyt/test_001_basic.py b/src/bin/pg_rewind/pyt/test_001_basic.py new file mode 100644 index 00000000000..011920af03b --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_001_basic.py @@ -0,0 +1,249 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic pg_rewind test. + +Run in each of the three source modes: a local data +directory ('local'), a live source server ('remote'), and a WAL archive +('archive'). +""" + +import os +import stat + +import pytest + +from pypg.command import PgBin +from pypg.util import WINDOWS_OS + + +def check_mode_recursive(path, dir_mode, file_mode): + """Assert every dir/file under *path* has the expected permission bits. + + Returns True when all entries match; raises AssertionError (with details) + otherwise. + """ + ok = True + for root, dirs, files in os.walk(path): + for name in dirs: + full = os.path.join(root, name) + if os.path.islink(full): + continue + actual = stat.S_IMODE(os.lstat(full).st_mode) + if actual != dir_mode: + print( + f"mode of directory {full} is {actual:#o}, " + f"expected {dir_mode:#o}" + ) + ok = False + for name in files: + full = os.path.join(root, name) + if os.path.islink(full): + continue + actual = stat.S_IMODE(os.lstat(full).st_mode) + if actual != file_mode: + print( + f"mode of file {full} is {actual:#o}, " f"expected {file_mode:#o}" + ) + ok = False + return ok + + +def run_test(rewind, bindir, test_mode): + rewind.setup_cluster(test_mode) + rewind.start_primary() + + # Create an in-place tablespace with some data on it. + rewind.primary_psql("CREATE TABLESPACE space_test LOCATION ''") + rewind.primary_psql("CREATE TABLE space_tbl (d text) TABLESPACE space_test") + rewind.primary_psql("INSERT INTO space_tbl VALUES ('in primary, before promotion')") + + # Create a test table and insert a row in primary. + rewind.primary_psql("CREATE TABLE tbl1 (d text)") + rewind.primary_psql("INSERT INTO tbl1 VALUES ('in primary')") + + # This test table will be used to test truncation, i.e. the table + # is extended in the old primary after promotion. + rewind.primary_psql("CREATE TABLE trunc_tbl (d text)") + rewind.primary_psql("INSERT INTO trunc_tbl VALUES ('in primary')") + + # This test table will be used to test the "copy-tail" case, i.e. the + # table is truncated in the old primary after promotion. + rewind.primary_psql("CREATE TABLE tail_tbl (id integer, d text)") + rewind.primary_psql("INSERT INTO tail_tbl VALUES (0, 'in primary')") + + # This test table is dropped in the old primary after promotion. + rewind.primary_psql("CREATE TABLE drop_tbl (d text)") + rewind.primary_psql("INSERT INTO drop_tbl VALUES ('in primary')") + + rewind.primary_psql("CHECKPOINT") + + rewind.create_standby(test_mode) + + # Insert additional data on primary that will be replicated to standby. + rewind.primary_psql("INSERT INTO tbl1 values ('in primary, before promotion')") + rewind.primary_psql("INSERT INTO trunc_tbl values ('in primary, before promotion')") + rewind.primary_psql( + "INSERT INTO tail_tbl SELECT g, 'in primary, before promotion: ' || g " + "FROM generate_series(1, 10000) g" + ) + + rewind.primary_psql("CHECKPOINT") + + rewind.promote_standby() + + # Insert a row in the old primary. This causes the primary and standby to + # have "diverged", it's no longer possible to just apply the standby's + # logs over primary directory - you need to rewind. + rewind.primary_psql("INSERT INTO tbl1 VALUES ('in primary, after promotion')") + + # Also insert a new row in the standby, which won't be present in the old + # primary. + rewind.standby_psql("INSERT INTO tbl1 VALUES ('in standby, after promotion')") + + # Insert enough rows to trunc_tbl to extend the file. pg_rewind should + # truncate it back to the old size. + rewind.primary_psql( + "INSERT INTO trunc_tbl SELECT 'in primary, after promotion: ' || g " + "FROM generate_series(1, 10000) g" + ) + + # Truncate tail_tbl. pg_rewind should copy back the truncated part. + # (We cannot use an actual TRUNCATE command here, as that creates a whole + # new relfilenode.) + rewind.primary_psql("DELETE FROM tail_tbl WHERE id > 10") + rewind.primary_psql("VACUUM tail_tbl") + + # Drop drop_tbl. pg_rewind should copy it back. + rewind.primary_psql("insert into drop_tbl values ('in primary, after promotion')") + rewind.primary_psql("DROP TABLE drop_tbl") + + # Insert some data in the in-place tablespace for the old primary and the + # standby. + rewind.primary_psql("INSERT INTO space_tbl VALUES ('in primary, after promotion')") + rewind.standby_psql("INSERT INTO space_tbl VALUES ('in standby, after promotion')") + + # Before running pg_rewind, do a couple of extra tests with several option + # combinations. As the code paths taken by those tests do not change for + # the "local" and "remote" modes, just run them in "local" mode for + # simplicity's sake. + if test_mode == "local": + pg_bin = PgBin(bindir) + primary_pgdata = rewind.node_primary.data_dir + standby_pgdata = rewind.node_standby.data_dir + + # First check that pg_rewind fails if the target cluster is not + # stopped as it fails to start up for the forced recovery step. + pg_bin.command_fails( + [ + "pg_rewind", + "--debug", + "--source-pgdata", + standby_pgdata, + "--target-pgdata", + primary_pgdata, + "--no-sync", + ], + "pg_rewind with running target", + ) + + # Again with --no-ensure-shutdown, which should equally fail. This + # time pg_rewind complains without attempting to perform recovery once. + pg_bin.command_fails( + [ + "pg_rewind", + "--debug", + "--source-pgdata", + standby_pgdata, + "--target-pgdata", + primary_pgdata, + "--no-sync", + "--no-ensure-shutdown", + ], + "pg_rewind --no-ensure-shutdown with running target", + ) + + # Stop the target, and attempt to run with a local source still + # running. This fails as pg_rewind requires the source cleanly + # stopped. + rewind.node_primary.stop() + pg_bin.command_fails( + [ + "pg_rewind", + "--debug", + "--source-pgdata", + standby_pgdata, + "--target-pgdata", + primary_pgdata, + "--no-sync", + "--no-ensure-shutdown", + ], + "pg_rewind with unexpected running source", + ) + + # Stop the target cluster cleanly, and run pg_rewind again in + # --dry-run mode. If anything gets generated in the data folder, the + # follow-up run of pg_rewind will most likely fail, so keep this test + # as the last one of this subset. + rewind.node_standby.stop() + pg_bin.command_ok( + [ + "pg_rewind", + "--debug", + "--source-pgdata", + standby_pgdata, + "--target-pgdata", + primary_pgdata, + "--no-sync", + "--dry-run", + ], + "pg_rewind --dry-run", + ) + + # Both clusters need to be alive moving forward. + rewind.node_standby.start() + rewind.node_primary.start() + + rewind.run_pg_rewind(test_mode) + + rewind.check_query( + "SELECT * FROM space_tbl ORDER BY d", + "in primary, before promotion\nin standby, after promotion\n", + "table content", + ) + + rewind.check_query( + "SELECT * FROM tbl1", + "in primary\nin primary, before promotion\nin standby, after promotion\n", + "table content", + ) + + rewind.check_query( + "SELECT * FROM trunc_tbl", + "in primary\nin primary, before promotion\n", + "truncation", + ) + + rewind.check_query( + "SELECT count(*) FROM tail_tbl", + "10001\n", + "tail-copy", + ) + + rewind.check_query( + "SELECT * FROM drop_tbl", + "in primary\n", + "drop", + ) + + # Permissions on PGDATA should be default (unix-only; skipped on Windows). + if not WINDOWS_OS: + assert check_mode_recursive( + rewind.node_primary.data_dir, 0o700, 0o600 + ), "check PGDATA permissions" + + rewind.clean_rewind_test() + + +@pytest.mark.parametrize("mode", ["local", "remote", "archive"]) +def test_001_basic(rewind, bindir, mode): + run_test(rewind, bindir, mode) diff --git a/src/bin/pg_rewind/pyt/test_002_databases.py b/src/bin/pg_rewind/pyt/test_002_databases.py new file mode 100644 index 00000000000..fd7e63a3b03 --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_002_databases.py @@ -0,0 +1,106 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that pg_rewind correctly reconciles the set of databases present in +the source and target clusters, and that PGDATA permissions are preserved. +""" + +import os +import stat + +from pypg.util import WINDOWS_OS + +import pytest + + +def check_mode_recursive(path, dir_mode, file_mode): + """Assert every dir/file under *path* has the expected permission bits. + + Returns True when all entries match; raises AssertionError (with details) + otherwise. + """ + ok = True + for root, dirs, files in os.walk(path): + for name in dirs: + full = os.path.join(root, name) + if os.path.islink(full): + continue + actual = stat.S_IMODE(os.lstat(full).st_mode) + if actual != dir_mode: + print( + f"mode of directory {full} is {actual:#o}, " + f"expected {dir_mode:#o}" + ) + ok = False + for name in files: + full = os.path.join(root, name) + if os.path.islink(full): + continue + actual = stat.S_IMODE(os.lstat(full).st_mode) + if actual != file_mode: + print( + f"mode of file {full} is {actual:#o}, " f"expected {file_mode:#o}" + ) + ok = False + return ok + + +def run_test(rewind, test_mode): + rewind.setup_cluster(extra_name=test_mode, extra=["-g"]) + rewind.start_primary() + + # Create a database in primary with a table. + rewind.primary_psql("CREATE DATABASE inprimary") + rewind.primary_psql("CREATE TABLE inprimary_tab (a int)", dbname="inprimary") + + rewind.create_standby(test_mode) + + # Create another database with another table, the creation is + # replicated to the standby. + rewind.primary_psql("CREATE DATABASE beforepromotion") + rewind.primary_psql( + "CREATE TABLE beforepromotion_tab (a int)", dbname="beforepromotion" + ) + + rewind.promote_standby() + + # Create databases in the old primary and the new promoted standby. + rewind.primary_psql("CREATE DATABASE primary_afterpromotion") + rewind.primary_psql( + "CREATE TABLE primary_promotion_tab (a int)", + dbname="primary_afterpromotion", + ) + rewind.standby_psql("CREATE DATABASE standby_afterpromotion") + rewind.standby_psql( + "CREATE TABLE standby_promotion_tab (a int)", + dbname="standby_afterpromotion", + ) + + # The clusters are now diverged. + + rewind.run_pg_rewind(test_mode) + + # Check that the correct databases are present after pg_rewind. + rewind.check_query( + "SELECT datname FROM pg_database ORDER BY 1", + "beforepromotion\n" + "inprimary\n" + "postgres\n" + "standby_afterpromotion\n" + "template0\n" + "template1\n", + "database names", + ) + + # Permissions on PGDATA should have group permissions (unix-only; skipped + # on Windows). + if not WINDOWS_OS: + assert check_mode_recursive( + rewind.node_primary.data_dir, 0o750, 0o640 + ), "check PGDATA permissions" + + rewind.clean_rewind_test() + + +@pytest.mark.parametrize("mode", ["local", "remote"]) +def test_002_databases(rewind, mode): + run_test(rewind, mode) diff --git a/src/bin/pg_rewind/pyt/test_003_extrafiles.py b/src/bin/pg_rewind/pyt/test_003_extrafiles.py new file mode 100644 index 00000000000..5818896f24a --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_003_extrafiles.py @@ -0,0 +1,130 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test how pg_rewind reacts to extra files and directories in the data dirs. +All the files that were present in the standby should be present after +rewind, and all the files that were added on the primary should be removed. +""" + +import os +import platform +import re + +import pytest + + +def append_to_file(path, content): + """Append *content* to the file at *path*.""" + with open(path, "a", encoding="utf-8") as fh: + fh.write(content) + + +def run_test(rewind, test_mode): + rewind.setup_cluster(test_mode) + rewind.start_primary() + + test_primary_datadir = rewind.node_primary.data_dir + + # Create a subdir and files that will be present in both + os.mkdir(os.path.join(test_primary_datadir, "tst_both_dir")) + append_to_file( + os.path.join(test_primary_datadir, "tst_both_dir", "both_file1"), "in both1" + ) + append_to_file( + os.path.join(test_primary_datadir, "tst_both_dir", "both_file2"), "in both2" + ) + os.mkdir(os.path.join(test_primary_datadir, "tst_both_dir", "both_subdir")) + append_to_file( + os.path.join(test_primary_datadir, "tst_both_dir", "both_subdir", "both_file3"), + "in both3", + ) + + rewind.create_standby(test_mode) + + # Create different subdirs and files in primary and standby + test_standby_datadir = rewind.node_standby.data_dir + + os.mkdir(os.path.join(test_standby_datadir, "tst_standby_dir")) + append_to_file( + os.path.join(test_standby_datadir, "tst_standby_dir", "standby_file1"), + "in standby1", + ) + append_to_file( + os.path.join(test_standby_datadir, "tst_standby_dir", "standby_file2"), + "in standby2", + ) + append_to_file( + os.path.join( + test_standby_datadir, "tst_standby_dir", "standby_file3 with 'quotes'" + ), + "in standby3", + ) + os.mkdir(os.path.join(test_standby_datadir, "tst_standby_dir", "standby_subdir")) + append_to_file( + os.path.join( + test_standby_datadir, "tst_standby_dir", "standby_subdir", "standby_file4" + ), + "in standby4", + ) + # Skip testing .DS_Store files on macOS to avoid risk of side effects + if platform.system() != "Darwin": + append_to_file( + os.path.join(test_standby_datadir, "tst_standby_dir", ".DS_Store"), + "macOS system file", + ) + + os.mkdir(os.path.join(test_primary_datadir, "tst_primary_dir")) + append_to_file( + os.path.join(test_primary_datadir, "tst_primary_dir", "primary_file1"), + "in primary1", + ) + append_to_file( + os.path.join(test_primary_datadir, "tst_primary_dir", "primary_file2"), + "in primary2", + ) + os.mkdir(os.path.join(test_primary_datadir, "tst_primary_dir", "primary_subdir")) + append_to_file( + os.path.join( + test_primary_datadir, "tst_primary_dir", "primary_subdir", "primary_file3" + ), + "in primary3", + ) + + rewind.promote_standby() + rewind.run_pg_rewind(test_mode) + + # List files in the data directory after rewind. All the files that + # were present in the standby should be present after rewind, and + # all the files that were added on the primary should be removed. + paths = [] + for root, dirs, files in os.walk(test_primary_datadir): + for name in dirs + files: + full = os.path.join(root, name) + if re.search(r".*tst_.*", full): + paths.append(full) + paths = sorted(paths) + + assert paths == [ + os.path.join(test_primary_datadir, "tst_both_dir"), + os.path.join(test_primary_datadir, "tst_both_dir", "both_file1"), + os.path.join(test_primary_datadir, "tst_both_dir", "both_file2"), + os.path.join(test_primary_datadir, "tst_both_dir", "both_subdir"), + os.path.join(test_primary_datadir, "tst_both_dir", "both_subdir", "both_file3"), + os.path.join(test_primary_datadir, "tst_standby_dir"), + os.path.join(test_primary_datadir, "tst_standby_dir", "standby_file1"), + os.path.join(test_primary_datadir, "tst_standby_dir", "standby_file2"), + os.path.join( + test_primary_datadir, "tst_standby_dir", "standby_file3 with 'quotes'" + ), + os.path.join(test_primary_datadir, "tst_standby_dir", "standby_subdir"), + os.path.join( + test_primary_datadir, "tst_standby_dir", "standby_subdir", "standby_file4" + ), + ], "file lists match" + + rewind.clean_rewind_test() + + +# Run the test in both modes. +@pytest.mark.parametrize("mode", ["local", "remote"]) +def test_003_extrafiles(rewind, mode): + run_test(rewind, mode) diff --git a/src/bin/pg_rewind/pyt/test_004_pg_xlog_symlink.py b/src/bin/pg_rewind/pyt/test_004_pg_xlog_symlink.py new file mode 100644 index 00000000000..cb41ea6bd6d --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_004_pg_xlog_symlink.py @@ -0,0 +1,76 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_rewind when the target's pg_wal directory is a symlink.""" + +import os +import shutil + +import pytest + +from pypg.util import dir_symlink, short_tempdir + + +def _run_test(rewind, test_mode, xlog_parent): + rewind.setup_cluster(test_mode) + + test_primary_datadir = rewind.node_primary.data_dir + + # External directory that pg_wal is moved to and linked back from. It must + # have a SHORT path: on Windows a directory junction stores its target path + # twice, and the server reads the reparse data into a fixed MAX_PATH-sized + # buffer (pgreadlink), so a long target -- such as one under the deep + # per-test data directory -- overflows it and the server fails to start + # with "could not get junction". short_tempdir keeps it well within range. + primary_xlogdir = os.path.join(xlog_parent, "xlog_primary") + + # Turn pg_wal into a symlink (a junction on Windows). + pg_wal = os.path.join(test_primary_datadir, "pg_wal") + print(f"moving {pg_wal} to {primary_xlogdir}") + shutil.move(pg_wal, primary_xlogdir) + dir_symlink(primary_xlogdir, pg_wal) + + rewind.start_primary() + + # Create a test table and insert a row in primary. + rewind.primary_psql("CREATE TABLE tbl1 (d text)") + rewind.primary_psql("INSERT INTO tbl1 VALUES ('in primary')") + + rewind.primary_psql("CHECKPOINT") + + rewind.create_standby(test_mode) + + # Insert additional data on primary that will be replicated to standby. + rewind.primary_psql("INSERT INTO tbl1 values ('in primary, before promotion')") + + rewind.primary_psql("CHECKPOINT") + + rewind.promote_standby() + + # Insert a row in the old primary. This causes the primary and standby to + # have "diverged", it's no longer possible to just apply the standby's logs + # over primary directory - you need to rewind. + rewind.primary_psql("INSERT INTO tbl1 VALUES ('in primary, after promotion')") + + # Also insert a new row in the standby, which won't be present in the old + # primary. + rewind.standby_psql("INSERT INTO tbl1 VALUES ('in standby, after promotion')") + + rewind.run_pg_rewind(test_mode) + + rewind.check_query( + "SELECT * FROM tbl1", + "in primary\nin primary, before promotion\nin standby, after promotion\n", + "table content", + ) + + rewind.clean_rewind_test() + + +# Run the test in both modes. +@pytest.mark.parametrize("mode", ["local", "remote"]) +def test_004_pg_xlog_symlink(rewind, mode): + xlog_parent = short_tempdir() + try: + _run_test(rewind, mode, xlog_parent) + finally: + shutil.rmtree(xlog_parent, ignore_errors=True) diff --git a/src/bin/pg_rewind/pyt/test_005_same_timeline.py b/src/bin/pg_rewind/pyt/test_005_same_timeline.py new file mode 100644 index 00000000000..e8bcd4e8f78 --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_005_same_timeline.py @@ -0,0 +1,13 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that running pg_rewind with the source and target clusters on the same +timeline runs successfully. +""" + + +def test_005_same_timeline(rewind): + rewind.setup_cluster() + rewind.start_primary() + rewind.create_standby() + rewind.run_pg_rewind("local") + rewind.clean_rewind_test() diff --git a/src/bin/pg_rewind/pyt/test_006_options.py b/src/bin/pg_rewind/pyt/test_006_options.py new file mode 100644 index 00000000000..37b387054c2 --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_006_options.py @@ -0,0 +1,54 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test checking options of pg_rewind.""" + + +def test_006_options(pg_bin, tmp_path): + pg_bin.program_help_ok("pg_rewind") + pg_bin.program_version_ok("pg_rewind") + pg_bin.program_options_handling_ok("pg_rewind") + + primary_pgdata = str(tmp_path / "primary") + standby_pgdata = str(tmp_path / "standby") + + pg_bin.command_fails( + [ + "pg_rewind", + "--debug", + "--target-pgdata", + primary_pgdata, + "--source-pgdata", + standby_pgdata, + "extra_arg1", + ], + "too many arguments", + ) + pg_bin.command_fails( + ["pg_rewind", "--target-pgdata", primary_pgdata], + "no source specified", + ) + pg_bin.command_fails( + [ + "pg_rewind", + "--debug", + "--target-pgdata", + primary_pgdata, + "--source-pgdata", + standby_pgdata, + "--source-server", + "incorrect_source", + ], + "both remote and local sources specified", + ) + pg_bin.command_fails( + [ + "pg_rewind", + "--debug", + "--target-pgdata", + primary_pgdata, + "--source-pgdata", + standby_pgdata, + "--write-recovery-conf", + ], + "no local source with --write-recovery-conf", + ) diff --git a/src/bin/pg_rewind/pyt/test_007_standby_source.py b/src/bin/pg_rewind/pyt/test_007_standby_source.py new file mode 100644 index 00000000000..82222ee7abc --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_007_standby_source.py @@ -0,0 +1,169 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test using a standby server as the source. + +This sets up three nodes: A, B and C. First, A is the primary, +B follows A, and C follows B: + + A (primary) <--- B (standby) <--- C (standby) + +Then we promote C, and insert some divergent rows in A and C: + + A (primary) <--- B (standby) C (primary) + +Finally, we run pg_rewind on C, to re-point it at B again: + + A (primary) <--- B (standby) <--- C (standby) + +The test is similar to the basic tests, but since we're dealing with +three nodes, not two, we cannot use most of the RewindTest methods as is. +""" + +import os +import shutil + +from pypg.command import PgBin + + +def check_query(node, query, expected, test_name): + """Run *query* against *node* and assert its text output matches. + + Reproduces psql -At output: each row's columns joined by '|', rows + joined by newlines, with a trailing newline. + """ + result = node.sql(query) + lines = [] + for row in result.rows: + lines.append("|".join("" if v is None else str(v) for v in row)) + stdout = "".join(line + "\n" for line in lines) + assert stdout == expected, ( + f"{test_name}: query result matches\n" + f"got:\n{stdout!r}\nexpected:\n{expected!r}" + ) + + +def test_007_standby_source(create_pg, bindir): + pg_bin = PgBin(bindir) + + # Set up node A, as primary + # + # A (primary) + node_a = create_pg("node_a", start=False, allows_streaming=True) + node_a.append_conf("\nwal_keep_size = 320MB\nallow_in_place_tablespaces = on\n") + node_a.start() + + # Create a test table and insert a row in primary. + node_a.safe_sql("CREATE TABLE tbl1 (d text)") + node_a.safe_sql("INSERT INTO tbl1 VALUES ('in A')") + node_a.safe_sql("CHECKPOINT") + + # Set up node B and C, as cascaded standbys + # + # A (primary) <--- B (standby) <--- C (standby) + node_a.backup("my_backup") + node_b = create_pg("node_b", start=False) + node_b.init_from_backup(node_a, "my_backup", has_streaming=True) + node_b.set_standby_mode() + node_b.start() + + node_b.backup("my_backup") + node_c = create_pg("node_c", start=False) + node_c.init_from_backup(node_b, "my_backup", has_streaming=True) + node_c.set_standby_mode() + node_c.start() + + # Insert additional data on A, and wait for both standbys to catch up. + node_a.safe_sql("INSERT INTO tbl1 values ('in A, before promotion')") + node_a.safe_sql("CHECKPOINT") + + lsn = node_a.lsn("write") + node_a.wait_for_catchup("node_b", "write", lsn) + node_b.wait_for_catchup("node_c", "write", lsn) + + # Promote C + # + # A (primary) <--- B (standby) C (primary) + node_c.promote() + + # Insert a row in A. This causes A/B and C to have "diverged", so that + # it's no longer possible to just apply the standby's logs over primary + # directory - you need to rewind. + node_a.safe_sql("INSERT INTO tbl1 VALUES ('in A, after C was promoted')") + + # make sure it's replicated to B before we continue + node_a.wait_for_catchup("node_b") + + # Also insert a new row in the standby, which won't be present in the + # old primary. + node_c.safe_sql("INSERT INTO tbl1 VALUES ('in C, after C was promoted')") + + # + # All set up. We're ready to run pg_rewind. + # + node_c_pgdata = node_c.data_dir + + # Stop the node and be ready to perform the rewind. + node_c.stop("fast") + + # Keep a temporary postgresql.conf or it would be overwritten during the + # rewind. + tmp_folder = os.path.join(node_c.basedir, "rewind_tmp") + os.makedirs(tmp_folder, exist_ok=True) + saved_conf = os.path.join(tmp_folder, "node_c-postgresql.conf.tmp") + shutil.copy(os.path.join(node_c_pgdata, "postgresql.conf"), saved_conf) + + # Temporarily unset PGAPPNAME so that the server doesn't inherit it. + # Otherwise this could affect libpqwalreceiver connections in confusing + # ways. + # + # Do rewind using a remote connection as source, generating recovery + # configuration automatically. + pg_bin.command_ok( + [ + "pg_rewind", + "--debug", + "--source-server", + node_b.connstr("postgres"), + "--target-pgdata", + node_c_pgdata, + "--no-sync", + "--write-recovery-conf", + ], + "pg_rewind remote", + extra_env={"PGAPPNAME": None}, + ) + + # Now move back postgresql.conf with old settings. + shutil.move(saved_conf, os.path.join(node_c_pgdata, "postgresql.conf")) + + # Restart the node. + node_c.start() + + # Run some checks to verify that C has been successfully rewound, and + # connected back to follow B. + check_query( + node_c, + "SELECT * FROM tbl1", + "in A\nin A, before promotion\nin A, after C was promoted\n", + "table content after rewind", + ) + + # Insert another row, and observe that it's cascaded from A to B to C. + node_a.safe_sql("INSERT INTO tbl1 values ('in A, after rewind')") + + node_b.wait_for_replay_catchup("node_c", node_a) + + check_query( + node_c, + "SELECT * FROM tbl1", + "in A\n" + "in A, before promotion\n" + "in A, after C was promoted\n" + "in A, after rewind\n", + "table content after rewind and insert", + ) + + # clean up + node_a.teardown() + node_b.teardown() + node_c.teardown() diff --git a/src/bin/pg_rewind/pyt/test_008_min_recovery_point.py b/src/bin/pg_rewind/pyt/test_008_min_recovery_point.py new file mode 100644 index 00000000000..0deb0d79663 --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_008_min_recovery_point.py @@ -0,0 +1,194 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_rewind when the target contains WAL beyond minRecoveryPoint.""" + +# +# Test situation where a target data directory contains +# WAL records beyond both the last checkpoint and the divergence +# point: +# +# Target WAL (TLI 2): +# +# backup ... Checkpoint A ... INSERT 'rewind this' +# (TLI 1 -> 2) +# +# ^ last common ^ minRecoveryPoint +# checkpoint +# +# Source WAL (TLI 3): +# +# backup ... Checkpoint A ... Checkpoint B ... INSERT 'keep this' +# (TLI 1 -> 2) (TLI 2 -> 3) +# +# +# The last common checkpoint is Checkpoint A. But there is WAL on TLI 2 +# after the last common checkpoint that needs to be rewound. We used to +# have a bug where minRecoveryPoint was ignored, and pg_rewind concluded +# that the target doesn't need rewinding in this scenario, because the +# last checkpoint on the target TLI was an ancestor of the source TLI. +# +# +# This test does not make use of RewindTest as it requires three +# nodes. + +import os +import shutil + +from pypg.command import PgBin + + +def check_query(node, query, expected, test_name): + """Run *query* against *node* and assert its text output matches. + + Reproduces psql -At output: each row's columns joined by '|', rows + joined by newlines, with a trailing newline. + """ + result = node.sql(query) + lines = [] + for row in result.rows: + lines.append("|".join("" if v is None else str(v) for v in row)) + stdout = "".join(line + "\n" for line in lines) + assert stdout == expected, ( + f"{test_name}: query result matches\n" + f"got:\n{stdout!r}\nexpected:\n{expected!r}" + ) + + +def test_008_min_recovery_point(create_pg, bindir): + pg_bin = PgBin(bindir) + + node_1 = create_pg("node_1", start=False, allows_streaming=True) + node_1.append_conf("\nwal_keep_size='100 MB'\n") + node_1.start() + + # Create a couple of test tables + node_1.safe_sql("CREATE TABLE public.foo (t TEXT)") + node_1.safe_sql("CREATE TABLE public.bar (t TEXT)") + node_1.safe_sql("INSERT INTO public.bar VALUES ('in both')") + + # + # Create node_2 and node_3 as standbys following node_1 + # + backup_name = "my_backup" + node_1.backup(backup_name) + + node_2 = create_pg("node_2", start=False) + node_2.init_from_backup(node_1, backup_name, has_streaming=True) + node_2.start() + + node_3 = create_pg("node_3", start=False) + node_3.init_from_backup(node_1, backup_name, has_streaming=True) + node_3.start() + + # Wait until node 3 has connected and caught up + node_1.wait_for_catchup("node_3") + + # + # Swap the roles of node_1 and node_3, so that node_1 follows node_3. + # + node_1.stop("fast") + node_3.promote() + + # reconfigure node_1 as a standby following node_3 + # + # This framework's wait_for_catchup only polls pg_stat_replication and + # disambiguates the + # streaming connections by application_name, so set a distinct + # application_name for each standby's connection to node_3. (Otherwise + # both node_1 and node_2 would appear as 'walreceiver' and the polling + # query would match two rows.) + node_3_connstr = f"host={node_3.host} port={node_3.port}" + node_1.append_conf( + f"\nprimary_conninfo='{node_3_connstr} application_name=node_1'\n" + ) + node_1.set_standby_mode() + node_1.start() + + # also reconfigure node_2 to follow node_3 + node_2.append_conf( + f"\nprimary_conninfo='{node_3_connstr} application_name=node_2'\n" + ) + node_2.restart() + + # + # Promote node_1, to create a split-brain scenario. + # + + # make sure node_1 is full caught up with node_3 first + node_3.wait_for_catchup("node_1") + + node_1.promote() + + # + # We now have a split-brain with two primaries. Insert a row on both to + # demonstratively create a split brain. After the rewind, we should only + # see the insert on 1, as the insert on node 3 is rewound away. + # + node_1.safe_sql("INSERT INTO public.foo (t) VALUES ('keep this')") + # 'bar' is unmodified in node 1, so it won't be overwritten by replaying + # the WAL from node 1. + node_3.safe_sql("INSERT INTO public.bar (t) VALUES ('rewind this')") + + # Insert more rows in node 1, to bump up the XID counter. Otherwise, if + # rewind doesn't correctly rewind the changes made on the other node, + # we might fail to notice if the inserts are invisible because the XIDs + # are not marked as committed. + node_1.safe_sql("INSERT INTO public.foo (t) VALUES ('and this')") + node_1.safe_sql("INSERT INTO public.foo (t) VALUES ('and this too')") + + # Wait for node 2 to catch up + node_2.poll_query_until("SELECT COUNT(*) > 1 FROM public.bar", "t") + + # At this point node_2 will shut down without a shutdown checkpoint, + # but with WAL entries beyond the preceding shutdown checkpoint. + node_2.stop("fast") + node_3.stop("fast") + + node_2_pgdata = node_2.data_dir + node_1_connstr = node_1.connstr("postgres") + + # Keep a temporary postgresql.conf or it would be overwritten during the + # rewind. + tmp_folder = os.path.join(node_2.basedir, "rewind_tmp") + os.makedirs(tmp_folder, exist_ok=True) + saved_conf = os.path.join(tmp_folder, "node_2-postgresql.conf.tmp") + shutil.copy(os.path.join(node_2_pgdata, "postgresql.conf"), saved_conf) + + pg_bin.command_ok( + [ + "pg_rewind", + "--source-server", + node_1_connstr, + "--target-pgdata", + node_2_pgdata, + "--debug", + ], + "run pg_rewind", + ) + + # Now move back postgresql.conf with old settings + shutil.move(saved_conf, os.path.join(node_2_pgdata, "postgresql.conf")) + + node_2.start() + + # Check contents of the test tables after rewind. The rows inserted in + # node 3 before rewind should've been overwritten with the data from + # node 1. + check_query( + node_2, + "SELECT * FROM public.foo", + "keep this\nand this\nand this too\n", + "table foo after rewind", + ) + + check_query( + node_2, + "SELECT * FROM public.bar", + "in both\n", + "table bar after rewind", + ) + + # clean up + node_1.teardown() + node_2.teardown() + node_3.teardown() diff --git a/src/bin/pg_rewind/pyt/test_009_growing_files.py b/src/bin/pg_rewind/pyt/test_009_growing_files.py new file mode 100644 index 00000000000..5542623d959 --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_009_growing_files.py @@ -0,0 +1,91 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that pg_rewind reports an error when a source file grows while it is +being copied. +""" + +import os +import re +import subprocess + +from pypg.command import PgBin + + +def test_009_growing_files(rewind, bindir): + rewind.setup_cluster("local") + rewind.start_primary() + + # Create a test table and insert a row in primary. + rewind.primary_psql("CREATE TABLE tbl1 (d text)") + rewind.primary_psql("INSERT INTO tbl1 VALUES ('in primary')") + rewind.primary_psql("CHECKPOINT") + + rewind.create_standby("local") + + # Insert additional data on primary that will be replicated to standby + rewind.primary_psql("INSERT INTO tbl1 values ('in primary, before promotion')") + rewind.primary_psql("CHECKPOINT") + + rewind.promote_standby() + + # Insert a row in the old primary. This causes the primary and standby to + # have "diverged", it's no longer possible to just apply the standby's logs + # over primary directory - you need to rewind. Also insert a new row in + # the standby, which won't be present in the old primary. + rewind.primary_psql("INSERT INTO tbl1 VALUES ('in primary, after promotion')") + rewind.standby_psql("INSERT INTO tbl1 VALUES ('in standby, after promotion')") + + # Stop the nodes before running pg_rewind + rewind.node_standby.stop() + rewind.node_primary.stop() + + primary_pgdata = rewind.node_primary.data_dir + standby_pgdata = rewind.node_standby.data_dir + + # Add an extra file that we can tamper with without interfering with the + # data directory data files. + os.mkdir(os.path.join(standby_pgdata, "tst_both_dir")) + file1 = os.path.join(standby_pgdata, "tst_both_dir", "file1") + with open(file1, "a", encoding="utf-8") as fh: + fh.write("a") + + # Run pg_rewind and pipe the output from the run into the extra file we + # want to copy. This will ensure that the file is continuously growing + # during the copy operation and the result will be an error. + pg_bin = PgBin(bindir) + argv = [ + os.path.join(bindir, "pg_rewind"), + "--debug", + "--source-pgdata=" + standby_pgdata, + "--target-pgdata=" + primary_pgdata, + "--no-sync", + ] + print("# Running: " + " ".join(argv)) + with open(file1, "ab") as errfh: + proc = subprocess.run( + argv, + env=pg_bin.command_env(None), + stdout=subprocess.DEVNULL, + stderr=errfh, + check=False, + ) + ret = proc.returncode + assert ret != 0, "Error out on copying growing file" + + # Ensure that the files are of different size, the final error message + # should only be in one of them making them guaranteed to be different + primary_size = os.path.getsize( + os.path.join(primary_pgdata, "tst_both_dir", "file1") + ) + standby_size = os.path.getsize(file1) + assert standby_size != primary_size, "File sizes should differ" + + # Extract the last line from the verbose output as that should have the + # error message for the unexpected file size + last = None + with open(file1, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + last = line + assert last is not None and re.search( + r"error: size of source file", last + ), "Check error message" diff --git a/src/bin/pg_rewind/pyt/test_010_keep_recycled_wals.py b/src/bin/pg_rewind/pyt/test_010_keep_recycled_wals.py new file mode 100644 index 00000000000..38b7309ce86 --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_010_keep_recycled_wals.py @@ -0,0 +1,63 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test situation where a target data directory contains WAL files that were +already recycled by the new primary. +""" + +import re +import sys + +from pypg.command import PgBin + + +def test_010_keep_recycled_wals(rewind, bindir): + rewind.setup_cluster() + rewind.node_primary.enable_archiving() + rewind.start_primary() + + rewind.create_standby() + rewind.node_standby.enable_restoring(rewind.node_primary, standby=False) + rewind.node_standby.reload() + + rewind.primary_psql("CHECKPOINT") # last common checkpoint + + # We use the running interpreter with "exit(1)" as an alternative to + # "false", because the latter might not be available on Windows. + false = f'{sys.executable} -c "import sys; sys.exit(1)"' + rewind.node_primary.append_conf("\n" f"archive_command = '{false}'\n") + rewind.node_primary.reload() + + # advance WAL on primary; this WAL segment will never make it to the + # archive + rewind.primary_psql("CREATE TABLE t(a int)") + rewind.primary_psql("INSERT INTO t VALUES(0)") + rewind.primary_psql("SELECT pg_switch_wal()") + + rewind.promote_standby() + + # new primary loses diverging WAL segment + rewind.standby_psql("INSERT INTO t values(0)") + rewind.standby_psql("SELECT pg_switch_wal()") + + rewind.node_standby.stop() + rewind.node_primary.stop() + + pg_bin = PgBin(bindir) + res = pg_bin.result( + [ + "pg_rewind", + "--debug", + "--source-pgdata", + rewind.node_standby.data_dir, + "--target-pgdata", + rewind.node_primary.data_dir, + "--no-sync", + ] + ) + + assert re.search( + r"Not removing file .* because it is required for recovery", + res.stderr, + ), ( + "some WAL files were skipped\n" + res.stderr + ) diff --git a/src/bin/pg_rewind/pyt/test_011_wal_copy.py b/src/bin/pg_rewind/pyt/test_011_wal_copy.py new file mode 100644 index 00000000000..9a763a90d6b --- /dev/null +++ b/src/bin/pg_rewind/pyt/test_011_wal_copy.py @@ -0,0 +1,129 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test that pg_rewind copies WAL segments generated before the divergence +point from the source, replacing corrupted copies on the target. +""" + +import os +import re + +from pypg.command import PgBin + + +def test_011_wal_copy(rewind, bindir): + rewind.setup_cluster() + rewind.start_primary() + rewind.create_standby() + + node_primary = rewind.node_primary + node_standby = rewind.node_standby + + # Advance WAL on primary + rewind.primary_psql("CREATE TABLE t(a int)") + rewind.primary_psql("INSERT INTO t VALUES(0)") + + # Segment that is not copied from the source to the target, being + # generated before the servers have diverged. + wal_seg_skipped = node_primary.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_lsn())" + ) + + rewind.primary_psql("SELECT pg_switch_wal()") + + # Follow-up segment, that will include corrupted contents, and will be + # copied from the source to the target even if generated before the point + # of divergence. + rewind.primary_psql("INSERT INTO t VALUES(0)") + corrupt_wal_seg = node_primary.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_lsn())" + ) + rewind.primary_psql("SELECT pg_switch_wal()") + + rewind.primary_psql("CHECKPOINT") + rewind.promote_standby() + + # New segment on a new timeline, expected to be copied. + new_timeline_wal_seg = node_standby.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_lsn())" + ) + + # Corrupt a WAL segment on target that has been generated before the + # divergence point. We will check that it is copied from the source. + corrupt_wal_seg_in_target_path = os.path.join( + node_primary.data_dir, "pg_wal", corrupt_wal_seg + ) + with open(corrupt_wal_seg_in_target_path, "ab") as fh: + fh.write(b"a") + + assert os.path.exists( + corrupt_wal_seg_in_target_path + ), f"segment {corrupt_wal_seg} exists in target before rewind" + corrupt_wal_seg_size_before_rewind = os.stat(corrupt_wal_seg_in_target_path).st_size + + # Verify that the WAL segment on the new timeline does not exist in target + # before the rewind. + new_timeline_wal_seg_path = os.path.join( + node_primary.data_dir, "pg_wal", new_timeline_wal_seg + ) + assert not os.path.exists( + new_timeline_wal_seg_path + ), f"segment {new_timeline_wal_seg} does not exist in target before rewind" + + node_standby.stop() + node_primary.stop() + + # Cross-check how WAL segments are handled: + # - The "corrupted" segment generated before the point of divergence is + # copied. + # - The "clean" segment generated before the point of divergence is skipped. + # - The segment of the new timeline is copied. + pg_bin = PgBin(bindir) + pg_bin.command_checks_all( + [ + "pg_rewind", + "--debug", + "--source-pgdata", + node_standby.data_dir, + "--target-pgdata", + node_primary.data_dir, + "--no-sync", + ], + 0, + [re.compile("")], + [ + re.compile(r"pg_wal/" + re.escape(wal_seg_skipped) + r" \(NONE\)"), + re.compile(r"pg_wal/" + re.escape(corrupt_wal_seg) + r" \(COPY\)"), + re.compile(r"pg_wal/" + re.escape(new_timeline_wal_seg) + r" \(COPY\)"), + ], + "run pg_rewind", + ) + + # Verify that the first WAL segment of the new timeline now exists in + # target. + assert os.path.exists( + new_timeline_wal_seg_path + ), f"new timeline segment {new_timeline_wal_seg} exists in target after rewind" + + # Validate that the WAL segment with the same file name as the + # corrupted WAL segment in target has been copied from source + # where it was still intact. + corrupt_wal_seg_in_source_path = os.path.join( + node_standby.data_dir, "pg_wal", corrupt_wal_seg + ) + assert os.path.exists( + corrupt_wal_seg_in_source_path + ), f"corrupted {corrupt_wal_seg} exists in source after rewind" + corrupt_wal_seg_source_size = os.stat(corrupt_wal_seg_in_source_path).st_size + + assert os.path.exists( + corrupt_wal_seg_in_target_path + ), f"corrupted {corrupt_wal_seg} exists in target after rewind" + corrupt_wal_seg_size_after_rewind = os.stat(corrupt_wal_seg_in_target_path).st_size + + assert corrupt_wal_seg_size_before_rewind != corrupt_wal_seg_source_size, ( + f"different size of corrupted {corrupt_wal_seg} in source vs target " + "before rewind" + ) + assert corrupt_wal_seg_size_after_rewind == corrupt_wal_seg_source_size, ( + f"same size of corrupted {corrupt_wal_seg} in source and target " "after rewind" + ) diff --git a/src/bin/pg_verifybackup/meson.build b/src/bin/pg_verifybackup/meson.build index 0b21db9f1b5..b78a8af7205 100644 --- a/src/bin/pg_verifybackup/meson.build +++ b/src/bin/pg_verifybackup/meson.build @@ -40,6 +40,24 @@ tests += { 't/010_client_untar.pl', ], }, + 'pytest': { + 'env': {'GZIP_PROGRAM': gzip.found() ? gzip.full_path() : '', + 'TAR': tar.found() ? tar.full_path() : '', + 'LZ4': program_lz4.found() ? program_lz4.full_path() : '', + 'ZSTD': program_zstd.found() ? program_zstd.full_path() : ''}, + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_algorithm.py', + 'pyt/test_003_corruption.py', + 'pyt/test_004_options.py', + 'pyt/test_005_bad_manifest.py', + 'pyt/test_006_encoding.py', + 'pyt/test_007_wal.py', + 'pyt/test_008_untar.py', + 'pyt/test_009_extract.py', + 'pyt/test_010_client_untar.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_verifybackup/pyt/test_001_basic.py b/src/bin/pg_verifybackup/pyt/test_001_basic.py new file mode 100644 index 00000000000..15f22e81155 --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_001_basic.py @@ -0,0 +1,46 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic tests for pg_verifybackup option handling and output.""" + +import os + + +def test_001_basic(pg_bin, tmp_path): + pg_bin.program_help_ok("pg_verifybackup") + pg_bin.program_version_ok("pg_verifybackup") + pg_bin.program_options_handling_ok("pg_verifybackup") + + tempdir = str(tmp_path / "tempdir") + os.mkdir(tempdir) + + pg_bin.command_fails_like( + ["pg_verifybackup"], + r"no backup directory specified", + "target directory must be specified", + ) + pg_bin.command_fails_like( + ["pg_verifybackup", tempdir], + r'could not open file.*/backup_manifest"', + "pg_verifybackup requires a manifest", + ) + pg_bin.command_fails_like( + ["pg_verifybackup", tempdir, tempdir], + r"too many command-line arguments", + "multiple target directories not allowed", + ) + + # create fake manifest file + with open(os.path.join(tempdir, "backup_manifest"), "w", encoding="utf-8"): + pass + + # but then try to use an alternate, nonexisting manifest + pg_bin.command_fails_like( + [ + "pg_verifybackup", + "--manifest-path", + os.path.join(tempdir, "not_the_manifest"), + tempdir, + ], + r'could not open file.*/not_the_manifest"', + "pg_verifybackup respects -m flag", + ) diff --git a/src/bin/pg_verifybackup/pyt/test_002_algorithm.py b/src/bin/pg_verifybackup/pyt/test_002_algorithm.py new file mode 100644 index 00000000000..a98aebed7e1 --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_002_algorithm.py @@ -0,0 +1,84 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_verifybackup with each supported checksum algorithm.""" + +import os +import re +import shutil + +from pypg.util import slurp_file + + +def _test_checksums(primary, fmt, algorithm): + # Forward slashes: pg_basebackup creates the target directory with + # pg_mkdir_p, which splits on "/", so a backslash path would not have its + # intermediate directory created. + backup_path = "/".join([primary.backup_dir.replace("\\", "/"), fmt, algorithm]) + backup = [ + "pg_basebackup", + "--pgdata", + backup_path, + "--manifest-checksums", + algorithm, + "--no-sync", + "--checkpoint", + "fast", + ] + verify = ["pg_verifybackup", "--exit-on-error", backup_path] + + if fmt == "tar": + # Add switch to get a tar-format backup + backup += ["--format", "tar"] + + # A backup with a bogus algorithm should fail. + if algorithm == "bogus": + primary.command_fails( + backup, f'{fmt} format backup fails with algorithm "{algorithm}"' + ) + return + + # A backup with a valid algorithm should work. + primary.command_ok(backup, f'{fmt} format backup ok with algorithm "{algorithm}"') + + # We expect each real checksum algorithm to be mentioned on every line of + # the backup manifest file except the first and last; for simplicity, we + # just check that it shows up lots of times. When the checksum algorithm + # is none, we just check that the manifest exists. + if algorithm == "none": + assert os.path.isfile( + os.path.join(backup_path, "backup_manifest") + ), f"{fmt} format backup manifest exists" + else: + manifest = slurp_file(os.path.join(backup_path, "backup_manifest")) + count_of_algorithm_in_manifest = len( + re.findall(algorithm, manifest, re.M | re.I) + ) + assert ( + count_of_algorithm_in_manifest > 100 + ), f"{algorithm} is mentioned many times in the manifest" + + # Make sure that it verifies OK. + primary.command_ok( + verify, f'verify {fmt} format backup with algorithm "{algorithm}"' + ) + + # Remove backup immediately to save disk space. + shutil.rmtree(backup_path) + + +def test_002_algorithm(create_pg): + """Verify that we can take and verify backups with various checksum types.""" + primary = create_pg("primary", allows_streaming=True) + + # Do the check + for fmt in ("plain", "tar"): + for algorithm in ( + "bogus", + "none", + "crc32c", + "sha224", + "sha256", + "sha384", + "sha512", + ): + _test_checksums(primary, fmt, algorithm) diff --git a/src/bin/pg_verifybackup/pyt/test_003_corruption.py b/src/bin/pg_verifybackup/pyt/test_003_corruption.py new file mode 100644 index 00000000000..c3d1abbe66f --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_003_corruption.py @@ -0,0 +1,392 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that pg_verifybackup detects various forms of backup corruption.""" + +import os +import shutil +import subprocess +from typing import Any, Dict, List + +import pytest + +from pypg.util import short_tempdir, WINDOWS_OS + + +def _tar_portability_options(tar): + """Return the options needed so that the tar program produces a tarfile + pg_verifybackup can decode (i.e. no pax extensions). + """ + if not tar: + return [] + devnull = os.devnull + if ( + subprocess.call( + f"{tar} --format=ustar --owner=0 --group=0 -cf {devnull} {devnull}", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + == 0 + ): + # GNU tar (Linux), BSD tar (FreeBSD, NetBSD, macOS, Windows) + return ["--format=ustar", "--owner=0", "--group=0"] + if ( + subprocess.call( + f"{tar} -F ustar -cf {devnull} {devnull}", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + == 0 + ): + # OpenBSD tar + return ["-F", "ustar"] + return [] + + +def _slurp_dir_entries(path): + """Directory entries excluding '.' and '..'.""" + return os.listdir(path) + + +# -- mutilate / cleanup helpers ----------------------------------------------- + + +def _create_extra_file(backup_path, relative_path): + pathname = os.path.join(backup_path, relative_path) + with open(pathname, "w", encoding="utf-8") as fh: + fh.write("This is an extra file.\n") + + +def mutilate_extra_file(backup_path): + # Add a file into the root directory of the backup. + _create_extra_file(backup_path, "extra_file") + + +def mutilate_extra_tablespace_file(backup_path): + # Add a file inside the user-defined tablespace. + (tsoid,) = _slurp_dir_entries(os.path.join(backup_path, "pg_tblspc")) + (catvdir,) = _slurp_dir_entries(os.path.join(backup_path, "pg_tblspc", tsoid)) + (tsdboid,) = _slurp_dir_entries( + os.path.join(backup_path, "pg_tblspc", tsoid, catvdir) + ) + _create_extra_file( + backup_path, os.path.join("pg_tblspc", tsoid, catvdir, tsdboid, "extra_ts_file") + ) + + +def mutilate_missing_file(backup_path): + # Remove a file. + os.unlink(os.path.join(backup_path, "pg_xact", "0000")) + + +def mutilate_missing_tablespace(backup_path): + # Remove the symlink to the user-defined tablespace. + (tsoid,) = _slurp_dir_entries(os.path.join(backup_path, "pg_tblspc")) + os.unlink(os.path.join(backup_path, "pg_tblspc", tsoid)) + + +def mutilate_append_to_file(backup_path): + # Append an additional byte to a file. + with open(os.path.join(backup_path, "global", "pg_control"), "ab") as fh: + fh.write(b"x") + + +def mutilate_truncate_file(backup_path): + # Truncate a file to zero length. + pathname = os.path.join(backup_path, "pg_hba.conf") + with open(pathname, "w", encoding="utf-8"): + pass + + +def mutilate_replace_file(backup_path): + # Replace a file's contents without changing the length of the file. This + # is not a particularly efficient way to do this, so we pick a file that's + # expected to be short. + pathname = os.path.join(backup_path, "PG_VERSION") + with open(pathname, encoding="utf-8") as fh: + contents = fh.read() + with open(pathname, "w", encoding="utf-8") as fh: + fh.write("q" * len(contents)) + + +def mutilate_bad_manifest(backup_path): + # Corrupt the backup manifest. + with open(os.path.join(backup_path, "backup_manifest"), "ab") as fh: + fh.write(b"\n") + + +def mutilate_open_file_fails(backup_path): + # Create a file that can't be opened. (This is skipped on Windows.) + os.chmod(os.path.join(backup_path, "PG_VERSION"), 0) + + +def mutilate_open_directory_fails(backup_path): + # Create a directory that can't be opened. (This is skipped on Windows.) + os.chmod(os.path.join(backup_path, "pg_subtrans"), 0) + + +def cleanup_open_directory_fails(backup_path): + # restore permissions on the unreadable directory we created. + os.chmod(os.path.join(backup_path, "pg_subtrans"), 0o700) + + +def mutilate_search_directory_fails(backup_path): + # Create a directory that can't be searched. (This is skipped on Windows.) + os.chmod(os.path.join(backup_path, "base"), 0o400) + + +def cleanup_search_directory_fails(backup_path): + # rmtree can't cope with a mode 400 directory, so change back to 700. + os.chmod(os.path.join(backup_path, "base"), 0o700) + + +# The mutilate for the system_identifier scenario needs to spin up a second +# server, so it is handled specially in the test body rather than as a plain +# closure over the backup dir. +SYSTEM_IDENTIFIER = "system_identifier" + +# Heterogeneous per-scenario records: str names/regexps, callables for +# mutilate/cleanup hooks, and a bool flag. +SCENARIOS: List[Dict[str, Any]] = [ + { + "name": "extra_file", + "mutilate": mutilate_extra_file, + "fails_like": r'extra_file.*present (on disk|in archive "[^"]+") ' + r"but not in the manifest", + }, + { + "name": "extra_tablespace_file", + "mutilate": mutilate_extra_tablespace_file, + "fails_like": r'extra_ts_file.*present (on disk|in archive "[^"]+") ' + r"but not in the manifest", + }, + { + "name": "missing_file", + "mutilate": mutilate_missing_file, + "fails_like": r"pg_xact\/0000.*present in the manifest but not " + r'(on disk|in archive "[^"]+")', + }, + { + "name": "missing_tablespace", + "mutilate": mutilate_missing_tablespace, + "fails_like": r"pg_tblspc.*present in the manifest but not " + r'(on disk|in archive "[^"]+")', + }, + { + "name": "append_to_file", + "mutilate": mutilate_append_to_file, + "fails_like": r'has size \d+ (on disk|in archive "[^"]+") ' + r"but size \d+ in the manifest", + }, + { + "name": "truncate_file", + "mutilate": mutilate_truncate_file, + "fails_like": r'has size 0 (on disk|in archive "[^"]+") ' + r"but size \d+ in the manifest", + }, + { + "name": "replace_file", + "mutilate": mutilate_replace_file, + "fails_like": r"checksum mismatch for file", + }, + { + "name": SYSTEM_IDENTIFIER, + "mutilate": None, + "fails_like": r"manifest system identifier is .*, but control file has", + }, + { + "name": "bad_manifest", + "mutilate": mutilate_bad_manifest, + "fails_like": r"manifest checksum mismatch", + }, + { + "name": "open_file_fails", + "mutilate": mutilate_open_file_fails, + "fails_like": r"could not open file", + "needs_unix_permissions": True, + }, + { + "name": "open_directory_fails", + "mutilate": mutilate_open_directory_fails, + "cleanup": cleanup_open_directory_fails, + "fails_like": r"could not open directory", + "needs_unix_permissions": True, + }, + { + "name": "search_directory_fails", + "mutilate": mutilate_search_directory_fails, + "cleanup": cleanup_search_directory_fails, + "fails_like": r"could not stat file or directory", + "needs_unix_permissions": True, + }, +] + + +@pytest.mark.parametrize("scenario", SCENARIOS, ids=[s["name"] for s in SCENARIOS]) +def test_003_corruption(scenario, create_pg, tmp_path): + # Each corruption scenario is independent (it takes its own backup and + # verifies it), so build a fresh primary per parametrized case; + # create_pg tears it down at the end of the test. + primary = create_pg("primary", allows_streaming=True) + + # Include a user-defined tablespace in the hopes of detecting problems in + # that area. + source_ts_path = short_tempdir() + + # CREATE TABLESPACE cannot run inside a transaction block, so issue each + # statement separately rather than as one multi-statement implicit + # transaction. + primary.safe_sql("CREATE TABLE x1 (a int);") + primary.safe_sql("INSERT INTO x1 VALUES (111);") + primary.safe_sql(f"CREATE TABLESPACE ts1 LOCATION '{source_ts_path}';") + primary.safe_sql("CREATE TABLE x2 (a int) TABLESPACE ts1;") + primary.safe_sql("INSERT INTO x1 VALUES (222);") + + name = scenario["name"] + + # The *_fails scenarios make a file or directory unreadable with chmod(0) + # and expect pg_verifybackup to report it. Windows ignores those mode bits, + # so the backup still verifies and the scenario cannot be exercised there. + if scenario.get("needs_unix_permissions") and WINDOWS_OS: + pytest.skip("scenario requires UNIX file permissions") + + # Take a backup and check that it verifies OK. + backup_path = str(tmp_path / name) + backup_ts_path = short_tempdir() + # tablespace gets remapped into a short tempdir so paths stay short. + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup_path, + "--no-sync", + "--checkpoint", + "fast", + "--tablespace-mapping", + f"{source_ts_path}={backup_ts_path}", + ], + "base backup ok", + ) + primary.pg_bin.command_ok( + ["pg_verifybackup", backup_path], "intact backup verified" + ) + + # Mutilate the backup in some way. + if name == SYSTEM_IDENTIFIER: + # Set up another new database instance with different system identifier + # and make a backup; copy its manifest over to demonstrate the case + # where the wrong manifest is referred to. + node = create_pg("node", allows_streaming=True) + node.backup("backup2") + shutil.move( + os.path.join(node.backup_dir, "backup2", "backup_manifest"), + os.path.join(backup_path, "backup_manifest"), + ) + node.teardown() + else: + scenario["mutilate"](backup_path) + + # Now check that the backup no longer verifies. + primary.command_fails_like( + ["pg_verifybackup", backup_path], + scenario["fails_like"], + f"corrupt backup fails verification: {name}", + ) + + # Run cleanup hook, if provided. + if "cleanup" in scenario: + scenario["cleanup"](backup_path) + + # Turn it into a tar-format backup and see if we can still detect the same + # problem, unless the scenario needs UNIX permissions or we don't have a + # TAR program available. Note that this destructively modifies the backup + # directory. + tar = os.environ.get("TAR") + tar_p_flags = _tar_portability_options(tar) + if not scenario.get("needs_unix_permissions") and tar: + tar_backup_path = str(tmp_path / ("tar_" + name)) + os.mkdir(tar_backup_path) + + # tar and then remove each tablespace. We remove the original files so + # that they don't also end up in base.tar. + tsoids = _slurp_dir_entries(os.path.join(backup_path, "pg_tblspc")) + for tsoid in tsoids: + tspath = os.path.join(backup_path, "pg_tblspc", tsoid) + _tar_in( + tar, tar_p_flags, tspath, os.path.join(tar_backup_path, f"{tsoid}.tar") + ) + _rmtree_like_perl(tspath) + + # tar and remove pg_wal + _tar_in( + tar, + tar_p_flags, + os.path.join(backup_path, "pg_wal"), + os.path.join(tar_backup_path, "pg_wal.tar"), + ) + shutil.rmtree(os.path.join(backup_path, "pg_wal")) + + # move the backup manifest + shutil.move( + os.path.join(backup_path, "backup_manifest"), + os.path.join(tar_backup_path, "backup_manifest"), + ) + + # Construct base.tar with what's left. + _tar_in( + tar, tar_p_flags, backup_path, os.path.join(tar_backup_path, "base.tar") + ) + + primary.command_fails_like( + ["pg_verifybackup", tar_backup_path], + scenario["fails_like"], + f"corrupt backup fails verification: {name}", + ) + + shutil.rmtree(tar_backup_path) + + _rmtree_like_perl(backup_path) + + +def _rmtree_like_perl(path): + """Remove *path*, handling symlinks and unreadable subdirectories. + + In particular, a symlink (e.g. a pg_tblspc/ tablespace link) is just + unlinked, not followed; shutil.rmtree refuses to operate on a symlink. + Directories are removed recursively, restoring permissions on any + unreadable subdirectory first so the removal can proceed. + """ + if os.path.islink(path): + os.unlink(path) + return + if not os.path.exists(path): + return + + def _onerror(func, p, exc): + # Make the entry accessible and retry (e.g. mode-0 dirs/files left + # behind by the *_fails scenarios when cleanup did not run). + try: + os.chmod(p, 0o700) + except OSError: + return + func(p) + + # onexc (the non-deprecated spelling) requires Python 3.12; the suite's + # floor is 3.8, so stick with onerror. + shutil.rmtree(path, onerror=_onerror) # pylint: disable=deprecated-argument + + +def _tar_in(tar, tar_p_flags, cwd, outfile): + """Run tar with *cwd* as working directory, archiving '.'; assert success.""" + proc = subprocess.run( + [tar, *tar_p_flags, "-cf", outfile, "."], + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + assert ( + proc.returncode == 0 + ), f"tar in {cwd} failed ({proc.returncode}):\n{proc.stdout}" diff --git a/src/bin/pg_verifybackup/pyt/test_004_options.py b/src/bin/pg_verifybackup/pyt/test_004_options.py new file mode 100644 index 00000000000..5f012f6463a --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_004_options.py @@ -0,0 +1,160 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_verifybackup command-line options.""" + +import os +import re +import shutil + + +def test_004_options(create_pg, tmp_path): + # Start up the server and take a backup. + primary = create_pg("primary", allows_streaming=True) + backup_path = str(tmp_path / "test_options") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup_path, + "--no-sync", + "--checkpoint", + "fast", + ], + "base backup ok", + ) + + # Verify that pg_verifybackup --quiet succeeds and produces no output. + res = primary.pg_bin.result(["pg_verifybackup", "--quiet", backup_path]) + assert res.returncode == 0, "--quiet succeeds: exit code 0" + assert res.stdout == "", "--quiet succeeds: no stdout" + assert res.stderr == "", "--quiet succeeds: no stderr" + + # Should still work if we specify --format=plain. + primary.command_ok( + ["pg_verifybackup", "--format", "plain", backup_path], + "verifies with --format=plain", + ) + + # Should not work if we specify --format=y because that's invalid. + primary.command_fails_like( + ["pg_verifybackup", "--format", "y", backup_path], + r'invalid backup format "y", must be "plain" or "tar"', + "does not verify with --format=y", + ) + + # Should produce a lengthy list of errors; we test for just one of those. + primary.command_fails_like( + [ + "pg_verifybackup", + "--format", + "tar", + "--no-parse-wal", + backup_path, + ], + r'"pg_multixact" is not a regular file', + "does not verify with --format=tar --no-parse-wal", + ) + + # Test invalid options + primary.command_fails_like( + ["pg_verifybackup", "--progress", "--quiet", backup_path], + r"cannot specify both -P/--progress and -q/--quiet", + "cannot use --progress and --quiet at the same time", + ) + + # Corrupt the PG_VERSION file. + version_pathname = os.path.join(backup_path, "PG_VERSION") + with open(version_pathname, encoding="utf-8") as fh: + version_contents = fh.read() + with open(version_pathname, "w", encoding="utf-8") as fh: + fh.write("q" * len(version_contents)) + + # Verify that pg_verifybackup -q now fails. + primary.command_fails_like( + ["pg_verifybackup", "--quiet", backup_path], + r"checksum mismatch for file \"PG_VERSION\"", + "--quiet checksum mismatch", + ) + + # Since we didn't change the length of the file, verification should + # succeed if we ignore checksums. Check that we get the right message, too. + primary.command_like( + ["pg_verifybackup", "--skip-checksums", backup_path], + r"backup successfully verified", + "--skip-checksums skips checksumming", + ) + + # Validation should succeed if we ignore the problem file. Also, check + # the progress information. + primary.command_checks_all( + [ + "pg_verifybackup", + "--progress", + "--ignore", + "PG_VERSION", + backup_path, + ], + 0, + [r"backup successfully verified"], + [r"(\d+/\d+ kB \(\d+%\) verified)+"], + "--ignore ignores problem file", + ) + + # PG_VERSION is already corrupt; let's try also removing all of pg_xact. + shutil.rmtree(os.path.join(backup_path, "pg_xact")) + + # We're ignoring the problem with PG_VERSION, but not the problem with + # pg_xact, so verification should fail here. + primary.command_fails_like( + ["pg_verifybackup", "--ignore", "PG_VERSION", backup_path], + r"pg_xact.*is present in the manifest but not on disk", + "--ignore does not ignore all problems", + ) + + # If we use --ignore twice, we should be able to ignore all of the + # problems. + primary.command_like( + [ + "pg_verifybackup", + "--ignore", + "PG_VERSION", + "--ignore", + "pg_xact", + backup_path, + ], + r"backup successfully verified", + "multiple --ignore options work", + ) + + # Verify that when --ignore is not used, both problems are reported. + res = primary.pg_bin.result(["pg_verifybackup", backup_path]) + assert res.returncode != 0, "multiple problems: fails" + assert re.search( + r"pg_xact.*is present in the manifest but not on disk", res.stderr + ), "multiple problems: missing files reported" + assert re.search( + r"checksum mismatch for file \"PG_VERSION\"", res.stderr + ), "multiple problems: checksum mismatch reported" + + # Verify that when --exit-on-error is used, only the problem detected + # first is reported. + res = primary.pg_bin.result(["pg_verifybackup", "--exit-on-error", backup_path]) + assert res.returncode != 0, "--exit-on-error reports 1 error: fails" + assert re.search( + r"pg_xact.*is present in the manifest but not on disk", res.stderr + ), "--exit-on-error reports 1 error: missing files reported" + assert not re.search( + r"checksum mismatch for file \"PG_VERSION\"", res.stderr + ), "--exit-on-error reports 1 error: checksum mismatch not reported" + + # Test valid manifest with nonexistent backup directory. + primary.command_fails_like( + [ + "pg_verifybackup", + "--manifest-path", + os.path.join(backup_path, "backup_manifest"), + os.path.join(backup_path, "fake"), + ], + r"could not open directory", + "nonexistent backup directory", + ) diff --git a/src/bin/pg_verifybackup/pyt/test_005_bad_manifest.py b/src/bin/pg_verifybackup/pyt/test_005_bad_manifest.py new file mode 100644 index 00000000000..dd8da7271df --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_005_bad_manifest.py @@ -0,0 +1,275 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that pg_verifybackup rejects malformed or invalid backup manifests.""" + +import os +import re + + +def _test_bad_manifest(pg_bin, tempdir, test_name, regexp, manifest_contents): + """Write *manifest_contents* and run pg_verifybackup expecting *regexp*. + + Writes the manifest text to ``/backup_manifest`` and runs + ``pg_verifybackup ``, asserting that it fails with stderr matching + *regexp*. + """ + with open(os.path.join(tempdir, "backup_manifest"), "w", encoding="utf-8") as fh: + fh.write(manifest_contents) + + pg_bin.command_fails_like(["pg_verifybackup", tempdir], regexp, test_name) + + +def _test_parse_error(pg_bin, tempdir, test_name, manifest_contents): + """Assert pg_verifybackup reports a manifest parse error for *test_name*.""" + _test_bad_manifest( + pg_bin, + tempdir, + test_name, + r"could not parse backup manifest: " + re.escape(test_name), + manifest_contents, + ) + + +def _test_fatal_error(pg_bin, tempdir, test_name, manifest_contents): + """Assert pg_verifybackup reports a fatal error for *test_name*.""" + _test_bad_manifest( + pg_bin, tempdir, test_name, r"error: " + re.escape(test_name), manifest_contents + ) + + +def test_005_bad_manifest(pg_bin, tmp_path): + tempdir = str(tmp_path) + + _test_bad_manifest( + pg_bin, + tempdir, + "input string ended unexpectedly", + r"could not parse backup manifest: The input string ended unexpectedly", + "{\n", + ) + + _test_parse_error(pg_bin, tempdir, "unexpected object end", "{}\n") + + _test_parse_error(pg_bin, tempdir, "unexpected array start", "[]\n") + + _test_parse_error( + pg_bin, tempdir, "expected version indicator", '{"not-expected": 1}\n' + ) + + _test_parse_error( + pg_bin, + tempdir, + "manifest version not an integer", + '{"PostgreSQL-Backup-Manifest-Version": "phooey"}\n', + ) + + _test_parse_error( + pg_bin, + tempdir, + "unexpected manifest version", + '{"PostgreSQL-Backup-Manifest-Version": 9876599}\n', + ) + + _test_parse_error( + pg_bin, + tempdir, + "unexpected scalar", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": true}\n', + ) + + _test_parse_error( + pg_bin, + tempdir, + "unrecognized top-level field", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Oops": 1}\n', + ) + + _test_parse_error( + pg_bin, + tempdir, + "unexpected object start", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": {}}\n', + ) + + _test_parse_error( + pg_bin, + tempdir, + "missing path name", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [{}]}\n', + ) + + _test_parse_error( + pg_bin, + tempdir, + "both path name and encoded path name", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Path": "x", "Encoded-Path": "1234"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "unexpected file field", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Oops": 1}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "missing size", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Path": "x"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "file size is not an integer", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Path": "x", "Size": "Oops"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "could not decode file name", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Encoded-Path": "123", "Size": 0}\n' + "]}\n", + ) + + _test_fatal_error( + pg_bin, + tempdir, + "duplicate path name in backup manifest", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Path": "x", "Size": 0},\n' + ' {"Path": "x", "Size": 0}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "checksum without algorithm", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Path": "x", "Size": 100, "Checksum": "Oops"}\n' + "]}\n", + ) + + _test_fatal_error( + pg_bin, + tempdir, + "unrecognized checksum algorithm", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Path": "x", "Size": 100, "Checksum-Algorithm": "Oops", "Checksum": "00"}\n' + "]}\n", + ) + + _test_fatal_error( + pg_bin, + tempdir, + "invalid checksum for file", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [\n' + ' {"Path": "x", "Size": 100, "Checksum-Algorithm": "CRC32C", "Checksum": "0"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "missing start LSN", + '{"PostgreSQL-Backup-Manifest-Version": 1, "WAL-Ranges": [\n' + ' {"Timeline": 1}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "missing end LSN", + '{"PostgreSQL-Backup-Manifest-Version": 1, "WAL-Ranges": [\n' + ' {"Timeline": 1, "Start-LSN": "0/0"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "unexpected WAL range field", + '{"PostgreSQL-Backup-Manifest-Version": 1, "WAL-Ranges": [\n' + ' {"Oops": 1}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "missing timeline", + '{"PostgreSQL-Backup-Manifest-Version": 1, "WAL-Ranges": [\n {}\n]}\n', + ) + + _test_parse_error( + pg_bin, + tempdir, + "unexpected object end", + '{"PostgreSQL-Backup-Manifest-Version": 1, "WAL-Ranges": [\n' + ' {"Timeline": 1, "Start-LSN": "0/0", "End-LSN": "0/0"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "timeline is not an integer", + '{"PostgreSQL-Backup-Manifest-Version": 1, "WAL-Ranges": [\n' + ' {"Timeline": true, "Start-LSN": "0/0", "End-LSN": "0/0"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "could not parse start LSN", + '{"PostgreSQL-Backup-Manifest-Version": 1, "WAL-Ranges": [\n' + ' {"Timeline": 1, "Start-LSN": "oops", "End-LSN": "0/0"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "could not parse end LSN", + '{"PostgreSQL-Backup-Manifest-Version": 1, "WAL-Ranges": [\n' + ' {"Timeline": 1, "Start-LSN": "0/0", "End-LSN": "oops"}\n' + "]}\n", + ) + + _test_parse_error( + pg_bin, + tempdir, + "expected at least 2 lines", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [], "Manifest-Checksum": null}\n', + ) + + manifest_without_newline = ( + '{"PostgreSQL-Backup-Manifest-Version": 1,\n' + ' "Files": [],\n' + ' "Manifest-Checksum": null}' + ) + _test_parse_error( + pg_bin, tempdir, "last line not newline-terminated", manifest_without_newline + ) + + _test_fatal_error( + pg_bin, + tempdir, + "invalid manifest checksum", + '{"PostgreSQL-Backup-Manifest-Version": 1, "Files": [],\n' + ' "Manifest-Checksum": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234567890-"}\n', + ) diff --git a/src/bin/pg_verifybackup/pyt/test_006_encoding.py b/src/bin/pg_verifybackup/pyt/test_006_encoding.py new file mode 100644 index 00000000000..0b8ee307ffd --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_006_encoding.py @@ -0,0 +1,40 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Verify that pg_verifybackup handles hex-encoded filenames correctly.""" + +import os +import re + +from pypg.util import slurp_file + + +def test_006_encoding(create_pg, tmp_path): + primary = create_pg("primary", allows_streaming=True) + + backup_path = str(tmp_path / "test_encoding") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup_path, + "--no-sync", + "--checkpoint", + "fast", + "--manifest-force-encode", + ], + "backup ok with forced hex encoding", + ) + + manifest = slurp_file(os.path.join(backup_path, "backup_manifest")) + count_of_encoded_path_in_manifest = len( + re.findall(r"Encoded-Path", manifest, re.I | re.M) + ) + assert ( + count_of_encoded_path_in_manifest > 100 + ), "many paths are encoded in the manifest" + + primary.command_like( + ["pg_verifybackup", "--skip-checksums", backup_path], + r"backup successfully verified", + "backup with forced encoding verified", + ) diff --git a/src/bin/pg_verifybackup/pyt/test_007_wal.py b/src/bin/pg_verifybackup/pyt/test_007_wal.py new file mode 100644 index 00000000000..c55dacb1dcb --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_007_wal.py @@ -0,0 +1,121 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_verifybackup's WAL verification.""" + +import os +import re + + +def test_007_wal(create_pg, tmp_path): + # Start up the server and take a backup. + primary = create_pg("primary", allows_streaming=True) + + backup_path = str(tmp_path / "test_wal") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup_path, + "--no-sync", + "--checkpoint", + "fast", + ], + "base backup ok", + ) + + # Rename pg_wal. + original_pg_wal = os.path.join(backup_path, "pg_wal") + relocated_pg_wal = str(tmp_path / "relocated_pg_wal") + os.rename(original_pg_wal, relocated_pg_wal) + + # WAL verification should fail. + primary.command_fails_like( + ["pg_verifybackup", backup_path], + r"WAL parsing failed for timeline 1", + "missing pg_wal causes failure", + ) + + # Should work if we skip WAL verification. + primary.command_ok( + ["pg_verifybackup", "--no-parse-wal", backup_path], + "missing pg_wal OK if not verifying WAL", + ) + + # Should also work if we specify the correct WAL location. + primary.command_ok( + [ + "pg_verifybackup", + "--wal-path", + relocated_pg_wal, + backup_path, + ], + "--wal-path can be used to specify WAL directory", + ) + + # Move directory back to original location. + os.rename(relocated_pg_wal, original_pg_wal) + + # Get a list of files in that directory that look like WAL files. + walfiles = sorted( + f for f in os.listdir(original_pg_wal) if re.fullmatch(r"[0-9A-F]{24}", f) + ) + + # Replace the contents of one of the files with garbage of equal length. + wal_corruption_target = os.path.join(original_pg_wal, walfiles[0]) + wal_size = os.path.getsize(wal_corruption_target) + with open(wal_corruption_target, "wb") as fh: + fh.write(b"w" * wal_size) + + # WAL verification should fail. + primary.command_fails_like( + ["pg_verifybackup", backup_path], + r"WAL parsing failed for timeline 1", + "corrupt WAL file causes failure", + ) + + # Check that WAL-Ranges has correct values with a history file and + # a timeline > 1. Rather than plugging in a new standby, do a + # self-promotion of this node. + primary.stop() + primary.append_conf("", filename="standby.signal") + primary.start() + primary.promote() + primary.safe_sql("SELECT pg_switch_wal()") + backup_path2 = str(tmp_path / "test_tli") + # The base backup run below does a checkpoint, that removes the first + # segment of the current timeline. + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup_path2, + "--no-sync", + "--checkpoint", + "fast", + ], + "base backup 2 ok", + ) + primary.command_ok( + ["pg_verifybackup", backup_path2], "valid base backup with timeline > 1" + ) + + # Test WAL verification for a tar-format backup with a separate pg_wal.tar, + # as produced by pg_basebackup --format=tar --wal-method=stream. + backup_path3 = str(tmp_path / "test_tar_wal") + primary.command_ok( + [ + "pg_basebackup", + "--pgdata", + backup_path3, + "--no-sync", + "--format", + "tar", + "--checkpoint", + "fast", + ], + "tar backup with separate pg_wal.tar", + ) + primary.command_ok( + ["pg_verifybackup", backup_path3], + "WAL verification succeeds with separate pg_wal.tar", + ) diff --git a/src/bin/pg_verifybackup/pyt/test_008_untar.py b/src/bin/pg_verifybackup/pyt/test_008_untar.py new file mode 100644 index 00000000000..2ba289d4d3d --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_008_untar.py @@ -0,0 +1,156 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_verifybackup against server-side (tar-format) backups.""" + +import os +import shutil +import subprocess + +from pypg.util import short_tempdir + + +def _have_pg_config_define(define): + """Return True if pg_config.h contains the given #define line.""" + try: + out = subprocess.run( + ["pg_config", "--includedir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return False + header = os.path.join(out, "pg_config.h") + try: + with open(header, encoding="utf-8", errors="replace") as fh: + return define in fh.read() + except OSError: + return False + + +def _slurp_dir(path): + """Return directory entries, including '.' and '..'.""" + return [".", ".."] + os.listdir(path) + + +# This test case aims to verify that server-side backups and server-side +# backup compression work properly, and it also aims to verify that +# pg_verifybackup can verify a base backup that didn't start out in plain +# format. +def test_008_untar(create_pg): + primary = create_pg("primary", allows_streaming=True) + + # Create file with some random data and an arbitrary size, useful to check + # the solidity of the compression and decompression logic. The size of the + # file is chosen to be around 640kB. This has proven to be large enough to + # detect some issues related to LZ4, and low enough to not impact the + # runtime of the test significantly. + junk_data = primary.safe_sql( + "SELECT string_agg(encode(sha256(i::bytea), 'hex'), '') " + "FROM generate_series(1, 10240) s(i);" + ) + data_dir = primary.data_dir + junk_file = os.path.join(data_dir, "junk") + with open(junk_file, "w", encoding="utf-8") as jf: + jf.write(junk_data) + + # Create a tablespace directory. + source_ts_path = short_tempdir() + + # Create a tablespace with table in it. CREATE TABLESPACE cannot run + # inside a transaction block, so issue each statement separately on the + # cached session rather than as one multi-statement implicit transaction. + primary.safe_sql(f"CREATE TABLESPACE regress_ts1 LOCATION '{source_ts_path}';") + primary.safe_sql("SELECT oid FROM pg_tablespace WHERE spcname = 'regress_ts1';") + primary.safe_sql("CREATE TABLE regress_tbl1(i int) TABLESPACE regress_ts1;") + primary.safe_sql("INSERT INTO regress_tbl1 VALUES(generate_series(1,5));") + tsoid = primary.safe_sql( + "SELECT oid FROM pg_tablespace WHERE spcname = 'regress_ts1'" + ) + + backup_path = os.path.join(primary.backup_dir, "server-backup") + + test_configuration = [ + { + "compression_method": "none", + "backup_flags": [], + "backup_archive": ["base.tar", f"{tsoid}.tar"], + "enabled": True, + }, + { + "compression_method": "gzip", + "backup_flags": ["--compress", "server-gzip"], + "backup_archive": ["base.tar.gz", f"{tsoid}.tar.gz"], + "enabled": _have_pg_config_define("#define HAVE_LIBZ 1"), + }, + { + "compression_method": "lz4", + "backup_flags": ["--compress", "server-lz4"], + "backup_archive": ["base.tar.lz4", f"{tsoid}.tar.lz4"], + "enabled": _have_pg_config_define("#define USE_LZ4 1"), + }, + { + "compression_method": "lz4", + "backup_flags": ["--compress", "server-lz4:5"], + "backup_archive": ["base.tar.lz4", f"{tsoid}.tar.lz4"], + "enabled": _have_pg_config_define("#define USE_LZ4 1"), + }, + { + "compression_method": "zstd", + "backup_flags": ["--compress", "server-zstd"], + "backup_archive": ["base.tar.zst", f"{tsoid}.tar.zst"], + "enabled": _have_pg_config_define("#define USE_ZSTD 1"), + }, + { + "compression_method": "zstd", + "backup_flags": ["--compress", "server-zstd:level=1,long"], + "backup_archive": ["base.tar.zst", f"{tsoid}.tar.zst"], + "enabled": _have_pg_config_define("#define USE_ZSTD 1"), + }, + ] + + for tc in test_configuration: + method = tc["compression_method"] + + if not tc["enabled"]: + print(f"# skipping: {method} compression not supported by this build") + continue + # A configuration could also be skipped when its decompress_program is + # unavailable, but none of the configurations above set one, so that + # case never fires. + + # Take a server-side backup. + primary.command_ok( + [ + "pg_basebackup", + "--no-sync", + "--checkpoint", + "fast", + "--target", + f"server:{backup_path}", + "--wal-method", + "fetch", + *tc["backup_flags"], + ], + f"server side backup, compression {method}", + ) + + # Verify that the we got the files we expected. + backup_files = ",".join( + sorted(e for e in _slurp_dir(backup_path) if e not in (".", "..")) + ) + expected_backup_files = ",".join( + sorted(["backup_manifest", *tc["backup_archive"]]) + ) + assert ( + backup_files == expected_backup_files + ), f"found expected backup files, compression {method}" + + # Verify tar backup. + primary.command_ok( + ["pg_verifybackup", "--exit-on-error", backup_path], + f"verify backup, compression {method}", + ) + + # Cleanup. + shutil.rmtree(backup_path) diff --git a/src/bin/pg_verifybackup/pyt/test_009_extract.py b/src/bin/pg_verifybackup/pyt/test_009_extract.py new file mode 100644 index 00000000000..a5f7ed8c413 --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_009_extract.py @@ -0,0 +1,111 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test server-side extraction of compressed backups, then verify them.""" + +import os +import re +import shutil +import subprocess + + +def _have_pg_config_define(define): + """Return True if pg_config.h contains the given #define line.""" + try: + out = subprocess.run( + ["pg_config", "--includedir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return False + header = os.path.join(out, "pg_config.h") + try: + with open(header, encoding="utf-8", errors="replace") as fh: + return define in fh.read() + except OSError: + return False + + +def test_009_extract(create_pg): + """Verify that the client can decompress and extract a server-side backup.""" + primary = create_pg("primary", allows_streaming=True) + + test_configuration = [ + { + "compression_method": "none", + "backup_flags": [], + "enabled": True, + }, + { + "compression_method": "gzip", + "backup_flags": ["--compress", "server-gzip:5"], + "enabled": _have_pg_config_define("#define HAVE_LIBZ 1"), + }, + { + "compression_method": "lz4", + "backup_flags": ["--compress", "server-lz4:5"], + "enabled": _have_pg_config_define("#define USE_LZ4 1"), + }, + { + "compression_method": "zstd", + "backup_flags": ["--compress", "server-zstd:5"], + "enabled": _have_pg_config_define("#define USE_ZSTD 1"), + }, + { + "compression_method": "parallel zstd", + "backup_flags": ["--compress", "server-zstd:workers=3"], + "enabled": _have_pg_config_define("#define USE_ZSTD 1"), + "possibly_unsupported": r"could not set compression worker count to 3: " + r"Unsupported parameter", + }, + ] + + for tc in test_configuration: + backup_path = os.path.join(primary.backup_dir, "extract_backup") + method = tc["compression_method"] + + if not tc["enabled"]: + print(f"# skipping: {method} compression not supported by this build") + continue + + # A backup with a valid compression method should work. + backup = [ + "pg_basebackup", + "--pgdata", + backup_path, + "--wal-method", + "fetch", + "--no-sync", + "--checkpoint", + "fast", + "--format", + "plain", + *tc["backup_flags"], + ] + result = primary.pg_bin.result(backup) + if result.stdout: + print("# standard output was:\n" + result.stdout) + if result.stderr: + print("# standard error was:\n" + result.stderr) + + skipped = False + if ( + result.returncode != 0 + and tc.get("possibly_unsupported") + and re.search(tc["possibly_unsupported"], result.stderr) + ): + print(f"# skipping: compression with {method} not supported by this build") + skipped = True + else: + assert result.returncode == 0, f"backup done, compression {method}" + + if not skipped: + # Make sure that it verifies OK. + primary.command_ok( + ["pg_verifybackup", "--exit-on-error", backup_path], + f'backup verified, compression method "{method}"', + ) + + # Remove backup immediately to save disk space. + shutil.rmtree(backup_path, ignore_errors=True) diff --git a/src/bin/pg_verifybackup/pyt/test_010_client_untar.py b/src/bin/pg_verifybackup/pyt/test_010_client_untar.py new file mode 100644 index 00000000000..de6e455851d --- /dev/null +++ b/src/bin/pg_verifybackup/pyt/test_010_client_untar.py @@ -0,0 +1,166 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_verifybackup against client-side compressed (tar-format) backups.""" + +import os +import re +import shutil +import subprocess + + +# Compression-method tests gate on the HAVE_LIBZ / USE_LZ4 / USE_ZSTD defines. +# We probe the installed pg_config.h at runtime and skip those cases when a +# compression method is not supported by this build. + + +def _have_pg_config_define(define): + """Return True if pg_config.h contains the given #define line.""" + try: + out = subprocess.run( + ["pg_config", "--includedir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return False + header = os.path.join(out, "pg_config.h") + try: + with open(header, encoding="utf-8", errors="replace") as fh: + return define in fh.read() + except OSError: + return False + + +HAVE_LIBZ = _have_pg_config_define("#define HAVE_LIBZ 1") +USE_LZ4 = _have_pg_config_define("#define USE_LZ4 1") +USE_ZSTD = _have_pg_config_define("#define USE_ZSTD 1") + + +def test_010_client_untar(create_pg): + """Verify client-side backup compression and tar-format verification. + + This test case aims to verify that client-side backup compression works + properly, and it also aims to verify that pg_verifybackup can verify a base + backup that didn't start out in plain format. + """ + primary = create_pg("primary", allows_streaming=True) + + # Create file with some random data and an arbitrary size, useful to check + # the solidity of the compression and decompression logic. The size of the + # file is chosen to be around 640kB. This has proven to be large enough to + # detect some issues related to LZ4, and low enough to not impact the + # runtime of the test significantly. + junk_data = primary.safe_sql( + """ + SELECT string_agg(encode(sha256(i::bytea), 'hex'), '') + FROM generate_series(1, 10240) s(i);""" + ) + data_dir = primary.data_dir + junk_file = os.path.join(data_dir, "junk") + with open(junk_file, "w", encoding="utf-8") as jf: + jf.write(junk_data) + + backup_path = os.path.join(primary.backup_dir, "client-backup") + + test_configuration = [ + { + "compression_method": "none", + "backup_flags": [], + "backup_archive": "base.tar", + "enabled": True, + }, + { + "compression_method": "gzip", + "backup_flags": ["--compress", "client-gzip:5"], + "backup_archive": "base.tar.gz", + "enabled": HAVE_LIBZ, + }, + { + "compression_method": "lz4", + "backup_flags": ["--compress", "client-lz4:5"], + "backup_archive": "base.tar.lz4", + "enabled": USE_LZ4, + }, + { + "compression_method": "lz4", + "backup_flags": ["--compress", "client-lz4:1"], + "backup_archive": "base.tar.lz4", + "enabled": USE_LZ4, + }, + { + "compression_method": "zstd", + "backup_flags": ["--compress", "client-zstd:5"], + "backup_archive": "base.tar.zst", + "enabled": USE_ZSTD, + }, + { + "compression_method": "zstd", + "backup_flags": ["--compress", "client-zstd:level=1,long"], + "backup_archive": "base.tar.zst", + "enabled": USE_ZSTD, + }, + { + "compression_method": "parallel zstd", + "backup_flags": ["--compress", "client-zstd:workers=3"], + "backup_archive": "base.tar.zst", + "enabled": USE_ZSTD, + "possibly_unsupported": r"could not set compression worker count to 3: " + r"Unsupported parameter", + }, + ] + + for tc in test_configuration: + method = tc["compression_method"] + + if not tc["enabled"]: + # skip "$method compression not supported by this build" + continue + + # Take a client-side backup. + backup = primary.pg_bin.result( + [ + "pg_basebackup", + "--no-sync", + "--pgdata", + backup_path, + "--wal-method", + "fetch", + "--checkpoint", + "fast", + "--format", + "tar", + *tc["backup_flags"], + ] + ) + if backup.stdout != "": + print("# standard output was:\n" + backup.stdout) + if backup.stderr != "": + print("# standard error was:\n" + backup.stderr) + + if ( + backup.returncode != 0 + and tc.get("possibly_unsupported") + and re.search(tc["possibly_unsupported"], backup.stderr) + ): + # skip "compression with $method not supported by this build" + continue + assert backup.returncode == 0, f"client side backup, compression {method}" + + # Verify that the we got the files we expected. + backup_files = ",".join(sorted(os.listdir(backup_path))) + expected_backup_files = ",".join( + sorted(["backup_manifest", tc["backup_archive"]]) + ) + assert ( + backup_files == expected_backup_files + ), f"found expected backup files, compression {method}" + + # Verify tar backup. + primary.command_ok( + ["pg_verifybackup", "--exit-on-error", backup_path], + f"verify backup, compression {method}", + ) + + # Cleanup. + shutil.rmtree(backup_path) From 9698edd3f604f24e3a8d86740c267d48242c36ad Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:07 -0400 Subject: [PATCH 08/18] python tests: pytest suites for pg_dump and pg_upgrade bin/pg_dump, bin/pg_upgrade and the test_pg_dump module. pg_upgrade delegates dump adjustment to a small Perl CLI wrapper (pyt/adjust_dump.pl). --- src/bin/pg_dump/meson.build | 18 + src/bin/pg_dump/pyt/test_001_basic.py | 404 ++ src/bin/pg_dump/pyt/test_002_pg_dump.py | 5577 +++++++++++++++++ .../pyt/test_003_pg_dump_with_server.py | 54 + .../pg_dump/pyt/test_004_pg_dump_parallel.py | 115 + .../pyt/test_005_pg_dump_filterfile.py | 957 +++ .../pg_dump/pyt/test_006_pg_dump_compress.py | 665 ++ src/bin/pg_dump/pyt/test_007_pg_dumpall.py | 912 +++ src/bin/pg_dump/pyt/test_010_dump_connstr.py | 459 ++ src/bin/pg_upgrade/meson.build | 12 + src/bin/pg_upgrade/pyt/adjust_dump.pl | 58 + src/bin/pg_upgrade/pyt/test_001_basic.py | 9 + src/bin/pg_upgrade/pyt/test_002_pg_upgrade.py | 661 ++ .../pg_upgrade/pyt/test_003_logical_slots.py | 248 + .../pg_upgrade/pyt/test_004_subscription.py | 486 ++ .../pyt/test_005_char_signedness.py | 119 + .../pg_upgrade/pyt/test_006_transfer_modes.py | 219 + .../pyt/test_007_multixact_conversion.py | 367 ++ .../pyt/test_008_extension_control_path.py | 145 + src/test/modules/test_pg_dump/meson.build | 5 + .../modules/test_pg_dump/pyt/test_001_base.py | 1057 ++++ 21 files changed, 12547 insertions(+) create mode 100644 src/bin/pg_dump/pyt/test_001_basic.py create mode 100644 src/bin/pg_dump/pyt/test_002_pg_dump.py create mode 100644 src/bin/pg_dump/pyt/test_003_pg_dump_with_server.py create mode 100644 src/bin/pg_dump/pyt/test_004_pg_dump_parallel.py create mode 100644 src/bin/pg_dump/pyt/test_005_pg_dump_filterfile.py create mode 100644 src/bin/pg_dump/pyt/test_006_pg_dump_compress.py create mode 100644 src/bin/pg_dump/pyt/test_007_pg_dumpall.py create mode 100644 src/bin/pg_dump/pyt/test_010_dump_connstr.py create mode 100644 src/bin/pg_upgrade/pyt/adjust_dump.pl create mode 100644 src/bin/pg_upgrade/pyt/test_001_basic.py create mode 100644 src/bin/pg_upgrade/pyt/test_002_pg_upgrade.py create mode 100644 src/bin/pg_upgrade/pyt/test_003_logical_slots.py create mode 100644 src/bin/pg_upgrade/pyt/test_004_subscription.py create mode 100644 src/bin/pg_upgrade/pyt/test_005_char_signedness.py create mode 100644 src/bin/pg_upgrade/pyt/test_006_transfer_modes.py create mode 100644 src/bin/pg_upgrade/pyt/test_007_multixact_conversion.py create mode 100644 src/bin/pg_upgrade/pyt/test_008_extension_control_path.py create mode 100644 src/test/modules/test_pg_dump/pyt/test_001_base.py diff --git a/src/bin/pg_dump/meson.build b/src/bin/pg_dump/meson.build index 7c9a475963b..92e794b7bd7 100644 --- a/src/bin/pg_dump/meson.build +++ b/src/bin/pg_dump/meson.build @@ -107,6 +107,24 @@ tests += { 't/010_dump_connstr.pl', ], }, + 'pytest': { + 'env': { + 'GZIP_PROGRAM': gzip.found() ? gzip.full_path() : '', + 'LZ4': program_lz4.found() ? program_lz4.full_path() : '', + 'ZSTD': program_zstd.found() ? program_zstd.full_path() : '', + 'with_icu': icu.found() ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_pg_dump.py', + 'pyt/test_003_pg_dump_with_server.py', + 'pyt/test_004_pg_dump_parallel.py', + 'pyt/test_005_pg_dump_filterfile.py', + 'pyt/test_006_pg_dump_compress.py', + 'pyt/test_007_pg_dumpall.py', + 'pyt/test_010_dump_connstr.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_dump/pyt/test_001_basic.py b/src/bin/pg_dump/pyt/test_001_basic.py new file mode 100644 index 00000000000..99d15bd5935 --- /dev/null +++ b/src/bin/pg_dump/pyt/test_001_basic.py @@ -0,0 +1,404 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for pg_dump / pg_restore / pg_dumpall command-line handling. + +Tests pg_dump / pg_restore / pg_dumpall command-line option handling and +error messages. None of these cases require a running server (they all +exercise invalid options and disallowed option combinations), so the +programs under test are run as subprocesses through pg_bin. +""" + +import os +import re +import subprocess + +import pytest + + +def _have_pg_config_define(define): + """Return True if the installed pg_config.h contains the given #define.""" + try: + out = subprocess.run( + ["pg_config", "--includedir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return False + header = os.path.join(out, "pg_config.h") + try: + with open(header, encoding="utf-8", errors="replace") as fh: + return define in fh.read() + except OSError: + return False + + +######################################### +# Basic checks + + +def test_program_help_version_options(pg_bin): + for prog in ("pg_dump", "pg_restore", "pg_dumpall"): + pg_bin.program_help_ok(prog) + pg_bin.program_version_ok(prog) + pg_bin.program_options_handling_ok(prog) + + +######################################### +# Test various invalid options and disallowed combinations. +# Doesn't require a PG instance to be set up. +# +# Each entry: (name, argv, expected stderr regexp). Most cases match a +# literal string, so we use re.escape() on the literal text. +# A few cases use real regexp patterns, kept verbatim (marked raw=True). + +# (name, argv, pattern, raw) +_CASES = [ + ( + "pg_dump: too many command-line arguments", + ["pg_dump", "qqq", "abc"], + 'pg_dump: error: too many command-line arguments (first is "abc")', + ), + ( + "pg_restore: too many command-line arguments", + ["pg_restore", "qqq", "abc"], + 'pg_restore: error: too many command-line arguments (first is "abc")', + ), + ( + "pg_dumpall: too many command-line arguments", + ["pg_dumpall", "qqq", "abc"], + 'pg_dumpall: error: too many command-line arguments (first is "qqq")', + ), + ( + "pg_dump: options -a/--data-only and -s/--schema-only cannot be used together", + ["pg_dump", "-s", "-a"], + "pg_dump: error: options -a/--data-only and -s/--schema-only cannot be used together", + ), + ( + "pg_dump: error: options -s/--schema-only and --statistics-only cannot be used together", + ["pg_dump", "-s", "--statistics-only"], + "pg_dump: error: options -s/--schema-only and --statistics-only cannot be used together", + ), + ( + "pg_dump: error: options -a/--data-only and --statistics-only cannot be used together", + ["pg_dump", "-a", "--statistics-only"], + "pg_dump: error: options -a/--data-only and --statistics-only cannot be used together", + ), + ( + "pg_dump: options --include-foreign-data and -s/--schema-only cannot be used together", + ["pg_dump", "-s", "--include-foreign-data=xxx"], + "pg_dump: error: options --include-foreign-data and -s/--schema-only cannot be used together", + ), + ( + "pg_dump: options --statistics-only and --no-statistics cannot be used together", + ["pg_dump", "--statistics-only", "--no-statistics"], + "pg_dump: error: options --statistics-only and --no-statistics cannot be used together", + ), + ( + "pg_dump: option --include-foreign-data is not supported with parallel backup", + ["pg_dump", "-j2", "--include-foreign-data=xxx"], + "pg_dump: error: option --include-foreign-data is not supported with parallel backup", + ), + ( + "pg_restore: error: one of -d/--dbname and -f/--file must be specified", + ["pg_restore"], + "pg_restore: error: one of -d/--dbname and -f/--file must be specified", + ), + ( + "pg_restore: options -a/--data-only and -s/--schema-only cannot be used together", + ["pg_restore", "-s", "-a", "-f -"], + "pg_restore: error: options -a/--data-only and -s/--schema-only cannot be used together", + ), + ( + "pg_restore: options -d/--dbname and -f/--file cannot be used together", + ["pg_restore", "-d", "xxx", "-f", "xxx"], + "pg_restore: error: options -d/--dbname and -f/--file cannot be used together", + ), + ( + "pg_dump: options -c/--clean and -a/--data-only cannot be used together", + ["pg_dump", "-c", "-a"], + "pg_dump: error: options -c/--clean and -a/--data-only cannot be used together", + ), + ( + "pg_dumpall: options -c/--clean and -a/--data-only cannot be used together", + ["pg_dumpall", "-c", "-a"], + "pg_dumpall: error: options -c/--clean and -a/--data-only cannot be used together", + ), + ( + "pg_restore: options -c/--clean and -a/--data-only cannot be used together", + ["pg_restore", "-c", "-a", "-f -"], + "pg_restore: error: options -c/--clean and -a/--data-only cannot be used together", + ), + ( + "pg_dump: option --if-exists requires option -c/--clean", + ["pg_dump", "--if-exists"], + "pg_dump: error: option --if-exists requires option -c/--clean", + ), + ( + "pg_dump: parallel backup only supported by the directory format", + ["pg_dump", "-j3"], + "pg_dump: error: parallel backup only supported by the directory format", + ), + # Note the trailing whitespace for the value of --jobs, that is valid. + ( + "pg_dump: -j/--jobs must be in range", + ["pg_dump", "-j", "-1 "], + "pg_dump: error: -j/--jobs must be in range", + ), + ( + "pg_dump: invalid output format", + ["pg_dump", "-F", "garbage"], + "pg_dump: error: invalid output format", + ), + ( + "pg_restore: -j/--jobs must be in range", + ["pg_restore", "-j", "-1", "-f -"], + "pg_restore: error: -j/--jobs must be in range", + ), + ( + "pg_restore: cannot specify both --single-transaction and multiple jobs", + ["pg_restore", "--single-transaction", "-j3", "-f -"], + "pg_restore: error: cannot specify both --single-transaction and multiple jobs", + ), + ( + "pg_dump: invalid --compress", + ["pg_dump", "--compress", "garbage"], + "pg_dump: error: unrecognized compression algorithm", + ), + ( + "pg_dump: invalid compression specification: compression algorithm " + '"none" does not accept a compression level', + ["pg_dump", "--compress", "none:1"], + "pg_dump: error: invalid compression specification: compression algorithm " + '"none" does not accept a compression level', + ), +] + + +@pytest.mark.parametrize( + "name,argv,pattern", + _CASES, + ids=[c[0] for c in _CASES], +) +def test_option_errors(pg_bin, name, argv, pattern): + pg_bin.command_fails_like(argv, re.escape(pattern), name) + + +# Cases gated on libz support. +_LIBZ_CASES = [ + ( + "pg_dump: invalid compression specification: must be in range", + ["pg_dump", "-Z", "15"], + "pg_dump: error: invalid compression specification: compression algorithm " + '"gzip" expects a compression level between 1 and 9 (default at -1)', + ), + ( + "pg_dump: compression is not supported by tar archive format", + ["pg_dump", "--compress", "1", "--format", "tar"], + "pg_dump: error: compression is not supported by tar archive format", + ), + ( + "pg_dump: invalid compression specification: must be an integer", + ["pg_dump", "-Z", "gzip:nonInt"], + "pg_dump: error: invalid compression specification: unrecognized " + 'compression option: "nonInt"', + ), +] + +# Cases used when libz is NOT available. +_NO_LIBZ_CASES = [ + # --jobs > 1 forces an error with tar format. + ( + "pg_dump: warning: parallel backup not supported by tar format", + ["pg_dump", "--format", "tar", "-j3"], + "pg_dump: error: parallel backup only supported by the directory format", + ), + ( + "pg_dump: invalid compression specification: must be an integer", + ["pg_dump", "-Z", "gzip:nonInt", "--format", "tar", "-j2"], + "pg_dump: error: invalid compression specification: unrecognized compression option", + ), +] + + +@pytest.mark.parametrize( + "name,argv,pattern", + _LIBZ_CASES, + ids=[c[0] for c in _LIBZ_CASES], +) +def test_libz_option_errors(pg_bin, name, argv, pattern): + if not _have_pg_config_define("#define HAVE_LIBZ 1"): + pytest.skip("build does not have libz support") + pg_bin.command_fails_like(argv, re.escape(pattern), name) + + +@pytest.mark.parametrize( + "name,argv,pattern", + _NO_LIBZ_CASES, + ids=[c[0] for c in _NO_LIBZ_CASES], +) +def test_no_libz_option_errors(pg_bin, name, argv, pattern): + if _have_pg_config_define("#define HAVE_LIBZ 1"): + pytest.skip("build has libz support") + pg_bin.command_fails_like(argv, re.escape(pattern), name) + + +# Remaining option-error cases (those after the libz-conditional block). +_MORE_CASES = [ + ( + "pg_dump: --extra-float-digits must be in range", + ["pg_dump", "--extra-float-digits", "-16"], + "pg_dump: error: --extra-float-digits must be in range", + ), + ( + "pg_dump: --rows-per-insert must be in range", + ["pg_dump", "--rows-per-insert", "0"], + "pg_dump: error: --rows-per-insert must be in range", + ), + ( + "pg_restore: option --if-exists requires option -c/--clean", + ["pg_restore", "--if-exists", "-f -"], + "pg_restore: error: option --if-exists requires option -c/--clean", + ), + ( + "pg_restore: unrecognized archive format", + ["pg_restore", "-f -", "-F", "garbage"], + 'pg_restore: error: unrecognized archive format "garbage";', + ), + ( + "pg_restore: empty archive format", + ["pg_restore", "-f -", "-F", ""], + 'pg_restore: error: unrecognized archive format "";', + ), + ( + "pg_dump: --on-conflict-do-nothing requires --inserts, --rows-per-insert, --column-inserts", + ["pg_dump", "--on-conflict-do-nothing"], + "pg_dump: error: option --on-conflict-do-nothing requires option " + "--inserts, --rows-per-insert, or --column-inserts", + ), + # pg_dumpall command-line argument checks + ( + "pg_dumpall: options -g/--globals-only and -r/--roles-only cannot be used together", + ["pg_dumpall", "-g", "-r"], + "pg_dumpall: error: options -g/--globals-only and -r/--roles-only cannot be used together", + ), + ( + "pg_dumpall: options -g/--globals-only and -t/--tablespaces-only cannot be used together", + ["pg_dumpall", "-g", "-t"], + "pg_dumpall: error: options -g/--globals-only and -t/--tablespaces-only cannot be used together", + ), + ( + "pg_dumpall: options -r/--roles-only and -t/--tablespaces-only cannot be used together", + ["pg_dumpall", "-r", "-t"], + "pg_dumpall: error: options -r/--roles-only and -t/--tablespaces-only cannot be used together", + ), + ( + "pg_dumpall: option --if-exists requires option -c/--clean", + ["pg_dumpall", "--if-exists"], + "pg_dumpall: error: option --if-exists requires option -c/--clean", + ), + ( + "pg_restore: options -C/--create and -1/--single-transaction cannot be used together", + ["pg_restore", "-C", "-1", "-f -"], + "pg_restore: error: options -C/--create and -1/--single-transaction cannot be used together", + ), + # also fails for -r and -t, but it seems pointless to add more tests for those. + ( + "pg_dumpall: options --exclude-database and -g/--globals-only cannot be used together", + ["pg_dumpall", "--exclude-database=foo", "--globals-only"], + "pg_dumpall: error: options --exclude-database and -g/--globals-only cannot be used together", + ), + ( + "pg_dumpall: options -a/--data-only and --no-data cannot be used together", + ["pg_dumpall", "-a", "--no-data"], + "pg_dumpall: error: options -a/--data-only and --no-data cannot be used together", + ), + ( + "pg_dumpall: options -s/--schema-only and --no-schema cannot be used together", + ["pg_dumpall", "-s", "--no-schema"], + "pg_dumpall: error: options -s/--schema-only and --no-schema cannot be used together", + ), + ( + "pg_dumpall: options --statistics-only and --no-statistics cannot be used together", + ["pg_dumpall", "--statistics-only", "--no-statistics"], + "pg_dumpall: error: options --statistics-only and --no-statistics cannot be used together", + ), + ( + "pg_dumpall: options --statistics and --no-statistics cannot be used together", + ["pg_dumpall", "--statistics", "--no-statistics"], + "pg_dumpall: error: options --statistics and --no-statistics cannot be used together", + ), + ( + "pg_dumpall: options --statistics and -t/--tablespaces-only cannot be used together", + ["pg_dumpall", "--statistics", "--tablespaces-only"], + "pg_dumpall: error: options --statistics and -t/--tablespaces-only cannot be used together", + ), + ( + "pg_dumpall: unrecognized output format", + ["pg_dumpall", "--format", "x"], + 'pg_dumpall: error: unrecognized output format "x";', + ), + ( + "pg_dumpall: --restrict-key can only be used with plain dump format", + ["pg_dumpall", "--format", "d", "--restrict-key=uu", "-f dumpfile"], + "pg_dumpall: error: option --restrict-key can only be used with --format=plain", + ), + ( + "pg_dumpall: --clean and -g/--globals-only cannot be used together in non-text dump", + ["pg_dumpall", "--format", "d", "--globals-only", "--clean", "-f", "dumpfile"], + "pg_dumpall: error: options --clean and -g/--globals-only cannot be used together " + "in non-text dump", + ), + ( + "pg_dumpall: non-plain format requires --file option", + ["pg_dumpall", "--format", "d"], + "pg_dumpall: error: option -F/--format=d|c|t requires option -f/--file", + ), + ( + "pg_restore: options --exclude-database and -g/--globals-only cannot be used together", + ["pg_restore", "--exclude-database=foo", "--globals-only", "-d", "xxx"], + "pg_restore: error: options --exclude-database and -g/--globals-only cannot be used together", + ), + ( + "pg_restore: error: options -a/--data-only and -g/--globals-only cannot be used together", + ["pg_restore", "--data-only", "--globals-only", "-d", "xxx"], + "pg_restore: error: options -a/--data-only and -g/--globals-only cannot be used together", + ), + ( + "pg_restore: error: options -g/--globals-only and -s/--schema-only cannot be used together", + ["pg_restore", "--schema-only", "--globals-only", "-d", "xxx"], + "pg_restore: error: options -g/--globals-only and -s/--schema-only cannot be used together", + ), + ( + "pg_restore: error: options -g/--globals-only and --statistics-only cannot be used together", + ["pg_restore", "--statistics-only", "--globals-only", "-d", "xxx"], + "pg_restore: error: options -g/--globals-only and --statistics-only cannot be used together", + ), + ( + "When option --exclude-database is used in pg_restore with dump of pg_dump", + ["pg_restore", "--exclude-database=foo", "-d", "xxx", "dumpdir"], + "pg_restore: error: option --exclude-database can be used only when restoring " + "an archive created by pg_dumpall", + ), + ( + "When option --globals-only is used in pg_restore with the dump of pg_dump", + ["pg_restore", "--globals-only", "-d", "xxx", "dumpdir"], + "pg_restore: error: option -g/--globals-only can be used only when restoring " + "an archive created by pg_dumpall", + ), + ( + "options --no-globals and --globals-only cannot be used together", + ["pg_restore", "--globals-only", "--no-globals", "-d", "xxx", "dumpdir"], + "pg_restore: error: options -g/--globals-only and --no-globals cannot be used together", + ), +] + + +@pytest.mark.parametrize( + "name,argv,pattern", + _MORE_CASES, + ids=[c[0] for c in _MORE_CASES], +) +def test_more_option_errors(pg_bin, name, argv, pattern): + pg_bin.command_fails_like(argv, re.escape(pattern), name) diff --git a/src/bin/pg_dump/pyt/test_002_pg_dump.py b/src/bin/pg_dump/pyt/test_002_pg_dump.py new file mode 100644 index 00000000000..6ebac3c1878 --- /dev/null +++ b/src/bin/pg_dump/pyt/test_002_pg_dump.py @@ -0,0 +1,5577 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""The big shared pg_dump test matrix. + +The big shared tests / pgdump_runs matrix. Exercises pg_dump (plus +pg_dumpall / pg_restore) using a set of named "runs" (pg_dump invocations with +different options) and named "tests" (each with a regexp and like/unlike sets +of run names stating which runs the regexp is expected to match). + +For each run the dump output file is slurped; for every test, a run listed in +the test's "like" (and not in its "unlike") must match the regexp, and every +other run must not match it. A handful of standalone command_ok / +command_fails_like cases run before the matrix loop. + +pg_dump / pg_restore / pg_dumpall are the binaries under test and are run as +subprocesses through the node's pg_bin (PGHOST/PGPORT point at the server). +The seed SQL the test itself runs is executed in-process via safe_sql. + +The regexp bodies are compiled by _qr(), which supports \\Q..\\E literal spans +(escaped via re.escape) and verbose/multiline/dotall flags. +""" + +import os +import re +import subprocess + +from pypg.util import TIMEOUT_DEFAULT, slurp_file + + +# -------------------------------------------------------------------------- +# Regex pattern compilation helpers. +# -------------------------------------------------------------------------- + +# Verbose, multiline and dotall flag combinations used by the patterns below. +# Most patterns are verbose+multiline; some add dotall; a few are multiline +# only. +_XM = re.VERBOSE | re.MULTILINE +_XMS = re.VERBOSE | re.MULTILINE | re.DOTALL +_M = re.MULTILINE +_S = re.DOTALL + + +def _qr(pattern, flags=0): + r"""Compile a regex body (which may contain \Q..\E) into a Python regex. + + Text inside \Q..\E spans is taken literally (re.escape); text outside is + passed through unchanged (already valid Python regex syntax under the same + flags). An unterminated \Q runs to the end of the pattern. + """ + # The pattern bodies treat "\/" as an escaped "/" delimiter standing for a + # literal "/". Normalize it away (both inside and outside \Q..\E spans, + # where it would otherwise survive re.escape as a literal backslash-slash). + pattern = pattern.replace(r"\/", "/") + + out = [] + i = 0 + n = len(pattern) + while i < n: + q = pattern.find(r"\Q", i) + if q < 0: + out.append(pattern[i:]) + break + out.append(pattern[i:q]) + e = pattern.find(r"\E", q + 2) + if e < 0: + out.append(re.escape(pattern[q + 2 :])) + i = n + else: + out.append(re.escape(pattern[q + 2 : e])) + i = e + 2 + return re.compile("".join(out), flags) + + +def _have_pg_config_define(define): + """Return True if the installed pg_config.h contains the given #define.""" + try: + out = subprocess.run( + ["pg_config", "--includedir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return False + header = os.path.join(out, "pg_config.h") + try: + with open(header, encoding="utf-8", errors="replace") as fh: + return define in fh.read() + except OSError: + return False + + +def _have_icu_configured(): + """Return True if the build was configured --with-icu. + + Determined by probing pg_config --configure for the --with-icu flag. + """ + try: + out = subprocess.run( + ["pg_config", "--configure"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout + except (OSError, subprocess.SubprocessError): + return False + return "--with-icu" in out + + +# Lock-wait timeout used by a couple of runs (ms): 1000 * the default +# test timeout. +_LOCK_WAIT_TIMEOUT = str(1000 * TIMEOUT_DEFAULT) + + +# -------------------------------------------------------------------------- +# Convenience run-name sets (dump_test_schema_runs and full_runs). +# -------------------------------------------------------------------------- + +# Tests which target the 'dump_test' schema, specifically. +DUMP_TEST_SCHEMA_RUNS = { + "only_dump_test_schema", + "only_dump_measurement", + "test_schema_plus_large_objects", +} + +# Runs which are considered 'full' dumps by pg_dump, but with flags used to +# exclude specific items (ACLs, LOs, etc). Note schema_only_with_statistics +# is referenced here and in many like/unlike sets but is not a defined run, so +# it is a harmless no-op (it never appears as an actual run). +FULL_RUNS = { + "binary_upgrade", + "clean", + "clean_if_exists", + "createdb", + "defaults", + "exclude_dump_test_schema", + "exclude_test_table", + "exclude_test_table_data", + "exclude_measurement", + "exclude_measurement_data", + "no_toast_compression", + "no_large_objects", + "no_owner", + "no_policies", + "no_policies_restore", + "no_privs", + "no_statistics", + "no_subscriptions", + "no_subscriptions_restore", + "no_table_access_method", + "pg_dumpall_dbprivs", + "pg_dumpall_exclude", + "schema_only", + "schema_only_with_statistics", +} + + +def _pgdump_runs(tempdir, supports_gzip): + """Definition of the pg_dump runs to make. + + Each run has a dump_cmd (argv list; cmd[0] resolved in the node's bindir). + Optional keys: restore_cmd, test_key (reuse another run's like/unlike + sets), database (run against a non-default database), command_like (run a + command and check its stdout), glob_patterns (files that must exist after + the dump). + """ + return { + "binary_upgrade": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "custom", + "--file", + f"{tempdir}/binary_upgrade.dump", + "--no-password", + "--no-data", + "--sequence-data", + "--binary-upgrade", + "--statistics", + "--dbname", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--format", + "custom", + "--verbose", + "--file", + f"{tempdir}/binary_upgrade.sql", + "--statistics", + f"{tempdir}/binary_upgrade.dump", + ], + }, + "clean": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/clean.sql", + "--clean", + "--statistics", + "--dbname", + "postgres", + ], + }, + "clean_if_exists": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/clean_if_exists.sql", + "--clean", + "--if-exists", + "--encoding", + "UTF8", + "--statistics", + "postgres", + ], + }, + "column_inserts": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/column_inserts.sql", + "--data-only", + "--column-inserts", + "postgres", + ], + }, + "createdb": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/createdb.sql", + "--create", + "--no-reconnect", + "--verbose", + "--statistics", + "postgres", + ], + }, + "data_only": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/data_only.sql", + "--data-only", + "--superuser", + "test_superuser", + "--disable-triggers", + "--verbose", + "postgres", + ], + }, + "defaults": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/defaults.sql", + "--statistics", + "postgres", + ], + }, + "defaults_no_public": { + "database": "regress_pg_dump_test", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/defaults_no_public.sql", + "--statistics", + "regress_pg_dump_test", + ], + }, + "defaults_no_public_clean": { + "database": "regress_pg_dump_test", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--clean", + "--file", + f"{tempdir}/defaults_no_public_clean.sql", + "--statistics", + "regress_pg_dump_test", + ], + }, + "defaults_public_owner": { + "database": "regress_public_owner", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/defaults_public_owner.sql", + "--statistics", + "regress_public_owner", + ], + }, + # Do not use --no-sync to give test coverage for data sync. By + # default, the custom format compresses its data file when compiled + # with gzip support, and leaves them uncompressed when not. + "defaults_custom_format": { + "test_key": "defaults", + "dump_cmd": [ + "pg_dump", + "--format", + "custom", + "--file", + f"{tempdir}/defaults_custom_format.dump", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--format", + "custom", + "--file", + f"{tempdir}/defaults_custom_format.sql", + "--statistics", + f"{tempdir}/defaults_custom_format.dump", + ], + "command_like": { + "command": [ + "pg_restore", + "--list", + f"{tempdir}/defaults_custom_format.dump", + ], + "expected": re.compile( + r"Compression: gzip" if supports_gzip else r"Compression: none" + ), + "name": "data content is gzip-compressed by default if available", + }, + }, + # By default, the directory format compresses its data files when + # compiled with gzip support, and leaves them uncompressed when not. + "defaults_dir_format": { + "test_key": "defaults", + "dump_cmd": [ + "pg_dump", + "--format", + "directory", + "--file", + f"{tempdir}/defaults_dir_format", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--format", + "directory", + "--file", + f"{tempdir}/defaults_dir_format.sql", + "--statistics", + f"{tempdir}/defaults_dir_format", + ], + "command_like": { + "command": [ + "pg_restore", + "--list", + f"{tempdir}/defaults_dir_format", + ], + "expected": re.compile( + r"Compression: gzip" if supports_gzip else r"Compression: none" + ), + "name": "data content is gzip-compressed by default", + }, + "glob_patterns": [ + f"{tempdir}/defaults_dir_format/toc.dat", + f"{tempdir}/defaults_dir_format/blobs_*.toc", + ( + f"{tempdir}/defaults_dir_format/*.dat.gz" + if supports_gzip + else f"{tempdir}/defaults_dir_format/*.dat" + ), + ], + }, + "defaults_parallel": { + "test_key": "defaults", + "dump_cmd": [ + "pg_dump", + "--format", + "directory", + "--jobs", + "2", + "--file", + f"{tempdir}/defaults_parallel", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/defaults_parallel.sql", + "--statistics", + f"{tempdir}/defaults_parallel", + ], + }, + "defaults_tar_format": { + "test_key": "defaults", + "dump_cmd": [ + "pg_dump", + "--format", + "tar", + "--file", + f"{tempdir}/defaults_tar_format.tar", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--format", + "tar", + "--file", + f"{tempdir}/defaults_tar_format.sql", + "--statistics", + f"{tempdir}/defaults_tar_format.tar", + ], + }, + "exclude_dump_test_schema": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/exclude_dump_test_schema.sql", + "--exclude-schema", + "dump_test", + "--statistics", + "postgres", + ], + }, + "exclude_test_table": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/exclude_test_table.sql", + "--exclude-table", + "dump_test.test_table", + "--statistics", + "postgres", + ], + }, + "exclude_measurement": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/exclude_measurement.sql", + "--exclude-table-and-children", + "dump_test.measurement", + "--statistics", + "postgres", + ], + }, + "exclude_measurement_data": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/exclude_measurement_data.sql", + "--exclude-table-data-and-children", + "dump_test.measurement", + "--no-unlogged-table-data", + "--statistics", + "postgres", + ], + }, + "exclude_test_table_data": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/exclude_test_table_data.sql", + "--exclude-table-data", + "dump_test.test_table", + "--no-unlogged-table-data", + "--statistics", + "postgres", + ], + }, + "inserts": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/inserts.sql", + "--data-only", + "--inserts", + "postgres", + ], + }, + "pg_dumpall_globals": { + "dump_cmd": [ + "pg_dumpall", + "--verbose", + "--file", + f"{tempdir}/pg_dumpall_globals.sql", + "--globals-only", + "--no-sync", + ], + }, + "pg_dumpall_globals_clean": { + "dump_cmd": [ + "pg_dumpall", + "--file", + f"{tempdir}/pg_dumpall_globals_clean.sql", + "--globals-only", + "--clean", + "--no-sync", + ], + }, + "pg_dumpall_dbprivs": { + "dump_cmd": [ + "pg_dumpall", + "--no-sync", + "--file", + f"{tempdir}/pg_dumpall_dbprivs.sql", + "--statistics", + ], + }, + "pg_dumpall_exclude": { + "dump_cmd": [ + "pg_dumpall", + "--verbose", + "--file", + f"{tempdir}/pg_dumpall_exclude.sql", + "--exclude-database", + "*dump_test*", + "--no-sync", + "--statistics", + ], + }, + "no_toast_compression": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_toast_compression.sql", + "--no-toast-compression", + "--statistics", + "postgres", + ], + }, + "no_large_objects": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_large_objects.sql", + "--no-large-objects", + "--statistics", + "postgres", + ], + }, + "no_policies": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_policies.sql", + "--no-policies", + "--statistics", + "postgres", + ], + }, + "no_policies_restore": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "custom", + "--file", + f"{tempdir}/no_policies_restore.dump", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--format", + "custom", + "--file", + f"{tempdir}/no_policies_restore.sql", + "--no-policies", + "--statistics", + f"{tempdir}/no_policies_restore.dump", + ], + }, + "no_privs": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_privs.sql", + "--no-privileges", + "--statistics", + "postgres", + ], + }, + "no_owner": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_owner.sql", + "--no-owner", + "--statistics", + "postgres", + ], + }, + "no_subscriptions": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_subscriptions.sql", + "--no-subscriptions", + "--statistics", + "postgres", + ], + }, + "no_subscriptions_restore": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "custom", + "--file", + f"{tempdir}/no_subscriptions_restore.dump", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--format", + "custom", + "--file", + f"{tempdir}/no_subscriptions_restore.sql", + "--no-subscriptions", + "--statistics", + f"{tempdir}/no_subscriptions_restore.dump", + ], + }, + "no_table_access_method": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_table_access_method.sql", + "--no-table-access-method", + "--statistics", + "postgres", + ], + }, + "only_dump_test_schema": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/only_dump_test_schema.sql", + "--schema", + "dump_test", + "--statistics", + "postgres", + ], + }, + "only_dump_test_table": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/only_dump_test_table.sql", + "--table", + "dump_test.test_table", + "--lock-wait-timeout", + _LOCK_WAIT_TIMEOUT, + "--statistics", + "postgres", + ], + }, + "only_dump_measurement": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/only_dump_measurement.sql", + "--table-and-children", + "dump_test.measurement", + "--lock-wait-timeout", + _LOCK_WAIT_TIMEOUT, + "--statistics", + "postgres", + ], + }, + "role": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/role.sql", + "--role", + "regress_dump_test_role", + "--schema", + "dump_test_second_schema", + "--statistics", + "postgres", + ], + }, + "role_parallel": { + "test_key": "role", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "directory", + "--jobs", + "2", + "--file", + f"{tempdir}/role_parallel", + "--role", + "regress_dump_test_role", + "--schema", + "dump_test_second_schema", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/role_parallel.sql", + "--statistics", + f"{tempdir}/role_parallel", + ], + }, + "rows_per_insert": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/rows_per_insert.sql", + "--data-only", + "--rows-per-insert", + "4", + "--table", + "dump_test.test_table", + "--table", + "dump_test.test_fourth_table", + "postgres", + ], + }, + "schema_only": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "plain", + "--file", + f"{tempdir}/schema_only.sql", + "--schema-only", + "postgres", + ], + }, + "section_pre_data": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/section_pre_data.sql", + "--section", + "pre-data", + "--statistics", + "postgres", + ], + }, + "section_data": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/section_data.sql", + "--section", + "data", + "--statistics", + "postgres", + ], + }, + "section_post_data": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/section_post_data.sql", + "--section", + "post-data", + "--statistics", + "postgres", + ], + }, + "test_schema_plus_large_objects": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/test_schema_plus_large_objects.sql", + "--schema", + "dump_test", + "--large-objects", + "--no-large-objects", + "--statistics", + "postgres", + ], + }, + "no_statistics": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + f"--file={tempdir}/no_statistics.sql", + "--no-statistics", + "postgres", + ], + }, + "no_data_no_schema": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + f"--file={tempdir}/no_data_no_schema.sql", + "--no-data", + "--no-schema", + "postgres", + "--statistics", + ], + }, + "statistics_only": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + f"--file={tempdir}/statistics_only.sql", + "--statistics-only", + "postgres", + ], + }, + "no_schema": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + f"--file={tempdir}/no_schema.sql", + "--no-schema", + "--statistics", + "postgres", + ], + }, + } + + +def _tests(full, dts): + """Definition of the tests to run. + + *full* and *dts* are the FULL_RUNS and DUMP_TEST_SCHEMA_RUNS sets, passed + in so callers can pre-copy them. Each entry may have: create_order, + create_sql (seed SQL run before any dump), regexp (compiled), like/unlike + sets of run names, all_runs (matches every run), database (run against a + non-default db), collation / icu (gate on build feature), catch_all (a + documentation-only key, ignored here). + + A run listed in 'like' (or all_runs) and not in 'unlike' must match the + regexp; every other run must not. The definitions are assembled from + several part functions purely to keep each function a manageable size. + """ + tests = {} + tests.update(_tests_part1(full, dts)) + tests.update(_tests_part2(full, dts)) + tests.update(_tests_part3(full, dts)) + return tests + + +def _tests_part1(full, dts): + return { + "restrict": { + "all_runs": True, + "regexp": _qr(r"^\\restrict [a-zA-Z0-9]+$", _M), + }, + "unrestrict": { + "all_runs": True, + "regexp": _qr(r"^\\unrestrict [a-zA-Z0-9]+$", _M), + }, + "ALTER DEFAULT PRIVILEGES FOR ROLE regress_dump_test_role GRANT": { + "create_order": 14, + "create_sql": "ALTER DEFAULT PRIVILEGES\n" + "FOR ROLE regress_dump_test_role IN SCHEMA dump_test\n" + "GRANT SELECT ON TABLES TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QALTER DEFAULT PRIVILEGES \E + \QFOR ROLE regress_dump_test_role IN SCHEMA dump_test \E + \QGRANT SELECT ON TABLES TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "only_dump_measurement", + }, + }, + "ALTER DEFAULT PRIVILEGES FOR ROLE regress_dump_test_role GRANT EXECUTE ON FUNCTIONS": { + "create_order": 15, + "create_sql": "ALTER DEFAULT PRIVILEGES\n" + "FOR ROLE regress_dump_test_role IN SCHEMA dump_test\n" + "GRANT EXECUTE ON FUNCTIONS TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QALTER DEFAULT PRIVILEGES \E + \QFOR ROLE regress_dump_test_role IN SCHEMA dump_test \E + \QGRANT ALL ON FUNCTIONS TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "only_dump_measurement", + }, + }, + "ALTER DEFAULT PRIVILEGES FOR ROLE regress_dump_test_role REVOKE": { + "create_order": 55, + "create_sql": "ALTER DEFAULT PRIVILEGES\n" + "FOR ROLE regress_dump_test_role\n" + "REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;", + "regexp": _qr( + r"""^ + \QALTER DEFAULT PRIVILEGES \E + \QFOR ROLE regress_dump_test_role \E + \QREVOKE ALL ON FUNCTIONS FROM PUBLIC;\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + "unlike": {"no_privs"}, + }, + "ALTER DEFAULT PRIVILEGES FOR ROLE regress_dump_test_role REVOKE SELECT": { + "create_order": 56, + "create_sql": "ALTER DEFAULT PRIVILEGES\n" + "FOR ROLE regress_dump_test_role\n" + "REVOKE SELECT ON TABLES FROM regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QALTER DEFAULT PRIVILEGES \E + \QFOR ROLE regress_dump_test_role \E + \QREVOKE ALL ON TABLES FROM regress_dump_test_role;\E\n + \QALTER DEFAULT PRIVILEGES \E + \QFOR ROLE regress_dump_test_role \E + \QGRANT INSERT,REFERENCES,DELETE,TRIGGER,TRUNCATE,MAINTAIN,UPDATE ON TABLES TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + "unlike": {"no_privs"}, + }, + "ALTER ROLE regress_dump_test_role": { + "regexp": _qr( + r"""^ + \QALTER ROLE regress_dump_test_role WITH \E + \QNOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN \E + \QNOREPLICATION NOBYPASSRLS;\E + """, + _XM, + ), + "like": { + "pg_dumpall_dbprivs", + "pg_dumpall_globals", + "pg_dumpall_globals_clean", + "pg_dumpall_exclude", + }, + }, + "ALTER COLLATION test0 OWNER TO": { + "regexp": _qr(r"^\QALTER COLLATION public.test0 OWNER TO \E.+;", _M), + "collation": True, + "like": full | {"section_pre_data"}, + "unlike": {"no_owner"}, + }, + "ALTER FOREIGN DATA WRAPPER dummy OWNER TO": { + "regexp": _qr(r"^ALTER FOREIGN DATA WRAPPER dummy OWNER TO .+;", _M), + "like": full | {"section_pre_data"}, + "unlike": {"no_owner"}, + }, + "ALTER SERVER s1 OWNER TO": { + "regexp": _qr(r"^ALTER SERVER s1 OWNER TO .+;", _M), + "like": full | {"section_pre_data"}, + "unlike": {"no_owner"}, + }, + "ALTER FUNCTION dump_test.pltestlang_call_handler() OWNER TO": { + "regexp": _qr( + r"""^ + \QALTER FUNCTION dump_test.pltestlang_call_handler() \E + \QOWNER TO \E + .+;""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "only_dump_measurement", + }, + }, + "ALTER OPERATOR FAMILY dump_test.op_family OWNER TO": { + "regexp": _qr( + r"""^ + \QALTER OPERATOR FAMILY dump_test.op_family USING btree \E + \QOWNER TO \E + .+;""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "only_dump_measurement", + }, + }, + "ALTER OPERATOR FAMILY dump_test.op_family USING btree": { + "create_order": 75, + "create_sql": "ALTER OPERATOR FAMILY dump_test.op_family USING btree ADD\n" + " OPERATOR 1 <(bigint,int4),\n" + " OPERATOR 2 <=(bigint,int4),\n" + " OPERATOR 3 =(bigint,int4),\n" + " OPERATOR 4 >=(bigint,int4),\n" + " OPERATOR 5 >(bigint,int4),\n" + " FUNCTION 1 (int4, int4) btint4cmp(int4,int4),\n" + " FUNCTION 2 (int4, int4) btint4sortsupport(internal),\n" + " FUNCTION 4 (int4, int4) btequalimage(oid);", + "regexp": _qr( + r"""^ + \QALTER OPERATOR FAMILY dump_test.op_family USING btree ADD\E\n\s+ + \QOPERATOR 1 <(bigint,integer) ,\E\n\s+ + \QOPERATOR 2 <=(bigint,integer) ,\E\n\s+ + \QOPERATOR 3 =(bigint,integer) ,\E\n\s+ + \QOPERATOR 4 >=(bigint,integer) ,\E\n\s+ + \QOPERATOR 5 >(bigint,integer) ,\E\n\s+ + \QFUNCTION 1 (integer, integer) btint4cmp(integer,integer) ,\E\n\s+ + \QFUNCTION 2 (bigint, bigint) btint8sortsupport(internal) ,\E\n\s+ + \QFUNCTION 2 (integer, integer) btint4sortsupport(internal) ,\E\n\s+ + \QFUNCTION 4 (bigint, bigint) btequalimage(oid) ,\E\n\s+ + \QFUNCTION 4 (integer, integer) btequalimage(oid);\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "ALTER OPERATOR CLASS dump_test.op_class OWNER TO": { + "regexp": _qr( + r"""^ + \QALTER OPERATOR CLASS dump_test.op_class USING btree \E + \QOWNER TO \E + .+;""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "only_dump_measurement", + }, + }, + "ALTER PUBLICATION pub1 OWNER TO": { + "regexp": _qr(r"^ALTER PUBLICATION pub1 OWNER TO .+;", _M), + "like": full | {"section_post_data"}, + "unlike": {"no_owner"}, + }, + "ALTER LARGE OBJECT ... OWNER TO": { + "regexp": _qr(r"^ALTER LARGE OBJECT \d+ OWNER TO .+;", _M), + "like": full + | { + "column_inserts", + "data_only", + "inserts", + "no_schema", + "section_data", + "test_schema_plus_large_objects", + }, + "unlike": { + "binary_upgrade", + "no_large_objects", + "no_owner", + "schema_only", + "schema_only_with_statistics", + }, + }, + "ALTER PROCEDURAL LANGUAGE pltestlang OWNER TO": { + "regexp": _qr(r"^ALTER PROCEDURAL LANGUAGE pltestlang OWNER TO .+;", _M), + "like": full | {"section_pre_data"}, + "unlike": {"no_owner"}, + }, + "ALTER SCHEMA dump_test OWNER TO": { + "regexp": _qr(r"^ALTER SCHEMA dump_test OWNER TO .+;", _M), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "only_dump_measurement", + }, + }, + "ALTER SCHEMA dump_test_second_schema OWNER TO": { + "regexp": _qr(r"^ALTER SCHEMA dump_test_second_schema OWNER TO .+;", _M), + "like": full | {"role", "section_pre_data"}, + "unlike": {"no_owner"}, + }, + "ALTER SCHEMA public OWNER TO": { + "create_order": 15, + "create_sql": 'ALTER SCHEMA public OWNER TO "regress_quoted \\"" role";', + "regexp": _qr(r"^ALTER SCHEMA public OWNER TO .+;", _M), + "like": full | {"section_pre_data"}, + "unlike": {"no_owner"}, + }, + "ALTER SCHEMA public OWNER TO (w/o ACL changes)": { + "database": "regress_public_owner", + "create_order": 100, + "create_sql": 'ALTER SCHEMA public OWNER TO "regress_quoted \\"" role";', + "regexp": _qr(r"^(GRANT|REVOKE)", _M), + "like": set(), + }, + "ALTER SEQUENCE test_table_col1_seq": { + "regexp": _qr( + r"""^ + \QALTER SEQUENCE dump_test.test_table_col1_seq OWNED BY dump_test.test_table.col1;\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "ALTER TABLE ONLY test_table ADD CONSTRAINT ... PRIMARY KEY": { + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.test_table\E \n^\s+ + \QADD CONSTRAINT test_table_pkey PRIMARY KEY (col1);\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "CONSTRAINT NOT NULL / NOT VALID": { + "create_sql": "CREATE TABLE dump_test.test_table_nn (\n" + " col1 int);\n" + "CREATE TABLE dump_test.test_table_nn_2 (\n" + " col1 int NOT NULL);\n" + "CREATE TABLE dump_test.test_table_nn_chld1 (\n" + ") INHERITS (dump_test.test_table_nn);\n" + "CREATE TABLE dump_test.test_table_nn_chld2 (\n" + " col1 int\n" + ") INHERITS (dump_test.test_table_nn);\n" + "CREATE TABLE dump_test.test_table_nn_chld3 (\n" + ") INHERITS (dump_test.test_table_nn, dump_test.test_table_nn_2);\n" + "ALTER TABLE dump_test.test_table_nn ADD CONSTRAINT nn NOT NULL col1 NOT VALID;\n" + "ALTER TABLE dump_test.test_table_nn_chld1 VALIDATE CONSTRAINT nn;\n" + "ALTER TABLE dump_test.test_table_nn_chld2 VALIDATE CONSTRAINT nn;\n" + "COMMENT ON CONSTRAINT nn ON dump_test.test_table_nn IS 'nn comment is valid';\n" + "COMMENT ON CONSTRAINT nn ON dump_test.test_table_nn_chld2 IS 'nn_chld2 comment is valid';", + "regexp": _qr( + r"""^ + \QALTER TABLE dump_test.test_table_nn\E \n^\s+ + \QADD CONSTRAINT nn NOT NULL col1 NOT VALID;\E + """, + _XM, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON CONSTRAINT ON test_table_nn": { + "regexp": _qr( + r"""^ + \QCOMMENT ON CONSTRAINT nn ON dump_test.test_table_nn IS\E + """, + _XM, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON CONSTRAINT ON test_table_chld2": { + "regexp": _qr( + r"""^ + \QCOMMENT ON CONSTRAINT nn ON dump_test.test_table_nn_chld2 IS\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CONSTRAINT NOT NULL / NOT VALID (child1)": { + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_nn_chld1 (\E\n + ^\s+\QCONSTRAINT nn NOT NULL col1\E$ + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + "binary_upgrade", + }, + }, + "CONSTRAINT NOT NULL / NOT VALID (child2)": { + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_nn_chld2 (\E\n + ^\s+\Qcol1 integer CONSTRAINT nn NOT NULL\E$ + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CONSTRAINT NOT NULL / NOT VALID (child3)": { + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_nn_chld3 (\E\n + ^\Q)\E$ + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + "binary_upgrade", + }, + }, + "CONSTRAINT NOT NULL / NO INHERIT": { + "create_sql": "CREATE TABLE dump_test.test_table_nonn (\n" + "col1 int NOT NULL NO INHERIT,\n" + "col2 int);\n" + "CREATE TABLE dump_test.test_table_nonn_chld1 (\n" + " CONSTRAINT nn NOT NULL col2 NO INHERIT)\n" + "INHERITS (dump_test.test_table_nonn); ", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_nonn (\E \n^\s+ + \Qcol1 integer NOT NULL NO INHERIT\E + """, + _XM, + ), + "like": full + | dts + | { + "section_pre_data", + "binary_upgrade", + }, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CONSTRAINT NOT NULL / NO INHERIT (child1)": { + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_nonn_chld1 (\E \n^\s+ + \QCONSTRAINT nn NOT NULL col2 NO INHERIT\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + "binary_upgrade", + }, + }, + "CONSTRAINT PRIMARY KEY / WITHOUT OVERLAPS": { + "create_sql": "CREATE TABLE dump_test.test_table_tpk (\n" + " col1 int4range,\n" + " col2 tstzrange,\n" + " CONSTRAINT test_table_tpk_pkey PRIMARY KEY (col1, col2 WITHOUT OVERLAPS));", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.test_table_tpk\E \n^\s+ + \QADD CONSTRAINT test_table_tpk_pkey PRIMARY KEY (col1, col2 WITHOUT OVERLAPS);\E + """, + _XM, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CONSTRAINT UNIQUE / WITHOUT OVERLAPS": { + "create_sql": "CREATE TABLE dump_test.test_table_tuq (\n" + " col1 int4range,\n" + " col2 tstzrange,\n" + " CONSTRAINT test_table_tuq_uq UNIQUE (col1, col2 WITHOUT OVERLAPS));", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.test_table_tuq\E \n^\s+ + \QADD CONSTRAINT test_table_tuq_uq UNIQUE (col1, col2 WITHOUT OVERLAPS);\E + """, + _XM, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "ALTER TABLE (partitioned) ADD CONSTRAINT ... FOREIGN KEY": { + "create_order": 4, + "create_sql": "CREATE TABLE dump_test.test_table_fk (\n" + " col1 int references dump_test.test_table)\n" + " PARTITION BY RANGE (col1);\n" + " CREATE TABLE dump_test.test_table_fk_1\n" + " PARTITION OF dump_test.test_table_fk\n" + " FOR VALUES FROM (0) TO (10);", + "regexp": _qr( + r""" + \QADD CONSTRAINT test_table_fk_col1_fkey FOREIGN KEY (col1) REFERENCES dump_test.test_table\E + """, + _XM, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "ALTER TABLE ONLY test_table ALTER COLUMN col1 SET STATISTICS 90": { + "create_order": 93, + "create_sql": "ALTER TABLE dump_test.test_table ALTER COLUMN col1 SET STATISTICS 90;", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.test_table ALTER COLUMN col1 SET STATISTICS 90;\E\n + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "ALTER TABLE ONLY test_table ALTER COLUMN col2 SET STORAGE": { + "create_order": 94, + "create_sql": "ALTER TABLE dump_test.test_table ALTER COLUMN col2 SET STORAGE EXTERNAL;", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.test_table ALTER COLUMN col2 SET STORAGE EXTERNAL;\E\n + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "ALTER TABLE ONLY test_table ALTER COLUMN col3 SET STORAGE": { + "create_order": 95, + "create_sql": "ALTER TABLE dump_test.test_table ALTER COLUMN col3 SET STORAGE MAIN;", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.test_table ALTER COLUMN col3 SET STORAGE MAIN;\E\n + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "ALTER TABLE ONLY test_table ALTER COLUMN col4 SET n_distinct": { + "create_order": 95, + "create_sql": "ALTER TABLE dump_test.test_table ALTER COLUMN col4 SET (n_distinct = 10);", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.test_table ALTER COLUMN col4 SET (n_distinct=10);\E\n + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "ALTER TABLE ONLY dump_test.measurement ATTACH PARTITION measurement_y2006m2": { + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.measurement ATTACH PARTITION dump_test_second_schema.measurement_y2006m2 \E + \QFOR VALUES FROM ('2006-02-01') TO ('2006-03-01');\E\n + """, + _XM, + ), + "like": full + | { + "role", + "section_pre_data", + "binary_upgrade", + "only_dump_measurement", + }, + "unlike": {"exclude_measurement"}, + }, + "ALTER TABLE test_table CLUSTER ON test_table_pkey": { + "create_order": 96, + "create_sql": "ALTER TABLE dump_test.test_table CLUSTER ON test_table_pkey", + "regexp": _qr( + r"""^ + \QALTER TABLE dump_test.test_table CLUSTER ON test_table_pkey;\E\n + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "ALTER TABLE test_table DISABLE TRIGGER ALL": { + "regexp": _qr( + r"""^ + \QSET SESSION AUTHORIZATION 'test_superuser';\E\n\n + \QALTER TABLE dump_test.test_table DISABLE TRIGGER ALL;\E\n\n + \QCOPY dump_test.test_table (col1, col2, col3, col4) FROM stdin;\E + \n(?:\d\t\\N\t\\N\t\\N\n){9}\\\.\n\n\n + \QALTER TABLE dump_test.test_table ENABLE TRIGGER ALL;\E""", + _XM, + ), + "like": {"data_only"}, + }, + "ALTER FOREIGN TABLE foreign_table ALTER COLUMN c1 OPTIONS": { + "regexp": _qr( + r"""^ + \QALTER FOREIGN TABLE ONLY dump_test.foreign_table ALTER COLUMN c1 OPTIONS (\E\n + \s+\Qcolumn_name 'col1'\E\n + \Q);\E\n + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "ALTER TABLE test_table OWNER TO": { + "regexp": _qr(r"^\QALTER TABLE dump_test.test_table OWNER TO \E.+;", _M), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + "no_owner", + }, + }, + "ALTER TABLE test_table ENABLE ROW LEVEL SECURITY": { + "create_order": 23, + "create_sql": "ALTER TABLE dump_test.test_table\n" + "ENABLE ROW LEVEL SECURITY;", + "regexp": _qr( + r"^\QALTER TABLE dump_test.test_table ENABLE ROW LEVEL SECURITY;\E", _M + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_policies", + "no_policies_restore", + "only_dump_measurement", + }, + }, + "ALTER TABLE test_second_table OWNER TO": { + "regexp": _qr( + r"^\QALTER TABLE dump_test.test_second_table OWNER TO \E.+;", _M + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "only_dump_measurement", + }, + }, + "ALTER TABLE measurement OWNER TO": { + "regexp": _qr(r"^\QALTER TABLE dump_test.measurement OWNER TO \E.+;", _M), + "like": full + | dts + | { + "section_pre_data", + "only_dump_measurement", + }, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "exclude_measurement", + }, + }, + "ALTER TABLE measurement_y2006m2 OWNER TO": { + "regexp": _qr( + r"^\QALTER TABLE dump_test_second_schema.measurement_y2006m2 OWNER TO \E.+;", + _M, + ), + "like": full + | { + "role", + "section_pre_data", + "only_dump_measurement", + }, + "unlike": { + "no_owner", + "exclude_measurement", + }, + }, + "ALTER FOREIGN TABLE foreign_table OWNER TO": { + "regexp": _qr( + r"^\QALTER FOREIGN TABLE dump_test.foreign_table OWNER TO \E.+;", _M + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "only_dump_measurement", + }, + }, + "ALTER TEXT SEARCH CONFIGURATION alt_ts_conf1 OWNER TO": { + "regexp": _qr( + r"^\QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1 OWNER TO \E.+;", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "only_dump_measurement", + }, + }, + "ALTER TEXT SEARCH DICTIONARY alt_ts_dict1 OWNER TO": { + "regexp": _qr( + r"^\QALTER TEXT SEARCH DICTIONARY dump_test.alt_ts_dict1 OWNER TO \E.+;", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_owner", + "only_dump_measurement", + }, + }, + "LO create (using lo_from_bytea)": { + "create_order": 50, + "create_sql": "SELECT pg_catalog.lo_from_bytea(0, " + "'\\x310a320a330a340a350a360a370a380a390a');", + "regexp": _qr(r"^SELECT pg_catalog\.lo_create\('\d+'\);", _M), + "like": full + | { + "column_inserts", + "data_only", + "inserts", + "no_schema", + "section_data", + "test_schema_plus_large_objects", + }, + "unlike": { + "binary_upgrade", + "schema_only", + "schema_only_with_statistics", + "no_large_objects", + }, + }, + "LO load (using lo_from_bytea)": { + "regexp": _qr( + r"""^ + \QSELECT pg_catalog.lo_open\E \('\d+',\ \d+\);\n + \QSELECT pg_catalog.lowrite(0, \E + \Q'\x310a320a330a340a350a360a370a380a390a');\E\n + \QSELECT pg_catalog.lo_close(0);\E + """, + _XM, + ), + "like": full + | { + "column_inserts", + "data_only", + "inserts", + "no_schema", + "section_data", + "test_schema_plus_large_objects", + }, + "unlike": { + "binary_upgrade", + "no_large_objects", + "schema_only", + "schema_only_with_statistics", + }, + }, + "LO create (with no data)": { + "create_sql": "SELECT pg_catalog.lo_create(0);", + "regexp": _qr( + r"""^ + \QSELECT pg_catalog.lo_open\E \('\d+',\ \d+\);\n + \QSELECT pg_catalog.lo_close(0);\E + """, + _XM, + ), + "like": full + | { + "column_inserts", + "data_only", + "inserts", + "no_schema", + "section_data", + "test_schema_plus_large_objects", + }, + "unlike": { + "binary_upgrade", + "no_large_objects", + "schema_only", + "schema_only_with_statistics", + }, + }, + "COMMENT ON DATABASE postgres": { + "regexp": _qr(r"^COMMENT ON DATABASE postgres IS .+;", _M), + "like": {"createdb"}, + }, + "COMMENT ON EXTENSION plpgsql": { + "regexp": _qr(r"^COMMENT ON EXTENSION plpgsql IS .+;", _M), + "like": set(), + }, + "COMMENT ON SCHEMA public": { + "regexp": _qr(r"^COMMENT ON SCHEMA public IS .+;", _M), + "like": { + "pg_dumpall_dbprivs", + "pg_dumpall_exclude", + }, + }, + "COMMENT ON SCHEMA public IS NULL": { + "database": "regress_public_owner", + "create_order": 100, + "create_sql": "COMMENT ON SCHEMA public IS NULL;", + "regexp": _qr(r"^COMMENT ON SCHEMA public IS '';", _M), + "like": {"defaults_public_owner"}, + }, + } + + +def _tests_part2(full, dts): + return { + "COMMENT ON TABLE dump_test.test_table": { + "create_order": 36, + "create_sql": "COMMENT ON TABLE dump_test.test_table\n" + "IS 'comment on table';", + "regexp": _qr( + r"^\QCOMMENT ON TABLE dump_test.test_table IS 'comment on table';\E", _M + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "COMMENT ON COLUMN dump_test.test_table.col1": { + "create_order": 36, + "create_sql": "COMMENT ON COLUMN dump_test.test_table.col1\n" + "IS 'comment on column';", + "regexp": _qr( + r"""^ + \QCOMMENT ON COLUMN dump_test.test_table.col1 IS 'comment on column';\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "COMMENT ON COLUMN dump_test.composite.f1": { + "create_order": 44, + "create_sql": "COMMENT ON COLUMN dump_test.composite.f1\n" + "IS 'comment on column of type';", + "regexp": _qr( + r"""^ + \QCOMMENT ON COLUMN dump_test.composite.f1 IS 'comment on column of type';\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON COLUMN dump_test.test_second_table.col1": { + "create_order": 63, + "create_sql": "COMMENT ON COLUMN dump_test.test_second_table.col1\n" + "IS 'comment on column col1';", + "regexp": _qr( + r"""^ + \QCOMMENT ON COLUMN dump_test.test_second_table.col1 IS 'comment on column col1';\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON COLUMN dump_test.test_second_table.col2": { + "create_order": 64, + "create_sql": "COMMENT ON COLUMN dump_test.test_second_table.col2\n" + "IS 'comment on column col2';", + "regexp": _qr( + r"""^ + \QCOMMENT ON COLUMN dump_test.test_second_table.col2 IS 'comment on column col2';\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON CONVERSION dump_test.test_conversion": { + "create_order": 79, + "create_sql": "COMMENT ON CONVERSION dump_test.test_conversion\n" + "IS 'comment on test conversion';", + "regexp": _qr( + r"^\QCOMMENT ON CONVERSION dump_test.test_conversion IS 'comment on test conversion';\E", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON COLLATION test0": { + "create_order": 77, + "create_sql": "COMMENT ON COLLATION test0\n" + "IS 'comment on test0 collation';", + "regexp": _qr( + r"^\QCOMMENT ON COLLATION public.test0 IS 'comment on test0 collation';\E", + _M, + ), + "collation": True, + "like": full | {"section_pre_data"}, + }, + "COMMENT ON LARGE OBJECT ...": { + "create_order": 65, + "create_sql": "DO $$\n" + " DECLARE myoid oid;\n" + " BEGIN\n" + " SELECT loid FROM pg_largeobject INTO myoid;\n" + " EXECUTE 'COMMENT ON LARGE OBJECT ' || myoid || ' IS ''comment on large object'';';\n" + " END;\n" + " $$;", + "regexp": _qr( + r"""^ + \QCOMMENT ON LARGE OBJECT \E[0-9]+\Q IS 'comment on large object';\E + """, + _XM, + ), + "like": full + | { + "column_inserts", + "data_only", + "inserts", + "no_schema", + "section_data", + "test_schema_plus_large_objects", + }, + "unlike": { + "no_large_objects", + "schema_only", + "schema_only_with_statistics", + }, + }, + "COMMENT ON POLICY p1": { + "create_order": 55, + "create_sql": "COMMENT ON POLICY p1 ON dump_test.test_table\n" + "IS 'comment on policy';", + "regexp": _qr( + r"^COMMENT ON POLICY p1 ON dump_test.test_table IS 'comment on policy';", + _M, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_policies", + "no_policies_restore", + "only_dump_measurement", + }, + }, + "COMMENT ON PUBLICATION pub1": { + "create_order": 55, + "create_sql": "COMMENT ON PUBLICATION pub1\n" + "IS 'comment on publication';", + "regexp": _qr( + r"^COMMENT ON PUBLICATION pub1 IS 'comment on publication';", _M + ), + "like": full | {"section_post_data"}, + }, + "COMMENT ON SUBSCRIPTION sub1": { + "create_order": 55, + "create_sql": "COMMENT ON SUBSCRIPTION sub1\n" + "IS 'comment on subscription';", + "regexp": _qr( + r"^COMMENT ON SUBSCRIPTION sub1 IS 'comment on subscription';", _M + ), + "like": full | {"section_post_data"}, + "unlike": { + "no_subscriptions", + "no_subscriptions_restore", + }, + }, + "COMMENT ON TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1": { + "create_order": 84, + "create_sql": "COMMENT ON TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\n" + "IS 'comment on text search configuration';", + "regexp": _qr( + r"^\QCOMMENT ON TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1 IS 'comment on text search configuration';\E", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON TEXT SEARCH DICTIONARY dump_test.alt_ts_dict1": { + "create_order": 84, + "create_sql": "COMMENT ON TEXT SEARCH DICTIONARY dump_test.alt_ts_dict1\n" + "IS 'comment on text search dictionary';", + "regexp": _qr( + r"^\QCOMMENT ON TEXT SEARCH DICTIONARY dump_test.alt_ts_dict1 IS 'comment on text search dictionary';\E", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON TEXT SEARCH PARSER dump_test.alt_ts_prs1": { + "create_order": 84, + "create_sql": "COMMENT ON TEXT SEARCH PARSER dump_test.alt_ts_prs1\n" + "IS 'comment on text search parser';", + "regexp": _qr( + r"^\QCOMMENT ON TEXT SEARCH PARSER dump_test.alt_ts_prs1 IS 'comment on text search parser';\E", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON TEXT SEARCH TEMPLATE dump_test.alt_ts_temp1": { + "create_order": 84, + "create_sql": "COMMENT ON TEXT SEARCH TEMPLATE dump_test.alt_ts_temp1\n" + "IS 'comment on text search template';", + "regexp": _qr( + r"^\QCOMMENT ON TEXT SEARCH TEMPLATE dump_test.alt_ts_temp1 IS 'comment on text search template';\E", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON TYPE dump_test.planets - ENUM": { + "create_order": 68, + "create_sql": "COMMENT ON TYPE dump_test.planets\n" + "IS 'comment on enum type';", + "regexp": _qr( + r"^\QCOMMENT ON TYPE dump_test.planets IS 'comment on enum type';\E", _M + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON TYPE dump_test.textrange - RANGE": { + "create_order": 69, + "create_sql": "COMMENT ON TYPE dump_test.textrange\n" + "IS 'comment on range type';", + "regexp": _qr( + r"^\QCOMMENT ON TYPE dump_test.textrange IS 'comment on range type';\E", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON TYPE dump_test.int42 - Regular": { + "create_order": 70, + "create_sql": "COMMENT ON TYPE dump_test.int42\n" + "IS 'comment on regular type';", + "regexp": _qr( + r"^\QCOMMENT ON TYPE dump_test.int42 IS 'comment on regular type';\E", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON TYPE dump_test.undefined - Undefined": { + "create_order": 71, + "create_sql": "COMMENT ON TYPE dump_test.undefined\n" + "IS 'comment on undefined type';", + "regexp": _qr( + r"^\QCOMMENT ON TYPE dump_test.undefined IS 'comment on undefined type';\E", + _M, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COPY test_table": { + "create_order": 4, + "create_sql": "INSERT INTO dump_test.test_table (col1) " + "SELECT generate_series FROM generate_series(1,9);", + "regexp": _qr( + r"""^ + \QCOPY dump_test.test_table (col1, col2, col3, col4) FROM stdin;\E + \n(?:\d\t\\N\t\\N\t\\N\n){9}\\\.\n + """, + _XM, + ), + "like": full + | dts + | { + "data_only", + "no_schema", + "only_dump_test_table", + "section_data", + }, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "exclude_test_table", + "exclude_test_table_data", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "COPY fk_reference_test_table": { + "create_order": 22, + "create_sql": "INSERT INTO dump_test.fk_reference_test_table (col1) " + "SELECT generate_series FROM generate_series(1,5);", + "regexp": _qr( + r"""^ + \QCOPY dump_test.fk_reference_test_table (col1) FROM stdin;\E + \n(?:\d\n){5}\\\.\n + """, + _XM, + ), + "like": full + | dts + | { + "data_only", + "exclude_test_table", + "exclude_test_table_data", + "no_schema", + "section_data", + }, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "COPY fk_reference_test_table second": { + "regexp": _qr( + r"""^ + \QCOPY dump_test.test_table (col1, col2, col3, col4) FROM stdin;\E + \n(?:\d\t\\N\t\\N\t\\N\n){9}\\\.\n.* + \QCOPY dump_test.fk_reference_test_table (col1) FROM stdin;\E + \n(?:\d\n){5}\\\.\n + """, + _XMS, + ), + "like": { + "data_only", + "no_schema", + }, + }, + "COPY test_second_table": { + "create_order": 7, + "create_sql": "INSERT INTO dump_test.test_second_table (col1, col2) " + "SELECT generate_series, generate_series::text " + "FROM generate_series(1,9);", + "regexp": _qr( + r"""^ + \QCOPY dump_test.test_second_table (col1, col2) FROM stdin;\E + \n(?:\d\t\d\n){9}\\\.\n + """, + _XM, + ), + "like": full + | dts + | { + "data_only", + "no_schema", + "section_data", + }, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "COPY test_third_table": { + "create_order": 7, + "create_sql": "INSERT INTO dump_test.test_third_table VALUES (123, DEFAULT, 456);", + "regexp": _qr( + r"""^ + \QCOPY dump_test.test_third_table (f1, "F3") FROM stdin;\E + \n123\t456\n\\\.\n + """, + _XM, + ), + "like": full + | dts + | { + "data_only", + "no_schema", + "section_data", + }, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "COPY test_fourth_table": { + "create_order": 7, + "create_sql": "INSERT INTO dump_test.test_fourth_table DEFAULT VALUES;" + "INSERT INTO dump_test.test_fourth_table DEFAULT VALUES;", + "regexp": _qr( + r"""^ + \QCOPY dump_test.test_fourth_table FROM stdin;\E + \n\n\n\\\.\n + """, + _XM, + ), + "like": full + | dts + | { + "data_only", + "no_schema", + "section_data", + }, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "COPY test_fifth_table": { + "create_order": 54, + "create_sql": "INSERT INTO dump_test.test_fifth_table VALUES (NULL, true, false, '11001'::bit(5), 'NaN');", + "regexp": _qr( + r"""^ + \QCOPY dump_test.test_fifth_table (col1, col2, col3, col4, col5) FROM stdin;\E + \n\\N\tt\tf\t11001\tNaN\n\\\.\n + """, + _XM, + ), + "like": full + | dts + | { + "data_only", + "no_schema", + "section_data", + }, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "COPY test_table_identity": { + "create_order": 54, + "create_sql": "INSERT INTO dump_test.test_table_identity (col2) VALUES ('test');", + "regexp": _qr( + r"""^ + \QCOPY dump_test.test_table_identity (col1, col2) FROM stdin;\E + \n1\ttest\n\\\.\n + """, + _XM, + ), + "like": full + | dts + | { + "data_only", + "no_schema", + "section_data", + }, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "INSERT INTO test_table": { + "regexp": _qr( + r"""^ + (?:INSERT\ INTO\ dump_test\.test_table\ \(col1,\ col2,\ col3,\ col4\)\ VALUES\ \(\d,\ NULL,\ NULL,\ NULL\);\n){9} + """, + _XM, + ), + "like": {"column_inserts"}, + }, + "test_table with 4-row INSERTs": { + "regexp": _qr( + r"""^ + (?: + INSERT\ INTO\ dump_test\.test_table\ VALUES\n + (?:\t\(\d,\ NULL,\ NULL,\ NULL\),\n){3} + \t\(\d,\ NULL,\ NULL,\ NULL\);\n + ){2} + INSERT\ INTO\ dump_test\.test_table\ VALUES\n + \t\(\d,\ NULL,\ NULL,\ NULL\); + """, + _XM, + ), + "like": {"rows_per_insert"}, + }, + "INSERT INTO test_second_table": { + "regexp": _qr( + r"""^ + (?:INSERT\ INTO\ dump_test\.test_second_table\ \(col1,\ col2\) + \ VALUES\ \(\d,\ '\d'\);\n){9}""", + _XM, + ), + "like": {"column_inserts"}, + }, + "INSERT INTO test_third_table (colnames)": { + "regexp": _qr( + r'^INSERT INTO dump_test\.test_third_table \(f1, "F3"\) VALUES \(123, 456\);\n', + _M, + ), + "like": {"column_inserts"}, + }, + "INSERT INTO test_third_table": { + "regexp": _qr( + r"^INSERT INTO dump_test\.test_third_table VALUES \(123, DEFAULT, 456, DEFAULT\);\n", + _M, + ), + "like": {"inserts"}, + }, + "INSERT INTO test_fourth_table": { + "regexp": _qr( + r"^(?:INSERT INTO dump_test\.test_fourth_table DEFAULT VALUES;\n){2}", + _M, + ), + "like": {"column_inserts", "inserts", "rows_per_insert"}, + }, + "INSERT INTO test_fifth_table": { + "regexp": _qr( + r"^\QINSERT INTO dump_test.test_fifth_table (col1, col2, col3, col4, col5) VALUES (NULL, true, false, B'11001', 'NaN');\E", + _M, + ), + "like": {"column_inserts"}, + }, + "INSERT INTO test_table_identity": { + "regexp": _qr( + r"^\QINSERT INTO dump_test.test_table_identity (col1, col2) OVERRIDING SYSTEM VALUE VALUES (1, 'test');\E", + _M, + ), + "like": {"column_inserts"}, + }, + "CREATE ROLE regress_dump_test_role": { + "create_order": 1, + "create_sql": "CREATE ROLE regress_dump_test_role;", + "regexp": _qr(r"^CREATE ROLE regress_dump_test_role;", _M), + "like": { + "pg_dumpall_dbprivs", + "pg_dumpall_exclude", + "pg_dumpall_globals", + "pg_dumpall_globals_clean", + }, + }, + "CREATE ROLE regress_quoted...": { + "create_order": 1, + "create_sql": 'CREATE ROLE "regress_quoted \\"" role";', + "regexp": _qr(r'^CREATE ROLE "regress_quoted \\"" role";', _M), + "like": { + "pg_dumpall_dbprivs", + "pg_dumpall_exclude", + "pg_dumpall_globals", + "pg_dumpall_globals_clean", + }, + }, + "newline of table name in comment": { + "create_sql": '-- meet getPartitioningInfo() "unsafe" condition\n' + " CREATE TYPE pp_colors AS\n" + " ENUM ('green', 'blue', 'black');\n" + " CREATE TABLE pp_enumpart (a pp_colors)\n" + " PARTITION BY HASH (a);\n" + " CREATE TABLE pp_enumpart1 PARTITION OF pp_enumpart\n" + " FOR VALUES WITH (MODULUS 2, REMAINDER 0);\n" + " CREATE TABLE pp_enumpart2 PARTITION OF pp_enumpart\n" + " FOR VALUES WITH (MODULUS 2, REMAINDER 1);\n" + " ALTER TABLE pp_enumpart\n" + ' RENAME TO "pp_enumpart\nattack";', + "regexp": _qr(r"\n--[^\n]*\nattack", _S), + "like": set(), + }, + "CREATE TABLESPACE regress_dump_tablespace": { + "create_order": 2, + "create_sql": "\n" + " SET allow_in_place_tablespaces = on;\n" + "CREATE TABLESPACE regress_dump_tablespace\n" + "OWNER regress_dump_test_role LOCATION ''", + "regexp": _qr( + r"^CREATE TABLESPACE regress_dump_tablespace OWNER regress_dump_test_role LOCATION '';", + _M, + ), + "like": { + "pg_dumpall_dbprivs", + "pg_dumpall_exclude", + "pg_dumpall_globals", + "pg_dumpall_globals_clean", + }, + }, + "CREATE DATABASE regression_invalid...": { + "create_order": 1, + "create_sql": "\n" + " CREATE DATABASE regression_invalid;\n" + "UPDATE pg_database SET datconnlimit = -2 WHERE datname = 'regression_invalid'", + "regexp": _qr(r"^CREATE DATABASE regression_invalid", _M), + "like": set(), + }, + "CREATE ACCESS METHOD gist2": { + "create_order": 52, + "create_sql": "CREATE ACCESS METHOD gist2 TYPE INDEX HANDLER gisthandler;", + "regexp": _qr( + r"CREATE ACCESS METHOD gist2 TYPE INDEX HANDLER gisthandler;", _M + ), + "like": full | {"section_pre_data"}, + }, + 'CREATE COLLATION test0 FROM "C"': { + "create_order": 76, + "create_sql": 'CREATE COLLATION test0 FROM "C";', + "regexp": _qr( + r"CREATE COLLATION public.test0 \(provider = libc, locale = 'C'(, version = '[^']*')?\);", + _M, + ), + "collation": True, + "like": full | {"section_pre_data"}, + }, + "CREATE COLLATION icu_collation": { + "create_order": 76, + "create_sql": "CREATE COLLATION icu_collation (PROVIDER = icu, LOCALE = 'en-US-u-va-posix');", + "regexp": _qr( + r"CREATE COLLATION public.icu_collation \(provider = icu, locale = 'en-US-u-va-posix'(, version = '[^']*')?\);", + _M, + ), + "icu": True, + "like": full | {"section_pre_data"}, + }, + "CREATE CAST FOR timestamptz": { + "create_order": 51, + "create_sql": "CREATE CAST (timestamptz AS interval) WITH FUNCTION age(timestamptz) AS ASSIGNMENT;", + "regexp": _qr( + r"CREATE CAST \(timestamp with time zone AS interval\) WITH FUNCTION pg_catalog\.age\(timestamp with time zone\) AS ASSIGNMENT;", + _M, + ), + "like": full | {"section_pre_data"}, + }, + "CREATE DATABASE postgres": { + "regexp": _qr( + r"""^ + \QCREATE DATABASE postgres WITH TEMPLATE = template0 \E + .+;""", + _XM, + ), + "like": {"createdb"}, + }, + "CREATE DATABASE dump_test": { + "create_order": 47, + "create_sql": "CREATE DATABASE dump_test;", + "regexp": _qr( + r"""^ + \QCREATE DATABASE dump_test WITH TEMPLATE = template0 \E + .+;""", + _XM, + ), + "like": {"pg_dumpall_dbprivs"}, + }, + "CREATE DATABASE dump_test2 LOCALE = 'C'": { + "create_order": 47, + "create_sql": "CREATE DATABASE dump_test2 LOCALE = 'C' TEMPLATE = template0;", + "regexp": _qr( + r"""^ + \QCREATE DATABASE dump_test2 \E.*\QLOCALE = 'C';\E + """, + _XM, + ), + "like": {"pg_dumpall_dbprivs"}, + }, + "CREATE EXTENSION ... plpgsql": { + "regexp": _qr( + r"""^ + \QCREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog;\E + """, + _XM, + ), + "like": set(), + }, + "CREATE AGGREGATE dump_test.newavg": { + "create_order": 25, + "create_sql": "CREATE AGGREGATE dump_test.newavg (\n" + " sfunc = int4_avg_accum,\n" + " basetype = int4,\n" + " stype = _int8,\n" + " finalfunc = int8_avg,\n" + " finalfunc_modify = shareable,\n" + " initcond1 = '{0,0}'\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE AGGREGATE dump_test.newavg(integer) (\E + \n\s+\QSFUNC = int4_avg_accum,\E + \n\s+\QSTYPE = bigint[],\E + \n\s+\QINITCOND = '{0,0}',\E + \n\s+\QFINALFUNC = int8_avg,\E + \n\s+\QFINALFUNC_MODIFY = SHAREABLE\E + \n\);""", + _XM, + ), + "like": full + | dts + | { + "exclude_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE CONVERSION dump_test.test_conversion": { + "create_order": 78, + "create_sql": "CREATE DEFAULT CONVERSION dump_test.test_conversion FOR 'LATIN1' TO 'UTF8' FROM iso8859_1_to_utf8;", + "regexp": _qr( + r"^\QCREATE DEFAULT CONVERSION dump_test.test_conversion FOR 'LATIN1' TO 'UTF8' FROM iso8859_1_to_utf8;\E", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE DOMAIN dump_test.us_postal_code": { + "create_order": 29, + "create_sql": "CREATE DOMAIN dump_test.us_postal_code AS TEXT\n" + ' COLLATE "C"\n' + "DEFAULT '10014'\n" + "CONSTRAINT nn NOT NULL\n" + "CHECK(VALUE ~ '^\\d{5}$' OR\n" + " VALUE ~ '^\\d{5}-\\d{4}$');\n" + "COMMENT ON CONSTRAINT nn\n" + " ON DOMAIN dump_test.us_postal_code IS 'not null';\n" + "COMMENT ON CONSTRAINT us_postal_code_check\n" + " ON DOMAIN dump_test.us_postal_code IS 'check it';", + "regexp": _qr( + r"""^ + \QCREATE DOMAIN dump_test.us_postal_code AS text COLLATE pg_catalog."C" CONSTRAINT nn NOT NULL DEFAULT '10014'::text\E\n\s+ + \QCONSTRAINT us_postal_code_check CHECK \E + \Q(((VALUE ~ '^\d{5}\E + \$\Q'::text) OR (VALUE ~ '^\d{5}-\d{4}\E\$ + \Q'::text)));\E(.|\n)* + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON CONSTRAINT ON DOMAIN (1)": { + "regexp": _qr( + r"""^ + \QCOMMENT ON CONSTRAINT nn ON DOMAIN dump_test.us_postal_code IS 'not null';\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "COMMENT ON CONSTRAINT ON DOMAIN (2)": { + "regexp": _qr( + r"""^ + \QCOMMENT ON CONSTRAINT us_postal_code_check ON DOMAIN dump_test.us_postal_code IS 'check it';\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE FUNCTION dump_test.pltestlang_call_handler": { + "create_order": 17, + "create_sql": "CREATE FUNCTION dump_test.pltestlang_call_handler()\n" + "RETURNS LANGUAGE_HANDLER AS '$libdir/plpgsql',\n" + "'plpgsql_call_handler' LANGUAGE C;", + "regexp": _qr( + r"""^ + \QCREATE FUNCTION dump_test.pltestlang_call_handler() \E + \QRETURNS language_handler\E + \n\s+\QLANGUAGE c\E + \n\s+AS\ \'\$ + \Qlibdir\/plpgsql', 'plpgsql_call_handler';\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE FUNCTION dump_test.trigger_func": { + "create_order": 30, + "create_sql": "CREATE FUNCTION dump_test.trigger_func()\n" + "RETURNS trigger LANGUAGE plpgsql\n" + "AS $$ BEGIN RETURN NULL; END;$$;", + "regexp": _qr( + r"""^ + \QCREATE FUNCTION dump_test.trigger_func() RETURNS trigger\E + \n\s+\QLANGUAGE plpgsql\E + \n\s+AS\ \$\$ + \Q BEGIN RETURN NULL; END;\E + \$\$;""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE FUNCTION dump_test.event_trigger_func": { + "create_order": 32, + "create_sql": "CREATE FUNCTION dump_test.event_trigger_func()\n" + "RETURNS event_trigger LANGUAGE plpgsql\n" + "AS $$ BEGIN RETURN; END;$$;", + "regexp": _qr( + r"""^ + \QCREATE FUNCTION dump_test.event_trigger_func() RETURNS event_trigger\E + \n\s+\QLANGUAGE plpgsql\E + \n\s+AS\ \$\$ + \Q BEGIN RETURN; END;\E + \$\$;""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE OPERATOR FAMILY dump_test.op_family": { + "create_order": 73, + "create_sql": "CREATE OPERATOR FAMILY dump_test.op_family USING btree;", + "regexp": _qr( + r"""^ + \QCREATE OPERATOR FAMILY dump_test.op_family USING btree;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE OPERATOR CLASS dump_test.op_class": { + "create_order": 74, + "create_sql": "CREATE OPERATOR CLASS dump_test.op_class\n" + " FOR TYPE bigint USING btree FAMILY dump_test.op_family\n" + "AS STORAGE bigint,\n" + "OPERATOR 1 <(bigint,bigint),\n" + "OPERATOR 2 <=(bigint,bigint),\n" + "OPERATOR 3 =(bigint,bigint),\n" + "OPERATOR 4 >=(bigint,bigint),\n" + "OPERATOR 5 >(bigint,bigint),\n" + "FUNCTION 1 btint8cmp(bigint,bigint),\n" + "FUNCTION 2 btint8sortsupport(internal),\n" + "FUNCTION 4 btequalimage(oid);", + "regexp": _qr( + r"""^ + \QCREATE OPERATOR CLASS dump_test.op_class\E\n\s+ + \QFOR TYPE bigint USING btree FAMILY dump_test.op_family AS\E\n\s+ + \QOPERATOR 1 <(bigint,bigint) ,\E\n\s+ + \QOPERATOR 2 <=(bigint,bigint) ,\E\n\s+ + \QOPERATOR 3 =(bigint,bigint) ,\E\n\s+ + \QOPERATOR 4 >=(bigint,bigint) ,\E\n\s+ + \QOPERATOR 5 >(bigint,bigint) ,\E\n\s+ + \QFUNCTION 1 (bigint, bigint) btint8cmp(bigint,bigint);\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE OPERATOR CLASS dump_test.op_class_custom": { + "create_order": 74, + "create_sql": "CREATE OPERATOR dump_test.~~ (\n" + " PROCEDURE = int4eq,\n" + " LEFTARG = int,\n" + " RIGHTARG = int);\n" + " CREATE OPERATOR CLASS dump_test.op_class_custom\n" + " FOR TYPE int USING btree AS\n" + " OPERATOR 3 dump_test.~~;\n" + " CREATE TYPE dump_test.range_type_custom AS RANGE (\n" + " subtype = int,\n" + " subtype_opclass = dump_test.op_class_custom);", + "regexp": _qr( + r"""^ + \QCREATE OPERATOR dump_test.~~ (\E\n.+ + \QCREATE OPERATOR FAMILY dump_test.op_class_custom USING btree;\E\n.+ + \QCREATE OPERATOR CLASS dump_test.op_class_custom\E\n\s+ + \QFOR TYPE integer USING btree FAMILY dump_test.op_class_custom AS\E\n\s+ + \QOPERATOR 3 dump_test.~~(integer,integer);\E\n.+ + \QCREATE TYPE dump_test.range_type_custom AS RANGE (\E\n\s+ + \Qsubtype = integer,\E\n\s+ + \Qmultirange_type_name = dump_test.multirange_type_custom,\E\n\s+ + \Qsubtype_opclass = dump_test.op_class_custom\E\n + \Q);\E + """, + _XMS, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE OPERATOR CLASS dump_test.op_class_empty": { + "create_order": 89, + "create_sql": "CREATE OPERATOR CLASS dump_test.op_class_empty\n" + " FOR TYPE bigint USING btree FAMILY dump_test.op_family\n" + "AS STORAGE bigint;", + "regexp": _qr( + r"""^ + \QCREATE OPERATOR CLASS dump_test.op_class_empty\E\n\s+ + \QFOR TYPE bigint USING btree FAMILY dump_test.op_family AS\E\n\s+ + \QSTORAGE bigint;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE EVENT TRIGGER test_event_trigger": { + "create_order": 33, + "create_sql": "CREATE EVENT TRIGGER test_event_trigger\n" + "ON ddl_command_start\n" + "EXECUTE FUNCTION dump_test.event_trigger_func();", + "regexp": _qr( + r"""^ + \QCREATE EVENT TRIGGER test_event_trigger \E + \QON ddl_command_start\E + \n\s+\QEXECUTE FUNCTION dump_test.event_trigger_func();\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE TRIGGER test_trigger": { + "create_order": 31, + "create_sql": "CREATE TRIGGER test_trigger\n" + "BEFORE INSERT ON dump_test.test_table\n" + "FOR EACH ROW WHEN (NEW.col1 > 10)\n" + "EXECUTE FUNCTION dump_test.trigger_func();", + "regexp": _qr( + r"""^ + \QCREATE TRIGGER test_trigger BEFORE INSERT ON dump_test.test_table \E + \QFOR EACH ROW WHEN ((new.col1 > 10)) \E + \QEXECUTE FUNCTION dump_test.trigger_func();\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_test_table", + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TYPE dump_test.planets AS ENUM": { + "create_order": 37, + "create_sql": "CREATE TYPE dump_test.planets\n" + "AS ENUM ( 'venus', 'earth', 'mars' );", + "regexp": _qr( + r"""^ + \QCREATE TYPE dump_test.planets AS ENUM (\E + \n\s+'venus', + \n\s+'earth', + \n\s+'mars' + \n\);""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TYPE dump_test.planets AS ENUM pg_upgrade": { + "regexp": _qr( + r"""^ + \QCREATE TYPE dump_test.planets AS ENUM (\E + \n\);.*^ + \QALTER TYPE dump_test.planets ADD VALUE 'venus';\E + \n.*^ + \QALTER TYPE dump_test.planets ADD VALUE 'earth';\E + \n.*^ + \QALTER TYPE dump_test.planets ADD VALUE 'mars';\E + \n""", + _XMS, + ), + "like": {"binary_upgrade"}, + }, + "CREATE TYPE dump_test.textrange AS RANGE": { + "create_order": 38, + "create_sql": "CREATE TYPE dump_test.textrange\n" + 'AS RANGE (subtype=text, collation="C");', + "regexp": _qr( + r"""^ + \QCREATE TYPE dump_test.textrange AS RANGE (\E + \n\s+\Qsubtype = text,\E + \n\s+\Qmultirange_type_name = dump_test.textmultirange,\E + \n\s+\Qcollation = pg_catalog."C"\E + \n\);""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TYPE dump_test.int42": { + "create_order": 39, + "create_sql": "CREATE TYPE dump_test.int42;", + "regexp": _qr(r"^\QCREATE TYPE dump_test.int42;\E", _M), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1": { + "create_order": 80, + "create_sql": "CREATE TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1 (copy=english);", + "regexp": _qr( + r"""^ + \QCREATE TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1 (\E\n + \s+\QPARSER = pg_catalog."default" );\E""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "ALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1 ...": { + "regexp": _qr( + r"""^ + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR asciiword WITH english_stem;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR word WITH english_stem;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR numword WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR email WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR url WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR host WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR sfloat WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR version WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR hword_numpart WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR hword_part WITH english_stem;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR hword_asciipart WITH english_stem;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR numhword WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR asciihword WITH english_stem;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR hword WITH english_stem;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR url_path WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR file WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR "float" WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR "int" WITH simple;\E\n + \n + \QALTER TEXT SEARCH CONFIGURATION dump_test.alt_ts_conf1\E\n + \s+\QADD MAPPING FOR uint WITH simple;\E\n + \n + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TEXT SEARCH TEMPLATE dump_test.alt_ts_temp1": { + "create_order": 81, + "create_sql": "CREATE TEXT SEARCH TEMPLATE dump_test.alt_ts_temp1 (lexize=dsimple_lexize);", + "regexp": _qr( + r"""^ + \QCREATE TEXT SEARCH TEMPLATE dump_test.alt_ts_temp1 (\E\n + \s+\QLEXIZE = dsimple_lexize );\E""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TEXT SEARCH PARSER dump_test.alt_ts_prs1": { + "create_order": 82, + "create_sql": "CREATE TEXT SEARCH PARSER dump_test.alt_ts_prs1\n" + "(start = prsd_start, gettoken = prsd_nexttoken, end = prsd_end, lextypes = prsd_lextype);", + "regexp": _qr( + r"""^ + \QCREATE TEXT SEARCH PARSER dump_test.alt_ts_prs1 (\E\n + \s+\QSTART = prsd_start,\E\n + \s+\QGETTOKEN = prsd_nexttoken,\E\n + \s+\QEND = prsd_end,\E\n + \s+\QLEXTYPES = prsd_lextype );\E\n + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TEXT SEARCH DICTIONARY dump_test.alt_ts_dict1": { + "create_order": 83, + "create_sql": "CREATE TEXT SEARCH DICTIONARY dump_test.alt_ts_dict1 (template=simple);", + "regexp": _qr( + r"""^ + \QCREATE TEXT SEARCH DICTIONARY dump_test.alt_ts_dict1 (\E\n + \s+\QTEMPLATE = pg_catalog.simple );\E\n + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE FUNCTION dump_test.int42_in": { + "create_order": 40, + "create_sql": "CREATE FUNCTION dump_test.int42_in(cstring)\n" + "RETURNS dump_test.int42 AS 'int4in'\n" + "LANGUAGE internal STRICT IMMUTABLE;", + "regexp": _qr( + r"""^ + \QCREATE FUNCTION dump_test.int42_in(cstring) RETURNS dump_test.int42\E + \n\s+\QLANGUAGE internal IMMUTABLE STRICT\E + \n\s+AS\ \$\$int4in\$\$; + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE FUNCTION dump_test.int42_out": { + "create_order": 41, + "create_sql": "CREATE FUNCTION dump_test.int42_out(dump_test.int42)\n" + "RETURNS cstring AS 'int4out'\n" + "LANGUAGE internal STRICT IMMUTABLE;", + "regexp": _qr( + r"""^ + \QCREATE FUNCTION dump_test.int42_out(dump_test.int42) RETURNS cstring\E + \n\s+\QLANGUAGE internal IMMUTABLE STRICT\E + \n\s+AS\ \$\$int4out\$\$; + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE FUNCTION ... SUPPORT": { + "create_order": 41, + "create_sql": "CREATE FUNCTION dump_test.func_with_support() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$ SUPPORT varchar_support;", + "regexp": _qr( + r"""^ + \QCREATE FUNCTION dump_test.func_with_support() RETURNS integer\E + \n\s+\QLANGUAGE sql SUPPORT varchar_support\E + \n\s+AS\ \$\$\Q SELECT 1 \E\$\$; + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "Check ordering of a function that depends on a primary key": { + "create_order": 41, + "create_sql": "\n" + "CREATE TABLE dump_test.ordering_table (id int primary key, data int);\n" + "CREATE FUNCTION dump_test.ordering_func ()\n" + "RETURNS SETOF dump_test.ordering_table\n" + "LANGUAGE sql BEGIN ATOMIC\n" + "SELECT * FROM dump_test.ordering_table GROUP BY id; END;", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.ordering_table\E + \n\s+\QADD CONSTRAINT ordering_table_pkey PRIMARY KEY (id);\E + .*^ + \QCREATE FUNCTION dump_test.ordering_func\E""", + _XMS, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE PROCEDURE dump_test.ptest1": { + "create_order": 41, + "create_sql": "CREATE PROCEDURE dump_test.ptest1(a int)\n" + "LANGUAGE SQL AS $$ INSERT INTO dump_test.test_table (col1) VALUES (a) $$;", + "regexp": _qr( + r"""^ + \QCREATE PROCEDURE dump_test.ptest1(IN a integer)\E + \n\s+\QLANGUAGE sql\E + \n\s+AS\ \$\$\Q INSERT INTO dump_test.test_table (col1) VALUES (a) \E\$\$; + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TYPE dump_test.int42 populated": { + "create_order": 42, + "create_sql": "CREATE TYPE dump_test.int42 (\n" + " internallength = 4,\n" + " input = dump_test.int42_in,\n" + " output = dump_test.int42_out,\n" + " alignment = int4,\n" + " default = 42,\n" + " passedbyvalue);", + "regexp": _qr( + r"""^ + \QCREATE TYPE dump_test.int42 (\E + \n\s+\QINTERNALLENGTH = 4,\E + \n\s+\QINPUT = dump_test.int42_in,\E + \n\s+\QOUTPUT = dump_test.int42_out,\E + \n\s+\QDEFAULT = '42',\E + \n\s+\QALIGNMENT = int4,\E + \n\s+\QSTORAGE = plain,\E + \n\s+PASSEDBYVALUE\n\); + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TYPE dump_test.composite": { + "create_order": 43, + "create_sql": "CREATE TYPE dump_test.composite AS (\n" + " f1 int,\n" + " f2 dump_test.int42\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE TYPE dump_test.composite AS (\E + \n\s+\Qf1 integer,\E + \n\s+\Qf2 dump_test.int42\E + \n\); + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TYPE dump_test.undefined": { + "create_order": 39, + "create_sql": "CREATE TYPE dump_test.undefined;", + "regexp": _qr(r"^\QCREATE TYPE dump_test.undefined;\E", _M), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE FOREIGN DATA WRAPPER dummy": { + "create_order": 35, + "create_sql": "CREATE FOREIGN DATA WRAPPER dummy;", + "regexp": _qr(r"CREATE FOREIGN DATA WRAPPER dummy;", _M), + "like": full | {"section_pre_data"}, + }, + "CREATE SERVER s1 FOREIGN DATA WRAPPER dummy": { + "create_order": 36, + "create_sql": "CREATE SERVER s1 FOREIGN DATA WRAPPER dummy;", + "regexp": _qr(r"CREATE SERVER s1 FOREIGN DATA WRAPPER dummy;", _M), + "like": full | {"section_pre_data"}, + }, + "CREATE FOREIGN TABLE dump_test.foreign_table SERVER s1": { + "create_order": 88, + "create_sql": "CREATE FOREIGN TABLE dump_test.foreign_table (c1 int options (column_name 'col1'))\n" + " SERVER s1 OPTIONS (schema_name 'x1');", + "regexp": _qr( + r""" + \QCREATE FOREIGN TABLE dump_test.foreign_table (\E\n + \s+\Qc1 integer\E\n + \Q)\E\n + \QSERVER s1\E\n + \QOPTIONS (\E\n + \s+\Qschema_name 'x1'\E\n + \Q);\E\n + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE USER MAPPING FOR regress_dump_test_role SERVER s1": { + "create_order": 86, + "create_sql": "CREATE USER MAPPING FOR regress_dump_test_role SERVER s1;", + "regexp": _qr( + r"CREATE USER MAPPING FOR regress_dump_test_role SERVER s1;", _M + ), + "like": full | {"section_pre_data"}, + }, + "CREATE TRANSFORM FOR int": { + "create_order": 34, + "create_sql": "CREATE TRANSFORM FOR int LANGUAGE SQL (FROM SQL WITH FUNCTION prsd_lextype(internal), TO SQL WITH FUNCTION int4recv(internal));", + "regexp": _qr( + r"CREATE TRANSFORM FOR integer LANGUAGE sql \(FROM SQL WITH FUNCTION pg_catalog\.prsd_lextype\(internal\), TO SQL WITH FUNCTION pg_catalog\.int4recv\(internal\)\);", + _M, + ), + "like": full | {"section_pre_data"}, + }, + "CREATE LANGUAGE pltestlang": { + "create_order": 18, + "create_sql": "CREATE LANGUAGE pltestlang\n" + "HANDLER dump_test.pltestlang_call_handler;", + "regexp": _qr( + r"""^ + \QCREATE PROCEDURAL LANGUAGE pltestlang \E + \QHANDLER dump_test.pltestlang_call_handler;\E + """, + _XM, + ), + "like": full | {"section_pre_data"}, + "unlike": {"exclude_dump_test_schema"}, + }, + "CREATE MATERIALIZED VIEW matview": { + "create_order": 20, + "create_sql": "CREATE MATERIALIZED VIEW dump_test.matview (col1) AS\n" + "SELECT col1 FROM dump_test.test_table;", + "regexp": _qr( + r"""^ + \QCREATE MATERIALIZED VIEW dump_test.matview AS\E + \n\s+\QSELECT col1\E + \n\s+\QFROM dump_test.test_table\E + \n\s+\QWITH NO DATA;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE MATERIALIZED VIEW matview_second": { + "create_order": 21, + "create_sql": "CREATE MATERIALIZED VIEW\n" + " dump_test.matview_second (col1) AS\n" + " SELECT * FROM dump_test.matview;", + "regexp": _qr( + r"""^ + \QCREATE MATERIALIZED VIEW dump_test.matview_second AS\E + \n\s+\QSELECT col1\E + \n\s+\QFROM dump_test.matview\E + \n\s+\QWITH NO DATA;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE MATERIALIZED VIEW matview_third": { + "create_order": 58, + "create_sql": "CREATE MATERIALIZED VIEW\n" + " dump_test.matview_third (col1) AS\n" + " SELECT * FROM dump_test.matview_second WITH NO DATA;", + "regexp": _qr( + r"""^ + \QCREATE MATERIALIZED VIEW dump_test.matview_third AS\E + \n\s+\QSELECT col1\E + \n\s+\QFROM dump_test.matview_second\E + \n\s+\QWITH NO DATA;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE MATERIALIZED VIEW matview_fourth": { + "create_order": 59, + "create_sql": "CREATE MATERIALIZED VIEW\n" + " dump_test.matview_fourth (col1) AS\n" + " SELECT * FROM dump_test.matview_third WITH NO DATA;", + "regexp": _qr( + r"""^ + \QCREATE MATERIALIZED VIEW dump_test.matview_fourth AS\E + \n\s+\QSELECT col1\E + \n\s+\QFROM dump_test.matview_third\E + \n\s+\QWITH NO DATA;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "Check ordering of a matview that depends on a primary key": { + "create_order": 42, + "create_sql": "\n" + "CREATE MATERIALIZED VIEW dump_test.ordering_view AS\n" + " SELECT * FROM dump_test.ordering_table GROUP BY id;", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.ordering_table\E + \n\s+\QADD CONSTRAINT ordering_table_pkey PRIMARY KEY (id);\E + .*^ + \QCREATE MATERIALIZED VIEW dump_test.ordering_view AS\E + \n\s+\QSELECT id,\E""", + _XMS, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE POLICY p1 ON test_table": { + "create_order": 22, + "create_sql": "CREATE POLICY p1 ON dump_test.test_table\n" + " USING (true)\n" + " WITH CHECK (true);", + "regexp": _qr( + r"""^ + \QCREATE POLICY p1 ON dump_test.test_table \E + \QUSING (true) WITH CHECK (true);\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_policies", + "no_policies_restore", + "only_dump_measurement", + }, + }, + "CREATE POLICY p2 ON test_table FOR SELECT": { + "create_order": 24, + "create_sql": "CREATE POLICY p2 ON dump_test.test_table\n" + " FOR SELECT TO regress_dump_test_role USING (true);", + "regexp": _qr( + r"""^ + \QCREATE POLICY p2 ON dump_test.test_table FOR SELECT TO regress_dump_test_role \E + \QUSING (true);\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_policies", + "no_policies_restore", + "only_dump_measurement", + }, + }, + "CREATE POLICY p3 ON test_table FOR INSERT": { + "create_order": 25, + "create_sql": "CREATE POLICY p3 ON dump_test.test_table\n" + " FOR INSERT TO regress_dump_test_role WITH CHECK (true);", + "regexp": _qr( + r"""^ + \QCREATE POLICY p3 ON dump_test.test_table FOR INSERT \E + \QTO regress_dump_test_role WITH CHECK (true);\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_policies", + "no_policies_restore", + "only_dump_measurement", + }, + }, + "CREATE POLICY p4 ON test_table FOR UPDATE": { + "create_order": 26, + "create_sql": "CREATE POLICY p4 ON dump_test.test_table FOR UPDATE\n" + " TO regress_dump_test_role USING (true) WITH CHECK (true);", + "regexp": _qr( + r"""^ + \QCREATE POLICY p4 ON dump_test.test_table FOR UPDATE TO regress_dump_test_role \E + \QUSING (true) WITH CHECK (true);\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_policies", + "no_policies_restore", + "only_dump_measurement", + }, + }, + "CREATE POLICY p5 ON test_table FOR DELETE": { + "create_order": 27, + "create_sql": "CREATE POLICY p5 ON dump_test.test_table\n" + " FOR DELETE TO regress_dump_test_role USING (true);", + "regexp": _qr( + r"""^ + \QCREATE POLICY p5 ON dump_test.test_table FOR DELETE \E + \QTO regress_dump_test_role USING (true);\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_policies", + "no_policies_restore", + "only_dump_measurement", + }, + }, + "CREATE POLICY p6 ON test_table AS RESTRICTIVE": { + "create_order": 27, + "create_sql": "CREATE POLICY p6 ON dump_test.test_table AS RESTRICTIVE\n" + " USING (false);", + "regexp": _qr( + r"""^ + \QCREATE POLICY p6 ON dump_test.test_table AS RESTRICTIVE \E + \QUSING (false);\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_post_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_policies", + "no_policies_restore", + "only_dump_measurement", + }, + }, + "CREATE PROPERTY GRAPH propgraph": { + "create_order": 20, + "create_sql": "CREATE PROPERTY GRAPH dump_test.propgraph;", + "regexp": _qr( + r"""^ + \QCREATE PROPERTY GRAPH dump_test.propgraph\E; + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE PUBLICATION pub1": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub1;", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub1 WITH (publish = 'insert, update, delete, truncate');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub2": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub2\n" + " FOR ALL TABLES\n" + " WITH (publish = '');", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub2 FOR ALL TABLES WITH (publish = '');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub3": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub3;", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub3 WITH (publish = 'insert, update, delete, truncate');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub4": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub4;", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub4 WITH (publish = 'insert, update, delete, truncate');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub5": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub5 WITH (publish_generated_columns = stored);", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub5 WITH (publish = 'insert, update, delete, truncate', publish_generated_columns = stored);\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub6": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub6\n" " FOR ALL SEQUENCES;", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub6 FOR ALL SEQUENCES WITH (publish = 'insert, update, delete, truncate');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub7": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub7\n" + " FOR ALL SEQUENCES, ALL TABLES\n" + " WITH (publish = '');", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub7 FOR ALL TABLES, ALL SEQUENCES WITH (publish = '');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub8": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub8 FOR ALL TABLES EXCEPT (TABLE dump_test.test_table);", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub8 FOR ALL TABLES EXCEPT (TABLE ONLY dump_test.test_table) WITH (publish = 'insert, update, delete, truncate');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub9": { + "create_order": 50, + "create_sql": "CREATE PUBLICATION pub9 FOR ALL TABLES EXCEPT (TABLE dump_test.test_table, dump_test.test_second_table);", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub9 FOR ALL TABLES EXCEPT (TABLE ONLY dump_test.test_table, TABLE ONLY dump_test.test_second_table) WITH (publish = 'insert, update, delete, truncate');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE PUBLICATION pub10": { + "create_order": 92, + "create_sql": "CREATE PUBLICATION pub10 FOR ALL TABLES EXCEPT (TABLE dump_test.test_inheritance_parent);", + "regexp": _qr( + r"""^ + \QCREATE PUBLICATION pub10 FOR ALL TABLES EXCEPT (TABLE ONLY dump_test.test_inheritance_parent, TABLE ONLY dump_test.test_inheritance_child) WITH (publish = 'insert, update, delete, truncate');\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE SUBSCRIPTION sub1": { + "create_order": 50, + "create_sql": "CREATE SUBSCRIPTION sub1\n" + " CONNECTION 'dbname=doesnotexist' PUBLICATION pub1\n" + " WITH (connect = false);", + "regexp": _qr( + r"""^ + \QCREATE SUBSCRIPTION sub1 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub1', streaming = parallel);\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + "unlike": { + "no_subscriptions", + "no_subscriptions_restore", + }, + }, + "CREATE SUBSCRIPTION sub2": { + "create_order": 50, + "create_sql": "CREATE SUBSCRIPTION sub2\n" + " CONNECTION 'dbname=doesnotexist' PUBLICATION pub1\n" + " WITH (connect = false, origin = none, streaming = off);", + "regexp": _qr( + r"""^ + \QCREATE SUBSCRIPTION sub2 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub2', streaming = off, origin = none);\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + "unlike": { + "no_subscriptions", + "no_subscriptions_restore", + }, + }, + "CREATE SUBSCRIPTION sub3": { + "create_order": 50, + "create_sql": "CREATE SUBSCRIPTION sub3\n" + " CONNECTION 'dbname=doesnotexist' PUBLICATION pub1\n" + " WITH (connect = false, origin = any, streaming = on);", + "regexp": _qr( + r"""^ + \QCREATE SUBSCRIPTION sub3 CONNECTION 'dbname=doesnotexist' PUBLICATION pub1 WITH (connect = false, slot_name = 'sub3', streaming = on);\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + "unlike": { + "no_subscriptions", + "no_subscriptions_restore", + }, + }, + "ALTER PUBLICATION pub1 ADD TABLE test_table": { + "create_order": 51, + "create_sql": "ALTER PUBLICATION pub1 ADD TABLE dump_test.test_table;", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub1 ADD TABLE ONLY dump_test.test_table;\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "ALTER PUBLICATION pub1 ADD TABLE test_second_table": { + "create_order": 52, + "create_sql": "ALTER PUBLICATION pub1 ADD TABLE dump_test.test_second_table;", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub1 ADD TABLE ONLY dump_test.test_second_table;\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "ALTER PUBLICATION pub1 ADD TABLE test_sixth_table (col3, col2)": { + "create_order": 52, + "create_sql": "ALTER PUBLICATION pub1 ADD TABLE dump_test.test_sixth_table (col3, col2);", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub1 ADD TABLE ONLY dump_test.test_sixth_table (col2, col3);\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "ALTER PUBLICATION pub1 ADD TABLE test_seventh_table (col3, col2) WHERE (col1 = 1)": { + "create_order": 52, + "create_sql": "ALTER PUBLICATION pub1 ADD TABLE dump_test.test_seventh_table (col3, col2) WHERE (col1 = 1);", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub1 ADD TABLE ONLY dump_test.test_seventh_table (col2, col3) WHERE ((col1 = 1));\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "ALTER PUBLICATION pub3 ADD TABLES IN SCHEMA dump_test": { + "create_order": 51, + "create_sql": "ALTER PUBLICATION pub3 ADD TABLES IN SCHEMA dump_test;", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub3 ADD TABLES IN SCHEMA dump_test;\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "ALTER PUBLICATION pub3 ADD TABLES IN SCHEMA public": { + "create_order": 52, + "create_sql": "ALTER PUBLICATION pub3 ADD TABLES IN SCHEMA public;", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub3 ADD TABLES IN SCHEMA public;\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "ALTER PUBLICATION pub3 ADD TABLE test_table": { + "create_order": 51, + "create_sql": "ALTER PUBLICATION pub3 ADD TABLE dump_test.test_table;", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub3 ADD TABLE ONLY dump_test.test_table;\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "ALTER PUBLICATION pub4 ADD TABLE test_table WHERE (col1 > 0);": { + "create_order": 51, + "create_sql": "ALTER PUBLICATION pub4 ADD TABLE dump_test.test_table WHERE (col1 > 0);", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub4 ADD TABLE ONLY dump_test.test_table WHERE ((col1 > 0));\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "ALTER PUBLICATION pub4 ADD TABLE test_second_table WHERE (col2 = 'test');": { + "create_order": 52, + "create_sql": "ALTER PUBLICATION pub4 ADD TABLE dump_test.test_second_table WHERE (col2 = 'test');", + "regexp": _qr( + r"""^ + \QALTER PUBLICATION pub4 ADD TABLE ONLY dump_test.test_second_table WHERE ((col2 = 'test'::text));\E + """, + _XM, + ), + "like": full | {"section_post_data"}, + }, + "CREATE SCHEMA public": { + "regexp": _qr(r"^CREATE SCHEMA public;", _M), + "like": set(), + }, + "CREATE SCHEMA dump_test": { + "create_order": 2, + "create_sql": "CREATE SCHEMA dump_test;", + "regexp": _qr(r"^CREATE SCHEMA dump_test;", _M), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE SCHEMA dump_test_second_schema": { + "create_order": 9, + "create_sql": "CREATE SCHEMA dump_test_second_schema;", + "regexp": _qr(r"^CREATE SCHEMA dump_test_second_schema;", _M), + "like": full + | { + "role", + "section_pre_data", + }, + }, + } + + +def _tests_part3(full, dts): + return { + "CREATE TABLE test_table": { + "create_order": 3, + "create_sql": "CREATE TABLE dump_test.test_table (\n" + " col1 serial primary key,\n" + " col2 text COMPRESSION pglz,\n" + " col3 text,\n" + " col4 text,\n" + " CHECK (col1 <= 1000)\n" + ") WITH (autovacuum_enabled = false, fillfactor=80);\n" + "COMMENT ON CONSTRAINT test_table_col1_check\n" + " ON dump_test.test_table IS 'bounds check';", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table (\E\n + \s+\Qcol1 integer NOT NULL,\E\n + \s+\Qcol2 text,\E\n + \s+\Qcol3 text,\E\n + \s+\Qcol4 text,\E\n + \s+\QCONSTRAINT test_table_col1_check CHECK ((col1 <= 1000))\E\n + \Q)\E\n + \QWITH (autovacuum_enabled='false', fillfactor='80');\E\n(.|\n)* + \QCOMMENT ON CONSTRAINT test_table_col1_check ON dump_test.test_table IS 'bounds check';\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "CREATE TABLE fk_reference_test_table": { + "create_order": 21, + "create_sql": "CREATE TABLE dump_test.fk_reference_test_table (\n" + " col1 int primary key references dump_test.test_table\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.fk_reference_test_table (\E + \n\s+\Qcol1 integer NOT NULL\E + \n\); + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_second_table": { + "create_order": 6, + "create_sql": "CREATE TABLE dump_test.test_second_table (\n" + " col1 int,\n" + " col2 text\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_second_table (\E + \n\s+\Qcol1 integer,\E + \n\s+\Qcol2 text\E + \n\); + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE measurement PARTITIONED BY": { + "create_order": 90, + "create_sql": "CREATE TABLE dump_test.measurement (\n" + "city_id serial not null,\n" + "logdate date not null,\n" + "peaktemp int CHECK (peaktemp >= -460),\n" + "unitsales int\n" + ") PARTITION BY RANGE (logdate);", + "regexp": _qr( + r"""^ + \Q-- Name: measurement;\E.*\n + \Q--\E\n\n + \QCREATE TABLE dump_test.measurement (\E\n + \s+\Qcity_id integer NOT NULL,\E\n + \s+\Qlogdate date NOT NULL,\E\n + \s+\Qpeaktemp integer,\E\n + \s+\Qunitsales integer,\E\n + \s+\QCONSTRAINT measurement_peaktemp_check CHECK ((peaktemp >= '-460'::integer))\E\n + \)\n + \QPARTITION BY RANGE (logdate);\E\n + """, + _XM, + ), + "like": full + | dts + | { + "section_pre_data", + "only_dump_measurement", + }, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "exclude_measurement", + }, + }, + "Partition measurement_y2006m2 creation": { + "create_order": 91, + "create_sql": "CREATE TABLE dump_test_second_schema.measurement_y2006m2\n" + "PARTITION OF dump_test.measurement (\n" + " unitsales DEFAULT 0 CHECK (unitsales >= 0)\n" + ")\n" + "FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test_second_schema.measurement_y2006m2 (\E\n + \s+\Qcity_id integer DEFAULT nextval('dump_test.measurement_city_id_seq'::regclass) CONSTRAINT measurement_city_id_not_null NOT NULL,\E\n + \s+\Qlogdate date CONSTRAINT measurement_logdate_not_null NOT NULL,\E\n + \s+\Qpeaktemp integer,\E\n + \s+\Qunitsales integer DEFAULT 0,\E\n + \s+\QCONSTRAINT measurement_peaktemp_check CHECK ((peaktemp >= '-460'::integer)),\E\n + \s+\QCONSTRAINT measurement_y2006m2_unitsales_check CHECK ((unitsales >= 0))\E\n + \);\n + """, + _XM, + ), + "like": full + | { + "section_pre_data", + "role", + "binary_upgrade", + "only_dump_measurement", + }, + "unlike": {"exclude_measurement"}, + }, + "Creation of row-level trigger in partitioned table": { + "create_order": 92, + "create_sql": "CREATE TRIGGER test_trigger\n" + " AFTER INSERT ON dump_test.measurement\n" + " FOR EACH ROW EXECUTE PROCEDURE dump_test.trigger_func()", + "regexp": _qr( + r"""^ + \QCREATE TRIGGER test_trigger AFTER INSERT ON dump_test.measurement \E + \QFOR EACH ROW \E + \QEXECUTE FUNCTION dump_test.trigger_func();\E + """, + _XM, + ), + "like": full + | dts + | { + "section_post_data", + "only_dump_measurement", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_measurement", + }, + }, + "COPY measurement": { + "create_order": 93, + "create_sql": "INSERT INTO dump_test.measurement (city_id, logdate, peaktemp, unitsales) " + "VALUES (1, '2006-02-12', 35, 1);", + "regexp": _qr( + r"""^ + \QCOPY dump_test_second_schema.measurement_y2006m2 (city_id, logdate, peaktemp, unitsales) FROM stdin;\E + \n(?:1\t2006-02-12\t35\t1\n)\\\.\n + """, + _XM, + ), + "like": full + | dts + | { + "data_only", + "no_schema", + "only_dump_measurement", + "section_data", + "only_dump_test_schema", + "role_parallel", + "role", + }, + "unlike": { + "binary_upgrade", + "schema_only", + "schema_only_with_statistics", + "exclude_measurement", + "only_dump_test_schema", + "test_schema_plus_large_objects", + "exclude_measurement_data", + }, + }, + "Disabled trigger on partition is altered": { + "create_order": 93, + "create_sql": "CREATE TABLE dump_test_second_schema.measurement_y2006m3\n" + "PARTITION OF dump_test.measurement\n" + "FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');\n" + "ALTER TABLE dump_test_second_schema.measurement_y2006m3 DISABLE TRIGGER test_trigger;\n" + "CREATE TABLE dump_test_second_schema.measurement_y2006m4\n" + "PARTITION OF dump_test.measurement\n" + "FOR VALUES FROM ('2006-04-01') TO ('2006-05-01');\n" + "ALTER TABLE dump_test_second_schema.measurement_y2006m4 ENABLE REPLICA TRIGGER test_trigger;\n" + "CREATE TABLE dump_test_second_schema.measurement_y2006m5\n" + "PARTITION OF dump_test.measurement\n" + "FOR VALUES FROM ('2006-05-01') TO ('2006-06-01');\n" + "ALTER TABLE dump_test_second_schema.measurement_y2006m5 ENABLE ALWAYS TRIGGER test_trigger;\n", + "regexp": _qr( + r"""^ + \QALTER TABLE dump_test_second_schema.measurement_y2006m3 DISABLE TRIGGER test_trigger;\E + """, + _XM, + ), + "like": full + | { + "section_post_data", + "role", + "binary_upgrade", + "only_dump_measurement", + }, + "unlike": {"exclude_measurement"}, + }, + "Replica trigger on partition is altered": { + "regexp": _qr( + r"""^ + \QALTER TABLE dump_test_second_schema.measurement_y2006m4 ENABLE REPLICA TRIGGER test_trigger;\E + """, + _XM, + ), + "like": full + | { + "section_post_data", + "role", + "binary_upgrade", + "only_dump_measurement", + }, + "unlike": {"exclude_measurement"}, + }, + "Always trigger on partition is altered": { + "regexp": _qr( + r"""^ + \QALTER TABLE dump_test_second_schema.measurement_y2006m5 ENABLE ALWAYS TRIGGER test_trigger;\E + """, + _XM, + ), + "like": full + | { + "section_post_data", + "role", + "binary_upgrade", + "only_dump_measurement", + }, + "unlike": {"exclude_measurement"}, + }, + "Disabled trigger on partition is not created": { + "regexp": _qr(r"CREATE TRIGGER test_trigger.*ON dump_test_second_schema"), + "like": set(), + }, + "Triggers on partitions are not dropped": { + "regexp": _qr(r"DROP TRIGGER test_trigger.*ON dump_test_second_schema"), + "like": set(), + }, + "CREATE TABLE test_third_table_generated_cols": { + "create_order": 6, + "create_sql": "CREATE TABLE dump_test.test_third_table (\n" + "f1 int, junk int,\n" + "g1 int generated always as (f1 * 2) stored,\n" + '"F3" int,\n' + 'g2 int generated always as ("F3" * 3) stored\n' + ");\n" + "ALTER TABLE dump_test.test_third_table DROP COLUMN junk;", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_third_table (\E\n + \s+\Qf1 integer,\E\n + \s+\Qg1 integer GENERATED ALWAYS AS ((f1 * 2)) STORED,\E\n + \s+\Q"F3" integer,\E\n + \s+\Qg2 integer GENERATED ALWAYS AS (("F3" * 3)) STORED\E\n + \);\n + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_fourth_table_zero_col": { + "create_order": 6, + "create_sql": "CREATE TABLE dump_test.test_fourth_table (\n" ");", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_fourth_table (\E + \n\); + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_fifth_table": { + "create_order": 53, + "create_sql": "CREATE TABLE dump_test.test_fifth_table (\n" + " col1 integer,\n" + " col2 boolean,\n" + " col3 boolean,\n" + " col4 bit(5),\n" + " col5 float8\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_fifth_table (\E + \n\s+\Qcol1 integer,\E + \n\s+\Qcol2 boolean,\E + \n\s+\Qcol3 boolean,\E + \n\s+\Qcol4 bit(5),\E + \n\s+\Qcol5 double precision\E + \n\); + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_sixth_table": { + "create_order": 6, + "create_sql": "CREATE TABLE dump_test.test_sixth_table (\n" + " col1 int,\n" + " col2 text,\n" + " col3 bytea\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_sixth_table (\E + \n\s+\Qcol1 integer,\E + \n\s+\Qcol2 text,\E + \n\s+\Qcol3 bytea\E + \n\); + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_seventh_table": { + "create_order": 6, + "create_sql": "CREATE TABLE dump_test.test_seventh_table (\n" + " col1 int,\n" + " col2 text,\n" + " col3 bytea\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_seventh_table (\E + \n\s+\Qcol1 integer,\E + \n\s+\Qcol2 text,\E + \n\s+\Qcol3 bytea\E + \n\); + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_table_identity": { + "create_order": 3, + "create_sql": "CREATE TABLE dump_test.test_table_identity (\n" + " col1 int generated always as identity primary key,\n" + " col2 text\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_identity (\E\n + \s+\Qcol1 integer NOT NULL,\E\n + \s+\Qcol2 text\E\n + \); + .* + \QALTER TABLE dump_test.test_table_identity ALTER COLUMN col1 ADD GENERATED ALWAYS AS IDENTITY (\E\n + \s+\QSEQUENCE NAME dump_test.test_table_identity_col1_seq\E\n + \s+\QSTART WITH 1\E\n + \s+\QINCREMENT BY 1\E\n + \s+\QNO MINVALUE\E\n + \s+\QNO MAXVALUE\E\n + \s+\QCACHE 1\E\n + \); + """, + _XMS, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_table_generated": { + "create_order": 3, + "create_sql": "CREATE TABLE dump_test.test_table_generated (\n" + " col1 int primary key,\n" + " col2 int generated always as (col1 * 2) stored,\n" + " col3 int generated always as (col1 * 3) virtual\n" + ");", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_generated (\E\n + \s+\Qcol1 integer NOT NULL,\E\n + \s+\Qcol2 integer GENERATED ALWAYS AS ((col1 * 2)) STORED,\E\n + \s+\Qcol3 integer GENERATED ALWAYS AS ((col1 * 3))\E\n + \); + """, + _XMS, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_table_generated_child1 (without local columns)": { + "create_order": 4, + "create_sql": "CREATE TABLE dump_test.test_table_generated_child1 ()\n" + " INHERITS (dump_test.test_table_generated);", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_generated_child1 (\E\n + \)\n + \QINHERITS (dump_test.test_table_generated);\E\n + """, + _XMS, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "ALTER TABLE test_table_generated_child1": { + "regexp": _qr( + r"^\QALTER TABLE ONLY dump_test.test_table_generated_child1 ALTER COLUMN col2 \E", + _M, + ), + "like": set(), + }, + "CREATE TABLE test_table_generated_child2 (with local columns)": { + "create_order": 4, + "create_sql": "CREATE TABLE dump_test.test_table_generated_child2 (\n" + " col1 int,\n" + " col2 int\n" + " ) INHERITS (dump_test.test_table_generated);", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_table_generated_child2 (\E\n + \s+\Qcol1 integer,\E\n + \s+\Qcol2 integer\E\n + \)\n + \QINHERITS (dump_test.test_table_generated);\E\n + """, + _XMS, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE table_with_stats": { + "create_order": 98, + "create_sql": "CREATE TABLE dump_test.table_index_stats (\n" + " col1 int,\n" + " col2 int,\n" + " col3 int);\n" + " CREATE INDEX index_with_stats\n" + " ON dump_test.table_index_stats\n" + " ((col1 + 1), col1, (col2 + 1), (col3 + 1));\n" + " ALTER INDEX dump_test.index_with_stats\n" + " ALTER COLUMN 1 SET STATISTICS 400;\n" + " ALTER INDEX dump_test.index_with_stats\n" + " ALTER COLUMN 3 SET STATISTICS 500;", + "regexp": _qr( + r"""^ + \QALTER INDEX dump_test.index_with_stats ALTER COLUMN 1 SET STATISTICS 400;\E\n + \QALTER INDEX dump_test.index_with_stats ALTER COLUMN 3 SET STATISTICS 500;\E\n + """, + _XMS, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_inheritance_parent": { + "create_order": 90, + "create_sql": "CREATE TABLE dump_test.test_inheritance_parent (\n" + " col1 int NOT NULL,\n" + " col2 int CHECK (col2 >= 42)\n" + " );", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_inheritance_parent (\E\n + \s+\Qcol1 integer NOT NULL,\E\n + \s+\Qcol2 integer,\E\n + \s+\QCONSTRAINT test_inheritance_parent_col2_check CHECK ((col2 >= 42))\E\n + \Q);\E\n + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE TABLE test_inheritance_child": { + "create_order": 91, + "create_sql": "CREATE TABLE dump_test.test_inheritance_child (\n" + " col1 int NOT NULL,\n" + " CONSTRAINT test_inheritance_child CHECK (col2 >= 142857)\n" + ") INHERITS (dump_test.test_inheritance_parent);", + "regexp": _qr( + r"""^ + \QCREATE TABLE dump_test.test_inheritance_child (\E\n + \s+\Qcol1 integer NOT NULL,\E\n + \s+\QCONSTRAINT test_inheritance_child CHECK ((col2 >= 142857))\E\n + \)\n + \QINHERITS (dump_test.test_inheritance_parent);\E\n + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE STATISTICS extended_stats_no_options": { + "create_order": 97, + "create_sql": "CREATE STATISTICS dump_test.test_ext_stats_no_options\n" + " ON col1, col2 FROM dump_test.test_table", + "regexp": _qr( + r"""^ + \QCREATE STATISTICS dump_test.test_ext_stats_no_options ON col1, col2 FROM dump_test.test_table;\E + """, + _XMS, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "only_dump_measurement", + }, + }, + "CREATE STATISTICS extended_stats_options": { + "create_order": 97, + "create_sql": "CREATE STATISTICS dump_test.test_ext_stats_opts\n" + " (ndistinct) ON col1, col2 FROM dump_test.test_fifth_table", + "regexp": _qr( + r"""^ + \QCREATE STATISTICS dump_test.test_ext_stats_opts (ndistinct) ON col1, col2 FROM dump_test.test_fifth_table;\E + """, + _XMS, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "ALTER STATISTICS extended_stats_options": { + "create_order": 98, + "create_sql": "ALTER STATISTICS dump_test.test_ext_stats_opts SET STATISTICS 1000", + "regexp": _qr( + r"""^ + \QALTER STATISTICS dump_test.test_ext_stats_opts SET STATISTICS 1000;\E + """, + _XMS, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE STATISTICS extended_stats_expression": { + "create_order": 99, + "create_sql": "CREATE STATISTICS dump_test.test_ext_stats_expr\n" + " ON (2 * col1) FROM dump_test.test_fifth_table", + "regexp": _qr( + r"""^ + \QCREATE STATISTICS dump_test.test_ext_stats_expr ON (2 * col1) FROM dump_test.test_fifth_table;\E + """, + _XMS, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE SEQUENCE test_table_col1_seq": { + "regexp": _qr( + r"""^ + \QCREATE SEQUENCE dump_test.test_table_col1_seq\E + \n\s+\QAS integer\E + \n\s+\QSTART WITH 1\E + \n\s+\QINCREMENT BY 1\E + \n\s+\QNO MINVALUE\E + \n\s+\QNO MAXVALUE\E + \n\s+\QCACHE 1;\E + """, + _XM, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "CREATE INDEX ON ONLY measurement": { + "create_order": 92, + "create_sql": "CREATE INDEX ON dump_test.measurement (city_id, logdate);", + "regexp": _qr( + r"""^ + \QCREATE INDEX measurement_city_id_logdate_idx ON ONLY dump_test.measurement USING\E + """, + _XM, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "exclude_dump_test_schema", + "exclude_measurement", + }, + }, + "ALTER TABLE measurement PRIMARY KEY": { + "catch_all": "CREATE ... commands", + "create_order": 93, + "create_sql": "ALTER TABLE dump_test.measurement ADD PRIMARY KEY (city_id, logdate);", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.measurement\E \n^\s+ + \QADD CONSTRAINT measurement_pkey PRIMARY KEY (city_id, logdate);\E + """, + _XM, + ), + "like": full + | dts + | { + "section_post_data", + "only_dump_measurement", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_measurement", + }, + }, + "CREATE INDEX ... ON measurement_y2006_m2": { + "regexp": _qr( + r"""^ + \QCREATE INDEX measurement_y2006m2_city_id_logdate_idx ON dump_test_second_schema.measurement_y2006m2 \E + """, + _XM, + ), + "like": full + | { + "role", + "section_post_data", + "only_dump_measurement", + }, + "unlike": {"exclude_measurement"}, + }, + "ALTER INDEX ... ATTACH PARTITION": { + "regexp": _qr( + r"""^ + \QALTER INDEX dump_test.measurement_city_id_logdate_idx ATTACH PARTITION dump_test_second_schema.measurement_y2006m2_city_id_logdate_idx\E + """, + _XM, + ), + "like": full + | { + "role", + "section_post_data", + "only_dump_measurement", + }, + "unlike": {"exclude_measurement"}, + }, + "ALTER INDEX ... ATTACH PARTITION (primary key)": { + "catch_all": "CREATE ... commands", + "regexp": _qr( + r"""^ + \QALTER INDEX dump_test.measurement_pkey ATTACH PARTITION dump_test_second_schema.measurement_y2006m2_pkey\E + """, + _XM, + ), + "like": full + | { + "role", + "section_post_data", + "only_dump_measurement", + }, + "unlike": {"exclude_measurement"}, + }, + "CREATE VIEW test_view": { + "create_order": 61, + "create_sql": "CREATE VIEW dump_test.test_view\n" + " WITH (check_option = 'local', security_barrier = true) AS\n" + " SELECT col1 FROM dump_test.test_table;", + "regexp": _qr( + r"""^ + \QCREATE VIEW dump_test.test_view WITH (security_barrier='true') AS\E + \n\s+\QSELECT col1\E + \n\s+\QFROM dump_test.test_table\E + \n\s+\QWITH LOCAL CHECK OPTION;\E""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "ALTER VIEW test_view SET DEFAULT": { + "create_order": 62, + "create_sql": "ALTER VIEW dump_test.test_view ALTER COLUMN col1 SET DEFAULT 1;", + "regexp": _qr( + r"""^ + \QALTER TABLE ONLY dump_test.test_view ALTER COLUMN col1 SET DEFAULT 1;\E""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "only_dump_measurement", + }, + }, + "DROP SCHEMA public (for testing without public schema)": { + "database": "regress_pg_dump_test", + "create_order": 100, + "create_sql": "DROP SCHEMA public;", + "regexp": _qr(r"^DROP SCHEMA public;", _M), + "like": set(), + }, + "DROP SCHEMA public": { + "regexp": _qr(r"^DROP SCHEMA public;", _M), + "like": set(), + }, + "DROP SCHEMA IF EXISTS public": { + "regexp": _qr(r"^DROP SCHEMA IF EXISTS public;", _M), + "like": set(), + }, + "DROP EXTENSION plpgsql": { + "regexp": _qr(r"^DROP EXTENSION plpgsql;", _M), + "like": set(), + }, + "DROP FUNCTION dump_test.pltestlang_call_handler()": { + "regexp": _qr( + r"^DROP FUNCTION dump_test\.pltestlang_call_handler\(\);", _M + ), + "like": {"clean"}, + }, + "DROP LANGUAGE pltestlang": { + "regexp": _qr(r"^DROP PROCEDURAL LANGUAGE pltestlang;", _M), + "like": {"clean"}, + }, + "DROP SCHEMA dump_test": { + "regexp": _qr(r"^DROP SCHEMA dump_test;", _M), + "like": {"clean"}, + }, + "DROP SCHEMA dump_test_second_schema": { + "regexp": _qr(r"^DROP SCHEMA dump_test_second_schema;", _M), + "like": {"clean"}, + }, + "DROP TABLE test_table": { + "regexp": _qr(r"^DROP TABLE dump_test\.test_table;", _M), + "like": {"clean"}, + }, + "DROP TABLE fk_reference_test_table": { + "regexp": _qr(r"^DROP TABLE dump_test\.fk_reference_test_table;", _M), + "like": {"clean"}, + }, + "DROP TABLE test_second_table": { + "regexp": _qr(r"^DROP TABLE dump_test\.test_second_table;", _M), + "like": {"clean"}, + }, + "DROP EXTENSION IF EXISTS plpgsql": { + "regexp": _qr(r"^DROP EXTENSION IF EXISTS plpgsql;", _M), + "like": set(), + }, + "DROP FUNCTION IF EXISTS dump_test.pltestlang_call_handler()": { + "regexp": _qr( + r"""^ + \QDROP FUNCTION IF EXISTS dump_test.pltestlang_call_handler();\E + """, + _XM, + ), + "like": {"clean_if_exists"}, + }, + "DROP LANGUAGE IF EXISTS pltestlang": { + "regexp": _qr(r"^DROP PROCEDURAL LANGUAGE IF EXISTS pltestlang;", _M), + "like": {"clean_if_exists"}, + }, + "DROP SCHEMA IF EXISTS dump_test": { + "regexp": _qr(r"^DROP SCHEMA IF EXISTS dump_test;", _M), + "like": {"clean_if_exists"}, + }, + "DROP SCHEMA IF EXISTS dump_test_second_schema": { + "regexp": _qr(r"^DROP SCHEMA IF EXISTS dump_test_second_schema;", _M), + "like": {"clean_if_exists"}, + }, + "DROP TABLE IF EXISTS test_table": { + "regexp": _qr(r"^DROP TABLE IF EXISTS dump_test\.test_table;", _M), + "like": {"clean_if_exists"}, + }, + "DROP TABLE IF EXISTS test_second_table": { + "regexp": _qr(r"^DROP TABLE IF EXISTS dump_test\.test_second_table;", _M), + "like": {"clean_if_exists"}, + }, + "DROP ROLE regress_dump_test_role": { + "regexp": _qr( + r"""^ + \QDROP ROLE regress_dump_test_role;\E + """, + _XM, + ), + "like": {"pg_dumpall_globals_clean"}, + }, + "DROP ROLE pg_": { + "regexp": _qr( + r"""^ + \QDROP ROLE pg_\E.+; + """, + _XM, + ), + "like": set(), + }, + "GRANT USAGE ON SCHEMA dump_test_second_schema": { + "create_order": 10, + "create_sql": "GRANT USAGE ON SCHEMA dump_test_second_schema\n" + " TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT USAGE ON SCHEMA dump_test_second_schema TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full + | { + "role", + "section_pre_data", + }, + "unlike": {"no_privs"}, + }, + "GRANT USAGE ON FOREIGN DATA WRAPPER dummy": { + "create_order": 85, + "create_sql": "GRANT USAGE ON FOREIGN DATA WRAPPER dummy\n" + " TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON FOREIGN DATA WRAPPER dummy TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | {"section_pre_data"}, + "unlike": {"no_privs"}, + }, + "GRANT USAGE ON FOREIGN SERVER s1": { + "create_order": 85, + "create_sql": "GRANT USAGE ON FOREIGN SERVER s1\n" + " TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON FOREIGN SERVER s1 TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | {"section_pre_data"}, + "unlike": {"no_privs"}, + }, + "GRANT USAGE ON DOMAIN dump_test.us_postal_code": { + "create_order": 72, + "create_sql": "GRANT USAGE ON DOMAIN dump_test.us_postal_code TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON TYPE dump_test.us_postal_code TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "only_dump_measurement", + }, + }, + "GRANT USAGE ON TYPE dump_test.int42": { + "create_order": 87, + "create_sql": "GRANT USAGE ON TYPE dump_test.int42 TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON TYPE dump_test.int42 TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "only_dump_measurement", + }, + }, + "GRANT USAGE ON TYPE dump_test.planets - ENUM": { + "create_order": 66, + "create_sql": "GRANT USAGE ON TYPE dump_test.planets TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON TYPE dump_test.planets TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "only_dump_measurement", + }, + }, + "GRANT USAGE ON TYPE dump_test.textrange - RANGE": { + "create_order": 67, + "create_sql": "GRANT USAGE ON TYPE dump_test.textrange TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON TYPE dump_test.textrange TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "only_dump_measurement", + }, + }, + "GRANT CREATE ON DATABASE dump_test": { + "create_order": 48, + "create_sql": "GRANT CREATE ON DATABASE dump_test TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT CREATE ON DATABASE dump_test TO regress_dump_test_role;\E + """, + _XM, + ), + "like": {"pg_dumpall_dbprivs"}, + }, + "GRANT SELECT ON TABLE test_table": { + "create_order": 5, + "create_sql": "GRANT SELECT ON TABLE dump_test.test_table\n" + " TO regress_dump_test_role;", + "regexp": _qr( + r"^\QGRANT SELECT ON TABLE dump_test.test_table TO regress_dump_test_role;\E", + _M, + ), + "like": full + | dts + | { + "only_dump_test_table", + "section_pre_data", + }, + "unlike": { + "exclude_dump_test_schema", + "exclude_test_table", + "no_privs", + "only_dump_measurement", + }, + }, + "GRANT SELECT ON TABLE measurement": { + "create_order": 91, + "create_sql": "GRANT SELECT ON TABLE dump_test.measurement\n" + " TO regress_dump_test_role;\n" + "GRANT SELECT(city_id) ON TABLE dump_test.measurement\n" + ' TO "regress_quoted \\"" role";', + "regexp": _qr( + r"""^\QGRANT SELECT ON TABLE dump_test.measurement TO regress_dump_test_role;\E\n.* + ^\QGRANT SELECT(city_id) ON TABLE dump_test.measurement TO "regress_quoted \"" role";\E""", + _XMS, + ), + "like": full + | dts + | { + "section_pre_data", + "only_dump_measurement", + }, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "exclude_measurement", + }, + }, + "GRANT SELECT ON TABLE measurement_y2006m2": { + "create_order": 94, + "create_sql": "GRANT SELECT ON TABLE\n" + " dump_test_second_schema.measurement_y2006m2,\n" + " dump_test_second_schema.measurement_y2006m3,\n" + " dump_test_second_schema.measurement_y2006m4,\n" + " dump_test_second_schema.measurement_y2006m5\n" + " TO regress_dump_test_role;", + "regexp": _qr( + r"^\QGRANT SELECT ON TABLE dump_test_second_schema.measurement_y2006m2 TO regress_dump_test_role;\E", + _M, + ), + "like": full + | { + "role", + "section_pre_data", + "only_dump_measurement", + }, + "unlike": { + "no_privs", + "exclude_measurement", + }, + }, + "GRANT ALL ON LARGE OBJECT ...": { + "create_order": 60, + "create_sql": "DO $$\n" + " DECLARE myoid oid;\n" + " BEGIN\n" + " SELECT loid FROM pg_largeobject INTO myoid;\n" + " EXECUTE 'GRANT ALL ON LARGE OBJECT ' || myoid || ' TO regress_dump_test_role;';\n" + " END;\n" + " $$;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON LARGE OBJECT \E[0-9]+\Q TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full + | { + "column_inserts", + "data_only", + "inserts", + "no_schema", + "section_data", + "test_schema_plus_large_objects", + }, + "unlike": { + "binary_upgrade", + "no_large_objects", + "no_privs", + "schema_only", + "schema_only_with_statistics", + }, + }, + "GRANT INSERT(col1) ON TABLE test_second_table": { + "create_order": 8, + "create_sql": "GRANT INSERT (col1) ON TABLE dump_test.test_second_table\n" + " TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT INSERT(col1) ON TABLE dump_test.test_second_table TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "only_dump_measurement", + }, + }, + "GRANT SELECT ON PROPERTY GRAPH propgraph": { + "create_order": 21, + "create_sql": "GRANT SELECT ON PROPERTY GRAPH dump_test.propgraph TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON PROPERTY GRAPH dump_test.propgraph TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_privs", + "only_dump_measurement", + }, + }, + "GRANT EXECUTE ON FUNCTION pg_sleep() TO regress_dump_test_role": { + "create_order": 16, + "create_sql": "GRANT EXECUTE ON FUNCTION pg_sleep(float8)\n" + " TO regress_dump_test_role;", + "regexp": _qr( + r"""^ + \QGRANT ALL ON FUNCTION pg_catalog.pg_sleep(double precision) TO regress_dump_test_role;\E + """, + _XM, + ), + "like": full | {"section_pre_data"}, + "unlike": {"no_privs"}, + }, + "GRANT SELECT (proname ...) ON TABLE pg_proc TO public": { + "create_order": 46, + "create_sql": "GRANT SELECT (\n" + " tableoid,\n" + " oid,\n" + " proname,\n" + " pronamespace,\n" + " proowner,\n" + " prolang,\n" + " procost,\n" + " prorows,\n" + " provariadic,\n" + " prosupport,\n" + " prokind,\n" + " prosecdef,\n" + " proleakproof,\n" + " proisstrict,\n" + " proretset,\n" + " provolatile,\n" + " proparallel,\n" + " pronargs,\n" + " pronargdefaults,\n" + " prorettype,\n" + " proargtypes,\n" + " proallargtypes,\n" + " proargmodes,\n" + " proargnames,\n" + " proargdefaults,\n" + " protrftypes,\n" + " prosrc,\n" + " probin,\n" + " proconfig,\n" + " proacl\n" + ") ON TABLE pg_proc TO public;", + "regexp": _qr( + r""" + \QGRANT SELECT(tableoid) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(oid) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proname) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(pronamespace) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proowner) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(prolang) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(procost) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(prorows) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(provariadic) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(prosupport) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(prokind) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(prosecdef) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proleakproof) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proisstrict) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proretset) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(provolatile) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proparallel) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(pronargs) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(pronargdefaults) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(prorettype) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proargtypes) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proallargtypes) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proargmodes) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proargnames) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proargdefaults) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(protrftypes) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(prosrc) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(probin) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proconfig) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E\n.* + \QGRANT SELECT(proacl) ON TABLE pg_catalog.pg_proc TO PUBLIC;\E""", + _XMS, + ), + "like": full | {"section_pre_data"}, + "unlike": {"no_privs"}, + }, + "GRANT USAGE ON SCHEMA public TO public": { + "regexp": _qr( + r"""^ + \Q--\E\n\n + \QGRANT USAGE ON SCHEMA public TO PUBLIC;\E + """, + _XM, + ), + "like": set(), + }, + "REFRESH MATERIALIZED VIEW matview": { + "regexp": _qr(r"^\QREFRESH MATERIALIZED VIEW dump_test.matview;\E", _M), + "like": full | dts | {"section_post_data"}, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "REFRESH MATERIALIZED VIEW matview_second": { + "regexp": _qr( + r"""^ + \QREFRESH MATERIALIZED VIEW dump_test.matview;\E + \n.* + \QREFRESH MATERIALIZED VIEW dump_test.matview_second;\E + """, + _XMS, + ), + "like": full | dts | {"section_post_data"}, + "unlike": { + "binary_upgrade", + "exclude_dump_test_schema", + "schema_only", + "schema_only_with_statistics", + "only_dump_measurement", + }, + }, + "REFRESH MATERIALIZED VIEW matview_third": { + "regexp": _qr( + r"""^ + \QREFRESH MATERIALIZED VIEW dump_test.matview_third;\E + """, + _XMS, + ), + "like": set(), + }, + "REFRESH MATERIALIZED VIEW matview_fourth": { + "regexp": _qr( + r"""^ + \QREFRESH MATERIALIZED VIEW dump_test.matview_fourth;\E + """, + _XMS, + ), + "like": set(), + }, + "REVOKE CONNECT ON DATABASE dump_test FROM public": { + "create_order": 49, + "create_sql": "REVOKE CONNECT ON DATABASE dump_test FROM public;", + "regexp": _qr( + r"""^ + \QREVOKE CONNECT,TEMPORARY ON DATABASE dump_test FROM PUBLIC;\E\n + \QGRANT TEMPORARY ON DATABASE dump_test TO PUBLIC;\E\n + \QGRANT CREATE ON DATABASE dump_test TO regress_dump_test_role;\E + """, + _XM, + ), + "like": {"pg_dumpall_dbprivs"}, + }, + "REVOKE EXECUTE ON FUNCTION pg_sleep() FROM public": { + "create_order": 15, + "create_sql": "REVOKE EXECUTE ON FUNCTION pg_sleep(float8)\n" + " FROM public;", + "regexp": _qr( + r"""^ + \QREVOKE ALL ON FUNCTION pg_catalog.pg_sleep(double precision) FROM PUBLIC;\E + """, + _XM, + ), + "like": full | {"section_pre_data"}, + "unlike": {"no_privs"}, + }, + "REVOKE EXECUTE ON FUNCTION pg_stat_reset FROM regress_dump_test_role": { + "create_order": 15, + "create_sql": "\n" + "ALTER FUNCTION pg_stat_reset OWNER TO regress_dump_test_role;\n" + "REVOKE EXECUTE ON FUNCTION pg_stat_reset\n" + " FROM regress_dump_test_role;", + "regexp": _qr(r"^[^-].*pg_stat_reset.* regress_dump_test_role", _M), + "like": set(), + }, + "REVOKE SELECT ON TABLE pg_proc FROM public": { + "create_order": 45, + "create_sql": "REVOKE SELECT ON TABLE pg_proc FROM public;", + "regexp": _qr( + r"^\QREVOKE SELECT ON TABLE pg_catalog.pg_proc FROM PUBLIC;\E", _M + ), + "like": full | {"section_pre_data"}, + "unlike": {"no_privs"}, + }, + "REVOKE ALL ON SCHEMA public": { + "create_order": 16, + "create_sql": 'REVOKE ALL ON SCHEMA public FROM "regress_quoted \\"" role";', + "regexp": _qr( + r'^REVOKE ALL ON SCHEMA public FROM "regress_quoted \\"" role";', _M + ), + "like": full | {"section_pre_data"}, + "unlike": {"no_privs"}, + }, + "REVOKE USAGE ON LANGUAGE plpgsql FROM public": { + "create_order": 16, + "create_sql": "REVOKE USAGE ON LANGUAGE plpgsql FROM public;", + "regexp": _qr(r"^REVOKE ALL ON LANGUAGE plpgsql FROM PUBLIC;", _M), + "like": full + | dts + | { + "only_dump_test_table", + "role", + "section_pre_data", + "only_dump_measurement", + }, + "unlike": {"no_privs"}, + }, + "CREATE ACCESS METHOD regress_test_table_am": { + "create_order": 11, + "create_sql": "CREATE ACCESS METHOD regress_table_am TYPE TABLE HANDLER heap_tableam_handler;", + "regexp": _qr( + r"""^ + \QCREATE ACCESS METHOD regress_table_am TYPE TABLE HANDLER heap_tableam_handler;\E + \n""", + _XM, + ), + "like": full | {"section_pre_data"}, + }, + "CREATE TABLE regress_pg_dump_table_am": { + "create_order": 12, + "create_sql": "\n" + "CREATE TABLE dump_test.regress_pg_dump_table_am_0() USING heap;\n" + "CREATE TABLE dump_test.regress_pg_dump_table_am_1 (col1 int) USING regress_table_am;\n" + "CREATE TABLE dump_test.regress_pg_dump_table_am_2() USING heap;", + "regexp": _qr( + r"""^ + \QSET default_table_access_method = regress_table_am;\E + (\n(?!SET[^;]+;)[^\n]*)* + \n\QCREATE TABLE dump_test.regress_pg_dump_table_am_1 (\E + \n\s+\Qcol1 integer\E + \n\);""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_table_access_method", + "only_dump_measurement", + }, + }, + "CREATE MATERIALIZED VIEW regress_pg_dump_matview_am": { + "create_order": 13, + "create_sql": "\n" + "CREATE MATERIALIZED VIEW dump_test.regress_pg_dump_matview_am_0 USING heap AS SELECT 1;\n" + "CREATE MATERIALIZED VIEW dump_test.regress_pg_dump_matview_am_1\n" + " USING regress_table_am AS SELECT count(*) FROM pg_class;\n" + "CREATE MATERIALIZED VIEW dump_test.regress_pg_dump_matview_am_2 USING heap AS SELECT 1;", + "regexp": _qr( + r"""^ + \QSET default_table_access_method = regress_table_am;\E + (\n(?!SET[^;]+;)[^\n]*)* + \QCREATE MATERIALIZED VIEW dump_test.regress_pg_dump_matview_am_1 AS\E + \n\s+\QSELECT count(*) AS count\E + \n\s+\QFROM pg_class\E + \n\s+\QWITH NO DATA;\E\n""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_table_access_method", + "only_dump_measurement", + }, + }, + "statistics_import": { + "create_sql": "\n" + "CREATE TABLE dump_test.has_stats\n" + "AS SELECT g.g AS x, g.g / 2 AS y FROM generate_series(1,100) AS g(g);\n" + "CREATE MATERIALIZED VIEW dump_test.has_stats_mv AS SELECT * FROM dump_test.has_stats;\n" + 'CREATE INDEX """dump_test""\'s post-data index" ON dump_test.has_stats(x, (x - 1));\n' + "ANALYZE dump_test.has_stats, dump_test.has_stats_mv;", + "regexp": _qr( + r"""^ + \QSELECT * FROM pg_catalog.pg_restore_relation_stats(\E\s+ + 'version',\s'\d+'::integer,\s+ + 'schemaname',\s'dump_test',\s+ + 'relname',\s'"dump_test"''s\ post-data\ index',\s+ + 'relpages',\s'\d+'::integer,\s+ + 'reltuples',\s'\d+'::real,\s+ + 'relallvisible',\s'\d+'::integer,\s+ + 'relallfrozen',\s'\d+'::integer\s+ + \);\s+ + \QSELECT * FROM pg_catalog.pg_restore_attribute_stats(\E\s+ + 'version',\s'\d+'::integer,\s+ + 'schemaname',\s'dump_test',\s+ + 'relname',\s'"dump_test"''s\ post-data\ index',\s+ + 'attnum',\s'2'::smallint,\s+ + 'inherited',\s'f'::boolean,\s+ + 'null_frac',\s'0'::real,\s+ + 'avg_width',\s'4'::integer,\s+ + 'n_distinct',\s'-1'::real,\s+ + 'histogram_bounds',\s'\{[0-9,]+\}'::text,\s+ + 'correlation',\s'1'::real\s+ + \);""", + _XM, + ), + "like": full + | dts + | { + "no_data_no_schema", + "no_schema", + "section_post_data", + "statistics_only", + "schema_only_with_statistics", + }, + "unlike": { + "exclude_dump_test_schema", + "no_statistics", + "only_dump_measurement", + "schema_only", + }, + }, + "extended_statistics_import": { + "create_sql": "\n" + "CREATE TABLE dump_test.has_ext_stats\n" + "AS SELECT g.g AS x, g.g / 2 AS y FROM generate_series(1,100) AS g(g);\n" + "CREATE STATISTICS dump_test.es1 ON x, (y % 2) FROM dump_test.has_ext_stats;\n" + "ANALYZE dump_test.has_ext_stats;", + "regexp": _qr( + r"""^ + \QSELECT * FROM pg_catalog.pg_restore_extended_stats(\E\s+""", + _XM, + ), + "like": full + | dts + | { + "no_data_no_schema", + "no_schema", + "section_post_data", + "statistics_only", + "schema_only_with_statistics", + }, + "unlike": { + "exclude_dump_test_schema", + "no_statistics", + "only_dump_measurement", + "schema_only", + }, + }, + "relstats_on_unanalyzed_tables": { + "regexp": _qr(r"pg_catalog.pg_restore_relation_stats"), + "like": full + | dts + | { + "no_data_no_schema", + "no_schema", + "only_dump_test_table", + "role", + "role_parallel", + "section_data", + "section_post_data", + "statistics_only", + "schema_only_with_statistics", + }, + "unlike": { + "no_statistics", + "schema_only", + }, + }, + "CREATE TABLE regress_pg_dump_table_part": { + "create_order": 19, + "create_sql": "\n" + "CREATE TABLE dump_test.regress_pg_dump_table_am_parent (id int) PARTITION BY LIST (id);\n" + "ALTER TABLE dump_test.regress_pg_dump_table_am_parent SET ACCESS METHOD regress_table_am;\n" + "CREATE TABLE dump_test.regress_pg_dump_table_am_child_1\n" + " PARTITION OF dump_test.regress_pg_dump_table_am_parent FOR VALUES IN (1);\n" + "CREATE TABLE dump_test.regress_pg_dump_table_am_child_2\n" + " PARTITION OF dump_test.regress_pg_dump_table_am_parent FOR VALUES IN (2) USING heap;", + "regexp": _qr( + r"""^ + \n\QCREATE TABLE dump_test.regress_pg_dump_table_am_parent (\E + (\n(?!SET[^;]+;)[^\n]*)* + \QALTER TABLE dump_test.regress_pg_dump_table_am_parent SET ACCESS METHOD regress_table_am;\E + (.*\n)* + \QSET default_table_access_method = regress_table_am;\E + (\n(?!SET[^;]+;)[^\n]*)* + \n\QCREATE TABLE dump_test.regress_pg_dump_table_am_child_1 (\E + (.*\n)* + \QSET default_table_access_method = heap;\E + (\n(?!SET[^;]+;)[^\n]*)* + \n\QCREATE TABLE dump_test.regress_pg_dump_table_am_child_2 (\E + (.*\n)*""", + _XM, + ), + "like": full | dts | {"section_pre_data"}, + "unlike": { + "exclude_dump_test_schema", + "no_table_access_method", + "only_dump_measurement", + }, + }, + } + + +def _create_order_key(item): + """Sort key implementing the create_order comparator. + + Tests with a create_order sort by it (ascending) and before tests without + one. Two orderless tests have unspecified relative order; we keep it + stable by name, which is fine because such tests' create_sql is + independent. + """ + name, spec = item + order = spec.get("create_order") + return (0, order, name) if order is not None else (1, 0, name) + + +def _split_sql(sql): + """Split *sql* into top-level statements on unquoted semicolons. + + Respects single-quoted strings, dollar-quoted bodies ($tag$...$tag$) and + line comments (-- ... \\n) so that semicolons inside them are not treated + as statement separators. This mirrors how psql breaks a script into + statements when the concatenated SQL is sent through a psql pipe. + """ + stmts = [] + buf = [] + i = 0 + n = len(sql) + while i < n: + ch = sql[i] + if ch == "'": + buf.append(ch) + i += 1 + while i < n: + buf.append(sql[i]) + if sql[i] == "'": + # '' is an escaped quote inside the string. + if i + 1 < n and sql[i + 1] == "'": + buf.append(sql[i + 1]) + i += 2 + continue + i += 1 + break + i += 1 + continue + if ch == "-" and i + 1 < n and sql[i + 1] == "-": + while i < n and sql[i] != "\n": + buf.append(sql[i]) + i += 1 + continue + if ch == "$": + m = re.match(r"\$[A-Za-z_0-9]*\$", sql[i:]) + if m: + tag = m.group(0) + end = sql.find(tag, i + len(tag)) + if end < 0: + end = n + else: + end += len(tag) + buf.append(sql[i:end]) + i = end + continue + if ch == ";": + stmts.append("".join(buf)) + buf = [] + i += 1 + continue + buf.append(ch) + i += 1 + if "".join(buf).strip(): + stmts.append("".join(buf)) + + # Re-merge statements split inside a "BEGIN ATOMIC ... END" function body: + # the semicolons separating the body's statements are not real statement + # boundaries. Such a body opens with "BEGIN ATOMIC" and closes with a + # trailing "END"; accumulate following pieces until that END is seen. + merged = [] + pending = None + for s in stmts: + if pending is not None: + pending = pending + ";" + s + elif re.search(r"\bBEGIN\s+ATOMIC\b", s, re.IGNORECASE): + pending = s + else: + merged.append(s) + continue + if re.search(r"\bEND\s*\Z", pending.rstrip(), re.IGNORECASE): + merged.append(pending) + pending = None + if pending is not None: + merged.append(pending) + return [s for s in merged if s.strip()] + + +def _seed_database(node, dbname, create_sql): + """Send a combined create_sql block to *dbname*, statement by statement. + + Sending the whole block as one simple query would wrap it in a single + transaction, which breaks the CREATE DATABASE / CREATE TABLESPACE + statements present here. So split into individual statements (honoring + quoting/dollar-quotes) and run each on its own. They share one persistent + session so that session GUCs (e.g. allow_in_place_tablespaces) set by an + earlier statement persist to a later CREATE TABLESPACE on the same block. + """ + with node.connect(dbname=dbname) as sess: + for stmt in _split_sql(create_sql): + sess.query_safe(stmt) + + +def test_pg_dump(pg, tmp_path): + node = pg + port = node.port + tempdir = str(tmp_path) + + supports_gzip = _have_pg_config_define("#define HAVE_LIBZ 1") + supports_icu = _have_icu_configured() + + pgdump_runs = _pgdump_runs(tempdir, supports_gzip) + tests = _tests(set(FULL_RUNS), set(DUMP_TEST_SCHEMA_RUNS)) + + # See if this system supports CREATE COLLATION; if not, skip all the + # COLLATION-related tests. + res = node.sql('CREATE COLLATION testing FROM "C"; DROP COLLATION testing;') + collation_support = res.error_message is None or "ERROR: " not in res.error_message + + # ICU doesn't work with some encodings. + encoding = node.safe_sql("show server_encoding").strip() + if encoding == "SQL_ASCII": + supports_icu = False + + # Create additional databases for mutations of schema public. + node.safe_sql("create database regress_pg_dump_test;") + node.safe_sql("create database regress_public_owner;") + + ######################################### + # Set up schemas, tables, etc, to be dumped. Build up the create + # statements per-database in create_order, then send them. + create_sql = {} + for _name, spec in sorted(tests.items(), key=_create_order_key): + test_db = spec.get("database", "postgres") + + if spec.get("icu"): + spec["collation"] = True + + if not spec.get("create_sql"): + continue + + # Skip collation/icu commands if unsupported. + if not collation_support and spec.get("collation"): + continue + if not supports_icu and spec.get("icu"): + continue + + # Normalize command ending: strip trailing whitespace/newlines, add a + # semicolon if missing, then two newlines. + sql = spec["create_sql"].rstrip("\r\n") + if not sql.endswith(";"): + sql += ";" + create_sql[test_db] = create_sql.get(test_db, "") + sql + "\n\n" + + for db in sorted(create_sql): + _seed_database(node, db, create_sql[db]) + + ######################################### + # Standalone command cases (run before the matrix loop). + + node.command_fails_like( + ["pg_dump", "--port", str(port), "qqq"], + r'pg_dump: error: connection to server .* failed: FATAL: database "qqq" does not exist', + "connecting to a non-existent database", + ) + node.command_fails_like( + ["pg_dump", "--dbname", "regression_invalid"], + r'pg_dump: error: connection to server .* failed: FATAL: cannot connect to invalid database "regression_invalid"', + "connecting to an invalid database", + ) + node.command_fails_like( + ["pg_dump", "--port", str(port), "--role", "regress_dump_test_role"], + re.escape("pg_dump: error: query failed: ERROR: permission denied for"), + "connecting with an unprivileged user", + ) + node.command_fails_like( + ["pg_dump", "--port", str(port), "--schema", "nonexistent"], + re.escape("pg_dump: error: no matching schemas were found"), + "dumping a non-existent schema", + ) + node.command_fails_like( + ["pg_dump", "--port", str(port), "--table", "nonexistent"], + re.escape("pg_dump: error: no matching tables were found"), + "dumping a non-existent table", + ) + node.command_fails_like( + ["pg_dump", "--port", str(port), "--strict-names", "--schema", "nonexistent*"], + re.escape("pg_dump: error: no matching schemas were found for pattern"), + "no matching schemas", + ) + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--strict-names", + "--schema-only", + "--statistics", + ], + re.escape( + "pg_dump: error: options --statistics and -s/--schema-only cannot be used together" + ), + "cannot use --statistics and --schema-only together", + ) + node.command_fails_like( + ["pg_dump", "--port", str(port), "--strict-names", "--table", "nonexistent*"], + re.escape("pg_dump: error: no matching tables were found for pattern"), + "no matching tables", + ) + node.command_fails_like( + ["pg_dumpall", "--exclude-database", "."], + r"pg_dumpall: error: improper qualified name \(too many dotted names\): \.", + 'pg_dumpall: option --exclude-database rejects multipart pattern "."', + ) + node.command_fails_like( + ["pg_dumpall", "--exclude-database", "myhost.mydb"], + r"pg_dumpall: error: improper qualified name \(too many dotted names\): myhost\.mydb", + "pg_dumpall: option --exclude-database rejects multipart database names", + ) + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--schema", + "pg_catalog", + "--file", + f"{tempdir}/pgdump_pgcatalog.dmp", + ], + "pg_dump: option -n pg_catalog", + ) + node.command_ok( + [ + "pg_dumpall", + "--port", + str(port), + "--exclude-database", + '"myhost.mydb"', + "--file", + f"{tempdir}/pgdumpall.dmp", + ], + "pg_dumpall: option --exclude-database handles database names with embedded dots", + ) + node.command_fails_like( + ["pg_dump", "--schema", "myhost.mydb.myschema"], + r"pg_dump: error: improper qualified name \(too many dotted names\): myhost\.mydb\.myschema", + "pg_dump: option --schema rejects three-part schema names", + ) + node.command_fails_like( + ["pg_dump", "--schema", "otherdb.myschema"], + r"pg_dump: error: cross-database references are not implemented: otherdb\.myschema", + "pg_dump: option --schema rejects cross-database multipart schema names", + ) + node.command_fails_like( + ["pg_dump", "--schema", "."], + r"pg_dump: error: cross-database references are not implemented: \.", + 'pg_dump: option --schema rejects degenerate two-part schema name: "."', + ) + node.command_fails_like( + ["pg_dump", "--schema", '"some.other.db".myschema'], + r'pg_dump: error: cross-database references are not implemented: "some\.other\.db"\.myschema', + "pg_dump: option --schema rejects cross-database multipart schema names with embedded dots", + ) + node.command_fails_like( + ["pg_dump", "--schema", ".."], + r"pg_dump: error: improper qualified name \(too many dotted names\): \.\.", + 'pg_dump: option --schema rejects degenerate three-part schema name: ".."', + ) + node.command_fails_like( + ["pg_dump", "--table", "myhost.mydb.myschema.mytable"], + r"pg_dump: error: improper relation name \(too many dotted names\): myhost\.mydb\.myschema\.mytable", + "pg_dump: option --table rejects four-part table names", + ) + node.command_fails_like( + ["pg_dump", "--table", "otherdb.pg_catalog.pg_class"], + r"pg_dump: error: cross-database references are not implemented: otherdb\.pg_catalog\.pg_class", + "pg_dump: option --table rejects cross-database three part table names", + ) + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--table", + '"some.other.db".pg_catalog.pg_class', + ], + r'pg_dump: error: cross-database references are not implemented: "some\.other\.db"\.pg_catalog\.pg_class', + "pg_dump: option --table rejects cross-database three part table names with embedded dots", + ) + + ######################################### + # Run all runs (sorted by name). + for run in sorted(pgdump_runs): + spec = pgdump_runs[run] + test_key = run + run_db = spec.get("database", "postgres") + + node.command_ok(spec["dump_cmd"], f"{run}: pg_dump runs") + + for glob_pattern in spec.get("glob_patterns", []): + import glob as _glob + + matches = _glob.glob(glob_pattern) + ok = len(matches) > 1 or (len(matches) == 1 and os.path.isfile(matches[0])) + assert ok, f"{run}: glob check for {glob_pattern}" + + if "command_like" in spec: + cl = spec["command_like"] + node.command_like(cl["command"], cl["expected"], f"{run}: {cl['name']}") + + if "restore_cmd" in spec: + node.command_ok(spec["restore_cmd"], f"{run}: pg_restore runs") + + if "test_key" in spec: + test_key = spec["test_key"] + + output_file = slurp_file(os.path.join(tempdir, f"{run}.sql")) + + ######################################### + # Run all tests where this run is included as a 'like' or 'unlike'. + for test_name in sorted(tests): + tspec = tests[test_name] + test_db = tspec.get("database", "postgres") + + all_runs_flag = tspec.get("all_runs", False) + like = tspec.get("like") + unlike = tspec.get("unlike", set()) + + # Either all_runs should be set or there must be a "like" list + # (even an empty one), to keep the test self-documenting. + assert ( + all_runs_flag or like is not None + ), f'missing "like" in test "{test_name}"' + like_set = like if like is not None else set() + + # Check for useless entries in "unlike": a run not listed in "like" + # doesn't need excluding. + assert not ( + test_key in unlike and test_key not in like_set + ), f'useless "unlike" entry "{test_key}" in test "{test_name}"' + + # Skip collation/icu commands if unsupported. + if not collation_support and tspec.get("collation"): + continue + if not supports_icu and tspec.get("icu"): + continue + + # A run only applies to tests targeting the same database. + if run_db != test_db: + continue + + if (test_key in like_set or all_runs_flag) and test_key not in unlike: + assert tspec["regexp"].search(output_file), ( + f"{run}: should dump {test_name}\n" + f"Review {run} results in {tempdir}" + ) + else: + assert not tspec["regexp"].search(output_file), ( + f"{run}: should not dump {test_name}\n" + f"Review {run} results in {tempdir}" + ) + + node.stop("fast") diff --git a/src/bin/pg_dump/pyt/test_003_pg_dump_with_server.py b/src/bin/pg_dump/pyt/test_003_pg_dump_with_server.py new file mode 100644 index 00000000000..33ce0768d97 --- /dev/null +++ b/src/bin/pg_dump/pyt/test_003_pg_dump_with_server.py @@ -0,0 +1,54 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests pg_dump of foreign data against a running server. + +Starts a server and runs pg_dump against it, checking that dumping foreign +data includes only foreign tables of matching servers. + +pg_dump is the binary under test and is run as a subprocess through the +node's pg_bin (PGHOST/PGPORT point at the server). The seed SQL the test +itself runs is executed in-process via safe_sql. +""" + + +def test_pg_dump_with_server(pg): + node = pg + port = node.port + + ######################################### + # Verify that dumping foreign data includes only foreign tables of + # matching servers + + node.safe_sql("CREATE FOREIGN DATA WRAPPER dummy") + node.safe_sql("CREATE SERVER s0 FOREIGN DATA WRAPPER dummy") + node.safe_sql("CREATE SERVER s1 FOREIGN DATA WRAPPER dummy") + node.safe_sql("CREATE SERVER s2 FOREIGN DATA WRAPPER dummy") + node.safe_sql("CREATE FOREIGN TABLE t0 (a int) SERVER s0") + node.safe_sql("CREATE FOREIGN TABLE t1 (a int) SERVER s1") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--include-foreign-data", + "s0", + "postgres", + ], + r'foreign-data wrapper "dummy" has no handler\r?\n' + r"pg_dump: detail: Query was: .*t0", + "correctly fails to dump a foreign table from a dummy FDW", + ) + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--data-only", + "--include-foreign-data", + "s2", + "postgres", + ], + "dump foreign server with no tables", + ) diff --git a/src/bin/pg_dump/pyt/test_004_pg_dump_parallel.py b/src/bin/pg_dump/pyt/test_004_pg_dump_parallel.py new file mode 100644 index 00000000000..96e36de0621 --- /dev/null +++ b/src/bin/pg_dump/pyt/test_004_pg_dump_parallel.py @@ -0,0 +1,115 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests pg_dump / pg_restore in parallel directory format. + +Creates a source database with a mix of objects (a table with a unique index, +hash-partitioned tables both "troublesome" and not), then exercises pg_dump in +parallel directory format (-Fd -j N) and pg_restore in parallel (-j N), +verifying the round-trip into two separate destination databases (one plain, +one via --inserts). + +pg_dump / pg_restore are the binaries under test and are run as subprocesses +through the node's pg_bin (PGHOST/PGPORT point at the server). The seed SQL +the test itself runs is executed in-process via safe_sql; the destination +databases are created with CREATE DATABASE (its own statement, not in a txn +block). +""" + +DBNAME1 = "regression_src" +DBNAME2 = "regression_dest1" +DBNAME3 = "regression_dest2" + +SETUP_SQL = """ +create type digit as enum ('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'); + +-- plain table with index +create table tplain (en digit, data int unique); +insert into tplain select (x%10)::text::digit, x from generate_series(1,1000) x; + +-- non-troublesome hashed partitioning +create table ths (mod int, data int, unique(mod, data)) partition by hash(mod); +create table ths_p1 partition of ths for values with (modulus 3, remainder 0); +create table ths_p2 partition of ths for values with (modulus 3, remainder 1); +create table ths_p3 partition of ths for values with (modulus 3, remainder 2); +insert into ths select (x%10), x from generate_series(1,1000) x; + +-- dangerous hashed partitioning +create table tht (en digit, data int, unique(en, data)) partition by hash(en); +create table tht_p1 partition of tht for values with (modulus 3, remainder 0); +create table tht_p2 partition of tht for values with (modulus 3, remainder 1); +create table tht_p3 partition of tht for values with (modulus 3, remainder 2); +insert into tht select (x%10)::text::digit, x from generate_series(1,1000) x; +""" + + +def test_pg_dump_parallel(pg, tmp_path): + node = pg + + # Create the source and the two destination databases. CREATE DATABASE + # cannot run inside a transaction block, so issue each as its own + # statement. + node.safe_sql(f"CREATE DATABASE {DBNAME1}") + node.safe_sql(f"CREATE DATABASE {DBNAME2}") + node.safe_sql(f"CREATE DATABASE {DBNAME3}") + + node.safe_sql(SETUP_SQL, dbname=DBNAME1) + + dump1 = str(tmp_path / "dump1") + dump2 = str(tmp_path / "dump2") + + node.command_ok( + [ + "pg_dump", + "--format", + "directory", + "--no-sync", + "--jobs", + "2", + "--file", + dump1, + node.connstr(DBNAME1), + ], + "parallel dump", + ) + + node.command_ok( + [ + "pg_restore", + "--verbose", + "--dbname", + node.connstr(DBNAME2), + "--jobs", + "3", + dump1, + ], + "parallel restore", + ) + + node.command_ok( + [ + "pg_dump", + "--format", + "directory", + "--no-sync", + "--jobs", + "2", + "--file", + dump2, + "--inserts", + node.connstr(DBNAME1), + ], + "parallel dump as inserts", + ) + + node.command_ok( + [ + "pg_restore", + "--verbose", + "--dbname", + node.connstr(DBNAME3), + "--jobs", + "3", + dump2, + ], + "parallel restore as inserts", + ) diff --git a/src/bin/pg_dump/pyt/test_005_pg_dump_filterfile.py b/src/bin/pg_dump/pyt/test_005_pg_dump_filterfile.py new file mode 100644 index 00000000000..fa7cd60d049 --- /dev/null +++ b/src/bin/pg_dump/pyt/test_005_pg_dump_filterfile.py @@ -0,0 +1,957 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Tests pg_dump / pg_dumpall / pg_restore --filter=FILE handling. + +Tests pg_dump / pg_dumpall / pg_restore --filter=FILE: writes filter files +containing include/exclude directives (for tables, schemas, foreign data, +functions, etc.), runs the tools, and checks both the dump output and the +error cases for malformed filter files. + +pg_dump / pg_dumpall / pg_restore are the binaries under test and are run as +subprocesses through the node's pg_bin (PGHOST/PGPORT point at the server). +The seed SQL the test itself runs is executed in-process via safe_sql. +""" + +import os +import re + +from pypg.util import slurp_file + + +def _write_filter(path, content): + """Write *content* verbatim to the filter file at *path*.""" + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + + +def _seed(node): + """Create the test objects used by the filter-file cases.""" + node.safe_sql("CREATE FOREIGN DATA WRAPPER dummy;") + node.safe_sql("CREATE SERVER dummyserver FOREIGN DATA WRAPPER dummy;") + + node.safe_sql("CREATE TABLE table_one(a varchar)") + node.safe_sql("CREATE TABLE table_two(a varchar)") + node.safe_sql("CREATE TABLE table_three(a varchar)") + node.safe_sql("CREATE TABLE table_three_one(a varchar)") + node.safe_sql("CREATE TABLE footab(a varchar)") + node.safe_sql("CREATE TABLE bootab() inherits (footab)") + node.safe_sql('CREATE TABLE "strange aaa\nname"(a varchar)') + node.safe_sql('CREATE TABLE "\nt\nt\n"(a int)') + + node.safe_sql("INSERT INTO table_one VALUES('*** TABLE ONE ***')") + node.safe_sql("INSERT INTO table_two VALUES('*** TABLE TWO ***')") + node.safe_sql("INSERT INTO table_three VALUES('*** TABLE THREE ***')") + node.safe_sql("INSERT INTO table_three_one VALUES('*** TABLE THREE_ONE ***')") + node.safe_sql("INSERT INTO bootab VALUES(10)") + + node.safe_sql("CREATE DATABASE sourcedb") + node.safe_sql("CREATE DATABASE targetdb") + + node.safe_sql( + "CREATE FUNCTION foo1(a int) RETURNS int AS $$ select $1 $$ LANGUAGE sql", + "sourcedb", + ) + node.safe_sql( + "CREATE FUNCTION foo2(a int) RETURNS int AS $$ select $1 $$ LANGUAGE sql", + "sourcedb", + ) + node.safe_sql( + "CREATE FUNCTION foo3(a double precision, b int) RETURNS " + "double precision AS $$ select $1 + $2 $$ LANGUAGE sql", + "sourcedb", + ) + node.safe_sql( + "CREATE FUNCTION foo_trg() RETURNS trigger AS $$ BEGIN RETURN NEW; " + "END $$ LANGUAGE plpgsql", + "sourcedb", + ) + node.safe_sql("CREATE SCHEMA s1", "sourcedb") + node.safe_sql("CREATE SCHEMA s2", "sourcedb") + node.safe_sql("CREATE TABLE s1.t1(a int)", "sourcedb") + node.safe_sql("CREATE SEQUENCE s1.s1", "sourcedb") + node.safe_sql("CREATE TABLE s2.t2(a int)", "sourcedb") + node.safe_sql("CREATE TABLE t1(a int, b int)", "sourcedb") + node.safe_sql("CREATE TABLE t2(a int, b int)", "sourcedb") + node.safe_sql("CREATE INDEX t1_idx1 ON t1(a)", "sourcedb") + node.safe_sql("CREATE INDEX t1_idx2 ON t1(b)", "sourcedb") + node.safe_sql( + "CREATE TRIGGER trg1 BEFORE INSERT ON t1 EXECUTE FUNCTION foo_trg()", + "sourcedb", + ) + node.safe_sql( + "CREATE TRIGGER trg2 BEFORE INSERT ON t1 EXECUTE FUNCTION foo_trg()", + "sourcedb", + ) + + +def test_pg_dump_filterfile(pg, tmp_path): + node = pg + port = node.port + tempdir = str(tmp_path) + inputfile = os.path.join(tempdir, "inputfile.txt") + inputfile2 = os.path.join(tempdir, "inputfile2.txt") + plainfile = os.path.join(tempdir, "plain.sql") + dumpfile = os.path.join(tempdir, "filter_test.dump") + + _seed(node) + + # + # Test interaction of correctly specified filter file + # + + # Empty filterfile + _write_filter(inputfile, "\n # a comment and nothing more\n\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "filter file without patterns", + ) + + dump = slurp_file(plainfile) + assert re.search(r"^CREATE TABLE public\.table_one", dump, re.M), "table one dumped" + assert re.search(r"^CREATE TABLE public\.table_two", dump, re.M), "table two dumped" + assert re.search( + r"^CREATE TABLE public\.table_three", dump, re.M + ), "table three dumped" + assert re.search( + r"^CREATE TABLE public\.table_three_one", dump, re.M + ), "table three one dumped" + + # Test various combinations of whitespace, comments and correct filters + _write_filter( + inputfile, + " include table table_one #comment\n" + "include table table_two\n" + "# skip this line\n" + "\n" + "\t\n" + " \t# another comment\n" + "exclude table_data table_one\n", + ) + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "dump tables with filter patterns as well as comments and whitespace", + ) + + dump = slurp_file(plainfile) + assert re.search(r"^CREATE TABLE public\.table_one", dump, re.M), "dumped table one" + assert re.search(r"^CREATE TABLE public\.table_two", dump, re.M), "dumped table two" + assert not re.search( + r"^CREATE TABLE public\.table_three", dump, re.M + ), "table three not dumped" + assert not re.search( + r"^CREATE TABLE public\.table_three_one", dump, re.M + ), "table three_one not dumped" + assert not re.search( + r"^COPY public\.table_one", dump, re.M + ), "content of table one is not included" + assert re.search( + r"^COPY public\.table_two", dump, re.M + ), "content of table two is included" + + # Test dumping tables specified by qualified names + _write_filter( + inputfile, + "include table public.table_one\n" + 'include table "public"."table_two"\n' + 'include table "public". table_three\n', + ) + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "filter file without patterns", + ) + + dump = slurp_file(plainfile) + assert re.search(r"^CREATE TABLE public\.table_one", dump, re.M), "dumped table one" + assert re.search(r"^CREATE TABLE public\.table_two", dump, re.M), "dumped table two" + assert re.search( + r"^CREATE TABLE public\.table_three", dump, re.M + ), "dumped table three" + + # Test dumping all tables except one + _write_filter(inputfile, "exclude table table_one\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "dump tables with exclusion of a single table", + ) + + dump = slurp_file(plainfile) + assert not re.search( + r"^CREATE TABLE public\.table_one", dump, re.M + ), "table one not dumped" + assert re.search(r"^CREATE TABLE public\.table_two", dump, re.M), "dumped table two" + assert re.search( + r"^CREATE TABLE public\.table_three", dump, re.M + ), "dumped table three" + assert re.search( + r"^CREATE TABLE public\.table_three_one", dump, re.M + ), "dumped table three_one" + + # Test dumping tables with a wildcard pattern + _write_filter(inputfile, "include table table_thre*\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "dump tables with wildcard in pattern", + ) + + dump = slurp_file(plainfile) + assert not re.search( + r"^CREATE TABLE public\.table_one", dump, re.M + ), "table one not dumped" + assert not re.search( + r"^CREATE TABLE public\.table_two", dump, re.M + ), "table two not dumped" + assert re.search( + r"^CREATE TABLE public\.table_three", dump, re.M + ), "dumped table three" + assert re.search( + r"^CREATE TABLE public\.table_three_one", dump, re.M + ), "dumped table three_one" + + # Test dumping table with multiline quoted tablename + _write_filter(inputfile, 'include table "strange aaa\nname"') + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "dump tables with multiline names requiring quoting", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE TABLE public.\"strange aaa", dump, re.M + ), "dump table with new line in name" + + # Test excluding multiline quoted tablename from dump + _write_filter(inputfile, 'exclude table "strange aaa\\nname"') + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "dump tables with filter", + ) + + dump = slurp_file(plainfile) + assert not re.search( + r"^CREATE TABLE public.\"strange aaa", dump, re.M + ), "dump table with new line in name" + + # Test excluding an entire schema + _write_filter(inputfile, "exclude schema public\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "exclude the public schema", + ) + + dump = slurp_file(plainfile) + assert not re.search(r"^CREATE TABLE", dump, re.M), "no table dumped" + + # Test including and excluding an entire schema by multiple filterfiles + _write_filter(inputfile, "include schema public\n") + _write_filter(inputfile2, "exclude schema public\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--filter", + inputfile2, + "postgres", + ], + "exclude the public schema with multiple filters", + ) + + dump = slurp_file(plainfile) + assert not re.search(r"^CREATE TABLE", dump, re.M), "no table dumped" + + # Test dumping a table with a single leading newline on a row + _write_filter(inputfile, 'include table "\nt\nt\n"') + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "dump tables with filter", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE TABLE public.\"\nt\nt\n\" \($", dump, re.M | re.S + ), "dump table with multiline strange name" + + _write_filter(inputfile, 'include table "\\nt\\nt\\n"') + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "dump tables with filter", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE TABLE public.\"\nt\nt\n\" \($", dump, re.M | re.S + ), "dump table with multiline strange name" + + ######################################### + # Test foreign_data + + _write_filter(inputfile, "include foreign_data doesnt_exists\n") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + r"pg_dump: error: no matching foreign servers were found for pattern", + "dump nonexisting foreign server", + ) + + _write_filter(inputfile, "include foreign_data dummyserver\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "dump foreign_data with filter", + ) + + dump = slurp_file(plainfile) + assert re.search(r"^CREATE SERVER dummyserver", dump, re.M), "dump foreign server" + + _write_filter(inputfile, "exclude foreign_data dummy*\n") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + r'exclude filter for "foreign data" is not allowed', + "erroneously exclude foreign server", + ) + + ######################################### + # Test broken input format + + # Test invalid filter command + _write_filter(inputfile, "k") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + r"invalid filter command", + "invalid syntax: incorrect filter command", + ) + + # Test invalid object type. + # + # This test also verifies that keywords are correctly recognized as + # strings of non-whitespace characters. "table-data" is used here as an + # intentionally invalid object type. + _write_filter(inputfile, "exclude table-data one") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + r'unsupported filter object type: "table-data"', + "invalid syntax: invalid object type specified", + ) + + # Test missing object identifier pattern + _write_filter(inputfile, "include table") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + r"missing object name", + "invalid syntax: missing object identifier pattern", + ) + + # Test adding extra content after the object identifier pattern + _write_filter(inputfile, "include table table one") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + r"no matching tables were found", + "invalid syntax: extra content after object identifier pattern", + ) + + ######################################### + # Combined with --strict-names + + # First ensure that a matching filter works + _write_filter(inputfile, "include table table_one\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--strict-names", + "postgres", + ], + "strict names with matching pattern", + ) + + dump = slurp_file(plainfile) + assert re.search(r"^CREATE TABLE public\.table_one", dump, re.M), "no table dumped" + + # Now append a pattern to the filter file which doesn't resolve + with open(inputfile, "a", encoding="utf-8") as fh: + fh.write("include table table_nonexisting_name") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--strict-names", + "postgres", + ], + r"no matching tables were found", + "inclusion of non-existing objects with --strict names", + ) + + ######################################### + # pg_dumpall tests + + ########################### + # Test dumping all tables except one + _write_filter(inputfile, "exclude database postgres\n") + + node.command_ok( + ["pg_dumpall", "--port", str(port), "--file", plainfile, "--filter", inputfile], + "dump tables with exclusion of a database", + ) + + dump = slurp_file(plainfile) + assert not re.search( + r"^\\connect postgres", dump, re.M + ), "database postgres is not dumped" + assert re.search( + r"^\\connect template1", dump, re.M + ), "database template1 is dumped" + + # Make sure this option doesn't break the existing limitation of using + # --globals-only with exclusions + node.command_fails_like( + [ + "pg_dumpall", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--globals-only", + ], + re.escape( + "pg_dumpall: error: options --exclude-database and " + "-g/--globals-only cannot be used together" + ), + "pg_dumpall: options --exclude-database and -g/--globals-only " + "cannot be used together", + ) + + # Test invalid filter command + _write_filter(inputfile, "k") + + node.command_fails_like( + ["pg_dumpall", "--port", str(port), "--file", plainfile, "--filter", inputfile], + r"invalid filter command", + "invalid syntax: incorrect filter command", + ) + + # Test invalid object type + _write_filter(inputfile, "exclude xxx") + + node.command_fails_like( + ["pg_dumpall", "--port", str(port), "--file", plainfile, "--filter", inputfile], + r'unsupported filter object type: "xxx"', + "invalid syntax: exclusion of non-existing object type", + ) + + _write_filter(inputfile, "exclude table foo") + + node.command_fails_like( + ["pg_dumpall", "--port", str(port), "--file", plainfile, "--filter", inputfile], + r"pg_dumpall: error: invalid format in filter", + "invalid syntax: exclusion of unsupported object type", + ) + + ######################################### + # pg_restore tests + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + dumpfile, + "--format", + "custom", + "postgres", + ], + "dump all tables", + ) + + _write_filter(inputfile, "include table table_two") + + node.command_ok( + [ + "pg_restore", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--format", + "custom", + dumpfile, + ], + "restore tables with filter", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE TABLE public\.table_two", dump, re.M + ), "wanted table restored" + assert not re.search( + r"^CREATE TABLE public\.table_one", dump, re.M + ), "unwanted table is not restored" + + _write_filter(inputfile, "include table_data xxx") + + node.command_fails_like( + ["pg_restore", "--port", str(port), "--file", plainfile, "--filter", inputfile], + r'include filter for "table data" is not allowed', + "invalid syntax: inclusion of unallowed object", + ) + + _write_filter(inputfile, "include extension xxx") + + node.command_fails_like( + ["pg_restore", "--port", str(port), "--file", plainfile, "--filter", inputfile], + r'include filter for "extension" is not allowed', + "invalid syntax: inclusion of unallowed object", + ) + + _write_filter(inputfile, "exclude extension xxx") + + node.command_fails_like( + ["pg_restore", "--port", str(port), "--file", plainfile, "--filter", inputfile], + r'exclude filter for "extension" is not allowed', + "invalid syntax: exclusion of unallowed object", + ) + + _write_filter(inputfile, "exclude table_data xxx") + + node.command_fails_like( + ["pg_restore", "--port", str(port), "--file", plainfile, "--filter", inputfile], + r'exclude filter for "table data" is not allowed', + "invalid syntax: exclusion of unallowed object", + ) + + ######################################### + # test restore of other objects + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + dumpfile, + "--format", + "custom", + "sourcedb", + ], + "dump all objects from sourcedb", + ) + + _write_filter(inputfile, "include function foo1(integer)") + + node.command_ok( + [ + "pg_restore", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--format", + "custom", + dumpfile, + ], + "restore function with filter", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE FUNCTION public\.foo1", dump, re.M + ), "wanted function restored" + assert not re.search( + r"^CREATE TABLE public\.foo2", dump, re.M + ), "unwanted function is not restored" + + # this should be white space tolerant (against the -P argument) + _write_filter( + inputfile, "include function foo3 ( double precision , integer) " + ) + + node.command_ok( + [ + "pg_restore", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--format", + "custom", + dumpfile, + ], + "restore function with filter", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE FUNCTION public\.foo3", dump, re.M + ), "wanted function restored" + + # attention! this hit pg_restore bug - correct name of trigger is "trg1" + # not "t1 trg1". Should be fixed when pg_restore will be fixed + _write_filter( + inputfile, + "include index t1_idx1\ninclude trigger t1 trg1\n", + ) + + node.command_ok( + [ + "pg_restore", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--format", + "custom", + dumpfile, + ], + "restore function with filter", + ) + + dump = slurp_file(plainfile) + assert re.search(r"^CREATE INDEX t1_idx1", dump, re.M), "wanted index restored" + assert not re.search( + r"^CREATE INDEX t2_idx2", dump, re.M + ), "unwanted index are not restored" + assert re.search(r"^CREATE TRIGGER trg1", dump, re.M), "wanted trigger restored" + assert not re.search( + r"^CREATE TRIGGER trg2", dump, re.M + ), "unwanted trigger is not restored" + + _write_filter(inputfile, "include schema s1\n") + + node.command_ok( + [ + "pg_restore", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--format", + "custom", + dumpfile, + ], + "restore function with filter", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE TABLE s1\.t1", dump, re.M + ), "wanted table from schema restored" + assert re.search( + r"^CREATE SEQUENCE s1\.s1", dump, re.M + ), "wanted sequence from schema restored" + assert not re.search( + r"^CREATE TABLE s2\t2", dump, re.M + ), "unwanted table is not restored" + + _write_filter(inputfile, "exclude schema s1\n") + + node.command_ok( + [ + "pg_restore", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "--format", + "custom", + dumpfile, + ], + "restore function with filter", + ) + + dump = slurp_file(plainfile) + assert not re.search( + r"^CREATE TABLE s1\.t1", dump, re.M + ), "unwanted table from schema is not restored" + assert not re.search( + r"^CREATE SEQUENCE s1\.s1", dump, re.M + ), "unwanted sequence from schema is not restored" + assert re.search(r"^CREATE TABLE s2\.t2", dump, re.M), "wanted table restored" + assert re.search(r"^CREATE TABLE public\.t1", dump, re.M), "wanted table restored" + + ######################################### + # test of supported syntax + + _write_filter(inputfile, "include table_and_children footab\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "filter file without patterns", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE TABLE public\.bootab", dump, re.M + ), "dumped children table" + + _write_filter(inputfile, "exclude table_and_children footab\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "filter file without patterns", + ) + + dump = slurp_file(plainfile) + assert not re.search( + r"^CREATE TABLE public\.bootab", dump, re.M + ), "exclude dumped children table" + + _write_filter(inputfile, "exclude table_data_and_children footab\n") + + node.command_ok( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + "filter file without patterns", + ) + + dump = slurp_file(plainfile) + assert re.search( + r"^CREATE TABLE public\.bootab", dump, re.M + ), "dumped children table" + assert not re.search( + r"^COPY public\.bootab", dump, re.M + ), "exclude dumped children table" + + ######################################### + # Test extension + + _write_filter(inputfile, "include extension doesnt_exists\n") + + node.command_fails_like( + [ + "pg_dump", + "--port", + str(port), + "--file", + plainfile, + "--filter", + inputfile, + "postgres", + ], + r"pg_dump: error: no matching extensions were found", + "dump nonexisting extension", + ) diff --git a/src/bin/pg_dump/pyt/test_006_pg_dump_compress.py b/src/bin/pg_dump/pyt/test_006_pg_dump_compress.py new file mode 100644 index 00000000000..19288526903 --- /dev/null +++ b/src/bin/pg_dump/pyt/test_006_pg_dump_compress.py @@ -0,0 +1,665 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests pg_dump / pg_restore compression handling. + +This test uses essentially the same matrix structure as test_002_pg_dump, but +is specialized to compression concerns: it dumps/restores across the gzip, +lz4 and zstd methods in the plain, custom and directory formats (with various +compression levels and the compress-spec syntax), and checks that the dump +output round-trips correctly. + +pg_dump / pg_restore are the binaries under test and are run as subprocesses +through the node's command_ok / command_like helpers. The seed SQL the test +itself runs is executed in-process via safe_sql. + +Each method's cases are gated on whether the corresponding compression +library was built in (HAVE_LIBZ / USE_LZ4 / USE_ZSTD in pg_config.h). Where a +"run" needs an external (de)compression program (gzip/lz4/zstd) for +manually-compressed TOC files or to decompress a plain dump, the program is +located via the GZIP_PROGRAM / LZ4 / ZSTD environment variables (set by the +build system) falling back to PATH; if it cannot be found, the rest of that +run is skipped. +""" + +import glob as _glob +import os +import re +import shutil +import subprocess + +from pypg.util import slurp_file + + +def _have_pg_config_define(define): + """Return True if the installed pg_config.h contains the given #define.""" + try: + out = subprocess.run( + ["pg_config", "--includedir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return False + header = os.path.join(out, "pg_config.h") + try: + with open(header, encoding="utf-8", errors="replace") as fh: + return define in fh.read() + except OSError: + return False + + +def _find_program(env_var, default_name): + """Locate an external (de)compression program. + + The build system sets GZIP_PROGRAM / LZ4 / ZSTD to the program's full path + (or an empty string when not found); honor that first. + When the variable is unset (e.g. running pytest standalone), fall back to + searching PATH so the test stays useful. Returns None when unavailable. + """ + val = os.environ.get(env_var) + if val is not None: + return val or None + return shutil.which(default_name) + + +# The regexps below run in verbose mode. re.VERBOSE (X) lets us keep the +# whitespace layout; re.MULTILINE (M) anchors ^/$ per line; re.DOTALL (S) is +# added where a pattern needs '.' to span newlines. +_XM = re.VERBOSE | re.MULTILINE +_XMS = re.VERBOSE | re.MULTILINE | re.DOTALL + + +def _pgdump_runs(tempdir): + """Definition of the pg_dump runs to make. + + Each run has a dump_cmd and a test_key (reuse another run's like/unlike + sets), and may have: a compile_option gating it on a compression library; + a restore_cmd; a compress_cmd (an external program + args used either to + decompress a plain dump into the .sql the matrix checks, or to manually + compress directory-format TOC files for restore coverage); glob_patterns + that must exist after dumping; and a command_like (run a command and check + its stdout). Commands are argv lists; cmd[0] is resolved in the node's + bindir. + """ + return { + "compression_none_custom": { + "test_key": "compression", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "custom", + "--compress", + "none", + "--file", + f"{tempdir}/compression_none_custom.dump", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/compression_none_custom.sql", + "--statistics", + f"{tempdir}/compression_none_custom.dump", + ], + }, + "compression_gzip_custom": { + "test_key": "compression", + "compile_option": "gzip", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "custom", + "--compress", + "1", + "--file", + f"{tempdir}/compression_gzip_custom.dump", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/compression_gzip_custom.sql", + "--statistics", + f"{tempdir}/compression_gzip_custom.dump", + ], + "command_like": { + "command": [ + "pg_restore", + "--list", + f"{tempdir}/compression_gzip_custom.dump", + ], + "expected": re.compile(r"Compression: gzip"), + "name": "data content is gzip-compressed", + }, + }, + "compression_gzip_dir": { + "test_key": "compression", + "compile_option": "gzip", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--jobs", + "2", + "--format", + "directory", + "--compress", + "gzip:1", + "--file", + f"{tempdir}/compression_gzip_dir", + "--statistics", + "postgres", + ], + # Give coverage for manually-compressed TOC files during restore. + "compress_cmd": { + "program": _find_program("GZIP_PROGRAM", "gzip"), + "args": [ + "-f", + f"{tempdir}/compression_gzip_dir/toc.dat", + f"{tempdir}/compression_gzip_dir/blobs_*.toc", + ], + }, + # Verify that TOC and data files were compressed. + "glob_patterns": [ + f"{tempdir}/compression_gzip_dir/toc.dat.gz", + f"{tempdir}/compression_gzip_dir/*.dat.gz", + ], + "restore_cmd": [ + "pg_restore", + "--jobs", + "2", + "--file", + f"{tempdir}/compression_gzip_dir.sql", + "--statistics", + f"{tempdir}/compression_gzip_dir", + ], + }, + "compression_gzip_plain": { + "test_key": "compression", + "compile_option": "gzip", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "plain", + "--compress", + "1", + "--file", + f"{tempdir}/compression_gzip_plain.sql.gz", + "--statistics", + "postgres", + ], + # Decompress the generated file to run through the tests. + "compress_cmd": { + "program": _find_program("GZIP_PROGRAM", "gzip"), + "args": ["-d", f"{tempdir}/compression_gzip_plain.sql.gz"], + }, + }, + "compression_lz4_custom": { + "test_key": "compression", + "compile_option": "lz4", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "custom", + "--compress", + "lz4", + "--file", + f"{tempdir}/compression_lz4_custom.dump", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/compression_lz4_custom.sql", + "--statistics", + f"{tempdir}/compression_lz4_custom.dump", + ], + "command_like": { + "command": [ + "pg_restore", + "--list", + f"{tempdir}/compression_lz4_custom.dump", + ], + "expected": re.compile(r"Compression: lz4"), + "name": "data content is lz4 compressed", + }, + }, + "compression_lz4_dir": { + "test_key": "compression", + "compile_option": "lz4", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--jobs", + "2", + "--format", + "directory", + "--compress", + "lz4:1", + "--file", + f"{tempdir}/compression_lz4_dir", + "--statistics", + "postgres", + ], + # Give coverage for manually-compressed TOC files during restore. + "compress_cmd": { + "program": _find_program("LZ4", "lz4"), + "args": [ + "-z", + "-f", + "-m", + "--rm", + f"{tempdir}/compression_lz4_dir/toc.dat", + f"{tempdir}/compression_lz4_dir/blobs_*.toc", + ], + }, + # Verify that TOC and data files were compressed. + "glob_patterns": [ + f"{tempdir}/compression_lz4_dir/toc.dat.lz4", + f"{tempdir}/compression_lz4_dir/*.dat.lz4", + ], + "restore_cmd": [ + "pg_restore", + "--jobs", + "2", + "--file", + f"{tempdir}/compression_lz4_dir.sql", + "--statistics", + f"{tempdir}/compression_lz4_dir", + ], + }, + "compression_lz4_plain": { + "test_key": "compression", + "compile_option": "lz4", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "plain", + "--compress", + "lz4", + "--file", + f"{tempdir}/compression_lz4_plain.sql.lz4", + "--statistics", + "postgres", + ], + # Decompress the generated file to run through the tests. + "compress_cmd": { + "program": _find_program("LZ4", "lz4"), + "args": [ + "-d", + "-f", + f"{tempdir}/compression_lz4_plain.sql.lz4", + f"{tempdir}/compression_lz4_plain.sql", + ], + }, + }, + "compression_zstd_custom": { + "test_key": "compression", + "compile_option": "zstd", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "custom", + "--compress", + "zstd", + "--file", + f"{tempdir}/compression_zstd_custom.dump", + "--statistics", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/compression_zstd_custom.sql", + "--statistics", + f"{tempdir}/compression_zstd_custom.dump", + ], + "command_like": { + "command": [ + "pg_restore", + "--list", + f"{tempdir}/compression_zstd_custom.dump", + ], + "expected": re.compile(r"Compression: zstd"), + "name": "data content is zstd compressed", + }, + }, + "compression_zstd_dir": { + "test_key": "compression", + "compile_option": "zstd", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--jobs", + "2", + "--format", + "directory", + "--compress", + "zstd:1", + "--file", + f"{tempdir}/compression_zstd_dir", + "--statistics", + "postgres", + ], + # Give coverage for manually-compressed TOC files during restore. + "compress_cmd": { + "program": _find_program("ZSTD", "zstd"), + "args": [ + "-z", + "-f", + "--rm", + f"{tempdir}/compression_zstd_dir/toc.dat", + f"{tempdir}/compression_zstd_dir/blobs_*.toc", + ], + }, + # Verify that TOC and data files were compressed. + "glob_patterns": [ + f"{tempdir}/compression_zstd_dir/toc.dat.zst", + f"{tempdir}/compression_zstd_dir/*.dat.zst", + ], + "restore_cmd": [ + "pg_restore", + "--jobs", + "2", + "--file", + f"{tempdir}/compression_zstd_dir.sql", + "--statistics", + f"{tempdir}/compression_zstd_dir", + ], + }, + # Exercise long mode for test coverage. + "compression_zstd_plain": { + "test_key": "compression", + "compile_option": "zstd", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "plain", + "--compress", + "zstd:long", + "--file", + f"{tempdir}/compression_zstd_plain.sql.zst", + "--statistics", + "postgres", + ], + # Decompress the generated file to run through the tests. + "compress_cmd": { + "program": _find_program("ZSTD", "zstd"), + "args": [ + "-d", + "-f", + f"{tempdir}/compression_zstd_plain.sql.zst", + "-o", + f"{tempdir}/compression_zstd_plain.sql", + ], + }, + }, + } + + +# Tests which are considered 'full' dumps by pg_dump (mirrors %full_runs). +FULL_RUNS = {"compression"} + + +def _tests(): + """Definition of the tests to run. + + Each entry: create_order/create_sql (seed SQL, run before any dump), + regexp (compiled), like/unlike sets of test keys, and an optional + compile_option gating the test on a compression library. A run whose + test_key is in 'like' (and not 'unlike') must match the regexp. + """ + full = set(FULL_RUNS) + return { + "CREATE MATERIALIZED VIEW matview_compression_lz4": { + "create_order": 20, + "create_sql": "CREATE MATERIALIZED VIEW\n" + " matview_compression_lz4 (col2) AS\n" + " SELECT repeat('xyzzy', 10000);\n" + " ALTER MATERIALIZED VIEW matview_compression_lz4\n" + " ALTER COLUMN col2 SET COMPRESSION lz4;", + "regexp": re.compile( + r"^" + r"CREATE\ MATERIALIZED\ VIEW\ public\.matview_compression_lz4\ AS" + r"\n\s+SELECT\ repeat\('xyzzy'::text,\ 10000\)\ AS\ col2" + r"\n\s+WITH\ NO\ DATA;" + r".*" + r"ALTER\ TABLE\ ONLY\ public\.matview_compression_lz4\ " + r"ALTER\ COLUMN\ col2\ SET\ COMPRESSION\ lz4;\n", + _XMS, + ), + "compile_option": "lz4", + "like": set(full), + }, + "CREATE TABLE test_compression_method": { + "create_order": 110, + "create_sql": "CREATE TABLE test_compression_method (\n" + " col1 text\n" + ");", + "regexp": re.compile( + r"^" + r"CREATE\ TABLE\ public\.test_compression_method\ \(\n" + r"\s+col1\ text\n" + r"\);", + _XM, + ), + "like": set(full), + }, + # Insert enough data to surpass DEFAULT_IO_BUFFER_SIZE during + # (de)compression operations. The data is the concatenation of the + # decimal representations of 1..65536, which is exactly 316574 digits, + # i.e. 31657 groups of ten digits followed by four more. + "COPY test_compression_method": { + "create_order": 111, + "create_sql": "INSERT INTO test_compression_method (col1) " + "SELECT string_agg(a::text, '') FROM generate_series(1,65536) a;", + "regexp": re.compile( + r"^" + r"COPY\ public\.test_compression_method\ \(col1\)\ FROM\ stdin;" + r"\n(?:(?:\d\d\d\d\d\d\d\d\d\d){31657}\d\d\d\d\n){1}\\\.\n", + _XM, + ), + "like": set(full), + }, + "CREATE TABLE test_compression": { + "create_order": 3, + "create_sql": "CREATE TABLE test_compression (\n" + " col1 int,\n" + " col2 text COMPRESSION lz4\n" + ");", + "regexp": re.compile( + r"^" + r"CREATE\ TABLE\ public\.test_compression\ \(\n" + r"\s+col1\ integer,\n" + r"\s+col2\ text\n" + r"\);\n" + r".*" + r"ALTER\ TABLE\ ONLY\ public\.test_compression\ " + r"ALTER\ COLUMN\ col2\ SET\ COMPRESSION\ lz4;\n", + _XMS, + ), + "compile_option": "lz4", + "like": set(full), + }, + # Create a large object so we can test compression of blobs.toc. + "LO create (using lo_from_bytea)": { + "create_order": 50, + "create_sql": "SELECT pg_catalog.lo_from_bytea(0, " + "'\\x310a320a330a340a350a360a370a380a390a');", + "regexp": re.compile( + r"^SELECT pg_catalog\.lo_create\('\d+'\);", re.MULTILINE + ), + "like": set(full), + }, + "LO load (using lo_from_bytea)": { + "regexp": re.compile( + r"^" + r"SELECT\ pg_catalog\.lo_open\('\d+',\ \d+\);\n" + r"SELECT\ pg_catalog\.lowrite\(0,\ " + r"'\\x310a320a330a340a350a360a370a380a390a'\);\n" + r"SELECT\ pg_catalog\.lo_close\(0\);", + _XM, + ), + "like": set(full), + }, + } + + +def _compile_option_supported(option, supports): + """Return True if *option* (gzip/lz4/zstd) is built in, per *supports*.""" + if not option: + return True + return supports.get(option, False) + + +def _create_order_key(item): + """Sort key implementing the create_order comparator. + + Tests with a create_order sort by it (ascending) and before tests without + one; among tests without a create_order, order is irrelevant for the + concatenated seed SQL but we keep it stable by name. + """ + name, spec = item + order = spec.get("create_order") + return (0, order, name) if order is not None else (1, 0, name) + + +def test_pg_dump_compress(pg, tmp_path): + node = pg + tempdir = str(tmp_path) + + supports = { + "gzip": _have_pg_config_define("#define HAVE_LIBZ 1"), + "lz4": _have_pg_config_define("#define USE_LZ4 1"), + "zstd": _have_pg_config_define("#define USE_ZSTD 1"), + } + + pgdump_runs = _pgdump_runs(tempdir) + tests = _tests() + + ######################################### + # Set up schemas, tables, etc, to be dumped. Build up the combined create + # statements in create_order (skipping ones needing an unsupported compile + # option), then send them. + create_sql = "" + for _name, spec in sorted(tests.items(), key=_create_order_key): + if not _compile_option_supported(spec.get("compile_option"), supports): + continue + if not spec.get("create_sql"): + continue + # Normalize command ending: strip trailing whitespace/newlines, add a + # semicolon if missing, then two newlines. + sql = spec["create_sql"].rstrip("\r\n") + if not sql.endswith(";"): + sql += ";" + create_sql += sql + "\n\n" + + node.safe_sql(create_sql) + + ######################################### + # Run all runs (sorted by name). + for run in sorted(pgdump_runs): + spec = pgdump_runs[run] + test_key = run + + # Skip runs that require an unsupported compile option. + opt = spec.get("compile_option") + if not _compile_option_supported(opt, supports): + print(f"# {run}: skipped due to no {opt} support") + continue + + node.command_ok(spec["dump_cmd"], f"{run}: pg_dump runs") + + if "compress_cmd" in spec: + compress_cmd = spec["compress_cmd"] + program = compress_cmd["program"] + + # Skip the rest of the test if the compression program is not + # available (the build env var was empty / program not on PATH). + if not program: + print(f"# {run}: skipped, no compression program available") + continue + + # Arguments may require globbing (e.g. blobs_*.toc). + full_compress_cmd = [program] + for arg in compress_cmd["args"]: + matches = sorted(_glob.glob(arg)) + full_compress_cmd += matches if matches else [arg] + + res = subprocess.run( + full_compress_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + assert res.returncode == 0, ( + f"{run}: compression commands\n" + f"{' '.join(full_compress_cmd)}\n{res.stdout}" + ) + + for glob_pattern in spec.get("glob_patterns", []): + matches = _glob.glob(glob_pattern) + ok = len(matches) > 1 or (len(matches) == 1 and os.path.isfile(matches[0])) + assert ok, f"{run}: glob check for {glob_pattern}" + + if "command_like" in spec: + cl = spec["command_like"] + node.command_like(cl["command"], cl["expected"], f"{run}: {cl['name']}") + + if "restore_cmd" in spec: + node.command_ok(spec["restore_cmd"], f"{run}: pg_restore runs") + + if "test_key" in spec: + test_key = spec["test_key"] + + output_file = slurp_file(os.path.join(tempdir, f"{run}.sql")) + + ######################################### + # Run all tests where this run is included as a 'like' or 'unlike'. + for test_name in sorted(tests): + tspec = tests[test_name] + + like = tspec.get("like") + unlike = tspec.get("unlike", set()) + all_runs_flag = tspec.get("all_runs", False) + + # Either all_runs should be set or there must be a "like" list, to + # keep the test self-documenting. + assert ( + all_runs_flag or like is not None + ), f'missing "like" in test "{test_name}"' + like_set = like if like is not None else set() + + # Check for useless entries in "unlike": a run not listed in "like" + # doesn't need excluding. + assert not ( + test_key in unlike and test_key not in like_set + ), f'useless "unlike" entry "{test_key}" in test "{test_name}"' + + # Skip tests that require an unsupported compile option. + if not _compile_option_supported(tspec.get("compile_option"), supports): + continue + + if (test_key in like_set or all_runs_flag) and test_key not in unlike: + assert tspec["regexp"].search(output_file), ( + f"{run}: should dump {test_name}\n" + f"Review {run} results in {tempdir}" + ) + else: + assert not tspec["regexp"].search(output_file), ( + f"{run}: should not dump {test_name}\n" + f"Review {run} results in {tempdir}" + ) + + node.stop("fast") diff --git a/src/bin/pg_dump/pyt/test_007_pg_dumpall.py b/src/bin/pg_dump/pyt/test_007_pg_dumpall.py new file mode 100644 index 00000000000..4fe2e24b244 --- /dev/null +++ b/src/bin/pg_dump/pyt/test_007_pg_dumpall.py @@ -0,0 +1,912 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests pg_dumpall and pg_restore of cluster-wide dumps. + +Tests pg_dumpall and pg_restore of cluster-wide dumps: multiple databases, +roles/globals, the --globals-only/--no-globals options, the various dump +formats, and a number of pg_restore option-combination error cases that are +specific to pg_dumpall archives. + +pg_dumpall / pg_restore are the binaries under test and run as subprocesses +through the node's pg_bin (PGHOST/PGPORT point at the server). The seed SQL +the test itself runs is executed in-process via safe_sql. Each test-matrix +"run" is dumped from the source node and restored into a freshly created +target node so no per-run cleanup is needed. +""" + +import os +import re + +from pypg.util import slurp_file + +# Verbose, multiline-allowing regex flags used by the patterns below. +_XM = re.VERBOSE | re.MULTILINE + + +def _pgdumpall_runs(tempdir, tablespace1, tablespace2, tablespace2_orig): + """Definition of the pg_dumpall test cases. + + Each entry has setup_sql (a list of statements run in-process, where each + element is (sql, dbname)), a dump_cmd and restore_cmd (argv lists), and + like and/or unlike compiled regexps matched against the restore output. + """ + # The dumped tablespace LOCATION may come back with either path separator + # (and a Windows string literal may double its backslashes), depending on + # how the server canonicalizes the path. Match the path components with a + # separator class that accepts "/", "\" or "\\" so the expected pattern + # agrees with the dump on every platform. + ts2_loc = r"[\\/]+".join( + re.escape(part) for part in re.split(r"[\\/]", tablespace2_orig) + ) + + return { + "restore_roles": { + "setup_sql": [ + ( + "CREATE ROLE dumpall WITH ENCRYPTED PASSWORD 'admin' SUPERUSER;", + "postgres", + ), + ( + "CREATE ROLE dumpall2 WITH REPLICATION CONNECTION LIMIT 10;", + "postgres", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "directory", + "--file", + f"{tempdir}/restore_roles", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--format", + "directory", + "--file", + f"{tempdir}/restore_roles.sql", + f"{tempdir}/restore_roles", + ], + "like": re.compile( + r"\s*CREATE\ ROLE\ dumpall2;" + r"\s*ALTER\ ROLE\ dumpall2\ WITH\ NOSUPERUSER\ INHERIT\ " + r"NOCREATEROLE\ NOCREATEDB\ NOLOGIN\ REPLICATION\ NOBYPASSRLS\ " + r"CONNECTION\ LIMIT\ 10;", + _XM, + ), + }, + "restore_tablespace": { + "setup_sql": [ + ("CREATE ROLE tap;", "postgres"), + ( + f"CREATE TABLESPACE tbl1 OWNER tap LOCATION '{tablespace1}';", + "postgres", + ), + ( + f"CREATE TABLESPACE tbl2 OWNER tap LOCATION '{tablespace2}' " + "WITH (seq_page_cost=1.0);", + "postgres", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "directory", + "--file", + f"{tempdir}/restore_tablespace", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--format", + "directory", + "--file", + f"{tempdir}/restore_tablespace.sql", + f"{tempdir}/restore_tablespace", + ], + # Match "E" as optional since it is added on LOCATION when running + # on Windows. + "like": re.compile( + r"^" + r"\n CREATE\ TABLESPACE\ tbl2\ OWNER\ tap\ LOCATION\ (?:E)?'" + + ts2_loc + + r"';" + + r"\n ALTER\ TABLESPACE\ tbl2\ SET\ \(seq_page_cost=1.0\);", + _XM, + ), + }, + "restore_grants": { + "setup_sql": [ + ("CREATE DATABASE tapgrantsdb;", "postgres"), + ("CREATE SCHEMA private;", "postgres"), + ("CREATE SEQUENCE serial START 101;", "postgres"), + ( + "CREATE FUNCTION fn() RETURNS void AS $$\n" + "BEGIN\nEND;\n$$ LANGUAGE plpgsql;", + "postgres", + ), + ("CREATE ROLE super;", "postgres"), + ("CREATE ROLE grant1;", "postgres"), + ("CREATE ROLE grant2;", "postgres"), + ("CREATE ROLE grant3;", "postgres"), + ("CREATE ROLE grant4;", "postgres"), + ("CREATE ROLE grant5;", "postgres"), + ("CREATE ROLE grant6;", "postgres"), + ("CREATE ROLE grant7;", "postgres"), + ("CREATE ROLE grant8;", "postgres"), + ( + "CREATE TABLE t (id int);\n" + "INSERT INTO t VALUES (1), (2), (3), (4);\n" + "GRANT SELECT ON TABLE t TO grant1;\n" + "GRANT INSERT ON TABLE t TO grant2;\n" + "GRANT ALL PRIVILEGES ON TABLE t to grant3;\n" + "GRANT CONNECT, CREATE ON DATABASE tapgrantsdb TO grant4;\n" + "GRANT USAGE, CREATE ON SCHEMA private TO grant5;\n" + "GRANT USAGE, SELECT, UPDATE ON SEQUENCE serial TO grant6;\n" + "GRANT super TO grant7;\n" + "GRANT EXECUTE ON FUNCTION fn() TO grant8;\n", + "postgres", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "directory", + "--file", + f"{tempdir}/restore_grants", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--format", + "directory", + "--file", + f"{tempdir}/restore_grants.sql", + f"{tempdir}/restore_grants", + ], + "like": re.compile( + r"^" + r"\n GRANT\ ALL\ ON\ SCHEMA\ private\ TO\ grant5;" + r"(.*\n)*" + r"\n GRANT\ ALL\ ON\ FUNCTION\ public.fn\(\)\ TO\ grant8;" + r"(.*\n)*" + r"\n GRANT\ ALL\ ON\ SEQUENCE\ public.serial\ TO\ grant6;" + r"(.*\n)*" + r"\n GRANT\ SELECT\ ON\ TABLE\ public.t\ TO\ grant1;" + r"\n GRANT\ INSERT\ ON\ TABLE\ public.t\ TO\ grant2;" + r"\n GRANT\ ALL\ ON\ TABLE\ public.t\ TO\ grant3;" + r"(.*\n)*" + r"\n GRANT\ CREATE,CONNECT\ ON\ DATABASE\ tapgrantsdb\ " + r"TO\ grant4;", + _XM, + ), + }, + "excluding_databases": { + "setup_sql": [ + ("CREATE DATABASE db1;", "postgres"), + ( + "CREATE TABLE t1 (id int);\n" + "INSERT INTO t1 VALUES (1), (2), (3), (4);\n" + "CREATE TABLE t2 (id int);\n" + "INSERT INTO t2 VALUES (1), (2), (3), (4);", + "db1", + ), + ("CREATE DATABASE db2;", "postgres"), + ( + "CREATE TABLE t3 (id int);\n" + "INSERT INTO t3 VALUES (1), (2), (3), (4);\n" + "CREATE TABLE t4 (id int);\n" + "INSERT INTO t4 VALUES (1), (2), (3), (4);", + "db2", + ), + ("CREATE DATABASE dbex3;", "postgres"), + ( + "CREATE TABLE t5 (id int);\n" + "INSERT INTO t5 VALUES (1), (2), (3), (4);\n" + "CREATE TABLE t6 (id int);\n" + "INSERT INTO t6 VALUES (1), (2), (3), (4);", + "dbex3", + ), + ("CREATE DATABASE dbex4;", "postgres"), + ( + "CREATE TABLE t7 (id int);\n" + "INSERT INTO t7 VALUES (1), (2), (3), (4);\n" + "CREATE TABLE t8 (id int);\n" + "INSERT INTO t8 VALUES (1), (2), (3), (4);", + "dbex4", + ), + ("CREATE DATABASE db5;", "postgres"), + ( + "CREATE TABLE t9 (id int);\n" + "INSERT INTO t9 VALUES (1), (2), (3), (4);\n" + "CREATE TABLE t10 (id int);\n" + "INSERT INTO t10 VALUES (1), (2), (3), (4);", + "db5", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "directory", + "--file", + f"{tempdir}/excluding_databases", + "--exclude-database", + "dbex*", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--format", + "directory", + "--file", + f"{tempdir}/excluding_databases.sql", + "--exclude-database", + "db5", + f"{tempdir}/excluding_databases", + ], + "like": re.compile( + r"^" + r"\n CREATE\ DATABASE\ db1" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t1\ \(" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t2\ \(" + r"(.*\n)*" + r"\n CREATE\ DATABASE\ db2" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t3\ \(" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t4\ \(", + _XM, + ), + "unlike": re.compile( + r"^" + r"\n CREATE\ DATABASE\ db3" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t5\ \(" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t6\ \(" + r"(.*\n)*" + r"\n CREATE\ DATABASE\ db4" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t7\ \(" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t8\ \(" + r"\n CREATE\ DATABASE\ db5" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t9\ \(" + r"(.*\n)*" + r"\n CREATE\ TABLE\ public.t10\ \(", + _XM, + ), + }, + "format_directory": { + "setup_sql": [ + ( + "CREATE TABLE format_directory(a int, b boolean, c text);\n" + "INSERT INTO format_directory VALUES " + "(1, true, 'name1'), (2, false, 'name2');", + "postgres", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "directory", + "--file", + f"{tempdir}/format_directory", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--format", + "directory", + "--file", + f"{tempdir}/format_directory.sql", + f"{tempdir}/format_directory", + ], + "like": re.compile( + r"^\n COPY\ public.format_directory\ \(a,\ b,\ c\)\ FROM\ stdin;", + _XM, + ), + }, + "format_tar": { + "setup_sql": [ + ( + "CREATE TABLE format_tar(a int, b boolean, c text);\n" + "INSERT INTO format_tar VALUES " + "(1, false, 'name3'), (2, true, 'name4');", + "postgres", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "tar", + "--file", + f"{tempdir}/format_tar", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--format", + "tar", + "--file", + f"{tempdir}/format_tar.sql", + f"{tempdir}/format_tar", + ], + "like": re.compile( + r"^\n COPY\ public.format_tar\ \(a,\ b,\ c\)\ FROM\ stdin;", + _XM, + ), + }, + "format_custom": { + "setup_sql": [ + ( + "CREATE TABLE format_custom(a int, b boolean, c text);\n" + "INSERT INTO format_custom VALUES " + "(1, false, 'name5'), (2, true, 'name6');", + "postgres", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "custom", + "--file", + f"{tempdir}/format_custom", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--format", + "custom", + "--file", + f"{tempdir}/format_custom.sql", + f"{tempdir}/format_custom", + ], + "like": re.compile( + r"^\n COPY\ public.format_custom\ \(a,\ b,\ c\)\ FROM\ stdin;", + _XM, + ), + }, + "dump_globals_only": { + "setup_sql": [ + ( + "CREATE TABLE format_dir(a int, b boolean, c text);\n" + "INSERT INTO format_dir VALUES " + "(1, false, 'name5'), (2, true, 'name6');", + "postgres", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "directory", + "--globals-only", + "--file", + f"{tempdir}/dump_globals_only", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--globals-only", + "--format", + "directory", + "--file", + f"{tempdir}/dump_globals_only.sql", + f"{tempdir}/dump_globals_only", + ], + "like": re.compile( + r"^\s* CREATE\ ROLE\ dumpall;\s*\n", + _XM, + ), + }, + "restore_no_globals": { + "setup_sql": [ + ( + "CREATE TABLE no_globals_test(a int, b text);\n" + "INSERT INTO no_globals_test VALUES " + "(1, 'hello'), (2, 'world');", + "postgres", + ), + ], + "dump_cmd": [ + "pg_dumpall", + "--format", + "directory", + "--file", + f"{tempdir}/restore_no_globals", + ], + "restore_cmd": [ + "pg_restore", + "-C", + "--no-globals", + "--format", + "directory", + "--file", + f"{tempdir}/restore_no_globals.sql", + f"{tempdir}/restore_no_globals", + ], + "like": re.compile( + r"^\n COPY\ public.no_globals_test\ \(a,\ b\)\ FROM\ stdin;", + _XM, + ), + "unlike": re.compile(r"^CREATE\ ROLE\ dumpall;", _XM), + }, + } + + +def test_pg_dumpall(pg, create_pg, tmp_path): + node = pg + + tempdir = str(tmp_path) + run_db = "postgres" + + # Tablespace locations used by the "restore_tablespace" test case. + tablespace1 = os.path.join(tempdir, "tbl1") + tablespace2 = os.path.join(tempdir, "tbl2") + os.mkdir(tablespace1) + os.mkdir(tablespace2) + tablespace2_orig = tablespace2 + + pgdumpall_runs = _pgdumpall_runs( + tempdir, tablespace1, tablespace2, tablespace2_orig + ) + + # First execute the setup_sql for all runs. + for run in sorted(pgdumpall_runs): + for sql, dbname in pgdumpall_runs[run].get("setup_sql", []): + node.safe_sql(sql, dbname=dbname) + + # Execute the tests. + for run in sorted(pgdumpall_runs): + spec = pgdumpall_runs[run] + + # Create a new target cluster to pg_restore each run into so that we + # don't need to clean up the target cluster after each run. + target_node = create_pg(f"target_{run}") + + # Dumpall from the source node cluster. + node.command_ok(spec["dump_cmd"], f"{run}: pg_dumpall runs") + + # Restore the dump on the target_node cluster. We deliberately + # don't assert on the restore's exit status (it may emit warnings); + # the assertions are on the --file output. + restore_cmd = list(spec["restore_cmd"]) + [ + "--host", + target_node.host, + "--port", + str(target_node.port), + ] + node.pg_bin.result(restore_cmd) + + output_file = slurp_file(os.path.join(tempdir, f"{run}.sql")) + + assert spec.get("like") or spec.get( + "unlike" + ), f'missing "like" or "unlike" in test "{run}"' + + if spec.get("like"): + assert spec["like"].search( + output_file + ), f"should dump {run}\nReview results in {tempdir}" + if spec.get("unlike"): + assert not spec["unlike"].search( + output_file + ), f"should not dump {run}\nReview results in {tempdir}" + + target_node.stop("fast") + + # Some negative test cases for pg_restore with a dump of pg_dumpall. + custom = f"{tempdir}/format_custom" + + # error when -C is not used in pg_restore with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "--format", + "custom", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: option -C/--create must be specified " + "when restoring an archive created by pg_dumpall" + ), + "When -C is not used in pg_restore with dump of pg_dumpall", + ) + + # error when \l/--list option is used with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--list", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: option -l/--list cannot be used when " + "restoring an archive created by pg_dumpall" + ), + "When --list is used in pg_restore with dump of pg_dumpall", + ) + + # error when -L/--use-list option is used with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--use-list", + "use", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: option -L/--use-list cannot be used " + "when restoring an archive created by pg_dumpall" + ), + "When -L/--use-list is used in pg_restore with dump of pg_dumpall", + ) + + # error when --strict-names option is used with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--strict-names", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: option --strict-names cannot be used " + "when restoring an archive created by pg_dumpall" + ), + "When --strict-names is used in pg_restore with dump of pg_dumpall", + ) + + # error when --clean and -g/--globals-only are used with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--clean", + "--globals-only", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: options --clean and -g/--globals-only " + "cannot be used together when restoring an archive created " + "by pg_dumpall" + ), + "When --clean and -g/--globals-only are used in pg_restore with dump " + "of pg_dumpall", + ) + + # error when non-existent database is given with -d option + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "-d", + "dbpq", + ], + re.escape('FATAL: database "dbpq" does not exist'), + "When non-existent database is given with -d option in pg_restore " + "with dump of pg_dumpall", + ) + + # error when --no-schema is used with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--no-schema", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: option --no-schema cannot be used when " + "restoring an archive created by pg_dumpall" + ), + "When --no-schema is used in pg_restore with dump of pg_dumpall", + ) + + # error when --data-only is used with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--data-only", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: option -a/--data-only cannot be used " + "when restoring an archive created by pg_dumpall" + ), + "When --data-only is used in pg_restore with dump of pg_dumpall", + ) + + # error when --statistics-only is used with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--statistics-only", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: option --statistics-only cannot be used " + "when restoring an archive created by pg_dumpall" + ), + "When --statistics-only is used in pg_restore with dump of pg_dumpall", + ) + + # error when --section excludes pre-data with dump of pg_dumpall + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--section", + "post-data", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: option --section cannot exclude " + "--pre-data when restoring a pg_dumpall archive" + ), + "When --section=post-data is used in pg_restore with dump of pg_dumpall", + ) + + # error when --globals-only and --data-only are used together + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--globals-only", + "--data-only", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: options -a/--data-only and " + "-g/--globals-only cannot be used together" + ), + "When --globals-only and --data-only are used together", + ) + + # error when --globals-only and --schema-only are used together + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--globals-only", + "--schema-only", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: options -g/--globals-only and " + "-s/--schema-only cannot be used together" + ), + "When --globals-only and --schema-only are used together", + ) + + # error when --globals-only and --statistics-only are used together + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--globals-only", + "--statistics-only", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: options -g/--globals-only and " + "--statistics-only cannot be used together" + ), + "When --globals-only and --statistics-only are used together", + ) + + # error when --globals-only and --statistics are used together + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--globals-only", + "--statistics", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: options --statistics and " + "-g/--globals-only cannot be used together" + ), + "When --globals-only and --statistics are used together", + ) + + # error when --globals-only and --exit-on-error are used together + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--globals-only", + "--exit-on-error", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: options --exit-on-error and " + "-g/--globals-only cannot be used together" + ), + "When --globals-only and --exit-on-error are used together", + ) + + # error when --globals-only and --single-transaction are used together + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--globals-only", + "--single-transaction", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: options -g/--globals-only and " + "-1/--single-transaction cannot be used together" + ), + "When --globals-only and --single-transaction are used together", + ) + + # error when --globals-only and --transaction-size are used together + node.command_fails_like( + [ + "pg_restore", + custom, + "-C", + "--format", + "custom", + "--globals-only", + "--transaction-size", + "100", + "--file", + f"{tempdir}/error_test.sql", + ], + re.escape( + "pg_restore: error: options -g/--globals-only and " + "--transaction-size cannot be used together" + ), + "When --globals-only and --transaction-size are used together", + ) + + # verify map.dat preamble exists + map_dat_content = slurp_file(os.path.join(tempdir, "format_directory", "map.dat")) + assert re.search( + r"^# map\.dat\n.*# This file maps oids to database names", + map_dat_content, + re.MULTILINE | re.DOTALL, + ), "map.dat contains expected preamble" + + # verify commenting out a line in map.dat skips that database + node.safe_sql("CREATE DATABASE comment_test_db;", dbname=run_db) + node.safe_sql("CREATE TABLE comment_test_table (id int);", dbname="comment_test_db") + + node.command_ok( + [ + "pg_dumpall", + "--format", + "directory", + "--file", + f"{tempdir}/comment_test", + ], + "pg_dumpall for comment test", + ) + + # Modify map.dat to comment out the comment_test_db entry. + map_path = os.path.join(tempdir, "comment_test", "map.dat") + map_content = slurp_file(map_path) + map_content = re.sub( + r"^(\d+ comment_test_db)$", r"# \1", map_content, flags=re.MULTILINE + ) + with open(map_path, "w", encoding="utf-8") as fh: + fh.write(map_content) + + # Create a target node and restore - commented db should be skipped. + target_comment = create_pg("target_comment") + + node.command_ok( + [ + "pg_restore", + "-C", + "--format", + "directory", + "--file", + f"{tempdir}/comment_test_restore.sql", + "--host", + target_comment.host, + "--port", + str(target_comment.port), + f"{tempdir}/comment_test", + ], + "pg_restore with commented out database in map.dat", + ) + + restore_output = slurp_file(f"{tempdir}/comment_test_restore.sql") + assert not re.search( + r"CREATE DATABASE comment_test_db", restore_output + ), "commented out database in map.dat is not restored" + + # Test that --clean implies --if-exists for pg_dumpall archives. + node.command_ok( + [ + "pg_restore", + "-C", + "--format", + "custom", + "--clean", + "--file", + f"{tempdir}/clean_test.sql", + custom, + ], + "pg_restore with --clean on pg_dumpall archive", + ) + + clean_output = slurp_file(f"{tempdir}/clean_test.sql") + assert re.search( + r"DROP ROLE IF EXISTS", clean_output + ), "--clean implies --if-exists: DROP ROLE IF EXISTS in output" + + node.stop("fast") diff --git a/src/bin/pg_dump/pyt/test_010_dump_connstr.py b/src/bin/pg_dump/pyt/test_010_dump_connstr.py new file mode 100644 index 00000000000..e02e3760113 --- /dev/null +++ b/src/bin/pg_dump/pyt/test_010_dump_connstr.py @@ -0,0 +1,459 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests connection-string handling in pg_dump / pg_dumpall / pg_restore. + +Exercises connection-string handling in pg_dump, pg_dumpall and pg_restore +against databases and roles whose names contain the full range of LATIN1 +characters (control characters, quotes, spaces and high-bit bytes). + +pg_dump / pg_dumpall / pg_restore (and psql, for the restore-through-psql +steps) are the programs under test and are run as subprocesses. The names use +byte sequences that aren't valid UTF-8, so the tools are run with +PGCLIENTENCODING=LATIN1 and the argv is encoded as raw LATIN1 bytes; the +framework's str-based command helpers would mangle the high-bit bytes through +their UTF-8 encoding, so a small bytes-aware runner is used instead. + +The SQL that creates the databases and roles is run in-process via the node's +libpq session with client_encoding=UTF8: a Python code point U+00xx is sent as +UTF-8 and converted by the server to the matching LATIN1 byte, so the names +round-trip to exactly the intended bytes. +""" + +import os +import subprocess + +import pytest + +from libpq import Session +from pypg.util import WINDOWS_OS + +# The database/role names contain high-bit byte sequences that are not valid +# UTF-8, and the tools under test are run as subprocesses with those bytes in +# argv. The Perl test passes them via a narrow (ANSI) process spawn, so it runs +# everywhere except MSYS2. Python's subprocess always uses CreateProcessW +# (wide): a bytes arg must be valid UTF-8 (fails here), and a str arg is encoded +# to the child's argv through the active code page, which cannot represent the +# 0x80-0x9F range at all. There is no portable way to pass these bytes as a +# child's arguments under Python on any Windows, so skip there. +pytestmark = pytest.mark.skipif( + WINDOWS_OS, + reason="high-bit byte names cannot be passed as subprocess arguments on " + "Windows under Python (subprocess uses CreateProcessW)", +) + + +def _generate_ascii_string(from_char, to_char): + """Build a string spanning a range of byte values. + + Build a string from the given inclusive range of byte values, mapping each + byte to the same Unicode code point (Latin-1 semantics). + """ + return "".join(chr(i) for i in range(from_char, to_char + 1)) + + +def _quote_ident(name): + """Double-quote an SQL identifier, doubling embedded double quotes.""" + return '"' + name.replace('"', '""') + '"' + + +def _connstr_dbname(host, port, dbname): + """Build a libpq connection string targeting *dbname*. + + The dbname is single-quoted with backslashes and single quotes escaped per + libpq connection-string rules. host/port are supplied explicitly rather + than reused from node.connstr() so the dbname quoting is under our control. + """ + quoted = dbname.replace("\\", "\\\\").replace("'", "\\'") + return f"host='{host}' port={port} dbname='{quoted}'" + + +def _run_bytes(node, argv, msg, extra_env=None, check=True): + """Run *argv* (a list of str) as a program under test, LATIN1-encoded. + + Mirrors node.command_ok(), but encodes every argv element as raw LATIN1 + bytes so high-bit and control characters in database/role names survive + intact (the str-based helpers would re-encode them as UTF-8). cmd[0] is + resolved within the node's bindir. With *check* (the default) a non-zero + exit raises, like command_ok; the completed process is returned. + """ + prog = argv[0] + candidate = os.path.join(node.bindir, prog) + if os.path.exists(candidate): + prog = candidate + + env = dict(os.environ) + env["PGHOST"] = node.host + env["PGPORT"] = str(node.port) + env["PGDATABASE"] = "postgres" + env["LC_ALL"] = "C" + env["PGCLIENTENCODING"] = "LATIN1" + if node.libdir: + env["LD_LIBRARY_PATH"] = ( + node.libdir + os.pathsep + env.get("LD_LIBRARY_PATH", "") + ) + if extra_env: + env.update(extra_env) + env = {k: v for k, v in env.items() if v is not None} + + bargv = [prog.encode("latin-1")] + [a.encode("latin-1") for a in argv[1:]] + print("# Running: " + " ".join(argv[:1] + [repr(a) for a in argv[1:]])) + proc = subprocess.run( + bargv, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False + ) + stderr = proc.stderr.decode("latin-1", "replace") + if check: + assert ( + proc.returncode == 0 + ), f"{msg}\nexit code: {proc.returncode}\nstderr:\n{stderr}" + return proc + + +def test_dump_connstr(create_pg): + src_bootstrap_super = "regress_postgres" + dst_bootstrap_super = "boot" + + # Create database and user names covering the range of LATIN1 characters, + # for use in a connection string by pg_dumpall. Skip ',' because of + # pg_regress --create-role, skip [\n\r] because pg_dumpall does not allow + # them. We also skip many ASCII letters, to keep the total number of + # tested characters to what will fit in four names. + dbname1 = ( + "regression" + + _generate_ascii_string(1, 9) + + _generate_ascii_string(11, 12) + + _generate_ascii_string(14, 33) + + '"x"' + + _generate_ascii_string(35, 43) # skip ',' + + _generate_ascii_string(45, 54) + ) + dbname2 = ( + "regression" + + _generate_ascii_string(55, 65) # skip 'B'-'W' + + _generate_ascii_string(88, 99) # skip 'd'-'w' + + _generate_ascii_string(120, 149) + ) + dbname3 = "regression" + _generate_ascii_string(150, 202) + dbname4 = "regression" + _generate_ascii_string(203, 255) + + username1 = "regress_" + dbname1[len("regression") :] + username2 = "regress_" + dbname2[len("regression") :] + username3 = "regress_" + dbname3[len("regression") :] + username4 = "regress_" + dbname4[len("regression") :] + + dbnames = [dbname1, dbname2, dbname3, dbname4] + usernames = [username1, username2, username3, username4] + + node = create_pg( + "main", + start=False, + initdb_extra=[ + "--username", + src_bootstrap_super, + "--locale", + "C", + "--encoding", + "LATIN1", + ], + ) + node.start() + + backupdir = node.backup_dir + discard = os.path.join(backupdir, "discard.sql") + plain = os.path.join(backupdir, "plain.sql") + dirfmt = os.path.join(backupdir, "dirfmt") + + # Create the databases and superuser roles via libpq with an UTF8 client + # encoding: each code point is converted by the server to the matching + # LATIN1 byte, so the stored names are exactly the byte sequences above. + # CREATE DATABASE cannot run inside a transaction block, so each statement + # is issued on its own. + admin = Session( + connstr=_connstr_dbname(node.host, node.port, "postgres") + + f" client_encoding=UTF8 user='{src_bootstrap_super}'", + libdir=node.libdir, + ) + try: + for dbname, username in zip(dbnames, usernames): + admin.query_safe("CREATE DATABASE " + _quote_ident(dbname)) + admin.query_safe( + "CREATE ROLE " + _quote_ident(username) + " SUPERUSER LOGIN" + ) + finally: + admin.close() + + # For these tests, pg_dumpall --roles-only is used because it produces a + # short dump. Each long ASCII name is reached through a connection string. + _run_bytes( + node, + [ + "pg_dumpall", + "--roles-only", + "--file", + discard, + "--dbname", + _connstr_dbname(node.host, node.port, dbname1), + "--username", + username4, + ], + "pg_dumpall with long ASCII name 1", + ) + _run_bytes( + node, + [ + "pg_dumpall", + "--no-sync", + "--roles-only", + "--file", + discard, + "--dbname", + _connstr_dbname(node.host, node.port, dbname2), + "--username", + username3, + ], + "pg_dumpall with long ASCII name 2", + ) + _run_bytes( + node, + [ + "pg_dumpall", + "--no-sync", + "--roles-only", + "--file", + discard, + "--dbname", + _connstr_dbname(node.host, node.port, dbname3), + "--username", + username2, + ], + "pg_dumpall with long ASCII name 3", + ) + _run_bytes( + node, + [ + "pg_dumpall", + "--no-sync", + "--roles-only", + "--file", + discard, + "--dbname", + _connstr_dbname(node.host, node.port, dbname4), + "--username", + username1, + ], + "pg_dumpall with long ASCII name 4", + ) + _run_bytes( + node, + [ + "pg_dumpall", + "--no-sync", + "--roles-only", + "--username", + src_bootstrap_super, + "--dbname", + "dbname=template1", + ], + "pg_dumpall --dbname accepts connection string", + ) + + # make a table, so the parallel worker has something to dump. dbname1 + # contains a single quote, so node.connstr() (which does not escape) can't + # be used; build an explicitly-escaped connection string instead. + db1_sess = Session( + connstr=_connstr_dbname(node.host, node.port, dbname1) + + f" client_encoding=UTF8 user='{src_bootstrap_super}'", + libdir=node.libdir, + ) + try: + db1_sess.query_safe("CREATE TABLE t0()") + finally: + db1_sess.close() + + # XXX no printed message when this fails, just SIGPIPE termination + _run_bytes( + node, + [ + "pg_dump", + "--format", + "directory", + "--no-sync", + "--jobs", + "2", + "--file", + dirfmt, + "--username", + username1, + _connstr_dbname(node.host, node.port, dbname1), + ], + "parallel dump", + ) + + # recreate $dbname1 for restore test + admin = Session( + connstr=_connstr_dbname(node.host, node.port, "postgres") + + f" client_encoding=UTF8 user='{src_bootstrap_super}'", + libdir=node.libdir, + ) + try: + admin.query_safe("DROP DATABASE " + _quote_ident(dbname1)) + admin.query_safe("CREATE DATABASE " + _quote_ident(dbname1)) + finally: + admin.close() + + _run_bytes( + node, + [ + "pg_restore", + "--verbose", + "--dbname", + "template1", + "--jobs", + "2", + "--username", + username1, + dirfmt, + ], + "parallel restore", + ) + + admin = Session( + connstr=_connstr_dbname(node.host, node.port, "postgres") + + f" client_encoding=UTF8 user='{src_bootstrap_super}'", + libdir=node.libdir, + ) + try: + admin.query_safe("DROP DATABASE " + _quote_ident(dbname1)) + finally: + admin.close() + + _run_bytes( + node, + [ + "pg_restore", + "--create", + "--verbose", + "--dbname", + "template1", + "--jobs", + "2", + "--username", + username1, + dirfmt, + ], + "parallel restore with create", + ) + + _run_bytes( + node, + [ + "pg_dumpall", + "--no-sync", + "--file", + plain, + "--username", + username1, + ], + "take full dump", + ) + + restore_super = "regress_a'b\\c=d\\ne\"f" + + # Restore full dump through psql using environment variables for + # dbname/user connection parameters. + envar_node = create_pg( + "destination_envar", + start=False, + initdb_extra=[ + "--username", + dst_bootstrap_super, + "--locale", + "C", + "--encoding", + "LATIN1", + ], + ) + envar_node.start() + + # make superuser for restore + envar_admin = Session( + connstr=_connstr_dbname(envar_node.host, envar_node.port, "postgres") + + f" client_encoding=UTF8 user='{dst_bootstrap_super}'", + libdir=envar_node.libdir, + ) + try: + envar_admin.query_safe( + "CREATE ROLE " + _quote_ident(restore_super) + " SUPERUSER LOGIN" + ) + finally: + envar_admin.close() + + proc = _run_bytes( + envar_node, + ["psql", "--no-psqlrc", "--file", plain], + "restore full dump using environment variables for connection parameters", + extra_env={ + "PGHOST": envar_node.host, + "PGPORT": str(envar_node.port), + "PGUSER": restore_super, + }, + check=False, + ) + assert proc.returncode == 0, ( + "restore full dump using environment variables for connection " + f"parameters\nstderr:\n{proc.stderr.decode('latin-1', 'replace')}" + ) + assert proc.stderr == b"", "no dump errors\nstderr:\n" + proc.stderr.decode( + "latin-1", "replace" + ) + + # Restore full dump through psql using command-line options for + # dbname/user connection parameters. "\connect dbname=" forgets user/port + # from command line. + cmdline_node = create_pg( + "destination_cmdline", + start=False, + initdb_extra=[ + "--username", + dst_bootstrap_super, + "--locale", + "C", + "--encoding", + "LATIN1", + ], + ) + cmdline_node.start() + + cmdline_admin = Session( + connstr=_connstr_dbname(cmdline_node.host, cmdline_node.port, "postgres") + + f" client_encoding=UTF8 user='{dst_bootstrap_super}'", + libdir=cmdline_node.libdir, + ) + try: + cmdline_admin.query_safe( + "CREATE ROLE " + _quote_ident(restore_super) + " SUPERUSER LOGIN" + ) + finally: + cmdline_admin.close() + + proc = _run_bytes( + cmdline_node, + [ + "psql", + "--port", + str(cmdline_node.port), + "--username", + restore_super, + "--no-psqlrc", + "--file", + plain, + ], + "restore full dump with command-line options for connection parameters", + check=False, + ) + assert proc.returncode == 0, ( + "restore full dump with command-line options for connection " + f"parameters\nstderr:\n{proc.stderr.decode('latin-1', 'replace')}" + ) + assert proc.stderr == b"", "no dump errors\nstderr:\n" + proc.stderr.decode( + "latin-1", "replace" + ) diff --git a/src/bin/pg_upgrade/meson.build b/src/bin/pg_upgrade/meson.build index ffbf6ae8d75..75c9538ad3a 100644 --- a/src/bin/pg_upgrade/meson.build +++ b/src/bin/pg_upgrade/meson.build @@ -73,6 +73,18 @@ tests += { 'deps': [test_ext], 'test_kwargs': {'priority': 40}, # pg_upgrade tests are slow }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_pg_upgrade.py', + 'pyt/test_003_logical_slots.py', + 'pyt/test_004_subscription.py', + 'pyt/test_005_char_signedness.py', + 'pyt/test_006_transfer_modes.py', + 'pyt/test_007_multixact_conversion.py', + 'pyt/test_008_extension_control_path.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/pg_upgrade/pyt/adjust_dump.pl b/src/bin/pg_upgrade/pyt/adjust_dump.pl new file mode 100644 index 00000000000..00a7bd52d59 --- /dev/null +++ b/src/bin/pg_upgrade/pyt/adjust_dump.pl @@ -0,0 +1,58 @@ +#!/usr/bin/perl + +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +# Thin CLI wrapper around PostgreSQL::Test::AdjustDump and +# PostgreSQL::Test::AdjustUpgrade, so the Python port of +# bin/pg_upgrade/t/002_pg_upgrade.pl can reuse the exact dump-adjustment logic +# (which is version-conditional and substantial) instead of reimplementing it. +# +# Reads a dump from stdin, writes the adjusted dump to stdout. Usage: +# +# adjust_dump.pl regress <0|1> # adjust_regress_dumpfile($dump, $adjust_child_columns) +# adjust_dump.pl old # adjust_old_dumpfile($old_version, $dump) +# adjust_dump.pl new # adjust_new_dumpfile($old_version, $dump) +# +# Run with the in-tree Perl test modules on the include path, e.g. +# perl -I src/test/perl adjust_dump.pl ... + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Version; +use PostgreSQL::Test::AdjustDump; +use PostgreSQL::Test::AdjustUpgrade; + +my $mode = shift @ARGV; +die "usage: adjust_dump.pl \n" + unless defined $mode; + +# Slurp the entire dump from stdin. +local $/; +my $dump = ; +$dump = '' unless defined $dump; + +my $out; +if ($mode eq 'regress') +{ + my $adjust_child_columns = shift @ARGV; + $adjust_child_columns = 0 unless defined $adjust_child_columns; + $out = adjust_regress_dumpfile($dump, $adjust_child_columns); +} +elsif ($mode eq 'old') +{ + my $old_version = PostgreSQL::Version->new(shift @ARGV); + $out = adjust_old_dumpfile($old_version, $dump); +} +elsif ($mode eq 'new') +{ + my $old_version = PostgreSQL::Version->new(shift @ARGV); + $out = adjust_new_dumpfile($old_version, $dump); +} +else +{ + die "unknown mode \"$mode\"\n"; +} + +binmode STDOUT; +print $out; diff --git a/src/bin/pg_upgrade/pyt/test_001_basic.py b/src/bin/pg_upgrade/pyt/test_001_basic.py new file mode 100644 index 00000000000..aecd5f5528a --- /dev/null +++ b/src/bin/pg_upgrade/pyt/test_001_basic.py @@ -0,0 +1,9 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Basic pg_upgrade command-line option handling checks.""" + + +def test_001_basic(pg_bin): + pg_bin.program_help_ok("pg_upgrade") + pg_bin.program_version_ok("pg_upgrade") + pg_bin.program_options_handling_ok("pg_upgrade") diff --git a/src/bin/pg_upgrade/pyt/test_002_pg_upgrade.py b/src/bin/pg_upgrade/pyt/test_002_pg_upgrade.py new file mode 100644 index 00000000000..1e4ed305c4e --- /dev/null +++ b/src/bin/pg_upgrade/pyt/test_002_pg_upgrade.py @@ -0,0 +1,661 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Set of tests for pg_upgrade, including cross-version checks. + +The test of pg_upgrade requires two clusters, an old one and a new one that +gets upgraded. Before running the upgrade, a logical dump of the old cluster +is taken, and a second logical dump of the new one is taken after the upgrade. +The upgrade test passes if there are no differences (after filtering) in these +two dumps. + +This test focuses on the same-version path (the default, where the environment +variables ``oldinstall`` and ``olddump`` are unset). The cross-version +branches are kept faithfully but never execute here because the old and new +clusters are always built from the running source tree. + +The dump-adjustment logic (which is version-conditional and substantial) is +delegated to the adjust_dump.pl wrapper, invoked as a subprocess, which reuses +the existing version-conditional dump-adjustment behavior. +""" + +import difflib +import os +import re +import shutil +import subprocess + +import pytest + +from pypg.regress import pg_regress_available, run_pg_regress +from pypg.util import enable_localhost_tcp, slurp_file + +# Repository root, derived from this file's location +# (.../src/bin/pg_upgrade/pyt/test_002_pg_upgrade.py). +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) +ADJUST_DUMP_PL = os.path.join(REPO, "src", "bin", "pg_upgrade", "pyt", "adjust_dump.pl") +PERL_LIB = os.path.join(REPO, "src", "test", "perl") + + +def _pg_version_token(pg_bin): + """Return the old cluster's major version token, like '19devel' or '18.1'. + + Parses ``pg_config --version`` (e.g. "PostgreSQL 19devel" -> "19devel"). + This token represents the old cluster's version and is what adjust_dump.pl + is fed to drive its version-conditional behavior. + """ + out = subprocess.run( + [os.path.join(pg_bin.bindir, "pg_config"), "--version"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout + # e.g. "PostgreSQL 19devel" / "PostgreSQL 18.1" / "PostgreSQL 17beta1" + m = re.search(r"PostgreSQL (\d+(?:\.\d+)?(?:devel|beta\d+|rc\d+|alpha\d+)?)", out) + assert m, f"could not parse pg_config --version output: {out!r}" + return m.group(1) + + +def _version_at_least(version_token, target): + """Compare *version_token* (e.g. '19devel') with *target* (e.g. '17devel'). + + Same-version runs are always >= '17devel'; this helper exists so the + version-conditional branches read clearly. + """ + + def major(tok): + return int(re.match(r"(\d+)", tok).group(1)) + + vt = major(version_token) + tt = major(target.rstrip("devel")) + if vt != tt: + return vt > tt + # Same major: 'devel' >= 'devel'/non-devel of same number. + return True + + +def _adjust_dump(mode, arg, dump_path, out_path): + """Filter *dump_path* through adjust_dump.pl , write *out_path*. + + DO NOT reimplement the adjust logic here -- delegate to the adjust_dump.pl + wrapper so we reuse the exact version-conditional dump-adjustment behavior. + """ + with open(dump_path, "rb") as fh: + dump_bytes = fh.read() + proc = subprocess.run( + ["perl", "-I", PERL_LIB, ADJUST_DUMP_PL, mode, str(arg)], + input=dump_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert ( + proc.returncode == 0 + ), f"adjust_dump.pl {mode} {arg} failed: {proc.stderr.decode(errors='replace')}" + with open(out_path, "wb") as fh: + fh.write(proc.stdout) + return out_path + + +def _filter_dump(is_old, old_version, dump_file): + """Adjust a dump and return the filtered path.""" + mode = "old" if is_old else "new" + return _adjust_dump(mode, old_version, dump_file, f"{dump_file}_filtered") + + +def _get_dump_for_comparison(node, db, file_prefix, adjust_child_columns, tempdir): + """Produce an adjusted dump suitable for before/after comparison. + + Dump *db* from *node* in plain format and adjust it (via adjust_dump.pl + regress) for comparing dumps from the original and the restored database. + Returns the path to the adjusted dump file. + """ + dumpfile = os.path.join(tempdir, file_prefix + ".sql") + dump_adjusted = dumpfile + "_adjusted" + node.pg_bin.command_ok( + [ + "pg_dump", + "--no-sync", + "--restrict-key", + "test", + "-d", + node.connstr(db), + "-f", + dumpfile, + ], + "pg_dump for comparison", + ) + _adjust_dump("regress", adjust_child_columns, dumpfile, dump_adjusted) + return dump_adjusted + + +def _generate_db(node, prefix, from_char, to_char, suffix): + """Create a database whose name spans a range of ASCII bytes. + + The name is built from *prefix*, the bytes *from_char*..*to_char* (skipping + BEL/LF/CR), and *suffix*, then createdb is run as a subprocess. + """ + dbname = prefix + for i in range(from_char, to_char + 1): + if i in (7, 10, 13): # skip BEL, LF, and CR + continue + dbname += chr(i) + dbname += suffix + node.pg_bin.command_ok( + ["createdb", dbname], + f"created database with ASCII characters from {from_char} to {to_char}", + ) + + +def _compare_files(file1, file2, msg): + """Assert the two files are byte-identical; show a bounded diff otherwise.""" + with open(file1, "r", encoding="utf-8", errors="replace") as fh: + lines1 = fh.readlines() + with open(file2, "r", encoding="utf-8", errors="replace") as fh: + lines2 = fh.readlines() + if lines1 == lines2: + return + diff = list( + difflib.unified_diff( + lines1, + lines2, + fromfile=file1, + tofile=file2, + n=3, + ) + ) + # Bound the diff so a huge mismatch does not flood the output. + snippet = "".join(diff[:200]) + pytest.fail(f"{msg}\n{snippet}") + + +def test_002_pg_upgrade(create_pg, pg_bin, tmp_path): + # Running the full regression suite to populate the old cluster requires + # the pg_regress driver; skip if the build did not supply it. + if not pg_regress_available(): + pytest.skip("pg_regress not available (PG_REGRESS unset)") + + tempdir = str(tmp_path) + + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_UPGRADE_MODE") or "--copy" + + # Cross-version testing requires both "olddump" and "oldinstall" to be set; + # having only one is an error. + olddump = os.environ.get("olddump") + oldinstall = os.environ.get("oldinstall") + if bool(olddump) != bool(oldinstall): + raise RuntimeError("olddump or oldinstall is undefined") + + # Paths to the dumps taken during the tests. + dump1_file = os.path.join(tempdir, "dump1.sql") + dump2_file = os.path.join(tempdir, "dump2.sql") + + print(f"# testing using transfer mode {mode}") + + # The old cluster is always built from the running source tree here, so its + # version is the current major version (>= 18). + old_version = _pg_version_token(pg_bin) + + # To increase coverage of non-standard segment size and group access + # without increasing test runtime, run these tests with a custom setting. + # --allow-group-access and --wal-segsize have been added in v11. + custom_opts = [] + if _version_at_least(old_version, "11"): + custom_opts += ["--wal-segsize", "1"] + custom_opts += ["--allow-group-access"] + + # Account for field additions and changes (same-version: >= 17devel). + if _version_at_least(old_version, "15"): + old_provider_field = "datlocprovider" + if _version_at_least(old_version, "17devel"): + old_datlocale_field = "datlocale" + else: + old_datlocale_field = "daticulocale AS datlocale" + else: + old_provider_field = "'c' AS datlocprovider" + old_datlocale_field = "NULL AS datlocale" + + # Set up the locale settings for the original cluster, so that we can test + # that pg_upgrade copies the locale settings of template0 from the old to + # the new cluster. + original_datcollate = "C" + original_datctype = "C" + with_icu = os.environ.get("with_icu") + if _version_at_least(old_version, "17devel"): + original_enc_name = "UTF-8" + original_provider = "b" + original_datlocale = "C.UTF-8" + elif _version_at_least(old_version, "15") and with_icu == "yes": + original_enc_name = "UTF-8" + original_provider = "i" + original_datlocale = "fr-CA" + else: + original_enc_name = "SQL_ASCII" + original_provider = "c" + original_datlocale = "" + + encodings = {"UTF-8": 6, "SQL_ASCII": 0} + original_encoding = encodings[original_enc_name] + + old_initdb_params = list(custom_opts) + old_initdb_params += ["--encoding", original_enc_name] + old_initdb_params += ["--lc-collate", original_datcollate] + old_initdb_params += ["--lc-ctype", original_datctype] + + # add --locale-provider, if supported + provider_name = {"b": "builtin", "i": "icu", "c": "libc"} + if _version_at_least(old_version, "15"): + old_initdb_params += ["--locale-provider", provider_name[original_provider]] + if original_provider == "b": + old_initdb_params += ["--builtin-locale", original_datlocale] + elif original_provider == "i": + old_initdb_params += ["--icu-locale", original_datlocale] + + # Since checksums are now enabled by default, and weren't before 18, pass + # '-k' to initdb on old versions so that upgrades work. + if not _version_at_least(old_version, "18"): + old_initdb_params += ["-k"] + + # The create_pg fixture passes --locale=C and --encoding=UTF8 by default; + # the explicit encoding/locale/provider opts above override those. + oldnode = create_pg("old_node", start=False, initdb_extra=old_initdb_params) + # pg_upgrade connects to the clusters over localhost TCP on Windows. + enable_localhost_tcp(oldnode) + # Override log_statement=all set by the init helper. This avoids large + # amounts of log traffic that slow this test down even more when run under + # valgrind. + oldnode.append_conf("log_statement = none") + # Set wal_level = replica to run the regression tests in the same wal_level + # as when 'make check' runs. + oldnode.append_conf("wal_level = replica") + oldnode.start() + + result = oldnode.safe_sql( + "SELECT encoding, %s, datcollate, datctype, %s " + "FROM pg_database WHERE datname='template0'" + % (old_provider_field, old_datlocale_field) + ) + assert result == ( + f"{original_encoding}|{original_provider}|{original_datcollate}|" + f"{original_datctype}|{original_datlocale}" + ), "check locales in original cluster" + + # The default location of the source code is the root of this directory. + srcdir = REPO + + # Set up the data of the old instance with a dump or pg_regress. + if olddump: + # Use the dump specified. (Cross-version path; not exercised here.) + assert os.path.exists(olddump), "no dump file found!" + oldnode.pg_bin.command_ok( + ["psql", "--no-psqlrc", "--file", olddump, "postgres"], + "loaded old dump file", + ) + else: + # Default is to use pg_regress to set up the old instance. + + # Create databases with names covering most ASCII bytes. The first + # name exercises backslashes adjacent to double quotes, a Windows + # special case. + _generate_db(oldnode, 'regression\\"\\', 1, 45, '\\\\"\\\\\\') + _generate_db(oldnode, "regression", 46, 90, "") + _generate_db(oldnode, "regression", 91, 127, "") + + # run_pg_regress mirrors the command_ok([$ENV{PG_REGRESS}, ...]) call: + # it honors EXTRA_REGRESS_OPTS, derives --dlpath from REGRESS_SHLIB, + # passes --bindir= (empty), and points --host/--port at the node. + res = run_pg_regress( + oldnode, + inputdir=os.path.join(srcdir, "src", "test", "regress"), + outputdir=os.path.join(tempdir, "regress_outputdir"), + schedule=os.path.join( + srcdir, "src", "test", "regress", "parallel_schedule" + ), + max_concurrent_tests=20, + ) + assert res.returncode == 0, ( + "regression tests in old instance\n" + f"stdout:\n{res.stdout}\nstderr:\n{res.stderr}" + ) + + # Initialize a new node for the upgrade. The new cluster will be + # initialized with different locale settings, but these settings will be + # overwritten with those of the original cluster. + new_initdb_params = list(custom_opts) + new_initdb_params += ["--encoding", "SQL_ASCII"] + new_initdb_params += ["--locale-provider", "libc"] + newnode = create_pg("new_node", start=False, initdb_extra=new_initdb_params) + enable_localhost_tcp(newnode) + # Avoid unnecessary log noise + newnode.append_conf("log_statement = none") + # Stabilize stats for comparison. + newnode.append_conf("autovacuum = off") + + # There is no node.config_data(); use pg_config --bindir directly. Both + # clusters come from the same install, so the bindir is shared. + bindir = subprocess.run( + [os.path.join(pg_bin.bindir, "pg_config"), "--bindir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + newbindir = bindir + oldbindir = bindir + + # Before dumping, get rid of objects not existing or not supported in later + # versions. This depends on the version of the old server used, and + # matters only if different major versions are used for the dump. This is + # the cross-version path and is not exercised in the same-version run; it + # would need a database-content adjustment step that is not wired through + # adjust_dump.pl. + if oldinstall: + raise RuntimeError( + "cross-version (oldinstall) adjust_database_contents path is not " + "supported by this port" + ) + + # Stabilize stats before pg_dump / pg_dumpall. Doing it after initializing + # the new node gives enough time for autovacuum to update statistics on the + # old node. + oldnode.append_conf("autovacuum = off") + oldnode.restart() + + # Test that dump/restore of the regression database roundtrips cleanly. + # This doesn't work well when the nodes are different versions, so skip it + # in that case. Note that this isn't a pg_upgrade test, but it's + # convenient to do it here because we've gone to the trouble of creating + # the regression database. + # + # Do this while the old cluster is running before it is shut down by the + # upgrade test but after turning its autovacuum off for stable statistics. + pg_test_extra = os.environ.get("PG_TEST_EXTRA", "") + if ( + pg_test_extra + and re.search(r"\bregress_dump_restore\b", pg_test_extra) + and not oldinstall + ): + # Set up destination database cluster with the same configuration as + # the source cluster to avoid any differences between dumps taken from + # both the clusters caused by differences in their configurations. + dstnode = create_pg("dst_node", start=False, initdb_extra=old_initdb_params) + dstnode.append_conf("log_statement = none") + dstnode.append_conf("autovacuum = off") + dstnode.start() + + # Use --create in dump and restore commands so that the restored + # database has the same configurable variable settings as the original + # database so that the dumps taken from both databases do not differ + # because of locale changes. Additionally this provides test coverage + # for --create option. Use directory format so that we can use + # parallel dump/restore. + dump_file = os.path.join(tempdir, "regression.dump") + oldnode.pg_bin.command_ok( + [ + "pg_dump", + "-Fd", + "-j2", + "--no-sync", + "-d", + oldnode.connstr("regression"), + "--create", + "-f", + dump_file, + ], + "pg_dump on source instance", + ) + dstnode.pg_bin.command_ok( + ["pg_restore", "--create", "-j2", "-d", "postgres", dump_file], + "pg_restore to destination instance", + ) + + # Dump original and restored database for comparison. + src_dump = _get_dump_for_comparison( + oldnode, "regression", "src_dump", 1, tempdir + ) + dst_dump = _get_dump_for_comparison( + dstnode, "regression", "dest_dump", 0, tempdir + ) + _compare_files( + src_dump, + dst_dump, + "dump outputs from original and restored regression databases match", + ) + + # Take a dump before performing the upgrade as a base comparison. Note + # that we need to use pg_dumpall from the new node here. + dump_command = [ + "pg_dumpall", + "--no-sync", + "--restrict-key", + "test", + "--dbname", + oldnode.connstr("postgres"), + "--file", + dump1_file, + ] + # --extra-float-digits is needed when upgrading from a version older than 11. + if not _version_at_least(old_version, "12"): + dump_command += ["--extra-float-digits", "0"] + newnode.pg_bin.command_ok(dump_command, "dump before running pg_upgrade") + + # After dumping, update references to the old source tree's regress.so to + # point to the new tree. This is only relevant for the cross-version + # (oldinstall) path, which is rejected above; skip otherwise. + if oldinstall: + # (Unreachable here, retained for the cross-version path.) + output = oldnode.safe_sql( + "SELECT DISTINCT probin::text FROM pg_proc " + "WHERE probin NOT LIKE '$libdir%';" + ) + libpaths = [p for p in output.split("\n") if p] + dump_data = slurp_file(dump1_file) + newregresssrc = os.path.dirname(os.environ["REGRESS_SHLIB"]) + for libpath in libpaths: + libpath = os.path.dirname(libpath) + dump_data = dump_data.replace(libpath, newregresssrc) + with open(dump1_file, "w", encoding="utf-8") as fh: + fh.write(dump_data) + output = oldnode.safe_sql("SELECT datname FROM pg_database WHERE datallowconn;") + for datname in [d for d in output.split("\n") if d]: + oldnode.safe_sql( + "UPDATE pg_proc SET probin = " + f"regexp_replace(probin, '.*/', '{newregresssrc}/') " + "WHERE probin NOT LIKE '$libdir/%'", + dbname=datname, + ) + + # Create an invalid database, will be deleted below. CREATE DATABASE + # cannot run inside a transaction block, so issue the statements + # separately (the in-process session would otherwise wrap them together). + oldnode.safe_sql("CREATE DATABASE regression_invalid") + oldnode.safe_sql( + "UPDATE pg_database SET datconnlimit = -2 " + "WHERE datname = 'regression_invalid'" + ) + + # In a VPATH build, we'll be started in the source directory, but we want + # to run pg_upgrade in the build directory so that any files generated + # finish in it, like delete_old_cluster.{sh,bat}. + upgrade_cwd = os.path.join(tempdir, "pg_upgrade_cwd") + os.makedirs(upgrade_cwd, exist_ok=True) + os.chdir(upgrade_cwd) + + # Upgrade the instance. + oldnode.stop() + + # Cause a failure at the start of pg_upgrade, this should create the + # logging directory pg_upgrade_output.d but leave it around. Keep --check + # for an early exit. + pg_bin.command_checks_all( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + oldnode.data_dir, + "--new-datadir", + newnode.data_dir, + "--old-bindir", + oldbindir + "/does/not/exist/", + "--new-bindir", + newbindir, + "--socketdir", + newnode.host, + "--old-port", + str(oldnode.port), + "--new-port", + str(newnode.port), + mode, + "--check", + ], + 1, + [r'check for ".*?does/not/exist" failed'], + [], + "run of pg_upgrade --check for new instance with incorrect binary path", + ) + assert os.path.isdir( + os.path.join(newnode.data_dir, "pg_upgrade_output.d") + ), "pg_upgrade_output.d/ not removed after pg_upgrade failure" + shutil.rmtree(os.path.join(newnode.data_dir, "pg_upgrade_output.d")) + + # Check that pg_upgrade aborts when encountering an invalid database. + # (Versions out of support by commit c66a7d75e652 don't know how to do + # this; the old cluster is always new enough here, so no skip is needed.) + if _version_at_least(old_version, "11"): + pg_bin.command_checks_all( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + oldnode.data_dir, + "--new-datadir", + newnode.data_dir, + "--old-bindir", + oldbindir, + "--new-bindir", + newbindir, + "--socketdir", + newnode.host, + "--old-port", + str(oldnode.port), + "--new-port", + str(newnode.port), + mode, + "--check", + ], + 1, + [r"datconnlimit"], + [r"^$"], + "invalid database causes failure", + ) + shutil.rmtree(os.path.join(newnode.data_dir, "pg_upgrade_output.d")) + + # And drop it, so we can continue + oldnode.start() + oldnode.safe_sql("DROP DATABASE regression_invalid") + oldnode.stop() + + # --check command works here, cleans up pg_upgrade_output.d. + pg_bin.command_ok( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + oldnode.data_dir, + "--new-datadir", + newnode.data_dir, + "--old-bindir", + oldbindir, + "--new-bindir", + newbindir, + "--socketdir", + newnode.host, + "--old-port", + str(oldnode.port), + "--new-port", + str(newnode.port), + mode, + "--check", + ], + "run of pg_upgrade --check for new instance", + ) + assert not os.path.isdir( + os.path.join(newnode.data_dir, "pg_upgrade_output.d") + ), "pg_upgrade_output.d/ removed after pg_upgrade --check success" + + # Actual run, pg_upgrade_output.d is removed at the end. + pg_bin.command_ok( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + oldnode.data_dir, + "--new-datadir", + newnode.data_dir, + "--old-bindir", + oldbindir, + "--new-bindir", + newbindir, + "--socketdir", + newnode.host, + "--old-port", + str(oldnode.port), + "--new-port", + str(newnode.port), + mode, + ], + "run of pg_upgrade for new instance", + ) + assert not os.path.isdir( + os.path.join(newnode.data_dir, "pg_upgrade_output.d") + ), "pg_upgrade_output.d/ removed after pg_upgrade success" + + newnode.start() + + # Check if there are any logs coming from pg_upgrade, that would only be + # retained on failure. + log_path = os.path.join(newnode.data_dir, "pg_upgrade_output.d") + if os.path.isdir(log_path): + print(f"=== pg_upgrade logs found under {log_path} ===") + for dirpath, _dirnames, filenames in os.walk(log_path): + for filename in filenames: + if filename.endswith(".log"): + log = os.path.join(dirpath, filename) + print(f"=== contents of {log} ===") + print(slurp_file(log)) + print("=== EOF ===") + + # Test that upgraded cluster has original locale settings. + result = newnode.safe_sql( + "SELECT encoding, datlocprovider, datcollate, datctype, datlocale " + "FROM pg_database WHERE datname='template0'" + ) + assert result == ( + f"{original_encoding}|{original_provider}|{original_datcollate}|" + f"{original_datctype}|{original_datlocale}" + ), "check that locales in new cluster match original cluster" + + # Second dump from the upgraded instance. + dump_command = [ + "pg_dumpall", + "--no-sync", + "--restrict-key", + "test", + "--dbname", + newnode.connstr("postgres"), + "--file", + dump2_file, + ] + if not _version_at_least(old_version, "12"): + dump_command += ["--extra-float-digits", "0"] + newnode.pg_bin.command_ok(dump_command, "dump after running pg_upgrade") + + # Filter the contents of the dumps. + dump1_filtered = _filter_dump(True, old_version, dump1_file) + dump2_filtered = _filter_dump(False, old_version, dump2_file) + + # Compare the two dumps, there should be no differences. + _compare_files( + dump1_filtered, + dump2_filtered, + "old and new dumps match after pg_upgrade", + ) diff --git a/src/bin/pg_upgrade/pyt/test_003_logical_slots.py b/src/bin/pg_upgrade/pyt/test_003_logical_slots.py new file mode 100644 index 00000000000..25c1a0fb8a4 --- /dev/null +++ b/src/bin/pg_upgrade/pyt/test_003_logical_slots.py @@ -0,0 +1,248 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Tests for upgrading logical replication slots.""" + +# Tests for upgrading logical replication slots + +import os +import re + +from pypg.util import enable_localhost_tcp + + +def _find_file(root, name_re): + """Return the first path under *root* whose name matches *name_re*.""" + regex = re.compile(name_re) + for dirpath, _dirnames, filenames in os.walk(root): + for filename in filenames: + if regex.search(filename): + return os.path.join(dirpath, filename) + return None + + +def _slurp_file(path): + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read() + + +def test_003_logical_slots(create_pg, pg_bin): + # Can be changed to test the other modes + mode = os.environ.get("PG_TEST_PG_UPGRADE_MODE") or "--copy" + + # Initialize old cluster + oldpub = create_pg("oldpub", start=False, allows_streaming="logical") + oldpub.append_conf("autovacuum = off") + # pg_upgrade connects to the clusters over localhost TCP on Windows. + enable_localhost_tcp(oldpub) + + # Initialize new cluster + newpub = create_pg("newpub", start=False, allows_streaming="logical") + enable_localhost_tcp(newpub) + + # During upgrade, when pg_restore performs CREATE DATABASE, bgwriter or + # checkpointer may flush buffers and hold a file handle for the system + # table. So, if later due to some reason we need to re-create the file + # with the same name like a TRUNCATE command on the same table, then the + # command will fail if OS (such as older Windows versions) doesn't remove + # an unlinked file completely till it is open. The probability of seeing + # this behavior is higher in this test because we use wal_level as logical + # via allows_streaming => 'logical' which in turn set shared_buffers as + # 1MB. + newpub.append_conf("bgwriter_lru_maxpages = 0\ncheckpoint_timeout = 1h\n") + + # Setup a common pg_upgrade command to be used by all the test cases + pg_upgrade_cmd = [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + oldpub.data_dir, + "--new-datadir", + newpub.data_dir, + "--old-bindir", + oldpub.bindir, + "--new-bindir", + newpub.bindir, + "--socketdir", + newpub.host, + "--old-port", + str(oldpub.port), + "--new-port", + str(newpub.port), + mode, + ] + + # In a VPATH build, we'll be started in the source directory, but we want + # to run pg_upgrade in the build directory so that any files generated + # finish in it, like delete_old_cluster.{sh,bat}. + tmp_cwd = os.path.join(newpub.basedir, "pg_upgrade_cwd") + os.makedirs(tmp_cwd, exist_ok=True) + os.chdir(tmp_cwd) + + # ------------------------------ + # TEST: Confirm pg_upgrade fails when the new cluster has wrong GUC values + + # Preparations for the subsequent test: + # 1. Create two slots on the old cluster + oldpub.start() + oldpub.safe_sql( + "SELECT pg_create_logical_replication_slot('test_slot1', 'test_decoding');" + "SELECT pg_create_logical_replication_slot('test_slot2', 'test_decoding');" + "SELECT pg_create_logical_replication_slot('test_slot3', 'test_decoding');" + ) + oldpub.stop() + + # 2. Set 'max_replication_slots' to be less than the number of slots (2) + # present on the old cluster. + newpub.append_conf("max_replication_slots = 1") + + # pg_upgrade will fail because the new cluster has insufficient + # max_replication_slots + pg_bin.command_checks_all( + pg_upgrade_cmd, + 1, + [ + r'"max_replication_slots" \(1\) must be greater than or equal to ' + r"the number of logical replication slots \(3\) on the old cluster" + ], + [r""], + "run of pg_upgrade where the new cluster has insufficient " + '"max_replication_slots"', + ) + assert os.path.isdir( + os.path.join(newpub.data_dir, "pg_upgrade_output.d") + ), "pg_upgrade_output.d/ not removed after pg_upgrade failure" + + # Set 'max_replication_slots' to match the number of slots (3) present on + # the old cluster. Both slots will be used for subsequent tests. + newpub.append_conf("max_replication_slots = 3") + + # ------------------------------ + # TEST: Confirm pg_upgrade fails when the slot still has unconsumed WAL + # records + + # Preparations for the subsequent test: + # 1. Generate extra WAL records. At this point none of the slots has + # consumed them. + # + # 2. Advance the slot test_slot2 up to the current WAL location, but + # test_slot1 still has unconsumed WAL records. + # + # 3. Emit a non-transactional message. This will cause test_slot2 to detect + # the unconsumed WAL record. + # + # 4. Advance the slot test_slots3 up to the current WAL location. + oldpub.start() + oldpub.safe_sql("CREATE TABLE tbl AS SELECT generate_series(1, 10) AS a") + oldpub.safe_sql( + "SELECT pg_replication_slot_advance('test_slot2', pg_current_wal_lsn())" + ) + oldpub.safe_sql( + "SELECT count(*) FROM pg_logical_emit_message('false', 'prefix', " + "'This is a non-transactional message', true)" + ) + oldpub.safe_sql( + "SELECT pg_replication_slot_advance('test_slot3', pg_current_wal_lsn())" + ) + oldpub.stop() + + # pg_upgrade will fail because there are slots still having unconsumed WAL + # records + pg_bin.command_checks_all( + pg_upgrade_cmd, + 1, + [ + r"Your installation contains logical replication slots that " + r"cannot be upgraded\." + ], + [r""], + "run of pg_upgrade of old cluster with slots having unconsumed WAL records", + ) + + # Verify the reason why the logical replication slot cannot be upgraded. + # Find a txt file that contains a list of logical replication slots that + # cannot be upgraded. We cannot predict the file's path because the output + # directory contains a milliseconds timestamp. + slots_filename = _find_file( + os.path.join(newpub.data_dir, "pg_upgrade_output.d"), + r"invalid_logical_slots\.txt", + ) + assert slots_filename is not None, "invalid_logical_slots.txt found" + + # Check the file content. While both test_slot1 and test_slot2 should be + # reporting that they have unconsumed WAL records, test_slot3 should not be + # reported as it has caught up. + content = _slurp_file(slots_filename) + assert re.search( + r'The slot "test_slot1" has not consumed the WAL yet', content + ), "the previous test failed due to unconsumed WALs" + assert re.search( + r'The slot "test_slot2" has not consumed the WAL yet', content + ), "the previous test failed due to unconsumed WALs" + assert not re.search(r"test_slot3", content), "caught-up slot is not reported" + + # ------------------------------ + # TEST: Successful upgrade + + # Preparations for the subsequent test: + # 1. Setup logical replication (first, cleanup slots from the previous + # tests) + old_connstr = f"host={oldpub.host} port={oldpub.port} dbname=postgres" + + oldpub.start() + oldpub.safe_sql( + "SELECT * FROM pg_drop_replication_slot('test_slot1');" + "SELECT * FROM pg_drop_replication_slot('test_slot2');" + "SELECT * FROM pg_drop_replication_slot('test_slot3');" + "CREATE PUBLICATION regress_pub FOR ALL TABLES;" + ) + + # Initialize subscriber cluster + sub = create_pg("sub") + + sub.safe_sql("CREATE TABLE tbl (a int)") + sub.safe_sql( + f"CREATE SUBSCRIPTION regress_sub CONNECTION '{old_connstr}' " + "PUBLICATION regress_pub WITH (two_phase = 'true', failover = 'true')" + ) + sub.wait_for_subscription_sync(oldpub, "regress_sub") + + # Also wait for two-phase to be enabled + twophase_query = ( + "SELECT count(1) = 0 FROM pg_subscription " + "WHERE subtwophasestate NOT IN ('e');" + ) + assert sub.poll_query_until( + twophase_query + ), "Timed out while waiting for subscriber to enable twophase" + + # 2. Temporarily disable the subscription + sub.safe_sql("ALTER SUBSCRIPTION regress_sub DISABLE") + oldpub.stop() + + # pg_upgrade should be successful + pg_bin.command_ok(pg_upgrade_cmd, "run of pg_upgrade of old cluster") + + # Check that the slot 'regress_sub' has migrated to the new cluster + newpub.start() + result = newpub.safe_sql( + "SELECT slot_name, two_phase, failover FROM pg_replication_slots" + ) + assert result == "regress_sub|t|t", "check the slot exists on new cluster" + + # Update the connection + new_connstr = f"host={newpub.host} port={newpub.port} dbname=postgres" + sub.safe_sql( + f"ALTER SUBSCRIPTION regress_sub CONNECTION '{new_connstr}';" + "ALTER SUBSCRIPTION regress_sub ENABLE;" + ) + + # Check whether changes on the new publisher get replicated to the + # subscriber + newpub.safe_sql("INSERT INTO tbl VALUES (generate_series(11, 20))") + newpub.wait_for_catchup("regress_sub") + result = sub.safe_sql("SELECT count(*) FROM tbl") + assert result == "20", "check changes are replicated to the sub" + + # Clean up + sub.stop() + newpub.stop() diff --git a/src/bin/pg_upgrade/pyt/test_004_subscription.py b/src/bin/pg_upgrade/pyt/test_004_subscription.py new file mode 100644 index 00000000000..b5bb195e9dc --- /dev/null +++ b/src/bin/pg_upgrade/pyt/test_004_subscription.py @@ -0,0 +1,486 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test for pg_upgrade of logical subscription.""" + +# Test for pg_upgrade of logical subscription. Note that after the successful +# upgrade test, we can't use the old cluster to prevent failing in --link mode. + +import os +import re +import shutil + +from pypg.util import enable_localhost_tcp + + +def _find_file(root, name_re): + """Return the path of a file under *root* whose name matches *name_re*.""" + pattern = re.compile(name_re) + for dirpath, _dirnames, filenames in os.walk(root): + for filename in filenames: + if pattern.search(filename): + return os.path.join(dirpath, filename) + return None + + +def test_004_subscription(create_pg, pg_bin, tmp_path): + # Can be changed to test the other modes. + mode = os.environ.get("PG_TEST_PG_UPGRADE_MODE") or "--copy" + + # Initialize publisher node + publisher = create_pg("publisher", allows_streaming="logical") + + # Initialize the old subscriber node + old_sub = create_pg("old_sub", allows_streaming="physical") + oldbindir = old_sub.bindir + + # Initialize the new subscriber + new_sub = create_pg("new_sub", allows_streaming="physical", start=False) + newbindir = new_sub.bindir + + # pg_upgrade connects to the upgraded clusters over localhost TCP on Windows. + enable_localhost_tcp(old_sub) + enable_localhost_tcp(new_sub) + + # In a VPATH build, we'll be started in the source directory, but we want + # to run pg_upgrade in the build directory so that any files generated + # finish in it, like delete_old_cluster.{sh,bat}. + os.chdir(str(tmp_path)) + + # Remember a connection string for the publisher node. It would be used + # several times. + connstr = f"host={publisher.host} port={publisher.port} dbname=postgres" + + # ------------------------------------------------------ + # Check that pg_upgrade fails when max_active_replication_origins configured + # in the new cluster is less than the number of subscriptions in the old + # cluster. + # ------------------------------------------------------ + # It is sufficient to use disabled subscription to test upgrade failure. + publisher.safe_sql("CREATE PUBLICATION regress_pub1") + old_sub.safe_sql( + f"CREATE SUBSCRIPTION regress_sub1 CONNECTION '{connstr}' " + "PUBLICATION regress_pub1 WITH (enabled = false)" + ) + + old_sub.stop() + + new_sub.append_conf("max_active_replication_origins = 0") + + # pg_upgrade will fail because the new cluster has insufficient + # max_active_replication_origins. + pg_bin.command_checks_all( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + old_sub.data_dir, + "--new-datadir", + new_sub.data_dir, + "--old-bindir", + oldbindir, + "--new-bindir", + newbindir, + "--socketdir", + new_sub.host, + "--old-port", + old_sub.port, + "--new-port", + new_sub.port, + mode, + "--check", + ], + 1, + [ + r'"max_active_replication_origins" \(0\) must be greater than or ' + r"equal to the number of subscriptions \(1\) on the old cluster" + ], + [r""], + "run of pg_upgrade where the new cluster has insufficient " + "max_active_replication_origins", + ) + + # Reset max_active_replication_origins + new_sub.append_conf("max_active_replication_origins = 10") + + # Cleanup + publisher.safe_sql("DROP PUBLICATION regress_pub1") + old_sub.start() + old_sub.safe_sql("DROP SUBSCRIPTION regress_sub1;") + + # ------------------------------------------------------ + # Check that pg_upgrade fails when max_replication_slots configured in the + # new cluster is less than the number of logical slots in the old cluster + + # 1 when subscription's retain_dead_tuples option is enabled. + # ------------------------------------------------------ + # It is sufficient to use disabled subscription to test upgrade failure. + + publisher.safe_sql("CREATE PUBLICATION regress_pub1") + old_sub.safe_sql( + f"CREATE SUBSCRIPTION regress_sub1 CONNECTION '{connstr}' " + "PUBLICATION regress_pub1 WITH (enabled = false, retain_dead_tuples = true)" + ) + + old_sub.stop() + + new_sub.append_conf("max_replication_slots = 0") + + # pg_upgrade will fail because the new cluster has insufficient + # max_replication_slots. + pg_bin.command_checks_all( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + old_sub.data_dir, + "--new-datadir", + new_sub.data_dir, + "--old-bindir", + oldbindir, + "--new-bindir", + newbindir, + "--socketdir", + new_sub.host, + "--old-port", + old_sub.port, + "--new-port", + new_sub.port, + mode, + "--check", + ], + 1, + [ + r'"max_replication_slots" \(0\) must be greater than or equal to ' + r"the number of logical replication slots on the old cluster plus " + r"one additional slot required for retaining conflict detection " + r"information \(1\)" + ], + [r""], + "run of pg_upgrade where the new cluster has insufficient " + "max_replication_slots", + ) + + # Reset max_replication_slots + new_sub.append_conf("max_replication_slots = 10") + + # Cleanup + publisher.safe_sql("DROP PUBLICATION regress_pub1") + old_sub.start() + old_sub.safe_sql("DROP SUBSCRIPTION regress_sub1;") + + # ------------------------------------------------------ + # Check that pg_upgrade refuses to run if: + # a) there's a subscription with tables in a state other than 'r' (ready) or + # 'i' (init) and/or + # b) the subscription has no replication origin. + # ------------------------------------------------------ + publisher.safe_sql( + """ + CREATE TABLE tab_primary_key(id serial PRIMARY KEY); + INSERT INTO tab_primary_key values(1); + CREATE PUBLICATION regress_pub2 FOR TABLE tab_primary_key; + """ + ) + + # Insert the same value that is already present in publisher to the primary + # key column of subscriber so that the table sync will fail. + old_sub.safe_sql( + """ + CREATE TABLE tab_primary_key(id serial PRIMARY KEY); + INSERT INTO tab_primary_key values(1); + """ + ) + old_sub.safe_sql( + f"CREATE SUBSCRIPTION regress_sub2 CONNECTION '{connstr}' " + "PUBLICATION regress_pub2" + ) + + # Table will be in 'd' (data is being copied) state as table sync will fail + # because of primary key constraint error. + started_query = ( + "SELECT count(1) = 1 FROM pg_subscription_rel WHERE srsubstate = 'd'" + ) + assert old_sub.poll_query_until( + started_query + ), "Timed out while waiting for the table state to become 'd' (datasync)" + + # Setup another logical replication and drop the subscription's replication + # origin. + publisher.safe_sql("CREATE PUBLICATION regress_pub3") + old_sub.safe_sql( + f"CREATE SUBSCRIPTION regress_sub3 CONNECTION '{connstr}' " + "PUBLICATION regress_pub3 WITH (enabled = false)" + ) + sub_oid = old_sub.safe_sql( + "SELECT oid FROM pg_subscription WHERE subname = 'regress_sub3'" + ) + replorigin = "pg_" + sub_oid + old_sub.safe_sql(f"SELECT pg_replication_origin_drop('{replorigin}')") + + old_sub.stop() + + pg_bin.command_checks_all( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + old_sub.data_dir, + "--new-datadir", + new_sub.data_dir, + "--old-bindir", + oldbindir, + "--new-bindir", + newbindir, + "--socketdir", + new_sub.host, + "--old-port", + old_sub.port, + "--new-port", + new_sub.port, + mode, + "--check", + ], + 1, + [ + re.escape( + "Your installation contains subscriptions without origin or " + "having relations not in i (initialize) or r (ready) state" + ) + ], + [], + "run of pg_upgrade --check for old instance with relation in 'd' " + "datasync(invalid) state and missing replication origin", + ) + + # Verify the reason why the subscriber cannot be upgraded. + # + # Find a txt file that contains a list of tables that cannot be upgraded. We + # cannot predict the file's path because the output directory contains a + # milliseconds timestamp. + sub_relstate_filename = _find_file( + new_sub.data_dir + "/pg_upgrade_output.d", r"subs_invalid\.txt" + ) + + with open(sub_relstate_filename, "r", encoding="utf-8", errors="replace") as fh: + sub_relstate_content = fh.read() + + # Check the file content which should have tab_primary_key table in an + # invalid state. + assert re.search( + r'The table sync state "d" is not allowed for database:"postgres" ' + r'subscription:"regress_sub2" schema:"public" relation:"tab_primary_key"', + sub_relstate_content, + re.MULTILINE, + ), "the previous test failed due to subscription table in invalid state" + + # Check the file content which should have regress_sub3 subscription. + assert re.search( + r'The replication origin is missing for database:"postgres" ' + r'subscription:"regress_sub3"', + sub_relstate_content, + re.MULTILINE, + ), "the previous test failed due to missing replication origin" + + # Cleanup + old_sub.start() + publisher.safe_sql( + """ + DROP PUBLICATION regress_pub2; + DROP PUBLICATION regress_pub3; + DROP TABLE tab_primary_key; + """ + ) + old_sub.safe_sql("DROP SUBSCRIPTION regress_sub2") + old_sub.safe_sql("DROP SUBSCRIPTION regress_sub3") + old_sub.safe_sql("DROP TABLE tab_primary_key") + shutil.rmtree(new_sub.data_dir + "/pg_upgrade_output.d", ignore_errors=True) + + # Verify that the upgrade should be successful with tables in 'ready'/'init' + # state along with retaining the replication origin's remote lsn, + # subscription's running status, failover option, and retain_dead_tuples + # option. Use multiple tables to verify deterministic pg_dump ordering + # of subscription relations during --binary-upgrade. + publisher.safe_sql( + """ + CREATE TABLE tab_upgraded(id int); + CREATE TABLE tab_upgraded1(id int); + CREATE PUBLICATION regress_pub4 FOR TABLE tab_upgraded, tab_upgraded1; + """ + ) + + old_sub.safe_sql( + """ + CREATE TABLE tab_upgraded(id int); + CREATE TABLE tab_upgraded1(id int); + """ + ) + old_sub.safe_sql( + f"CREATE SUBSCRIPTION regress_sub4 CONNECTION '{connstr}' " + "PUBLICATION regress_pub4 WITH (failover = true, retain_dead_tuples = true)" + ) + + # Wait till the tables tab_upgraded and tab_upgraded1 reach 'ready' state + synced_query = "SELECT count(1) = 2 FROM pg_subscription_rel WHERE srsubstate = 'r'" + assert old_sub.poll_query_until( + synced_query + ), "Timed out while waiting for the table to reach ready state" + + publisher.safe_sql("INSERT INTO tab_upgraded1 VALUES (generate_series(1,50))") + publisher.wait_for_catchup("regress_sub4") + + # Change configuration to prepare a subscription table in init state + old_sub.append_conf("max_logical_replication_workers = 0") + old_sub.restart() + + # Setup another logical replication + publisher.safe_sql( + """ + CREATE TABLE tab_upgraded2(id int); + CREATE PUBLICATION regress_pub5 FOR TABLE tab_upgraded2; + """ + ) + old_sub.safe_sql("CREATE TABLE tab_upgraded2(id int)") + old_sub.safe_sql( + f"CREATE SUBSCRIPTION regress_sub5 CONNECTION '{connstr}' " + "PUBLICATION regress_pub5" + ) + + # The table tab_upgraded2 will be in the init state as the subscriber's + # configuration for max_logical_replication_workers is set to 0. + result = old_sub.safe_sql( + "SELECT count(1) = 1 FROM pg_subscription_rel WHERE srsubstate = 'i'" + ) + assert result == "t", "Check that the table is in init state" + + # Get the replication origin's remote_lsn of the old subscriber + remote_lsn = old_sub.safe_sql( + "SELECT remote_lsn FROM pg_replication_origin_status os, pg_subscription s " + "WHERE os.external_id = 'pg_' || s.oid AND s.subname = 'regress_sub4'" + ) + # Have the subscription in disabled state before upgrade + old_sub.safe_sql("ALTER SUBSCRIPTION regress_sub5 DISABLE") + + tab_upgraded_oid = old_sub.safe_sql( + "SELECT oid FROM pg_class WHERE relname = 'tab_upgraded'" + ) + tab_upgraded1_oid = old_sub.safe_sql( + "SELECT oid FROM pg_class WHERE relname = 'tab_upgraded1'" + ) + tab_upgraded2_oid = old_sub.safe_sql( + "SELECT oid FROM pg_class WHERE relname = 'tab_upgraded2'" + ) + + old_sub.stop() + + # Change configuration so that initial table sync does not get started + # automatically + new_sub.append_conf("max_logical_replication_workers = 0") + + # ------------------------------------------------------ + # Check that pg_upgrade is successful when all tables are in ready or in + # init state (tab_upgraded and tab_upgraded1 tables are in ready state and + # tab_upgraded2 table is in init state) along with retaining the replication + # origin's remote lsn, subscription's running status, failover option, and + # retain_dead_tuples option. + # ------------------------------------------------------ + pg_bin.command_ok( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + old_sub.data_dir, + "--new-datadir", + new_sub.data_dir, + "--old-bindir", + oldbindir, + "--new-bindir", + newbindir, + "--socketdir", + new_sub.host, + "--old-port", + old_sub.port, + "--new-port", + new_sub.port, + mode, + ], + "run of pg_upgrade for old instance when the subscription tables are " + "in init/ready state", + ) + assert not os.path.isdir( + new_sub.data_dir + "/pg_upgrade_output.d" + ), "pg_upgrade_output.d/ removed after successful pg_upgrade" + + # ------------------------------------------------------ + # Check that the data inserted to the publisher when the new subscriber is + # down will be replicated once it is started. Also check that the old + # subscription states and relations origins are all preserved, and that the + # conflict detection slot is created. + # ------------------------------------------------------ + publisher.safe_sql( + """ + INSERT INTO tab_upgraded1 VALUES(51); + INSERT INTO tab_upgraded2 VALUES(1); + """ + ) + + new_sub.start() + + # The subscription's running status, failover option, and + # retain_dead_tuples option should be preserved in the upgraded instance. + # So regress_sub4 should still have subenabled, subfailover, and + # subretaindeadtuples set to true, while regress_sub5 should have both set + # to false. + result = new_sub.safe_sql( + "SELECT subname, subenabled, subfailover, subretaindeadtuples " + "FROM pg_subscription ORDER BY subname" + ) + assert result == "regress_sub4|t|t|t\nregress_sub5|f|f|f", ( + "check that the subscription's running status, failover, and " + "retain_dead_tuples are preserved" + ) + + # Subscription relations should be preserved + result = new_sub.safe_sql( + "SELECT srrelid, srsubstate FROM pg_subscription_rel ORDER BY srrelid" + ) + assert result == ( + f"{tab_upgraded_oid}|r\n{tab_upgraded1_oid}|r\n{tab_upgraded2_oid}|i" + ), ( + "there should be 3 rows in pg_subscription_rel(representing " + "tab_upgraded, tab_upgraded1 and tab_upgraded2)" + ) + + # The replication origin's remote_lsn should be preserved + sub_oid = new_sub.safe_sql( + "SELECT oid FROM pg_subscription WHERE subname = 'regress_sub4'" + ) + result = new_sub.safe_sql( + "SELECT remote_lsn FROM pg_replication_origin_status " + f"WHERE external_id = 'pg_' || {sub_oid}" + ) + assert result == remote_lsn, "remote_lsn should have been preserved" + + # The conflict detection slot should be created + result = new_sub.safe_sql( + "SELECT xmin IS NOT NULL from pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ) + assert result == "t", "conflict detection slot exists" + + # Resume the initial sync and wait until all tables of subscription + # 'regress_sub5' are synchronized + new_sub.append_conf("max_logical_replication_workers = 10") + new_sub.restart() + new_sub.safe_sql("ALTER SUBSCRIPTION regress_sub5 ENABLE") + new_sub.wait_for_subscription_sync(publisher, "regress_sub5") + + # wait for regress_sub4 to catchup as well + publisher.wait_for_catchup("regress_sub4") + + # Rows on tab_upgraded1 and tab_upgraded2 should have been replicated + result = new_sub.safe_sql("SELECT count(*) FROM tab_upgraded1") + assert result == "51", "check replicated inserts on new subscriber" + result = new_sub.safe_sql("SELECT count(*) FROM tab_upgraded2") + assert result == "1", ( + "check the data is synced after enabling the subscription for the " + "table that was in init state" + ) diff --git a/src/bin/pg_upgrade/pyt/test_005_char_signedness.py b/src/bin/pg_upgrade/pyt/test_005_char_signedness.py new file mode 100644 index 00000000000..93af103e485 --- /dev/null +++ b/src/bin/pg_upgrade/pyt/test_005_char_signedness.py @@ -0,0 +1,119 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Tests for handling the default char signedness during upgrade.""" + +import os + +from pypg.util import enable_localhost_tcp + + +def test_005_char_signedness(pg_bin, create_pg, bindir, tmp_path): + # Can be changed to test the other modes + mode = os.environ.get("PG_TEST_PG_UPGRADE_MODE") or "--copy" + + # Initialize old and new clusters. pg_upgrade needs the old cluster + # stopped and the new cluster freshly initdb'd, so neither is started. + old = create_pg("old", start=False) + new = create_pg("new", start=False) + # pg_upgrade connects to the clusters over localhost TCP on Windows. + enable_localhost_tcp(old) + enable_localhost_tcp(new) + + # Check the default char signedness of both the old and the new clusters. + # Newly created clusters unconditionally use 'signed'. + pg_bin.command_like( + ["pg_controldata", old.data_dir], + r"Default char data signedness:\s+signed", + "default char signedness of old cluster is signed in control file", + ) + pg_bin.command_like( + ["pg_controldata", new.data_dir], + r"Default char data signedness:\s+signed", + "default char signedness of new cluster is signed in control file", + ) + + # Set the old cluster's default char signedness to unsigned for test. + pg_bin.command_ok( + [ + "pg_resetwal", + "--char-signedness", + "unsigned", + "--force", + old.data_dir, + ], + "set old cluster's default char signedness to unsigned", + ) + + # Check if the value is successfully updated. + pg_bin.command_like( + ["pg_controldata", old.data_dir], + r"Default char data signedness:\s+unsigned", + "updated default char signedness is unsigned in control file", + ) + + # In a VPATH build, we'll be started in the source directory, but we want + # to run pg_upgrade in the build directory so that any files generated + # finish in it, like delete_old_cluster.{sh,bat}. Use the test's tmp_path + # as a clean cwd since pg_upgrade writes logs there. + os.chdir(tmp_path) + + # Cannot use --set-char-signedness option for upgrading from v18+ + pg_bin.command_checks_all( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + old.data_dir, + "--new-datadir", + new.data_dir, + "--old-bindir", + bindir, + "--new-bindir", + bindir, + "--socketdir", + new.host, + "--old-port", + str(old.port), + "--new-port", + str(new.port), + "--set-char-signedness", + "signed", + mode, + ], + 1, + [r"option --set-char-signedness cannot be used"], + [], + "--set-char-signedness option cannot be used for upgrading from v18 or later", + ) + + # pg_upgrade should be successful. + pg_bin.command_ok( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + old.data_dir, + "--new-datadir", + new.data_dir, + "--old-bindir", + bindir, + "--new-bindir", + bindir, + "--socketdir", + new.host, + "--old-port", + str(old.port), + "--new-port", + str(new.port), + mode, + ], + "run of pg_upgrade", + ) + + # Check if the default char signedness of the new cluster inherited + # the old cluster's value. + pg_bin.command_like( + ["pg_controldata", new.data_dir], + r"Default char data signedness:\s+unsigned", + "the default char signedness is updated during pg_upgrade", + ) diff --git a/src/bin/pg_upgrade/pyt/test_006_transfer_modes.py b/src/bin/pg_upgrade/pyt/test_006_transfer_modes.py new file mode 100644 index 00000000000..5cb0e7dc0d0 --- /dev/null +++ b/src/bin/pg_upgrade/pyt/test_006_transfer_modes.py @@ -0,0 +1,219 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Tests pg_upgrade across the various file transfer modes.""" + +import os +import re + +import pytest + +from pypg.util import enable_localhost_tcp + + +def check_extension(node, extension_name): + """Return True if *extension_name* is available on *node*.""" + result = node.safe_sql( + "SELECT count(*) > 0 FROM pg_available_extensions " + f"WHERE name = '{extension_name}';" + ) + return result == "t" + + +def command_ok_or_fails_like(pg_bin, cmd, expected_stdout, expected_stderr, test_name): + """Run *cmd*; succeed, or fail with stdout/stderr matching the patterns. + + Returns True if the command succeeded, False otherwise (after asserting + the output matches). + """ + res = pg_bin.result(cmd) + if res.returncode != 0: + assert re.search( + expected_stdout, res.stdout + ), f"{test_name}: stdout matches /{expected_stdout}/\n{res.stdout}" + assert re.search( + expected_stderr, res.stderr + ), f"{test_name}: stderr matches /{expected_stderr}/\n{res.stderr}" + return False + return True + + +@pytest.mark.parametrize( + "mode", + ["--clone", "--copy", "--copy-file-range", "--link", "--swap"], +) +def test_mode(create_pg, pg_bin, tmp_path, mode): + old = create_pg("old", start=False) + new = create_pg("new", start=False) + # pg_upgrade connects to the clusters over localhost TCP on Windows. + enable_localhost_tcp(old) + enable_localhost_tcp(new) + + # --swap can't be used to upgrade from versions older than 10, but this + # framework only ever runs against the current build, so the old cluster is + # always new enough; no version-based skip is needed. + # + # The create_pg fixture has already run initdb for both nodes (without + # '-k'); checksums are enabled by default on the current build. + + # allow_in_place_tablespaces is available as far back as v10; the current + # build always qualifies. + new.append_conf("allow_in_place_tablespaces = true") + old.append_conf("allow_in_place_tablespaces = true") + + # We can only test security labels if both the old and new installations + # have dummy_seclabel. + test_seclabel = True + old.start() + if not check_extension(old, "dummy_seclabel"): + test_seclabel = False + old.stop() + new.start() + if not check_extension(new, "dummy_seclabel"): + test_seclabel = False + new.stop() + + # Create a small variety of simple test objects on the old cluster. We'll + # check that these reach the new version after upgrading. + old.start() + old.safe_sql("CREATE TABLE test1 AS SELECT generate_series(1, 100)") + old.safe_sql("CREATE DATABASE testdb1") + old.safe_sql( + "CREATE TABLE test2 AS SELECT generate_series(200, 300)", dbname="testdb1" + ) + old.safe_sql("VACUUM FULL test2", dbname="testdb1") + old.safe_sql("CREATE SEQUENCE testseq START 5432", dbname="testdb1") + + # Non-in-place tablespaces require an old installation ($oldinstall), which + # this framework does not support, so those objects are not created here. + + # The old cluster is always >= v10, so we can test in-place tablespaces. + old.safe_sql("CREATE TABLESPACE inplc_tblspc LOCATION ''") + old.safe_sql("CREATE DATABASE testdb3 TABLESPACE inplc_tblspc") + old.safe_sql( + "CREATE TABLE test5 TABLESPACE inplc_tblspc " + "AS SELECT generate_series(503, 606)" + ) + old.safe_sql( + "CREATE TABLE test6 AS SELECT generate_series(607, 711)", dbname="testdb3" + ) + + # While we are here, test handling of large objects. + old.safe_sql( + r""" + CREATE ROLE regress_lo_1; + CREATE ROLE regress_lo_2; + + SELECT lo_from_bytea(4532, '\xffffff00'); + COMMENT ON LARGE OBJECT 4532 IS 'test'; + + SELECT lo_from_bytea(4533, '\x0f0f0f0f'); + ALTER LARGE OBJECT 4533 OWNER TO regress_lo_1; + GRANT SELECT ON LARGE OBJECT 4533 TO regress_lo_2; + """ + ) + + if test_seclabel: + old.safe_sql( + r""" + CREATE EXTENSION dummy_seclabel; + + SELECT lo_from_bytea(4534, '\x00ffffff'); + SECURITY LABEL ON LARGE OBJECT 4534 IS 'classified'; + """ + ) + old.stop() + + # Run pg_upgrade in a tmp directory to avoid leaving files like + # delete_old_cluster.{sh,bat} in the source directory. + cwd = os.getcwd() + os.chdir(tmp_path) + try: + result = command_ok_or_fails_like( + pg_bin, + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + old.data_dir, + "--new-datadir", + new.data_dir, + "--old-bindir", + old.bindir, + "--new-bindir", + new.bindir, + "--socketdir", + new.host, + "--old-port", + str(old.port), + "--new-port", + str(new.port), + mode, + ], + r".* not supported on this platform|" + r"could not .* between old and new data directories: .*", + r"^$", + f"pg_upgrade with transfer mode {mode}", + ) + finally: + os.chdir(cwd) + + # If pg_upgrade was successful, check that all of our test objects reached + # the new version. + if result: + new.start() + assert ( + new.safe_sql("SELECT COUNT(*) FROM test1") == "100" + ), f"test1 data after pg_upgrade {mode}" + assert ( + new.safe_sql("SELECT COUNT(*) FROM test2", dbname="testdb1") == "101" + ), f"test2 data after pg_upgrade {mode}" + assert ( + new.safe_sql("SELECT nextval('testseq')", dbname="testdb1") == "5432" + ), f"sequence data after pg_upgrade {mode}" + + # Tests for non-in-place tablespaces require $oldinstall; skipped. + + # Tests for in-place tablespaces (old cluster is always >= v10). + assert ( + new.safe_sql("SELECT COUNT(*) FROM test5") == "104" + ), f"test5 data after pg_upgrade {mode}" + assert ( + new.safe_sql("SELECT COUNT(*) FROM test6", dbname="testdb3") == "105" + ), f"test6 data after pg_upgrade {mode}" + + # Tests for large objects + assert ( + new.safe_sql("SELECT lo_get(4532)") == r"\xffffff00" + ), "LO contents after upgrade" + assert ( + new.safe_sql("SELECT obj_description(4532, 'pg_largeobject')") == "test" + ), "comment on LO after pg_upgrade" + + assert ( + new.safe_sql("SELECT lo_get(4533)") == r"\x0f0f0f0f" + ), "LO contents after upgrade" + assert ( + new.safe_sql( + "SELECT lomowner::regrole FROM pg_largeobject_metadata WHERE oid = 4533" + ) + == "regress_lo_1" + ), "LO owner after upgrade" + assert ( + new.safe_sql("SELECT lomacl FROM pg_largeobject_metadata WHERE oid = 4533") + == "{regress_lo_1=rw/regress_lo_1,regress_lo_2=r/regress_lo_1}" + ), "LO ACL after upgrade" + + if test_seclabel: + assert ( + new.safe_sql("SELECT lo_get(4534)") == r"\x00ffffff" + ), "LO contents after upgrade" + assert ( + new.safe_sql( + """ + SELECT label FROM pg_seclabel WHERE objoid = 4534 + AND classoid = 'pg_largeobject'::regclass + """ + ) + == "classified" + ), "seclabel on LO after pg_upgrade" + new.stop() diff --git a/src/bin/pg_upgrade/pyt/test_007_multixact_conversion.py b/src/bin/pg_upgrade/pyt/test_007_multixact_conversion.py new file mode 100644 index 00000000000..a4ad5a69039 --- /dev/null +++ b/src/bin/pg_upgrade/pyt/test_007_multixact_conversion.py @@ -0,0 +1,367 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Tests for MultiXact SLRU conversion during upgrade.""" + +# Version 19 expanded MultiXactOffset from 32 to 64 bits. Upgrading +# across that requires rewriting the SLRU files to the new format. +# This file contains tests for the conversion. +# +# A pre-v19 installation could be pointed at via the +# 'oldinstall' ENV variable. This pytest framework always builds a single +# installation, so the old cluster is the same version as the new one. That +# still performs a very basic test, upgrading a cluster with some multixacts. +# It's not very interesting, however, because there's no conversion involved +# in that case. The wraparound scenario, which relies on the pre-v19 file +# format, is skipped when the old version is v19 or above (always, here). + +import glob +import os +import re + +from pypg.util import enable_localhost_tcp + + +# A workload that consumes multixids. The purpose of this is to +# generate some multixids in the old cluster, so that we can test +# upgrading them. The workload is a mix of KEY SHARE locking queries +# and UPDATEs, and commits and aborts, to generate a mix of multixids +# with different statuses. It consumes around 3000 multixids with +# 60000 members in total. That's enough to span more than one +# multixids 'offsets' page, and more than one 'members' segment with +# the default block size. +# +# The workload leaves behind a table called 'mxofftest' containing a +# small number of rows referencing some of the generated multixids. +def mxact_workload(node): + node.start() + node.safe_sql( + """ + CREATE TABLE mxofftest (id INT PRIMARY KEY, n_updated INT) + WITH (AUTOVACUUM_ENABLED=FALSE); + INSERT INTO mxofftest SELECT G, 0 FROM GENERATE_SERIES(1, 50) G; + """ + ) + + nclients = 20 + update_every = 13 + abort_every = 11 + connections = [] + + # Silence the logging of the statements we run to avoid unnecessarily + # bloating the test logs. This runs before the upgrade we're testing, so + # the details should not be very interesting for debugging. But if needed, + # you can make it more verbose by setting this. + verbose = False + + # Open multiple connections to the database. Start a transaction in each + # connection. + for _ in range(0, nclients + 1): + conn = node.connect("postgres") + if not verbose: + conn.do("SET log_statement=none") + conn.do("SET enable_seqscan=off") + conn.do("BEGIN") + connections.append(conn) + + # Run queries using cycling through the connections in a round-robin + # fashion. We keep a transaction open in each connection at all times, and + # lock/update the rows. With 20 connections, each SELECT FOR KEY SHARE + # query generates a new multixid, containing the XIDs of all the + # transactions running at the time, ie. around 20 XIDs. + for i in range(0, 3000): + if i % 100 == 0: + print(f"# generating multixids {i} / 3000") + + conn = connections[i % nclients] + + conn.do("ABORT" if i % abort_every == 0 else "COMMIT") + + conn.do("BEGIN") + if i % update_every == 0: + sql = ( + f"UPDATE mxofftest SET n_updated = n_updated + 1 " + f"WHERE id = {i} % 50;" + ) + else: + threshold = int(i / 3000 * 50) + sql = ( + "select count(*) from (" + f" SELECT * FROM mxofftest WHERE id >= {threshold} FOR KEY SHARE" + ") as x" + ) + conn.do(sql) + + for conn in connections: + conn.close() + + node.stop() + + +# Return contents of the 'mxofftest' table, created by mxact_workload +def get_test_table_contents(node): + return node.safe_sql("SELECT ctid, xmin, xmax, * FROM mxofftest") + + +# Return the members of all updating multixids in the given range +def get_updating_multixact_members(node, from_, to): + contents = "" + + if to >= from_: + contents += node.safe_sql( + f""" + SELECT multi, mode, xid + FROM generate_series({from_}, {to} - 1) as multi, + pg_get_multixact_members(multi::text::xid) + WHERE mode not in ('keysh', 'sh'); + """ + ) + else: + # Multixids wrapped around. Split the query into two parts, before and + # after the wraparound. + contents += node.safe_sql( + f""" + SELECT multi, mode, xid + FROM generate_series({from_}, 4294967295) as multi, + pg_get_multixact_members(multi::text::xid) + WHERE mode not in ('keysh', 'sh'); + """ + ) + contents += node.safe_sql( + f""" + SELECT multi, mode, xid + FROM generate_series(1, {to} - 1) as multi, + pg_get_multixact_members(multi::text::xid) + WHERE mode not in ('keysh', 'sh'); + """ + ) + + return contents + + +# Read multixid related fields from the control file +def read_multixid_fields(pg_bin, node): + res = pg_bin.result(["pg_controldata", node.data_dir]) + stdout = res.stdout + + m = re.search(r"^Latest checkpoint's oldestMultiXid:\s*(.*)$", stdout, re.M) + assert m, "could not read oldestMultiXid from pg_controldata" + oldest_multi_xid = m.group(1) + m = re.search(r"^Latest checkpoint's NextMultiXactId:\s*(.*)$", stdout, re.M) + assert m, "could not read NextMultiXactId from pg_controldata" + next_multi_xid = m.group(1) + m = re.search(r"^Latest checkpoint's NextMultiOffset:\s*(.*)$", stdout, re.M) + assert m, "could not read NextMultiOffset from pg_controldata" + next_multi_offset = m.group(1) + + return (oldest_multi_xid, next_multi_xid, next_multi_offset) + + +# Reset a cluster's next multixid and mxoffset to given values. +# +# Note: This is used on the old installation, so the command arguments and the +# output parsing used here must work with all pre-v19 PostgreSQL versions +# supported by the test. +def reset_mxid_mxoffset_pre_v19(pg_bin, node, mxid, mxoffset): + # Get block size + res = pg_bin.result(["pg_resetwal", "--dry-run", node.data_dir]) + out = res.stdout + assert re.search(r"^Database block size: *(\d+)$", out, re.M) + + # Verify that no multixids are currently in use. Resetting would destroy + # them. (A freshly initialized cluster has no multixids.) + m = re.search(r"^Latest checkpoint's NextMultiXactId: *(\d+)$", out, re.M) + assert m + next_mxid = int(m.group(1)) + m = re.search(r"^Latest checkpoint's oldestMultiXid: *(\d+)$", out, re.M) + assert m + oldest_mxid = int(m.group(1)) + assert next_mxid == oldest_mxid, "cluster has some multixids in use" + + # Extract a few other values from pg_resetwal --dry-run output that we need + # for the calculations below + m = re.search(r"^Database block size: *(\d+)$", out, re.M) + assert m + blcksz = int(m.group(1)) + # SLRU_PAGES_PER_SEGMENT is always 32 on pre-19 versions + slru_pages_per_segment = 32 + + # Do the reset + pg_bin.command_ok( + [ + "pg_resetwal", + "--pgdata", + node.data_dir, + "--multixact-offset", + str(mxoffset), + "--multixact-ids", + f"{mxid},{mxid}", + ], + "reset multixids and offset", + ) + + # pg_resetwal just updates the control file. The cluster will refuse to + # start up, if the SLRU segments corresponding to the next multixid and + # offset does not exist. Create a segments that covers the given values, + # filled with zeros. But first remove any old segments. + for path in glob.glob(os.path.join(node.data_dir, "pg_multixact/offsets/*")): + os.unlink(path) + for path in glob.glob(os.path.join(node.data_dir, "pg_multixact/members/*")): + os.unlink(path) + + bytes_per_seg = slru_pages_per_segment * blcksz + + # Initialize the 'offsets' SLRU file containing the new next multixid with + # zeros + # + # sizeof(MultiXactOffset) == 4 in PostgreSQL versions before 19 + multixact_offsets_per_page = blcksz // 4 + segno = int(mxid / multixact_offsets_per_page / slru_pages_per_segment) + path = os.path.join(node.data_dir, "pg_multixact/offsets", "%04X" % segno) + with open(path, "wb") as fh: + fh.write(b"\0" * bytes_per_seg) + + # Same for the 'members' SLRU + multixact_members_per_page = (blcksz // 20) * 4 + segno = int(mxoffset / multixact_members_per_page / slru_pages_per_segment) + path = os.path.join(node.data_dir, "pg_multixact/members", "%04X" % segno) + with open(path, "wb") as fh: + fh.write(b"\0" * bytes_per_seg) + + +# Main test workhorse routine. Dump data on old version, run pg_upgrade, +# compare data after upgrade. +def upgrade_and_compare(pg_bin, bindir, oldnode, newnode): + pg_bin.command_ok( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + oldnode.data_dir, + "--new-datadir", + newnode.data_dir, + "--old-bindir", + bindir, + "--new-bindir", + bindir, + "--socketdir", + newnode.host, + "--old-port", + str(oldnode.port), + "--new-port", + str(newnode.port), + ], + "run of pg_upgrade for new instance", + ) + + # Dump contents of the test table, and the status of all updating multixids + # from the old cluster. (Locking-only multixids don't need to be preserved + # so we ignore those) + # + # Note: we do this *after* running pg_upgrade, to ensure that we don't set + # all the hint bits before upgrade by doing the SELECT on the table. + multixids_start, multixids_end, _ = read_multixid_fields(pg_bin, oldnode) + oldnode.start() + old_table_contents = get_test_table_contents(oldnode) + old_multixacts = get_updating_multixact_members( + oldnode, int(multixids_start), int(multixids_end) + ) + oldnode.stop() + + # Compare them with the upgraded cluster + newnode.start() + new_table_contents = get_test_table_contents(newnode) + new_multixacts = get_updating_multixact_members( + newnode, int(multixids_start), int(multixids_end) + ) + newnode.stop() + + assert ( + old_table_contents == new_table_contents + ), "test table contents from original and upgraded clusters match" + assert ( + old_multixacts == new_multixacts + ), "multixact members from original and upgraded clusters match" + + +def _old_version(pg_bin): + """Return the major version number of the (old) installation, e.g. 19.""" + res = pg_bin.result(["pg_config", "--version"]) + m = re.search(r"(\d+)", res.stdout) + assert m, "could not determine PostgreSQL version" + return int(m.group(1)) + + +def test_007_multixact_conversion(pg_bin, create_pg, bindir, tmp_path): + # In a VPATH build, we'll be started in the source directory, but we want + # to run pg_upgrade in the build directory so that any files generated + # finish in it, like delete_old_cluster.{sh,bat}. Use the test's tmp_path + # as a clean cwd since pg_upgrade writes logs there. + os.chdir(tmp_path) + + old_version = _old_version(pg_bin) + + # Basic scenario: Create a cluster using old installation, run + # multixid-creating workload on it, then upgrade. + # + # This works even if the old and new version is the same, although it's not + # very interesting as the conversion routines only run when upgrading from a + # pre-v19 cluster. + old = create_pg("basic_oldnode", start=False, initdb_extra=["-k"]) + new = create_pg("basic_newnode", start=False) + # pg_upgrade connects to the clusters over localhost TCP on Windows. + enable_localhost_tcp(old) + enable_localhost_tcp(new) + + print(f"# old installation is version {old_version}") + + # Run the workload + _, start_mxid, start_mxoff = read_multixid_fields(pg_bin, old) + mxact_workload(old) + _, finish_mxid, finish_mxoff = read_multixid_fields(pg_bin, old) + + print( + "# Testing upgrade, basic scenario\n" + f"# mxid from {start_mxid} to {finish_mxid}\n" + f"# oldnode mxoff from {start_mxoff} to {finish_mxoff}" + ) + + upgrade_and_compare(pg_bin, bindir, old, new) + + # Wraparound scenario: This is the same as the basic scenario, but the old + # cluster goes through multixid and offset wraparound. + # + # This requires the old installation to be version 18 or older, because the + # hacks we use to reset the old cluster to a state just before the + # wraparound rely on the pre-v19 file format. If the old cluster is of v19 + # or above, multixact SLRU conversion is not needed anyway. + if old_version >= 19: + # skipping mxoffset conversion tests because upgrading from the old + # version does not require conversion + return + + old = create_pg("wraparound_oldnode", start=False, initdb_extra=["-k"]) + new = create_pg("wraparound_newnode", start=False) + # pg_upgrade connects to the clusters over localhost TCP on Windows. + enable_localhost_tcp(old) + enable_localhost_tcp(new) + + # Reset the old cluster to just before multixid and 32-bit offset + # wraparound. + reset_mxid_mxoffset_pre_v19(pg_bin, old, 0xFFFFFA00, 0xFFFFEC00) + + # Run the workload. This crosses multixid and offset wraparound. + _, start_mxid, start_mxoff = read_multixid_fields(pg_bin, old) + mxact_workload(old) + _, finish_mxid, finish_mxoff = read_multixid_fields(pg_bin, old) + + print( + "# Testing upgrade, wraparound scenario\n" + f"# mxid from {start_mxid} to {finish_mxid}\n" + f"# oldnode mxoff from {start_mxoff} to {finish_mxoff}" + ) + + # Verify that wraparounds happened. + assert int(finish_mxid) < int(start_mxid), "multixid wrapped around in old cluster" + assert int(finish_mxoff) < int(start_mxoff), "mxoff wrapped around in old cluster" + + upgrade_and_compare(pg_bin, bindir, old, new) diff --git a/src/bin/pg_upgrade/pyt/test_008_extension_control_path.py b/src/bin/pg_upgrade/pyt/test_008_extension_control_path.py new file mode 100644 index 00000000000..1f1b887b8f3 --- /dev/null +++ b/src/bin/pg_upgrade/pyt/test_008_extension_control_path.py @@ -0,0 +1,145 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Tests pg_upgrade with extensions found via extension_control_path.""" + +import os +import re +import shutil + +import pytest + + +def _create_extension_files(ext_name, ext_dir): + """Write .control and --1.0.sql files into ``ext_dir/extension/``. + + ``module_pathname`` contains the ``$libdir/`` prefix to simulate most + extensions that use it by default in module_pathname. + """ + control_file = os.path.join(ext_dir, "extension", f"{ext_name}.control") + with open(control_file, "w", encoding="utf-8") as cf: + cf.write( + "comment = 'Test C extension for pg_upgrade + extension_control_path'\n" + ) + cf.write("default_version = '1.0'\n") + cf.write(f"module_pathname = '$libdir/{ext_name}'\n") + cf.write("relocatable = true\n") + + sql_file = os.path.join(ext_dir, "extension", f"{ext_name}--1.0.sql") + with open(sql_file, "w", encoding="utf-8") as sqlf: + sqlf.write(f"/* {ext_name}--1.0.sql */\n") + sqlf.write( + "-- complain if script is sourced in psql, rather than via " + "CREATE EXTENSION\n" + ) + sqlf.write( + f'\\echo Use "CREATE EXTENSION {ext_name}" to load this file. ' "\\quit\n" + ) + sqlf.write("CREATE FUNCTION test_ext()\n") + sqlf.write("RETURNS void AS 'MODULE_PATHNAME'\n") + sqlf.write("LANGUAGE C;\n") + + +def test_extension_control_path(create_pg, tmp_path): + # Make sure the extension .so path is provided. + ext_lib_so = os.environ.get("TEST_EXT_LIB") + if not ext_lib_so: + pytest.skip("couldn't get the extension so path (TEST_EXT_LIB not set)") + + # Create the custom extension directory layout: + # ext_dir/extension/ -- .control and .sql files + # ext_dir/lib/ -- .so file + ext_dir = str(tmp_path / "ext_dir") + os.makedirs(os.path.join(ext_dir, "extension")) + os.makedirs(os.path.join(ext_dir, "lib")) + ext_lib = os.path.join(ext_dir, "lib") + + # Copy the .so file into the lib/ subdirectory. + shutil.copy(ext_lib_so, ext_lib) + + _create_extension_files("test_ext", ext_dir) + + # Unix-only port: path separator is ":". + sep = ":" + ext_path = ext_dir + ext_lib_path = ext_lib + + extension_control_path_conf = ( + f"\nextension_control_path = '$system{sep}{ext_path}'\n" + f"dynamic_library_path = '$libdir{sep}{ext_lib_path}'\n" + ) + + old = create_pg("old", start=False) + + # Configure extension_control_path so the .control file is found in our + # extension/ directory, and dynamic_library_path so the .so is found in + # lib/. + old.append_conf(extension_control_path_conf) + + old.start() + + # CREATE EXTENSION 'test_ext' + old.safe_sql("CREATE EXTENSION test_ext") + + # Verify the extension works before the upgrade. + sess = old.session() + sess.clear_notices() + res = sess.query("SELECT test_ext()") + assert res.error_message is None, "extension works before upgrade" + assert re.search( + r"NOTICE: running successful", sess.get_notices_str() + ), "extension working" + + old.stop() + + new = create_pg("new", start=False) + + # Pre-configure the new cluster with dynamic_library_path and + # extension_control_path before running pg_upgrade. + new.append_conf(extension_control_path_conf) + + # In a VPATH build, we'll be started in the source directory, but we want + # to run pg_upgrade in the build directory so that any generated files + # finish there, like delete_old_cluster.{sh,bat}. Run from a tmp cwd. + run_cwd = str(tmp_path / "run") + os.makedirs(run_cwd) + old_cwd = os.getcwd() + os.chdir(run_cwd) + try: + new.command_ok( + [ + "pg_upgrade", + "--no-sync", + "--old-datadir", + old.data_dir, + "--new-datadir", + new.data_dir, + "--old-bindir", + old.bindir, + "--new-bindir", + new.bindir, + "--socketdir", + new.host, + "--old-port", + str(old.port), + "--new-port", + str(new.port), + "--copy", + ], + "pg_upgrade succeeds with extension installed via " + "extension_control_path", + ) + finally: + os.chdir(old_cwd) + + new.start() + + # Verify the extension still works after the upgrade. + sess = new.session() + sess.clear_notices() + res = sess.query("SELECT test_ext()") + assert res.error_message is None, "extension works after upgrade" + assert re.search( + r"NOTICE: running successful", sess.get_notices_str() + ), "extension working" + + new.stop() diff --git a/src/test/modules/test_pg_dump/meson.build b/src/test/modules/test_pg_dump/meson.build index 1d2f5736092..dcb44e4d6e3 100644 --- a/src/test/modules/test_pg_dump/meson.build +++ b/src/test/modules/test_pg_dump/meson.build @@ -19,4 +19,9 @@ tests += { 't/001_base.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_base.py', + ], + }, } diff --git a/src/test/modules/test_pg_dump/pyt/test_001_base.py b/src/test/modules/test_pg_dump/pyt/test_001_base.py new file mode 100644 index 00000000000..cf617baa199 --- /dev/null +++ b/src/test/modules/test_pg_dump/pyt/test_001_base.py @@ -0,0 +1,1057 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Exercise pg_dump against the test_pg_dump extension. + +Exercises pg_dump (and pg_dumpall/pg_restore) against the test_pg_dump +extension using a matrix of named "runs" (pg_dump invocations with different +options) and named "tests" (each with a regexp and like/unlike sets stating +which runs the regexp is expected to match). + +pg_dump / pg_restore / pg_dumpall are the binaries under test and are run as +subprocesses through the node's pg_bin (PGHOST/PGPORT point at the server). +The seed SQL the test itself runs is executed in-process via safe_sql. +""" + +import os +import re +import subprocess + +import pytest + +from pypg.util import slurp_file + + +def _have_pg_config_define(define): + """Return True if the installed pg_config.h contains the given #define.""" + try: + out = subprocess.run( + ["pg_config", "--includedir"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.SubprocessError): + return False + header = os.path.join(out, "pg_config.h") + try: + with open(header, encoding="utf-8", errors="replace") as fh: + return define in fh.read() + except OSError: + return False + + +def _have_extension(node, extname): + """Return True if *extname* is installable (present in the install tree).""" + return ( + node.safe_sql( + "SELECT count(*) > 0 FROM pg_available_extensions " + f"WHERE name = '{extname}'" + ) + == "t" + ) + + +# Runs that pg_dump considers "full" dumps, but with flags excluding specific +# items (ACLs, LOs, etc.). The set of runs that produce a full dump. +FULL_RUNS = { + "binary_upgrade", + "clean", + "clean_if_exists", + "createdb", + "defaults", + "exclude_table", + "no_privs", + "no_owner", + "privileged_internals", + "with_extension", + "exclude_extension", + "exclude_extension_filter", + "without_extension", +} + + +def _pgdump_runs(tempdir): + """Definition of the pg_dump runs to make. + + Each run has a dump_cmd; some have a restore_cmd, a test_key (reuse another + run's like/unlike sets), and/or a compile_option gating it on a build + feature. Commands are argv lists; cmd[0] is resolved in the node's bindir. + """ + return { + "binary_upgrade": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/binary_upgrade.sql", + "--schema-only", + "--sequence-data", + "--binary-upgrade", + "--dbname", + "postgres", + ], + }, + "clean": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/clean.sql", + "--clean", + "--dbname", + "postgres", + ], + }, + "clean_if_exists": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/clean_if_exists.sql", + "--clean", + "--if-exists", + "--encoding", + "UTF8", # no-op, just tests that it is accepted + "postgres", + ], + }, + "createdb": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/createdb.sql", + "--create", + "--no-reconnect", # no-op, just for testing + "postgres", + ], + }, + "data_only": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/data_only.sql", + "--data-only", + "--verbose", # no-op, just make sure it works + "postgres", + ], + }, + "defaults": { + "dump_cmd": [ + "pg_dump", + "--file", + f"{tempdir}/defaults.sql", + "postgres", + ], + }, + "defaults_custom_format": { + "test_key": "defaults", + "compile_option": "gzip", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "custom", + "--compress", + "6", + "--file", + f"{tempdir}/defaults_custom_format.dump", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/defaults_custom_format.sql", + f"{tempdir}/defaults_custom_format.dump", + ], + }, + "defaults_dir_format": { + "test_key": "defaults", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "directory", + "--file", + f"{tempdir}/defaults_dir_format", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/defaults_dir_format.sql", + f"{tempdir}/defaults_dir_format", + ], + }, + "defaults_parallel": { + "test_key": "defaults", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "directory", + "--jobs", + "2", + "--file", + f"{tempdir}/defaults_parallel", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/defaults_parallel.sql", + f"{tempdir}/defaults_parallel", + ], + }, + "defaults_tar_format": { + "test_key": "defaults", + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--format", + "tar", + "--file", + f"{tempdir}/defaults_tar_format.tar", + "postgres", + ], + "restore_cmd": [ + "pg_restore", + "--file", + f"{tempdir}/defaults_tar_format.sql", + f"{tempdir}/defaults_tar_format.tar", + ], + }, + "exclude_table": { + "dump_cmd": [ + "pg_dump", + "--exclude-table", + "regress_table_dumpable", + "--file", + f"{tempdir}/exclude_table.sql", + "postgres", + ], + }, + "extension_schema": { + "dump_cmd": [ + "pg_dump", + "--schema", + "public", + "--file", + f"{tempdir}/extension_schema.sql", + "postgres", + ], + }, + "pg_dumpall_globals": { + "dump_cmd": [ + "pg_dumpall", + "--no-sync", + "--file", + f"{tempdir}/pg_dumpall_globals.sql", + "--globals-only", + ], + }, + "no_privs": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_privs.sql", + "--no-privileges", + "postgres", + ], + }, + "no_owner": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/no_owner.sql", + "--no-owner", + "postgres", + ], + }, + # regress_dump_login_role shouldn't need SELECT rights on internal + # (undumped) extension tables + "privileged_internals": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/privileged_internals.sql", + # these two tables are irrelevant to the test case + "--exclude-table", + "regress_pg_dump_schema.external_tab", + "--exclude-table", + "regress_pg_dump_schema.extdependtab", + "--username", + "regress_dump_login_role", + "postgres", + ], + }, + "schema_only": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/schema_only.sql", + "--schema-only", + "postgres", + ], + }, + "section_pre_data": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/section_pre_data.sql", + "--section", + "pre-data", + "postgres", + ], + }, + "section_data": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/section_data.sql", + "--section", + "data", + "postgres", + ], + }, + "section_post_data": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/section_post_data.sql", + "--section", + "post-data", + "postgres", + ], + }, + "with_extension": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/with_extension.sql", + "--extension", + "test_pg_dump", + "postgres", + ], + }, + "exclude_extension": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/exclude_extension.sql", + "--exclude-extension", + "test_pg_dump", + "postgres", + ], + }, + "exclude_extension_filter": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/exclude_extension_filter.sql", + "--filter", + f"{tempdir}/exclude_extension_filter.txt", + "postgres", + ], + }, + # plpgsql in the list blocks the dump of extension test_pg_dump + "without_extension": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/without_extension.sql", + "--extension", + "plpgsql", + "postgres", + ], + }, + # plpgsql in the list of extensions blocks the dump of extension + # test_pg_dump. "public" is the schema used by the extension + # test_pg_dump, but none of its objects should be dumped. + "without_extension_explicit_schema": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/without_extension_explicit_schema.sql", + "--extension", + "plpgsql", + "--schema", + "public", + "postgres", + ], + }, + # plpgsql in the list of extensions blocks the dump of extension + # test_pg_dump, but not the dump of objects not dependent on the + # extension located on a schema maintained by the extension. + "without_extension_internal_schema": { + "dump_cmd": [ + "pg_dump", + "--no-sync", + "--file", + f"{tempdir}/without_extension_internal_schema.sql", + "--extension", + "plpgsql", + "--schema", + "regress_pg_dump_schema", + "postgres", + ], + }, + } + + +# The pattern strings are written in verbose form: whitespace and comments are +# stripped via re.VERBOSE, re.MULTILINE is always added, and re.DOTALL is added +# where a pattern must match across newlines. +_XM = re.VERBOSE | re.MULTILINE +_XMS = re.VERBOSE | re.MULTILINE | re.DOTALL + + +def _tests(): + """Definition of the tests to run. + + Each entry: create_order/create_sql (seed SQL, run before any dump), + regexp (compiled), and like/unlike sets of run names. A run listed in + 'like' (and not in 'unlike') must match the regexp; every other run must + not match it. + """ + full = set(FULL_RUNS) + return { + "ALTER EXTENSION test_pg_dump": { + "create_order": 9, + "create_sql": "ALTER EXTENSION test_pg_dump ADD TABLE " + "regress_pg_dump_table_added;", + "regexp": re.compile( + r"^CREATE\ TABLE\ public\.regress_pg_dump_table_added\ \(\n" + r"\s+col1\ integer\ NOT\ NULL,\n" + r"\s+col2\ integer\n" + r"\);\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "CREATE EXTENSION test_pg_dump": { + "create_order": 2, + "create_sql": "CREATE EXTENSION test_pg_dump;", + "regexp": re.compile( + r"^CREATE\ EXTENSION\ IF\ NOT\ EXISTS\ test_pg_dump\ WITH\ " + r"SCHEMA\ public;\n", + _XM, + ), + "like": full | {"schema_only", "section_pre_data"}, + "unlike": { + "binary_upgrade", + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "CREATE ROLE regress_dump_test_role": { + "create_order": 1, + "create_sql": "CREATE ROLE regress_dump_test_role;", + "regexp": re.compile( + r"^CREATE ROLE regress_dump_test_role;\n", re.MULTILINE + ), + "like": {"pg_dumpall_globals"}, + }, + "CREATE ROLE regress_dump_login_role": { + "create_order": 1, + "create_sql": "CREATE ROLE regress_dump_login_role LOGIN;", + "regexp": re.compile( + r"^CREATE\ ROLE\ regress_dump_login_role;\n" + r"ALTER\ ROLE\ regress_dump_login_role\ WITH\ .*\ LOGIN\ .*;\n", + _XM, + ), + "like": {"pg_dumpall_globals"}, + }, + "GRANT ALTER SYSTEM ON PARAMETER full_page_writes TO " + "regress_dump_test_role": { + "create_order": 2, + "create_sql": "GRANT ALTER SYSTEM ON PARAMETER full_page_writes TO " + "regress_dump_test_role;", + "regexp": re.compile( + r"^GRANT ALTER SYSTEM ON PARAMETER full_page_writes TO " + r"regress_dump_test_role;", + re.MULTILINE, + ), + "like": {"pg_dumpall_globals"}, + }, + "GRANT ALL ON PARAMETER Custom.Knob TO regress_dump_test_role WITH " + "GRANT OPTION": { + "create_order": 2, + "create_sql": "GRANT SET, ALTER SYSTEM ON PARAMETER Custom.Knob TO " + "regress_dump_test_role WITH GRANT OPTION;", + # "set" plus "alter system" is "all" privileges on parameters + "regexp": re.compile( + r'^GRANT ALL ON PARAMETER "custom.knob" TO ' + r"regress_dump_test_role WITH GRANT OPTION;", + re.MULTILINE, + ), + "like": {"pg_dumpall_globals"}, + }, + "GRANT ALL ON PARAMETER DateStyle TO regress_dump_test_role": { + "create_order": 2, + "create_sql": 'GRANT ALL ON PARAMETER "DateStyle" TO regress_dump_test_role ' + "WITH GRANT OPTION; REVOKE GRANT OPTION FOR ALL ON PARAMETER " + "DateStyle FROM regress_dump_test_role;", + # The revoke simplifies the ultimate grant so as to not include + # "with grant option" + "regexp": re.compile( + r"^GRANT ALL ON PARAMETER datestyle TO regress_dump_test_role;", + re.MULTILINE, + ), + "like": {"pg_dumpall_globals"}, + }, + "CREATE SCHEMA public": { + "regexp": re.compile(r"^CREATE SCHEMA public;", re.MULTILINE), + "like": {"extension_schema", "without_extension_explicit_schema"}, + }, + "CREATE SEQUENCE regress_pg_dump_table_col1_seq": { + "regexp": re.compile( + r"^CREATE\ SEQUENCE\ public\.regress_pg_dump_table_col1_seq\n" + r"\s+AS\ integer\n" + r"\s+START\ WITH\ 1\n" + r"\s+INCREMENT\ BY\ 1\n" + r"\s+NO\ MINVALUE\n" + r"\s+NO\ MAXVALUE\n" + r"\s+CACHE\ 1;\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "CREATE TABLE regress_pg_dump_table_added": { + "create_order": 7, + "create_sql": "CREATE TABLE regress_pg_dump_table_added " + "(col1 int not null, col2 int);", + "regexp": re.compile( + r"^CREATE\ TABLE\ public\.regress_pg_dump_table_added\ \(\n" + r"\s+col1\ integer\ NOT\ NULL,\n" + r"\s+col2\ integer\n" + r"\);\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "CREATE SEQUENCE regress_pg_dump_seq": { + "regexp": re.compile( + r"^CREATE\ SEQUENCE\ public\.regress_pg_dump_seq\n" + r"\s+START\ WITH\ 1\n" + r"\s+INCREMENT\ BY\ 1\n" + r"\s+NO\ MINVALUE\n" + r"\s+NO\ MAXVALUE\n" + r"\s+CACHE\ 1;\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "SETVAL SEQUENCE regress_seq_dumpable": { + "create_order": 6, + "create_sql": "SELECT nextval('regress_seq_dumpable');", + "regexp": re.compile( + r"^SELECT\ pg_catalog\.setval\(" + r"'public\.regress_seq_dumpable',\ 1,\ true\);\n", + _XM, + ), + "like": full | {"data_only", "section_data", "extension_schema"}, + "unlike": { + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "CREATE TABLE regress_pg_dump_table": { + "regexp": re.compile( + r"^CREATE\ TABLE\ public\.regress_pg_dump_table\ \(\n" + r"\s+col1\ integer\ NOT\ NULL,\n" + r"\s+col2\ integer,\n" + r"\s+CONSTRAINT\ regress_pg_dump_table_col2_check\ " + r"CHECK\ \(\(col2\ >\ 0\)\)\n" + r"\);\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "COPY public.regress_table_dumpable (col1)": { + "regexp": re.compile( + r"^COPY\ public\.regress_table_dumpable\ \(col1\)\ FROM\ stdin;\n", + _XM, + ), + "like": full | {"data_only", "section_data", "extension_schema"}, + "unlike": { + "binary_upgrade", + "exclude_table", + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "REVOKE ALL ON FUNCTION wgo_then_no_access": { + "create_order": 3, + "create_sql": "DO $$BEGIN EXECUTE format(\n" + " 'REVOKE ALL ON FUNCTION wgo_then_no_access()\n" + " FROM pg_signal_backend, public, %I',\n" + " (SELECT usename\n" + " FROM pg_user JOIN pg_proc ON proowner = usesysid\n" + " WHERE proname = 'wgo_then_no_access')); END$$;", + "regexp": re.compile( + r"^REVOKE\ ALL\ ON\ FUNCTION\ public\.wgo_then_no_access\(\)\ " + r"FROM\ PUBLIC;\n" + r"REVOKE\ ALL\ ON\ FUNCTION\ public\.wgo_then_no_access\(\)\ " + r"FROM\ .*;\n" + r"REVOKE\ ALL\ ON\ FUNCTION\ public\.wgo_then_no_access\(\)\ " + r"FROM\ pg_signal_backend;\n", + _XM, + ), + "like": full | {"schema_only", "section_pre_data"}, + "unlike": { + "no_privs", + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "REVOKE GRANT OPTION FOR UPDATE ON SEQUENCE wgo_then_regular": { + "create_order": 3, + "create_sql": "REVOKE GRANT OPTION FOR UPDATE ON SEQUENCE " + "wgo_then_regular FROM pg_signal_backend;", + "regexp": re.compile( + r"^REVOKE\ ALL\ ON\ SEQUENCE\ public\.wgo_then_regular\ " + r"FROM\ pg_signal_backend;\n" + r"GRANT\ SELECT,UPDATE\ ON\ SEQUENCE\ " + r"public\.wgo_then_regular\ TO\ pg_signal_backend;\n" + r"GRANT\ USAGE\ ON\ SEQUENCE\ public\.wgo_then_regular\ " + r"TO\ pg_signal_backend\ WITH\ GRANT\ OPTION;\n", + _XM, + ), + "like": full | {"schema_only", "section_pre_data"}, + "unlike": { + "no_privs", + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "CREATE ACCESS METHOD regress_test_am": { + "regexp": re.compile( + r"^CREATE\ ACCESS\ METHOD\ regress_test_am\ TYPE\ INDEX\ " + r"HANDLER\ bthandler;\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "COMMENT ON EXTENSION test_pg_dump": { + "regexp": re.compile( + r"^COMMENT\ ON\ EXTENSION\ test_pg_dump\ " + r"IS\ 'Test\ pg_dump\ with\ an\ extension';\n", + _XM, + ), + "like": full | {"schema_only", "section_pre_data"}, + "unlike": { + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "GRANT SELECT regress_pg_dump_table_added pre-ALTER EXTENSION": { + "create_order": 8, + "create_sql": "GRANT SELECT ON regress_pg_dump_table_added TO " + "regress_dump_test_role;", + "regexp": re.compile( + r"^GRANT\ SELECT\ ON\ TABLE\ " + r"public\.regress_pg_dump_table_added\ " + r"TO\ regress_dump_test_role;\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "REVOKE SELECT regress_pg_dump_table_added post-ALTER EXTENSION": { + "create_order": 10, + "create_sql": "REVOKE SELECT ON regress_pg_dump_table_added FROM " + "regress_dump_test_role;", + "regexp": re.compile( + r"^REVOKE\ SELECT\ ON\ TABLE\ " + r"public\.regress_pg_dump_table_added\ " + r"FROM\ regress_dump_test_role;\n", + _XM, + ), + "like": full | {"schema_only", "section_pre_data"}, + "unlike": { + "no_privs", + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "GRANT SELECT ON TABLE regress_pg_dump_table": { + "regexp": re.compile( + r"^SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(true\);\n" + r"GRANT\ SELECT\ ON\ TABLE\ public\.regress_pg_dump_table\ " + r"TO\ regress_dump_test_role;\n" + r"SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(false\);\n", + _XMS, + ), + "like": {"binary_upgrade"}, + }, + "GRANT SELECT(col1) ON regress_pg_dump_table": { + "regexp": re.compile( + r"^SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(true\);\n" + r"GRANT\ SELECT\(col1\)\ ON\ TABLE\ " + r"public\.regress_pg_dump_table\ TO\ PUBLIC;\n" + r"SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(false\);\n", + _XMS, + ), + "like": {"binary_upgrade"}, + }, + "GRANT SELECT(col2) ON regress_pg_dump_table TO " + "regress_dump_test_role": { + "create_order": 4, + "create_sql": "GRANT SELECT(col2) ON regress_pg_dump_table\n" + " TO regress_dump_test_role;", + "regexp": re.compile( + r"^GRANT\ SELECT\(col2\)\ ON\ TABLE\ " + r"public\.regress_pg_dump_table\ TO\ regress_dump_test_role;\n", + _XM, + ), + "like": full | {"schema_only", "section_pre_data"}, + "unlike": { + "no_privs", + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "GRANT USAGE ON regress_pg_dump_table_col1_seq TO " + "regress_dump_test_role": { + "create_order": 5, + "create_sql": "GRANT USAGE ON SEQUENCE regress_pg_dump_table_col1_seq\n" + " TO regress_dump_test_role;", + "regexp": re.compile( + r"^GRANT\ USAGE\ ON\ SEQUENCE\ " + r"public\.regress_pg_dump_table_col1_seq\ " + r"TO\ regress_dump_test_role;\n", + _XM, + ), + "like": full | {"schema_only", "section_pre_data"}, + "unlike": { + "no_privs", + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + "GRANT USAGE ON regress_pg_dump_seq TO regress_dump_test_role": { + "regexp": re.compile( + r"^GRANT\ USAGE\ ON\ SEQUENCE\ public\.regress_pg_dump_seq\ " + r"TO\ regress_dump_test_role;\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "REVOKE SELECT(col1) ON regress_pg_dump_table": { + "create_order": 3, + "create_sql": "REVOKE SELECT(col1) ON regress_pg_dump_table\n" + " FROM PUBLIC;", + "regexp": re.compile( + r"^REVOKE\ SELECT\(col1\)\ ON\ TABLE\ " + r"public\.regress_pg_dump_table\ FROM\ PUBLIC;\n", + _XM, + ), + "like": full | {"schema_only", "section_pre_data"}, + "unlike": { + "no_privs", + "exclude_extension", + "exclude_extension_filter", + "without_extension", + }, + }, + # Objects included in extension part of a schema created by this + # extension + "CREATE TABLE regress_pg_dump_schema.test_table": { + "regexp": re.compile( + r"^CREATE\ TABLE\ regress_pg_dump_schema\.test_table\ \(\n" + r"\s+col1\ integer,\n" + r"\s+col2\ integer,\n" + r"\s+CONSTRAINT\ test_table_col2_check\ " + r"CHECK\ \(\(col2\ >\ 0\)\)\n" + r"\);\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "GRANT SELECT ON regress_pg_dump_schema.test_table": { + "regexp": re.compile( + r"^SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(true\);\n" + r"GRANT\ SELECT\ ON\ TABLE\ " + r"regress_pg_dump_schema\.test_table\ " + r"TO\ regress_dump_test_role;\n" + r"SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(false\);\n", + _XMS, + ), + "like": {"binary_upgrade"}, + }, + "CREATE SEQUENCE regress_pg_dump_schema.test_seq": { + "regexp": re.compile( + r"^CREATE\ SEQUENCE\ regress_pg_dump_schema\.test_seq\n" + r"\s+START\ WITH\ 1\n" + r"\s+INCREMENT\ BY\ 1\n" + r"\s+NO\ MINVALUE\n" + r"\s+NO\ MAXVALUE\n" + r"\s+CACHE\ 1;\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "GRANT USAGE ON regress_pg_dump_schema.test_seq": { + "regexp": re.compile( + r"^SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(true\);\n" + r"GRANT\ USAGE\ ON\ SEQUENCE\ " + r"regress_pg_dump_schema\.test_seq\ " + r"TO\ regress_dump_test_role;\n" + r"SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(false\);\n", + _XMS, + ), + "like": {"binary_upgrade"}, + }, + "CREATE TYPE regress_pg_dump_schema.test_type": { + "regexp": re.compile( + r"^CREATE\ TYPE\ regress_pg_dump_schema\.test_type\ AS\ \(\n" + r"\s+col1\ integer\n" + r"\);\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "GRANT USAGE ON regress_pg_dump_schema.test_type": { + "regexp": re.compile( + r"^SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(true\);\n" + r"GRANT\ ALL\ ON\ TYPE\ regress_pg_dump_schema\.test_type\ " + r"TO\ regress_dump_test_role;\n" + r"SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(false\);\n", + _XMS, + ), + "like": {"binary_upgrade"}, + }, + "CREATE FUNCTION regress_pg_dump_schema.test_func": { + "regexp": re.compile( + r"^CREATE\ FUNCTION\ regress_pg_dump_schema\.test_func\(\)\ " + r"RETURNS\ integer\n" + r"\s+LANGUAGE\ sql\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "GRANT ALL ON regress_pg_dump_schema.test_func": { + "regexp": re.compile( + r"^SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(true\);\n" + r"GRANT\ ALL\ ON\ FUNCTION\ " + r"regress_pg_dump_schema\.test_func\(\)\ " + r"TO\ regress_dump_test_role;\n" + r"SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(false\);\n", + _XMS, + ), + "like": {"binary_upgrade"}, + }, + "CREATE AGGREGATE regress_pg_dump_schema.test_agg": { + "regexp": re.compile( + r"^CREATE\ AGGREGATE\ " + r"regress_pg_dump_schema\.test_agg\(smallint\)\ \(\n" + r"\s+SFUNC\ =\ int2_sum,\n" + r"\s+STYPE\ =\ bigint\n" + r"\);\n", + _XM, + ), + "like": {"binary_upgrade"}, + }, + "GRANT ALL ON regress_pg_dump_schema.test_agg": { + "regexp": re.compile( + r"^SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(true\);\n" + r"GRANT\ ALL\ ON\ FUNCTION\ " + r"regress_pg_dump_schema\.test_agg\(smallint\)\ " + r"TO\ regress_dump_test_role;\n" + r"SELECT\ pg_catalog\.binary_upgrade_set_record_init_privs" + r"\(false\);\n", + _XMS, + ), + "like": {"binary_upgrade"}, + }, + "ALTER INDEX pkey DEPENDS ON extension": { + "create_order": 11, + "create_sql": "CREATE TABLE regress_pg_dump_schema.extdependtab " + "(col1 integer primary key, col2 int);\n" + "CREATE INDEX ON regress_pg_dump_schema.extdependtab (col2);\n" + "ALTER INDEX regress_pg_dump_schema.extdependtab_col2_idx " + "DEPENDS ON EXTENSION test_pg_dump;\n" + "ALTER INDEX regress_pg_dump_schema.extdependtab_pkey " + "DEPENDS ON EXTENSION test_pg_dump;", + "regexp": re.compile( + r"^ALTER\ INDEX\ " + r"regress_pg_dump_schema\.extdependtab_pkey\ " + r"DEPENDS\ ON\ EXTENSION\ test_pg_dump;\n", + _XMS, + ), + "like": "ALL", + "unlike": { + "data_only", + "extension_schema", + "pg_dumpall_globals", + "privileged_internals", + "section_data", + "section_pre_data", + # Excludes this schema as extension is not listed. + "without_extension_explicit_schema", + }, + }, + "ALTER INDEX idx DEPENDS ON extension": { + "regexp": re.compile( + r"^ALTER\ INDEX\ " + r"regress_pg_dump_schema\.extdependtab_col2_idx\ " + r"DEPENDS\ ON\ EXTENSION\ test_pg_dump;\n", + _XMS, + ), + "like": "ALL", + "unlike": { + "data_only", + "extension_schema", + "pg_dumpall_globals", + "privileged_internals", + "section_data", + "section_pre_data", + # Excludes this schema as extension is not listed. + "without_extension_explicit_schema", + }, + }, + # Objects not included in extension, part of schema created by extension + "CREATE TABLE regress_pg_dump_schema.external_tab": { + "create_order": 4, + "create_sql": "CREATE TABLE regress_pg_dump_schema.external_tab\n" + " (col1 int);", + "regexp": re.compile( + r"^CREATE\ TABLE\ regress_pg_dump_schema\.external_tab\ \(\n" + r"\s+col1\ integer\n" + r"\);\n", + _XM, + ), + "like": full + | { + "schema_only", + "section_pre_data", + # Excludes the extension and keeps the schema's data. + "without_extension_internal_schema", + }, + "unlike": {"privileged_internals"}, + }, + } + + +def _create_order_key(item): + """Sort key controlling the order in which objects are created. + + Tests with a create_order sort by it (ascending) and before tests without + one; among tests without a create_order, order is irrelevant for the + concatenated seed SQL but we keep it stable by name. + """ + _name, spec = item + order = spec.get("create_order") + return (0, order) if order is not None else (1, 0) + + +def test_pg_dump_base(pg, tmp_path): + node = pg + + if not _have_extension(node, "test_pg_dump"): + pytest.skip("test_pg_dump extension not installed") + + tempdir = str(tmp_path) + pgdump_runs = _pgdump_runs(tempdir) + tests = _tests() + + supports_gzip = _have_pg_config_define("#define HAVE_LIBZ 1") + + # Build up the combined create statements in create_order, then seed. + create_sql = "" + for _name, spec in sorted(tests.items(), key=_create_order_key): + if spec.get("create_sql"): + create_sql += spec["create_sql"] + node.safe_sql(create_sql) + + # Create filter file for exclude_extension_filter test. + with open( + os.path.join(tempdir, "exclude_extension_filter.txt"), "w", encoding="utf-8" + ) as fh: + fh.write("exclude extension test_pg_dump\n") + + all_runs = set(pgdump_runs) + + # Run all runs, sorted by name. + for run in sorted(pgdump_runs): + spec = pgdump_runs[run] + test_key = run + + # Skip command-level tests for gzip if there is no support for it. + if spec.get("compile_option") == "gzip" and not supports_gzip: + print(f"# {run}: skipped due to no gzip support") + continue + + node.command_ok(spec["dump_cmd"], f"{run}: pg_dump runs") + + if "restore_cmd" in spec: + node.command_ok(spec["restore_cmd"], f"{run}: pg_restore runs") + + if "test_key" in spec: + test_key = spec["test_key"] + + output_file = slurp_file(os.path.join(tempdir, f"{run}.sql")) + + # Run all tests where this run is included as a 'like' or 'unlike'. + for test_name in sorted(tests): + tspec = tests[test_name] + + like = tspec["like"] + unlike = tspec.get("unlike", set()) + + # "ALL" means the regexp is expected to match every run. + like_set = all_runs if like == "ALL" else like + + # Check for useless entries in "unlike": runs not in "like" do not + # need excluding. + assert not ( + test_key in unlike and test_key not in like_set + ), f'useless "unlike" entry "{test_key}" in test "{test_name}"' + + if test_key in like_set and test_key not in unlike: + assert tspec["regexp"].search(output_file), ( + f"{run}: should dump {test_name}\n" + f"Review {run} results in {tempdir}" + ) + else: + assert not tspec["regexp"].search(output_file), ( + f"{run}: should not dump {test_name}\n" + f"Review {run} results in {tempdir}" + ) + + node.stop("fast") From 3e54c47ac72f8b89eb5f3ca9c5be3cb0f03eb5fa Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:07 -0400 Subject: [PATCH 09/18] python tests: pytest suite for src/test/recovery --- src/test/recovery/meson.build | 60 + src/test/recovery/pyt/test_001_stream_rep.py | 589 +++++++++ src/test/recovery/pyt/test_002_archiving.py | 162 +++ .../recovery/pyt/test_003_recovery_targets.py | 212 +++ .../recovery/pyt/test_004_timeline_switch.py | 141 ++ .../recovery/pyt/test_005_replay_delay.py | 99 ++ .../recovery/pyt/test_006_logical_decoding.py | 328 +++++ src/test/recovery/pyt/test_007_sync_rep.py | 213 +++ .../recovery/pyt/test_008_fsm_truncation.py | 88 ++ src/test/recovery/pyt/test_009_twophase.py | 544 ++++++++ .../test_010_logical_decoding_timelines.py | 198 +++ .../recovery/pyt/test_012_subtransactions.py | 170 +++ .../recovery/pyt/test_013_crash_restart.py | 237 ++++ .../recovery/pyt/test_014_unlogged_reinit.py | 146 +++ .../recovery/pyt/test_015_promotion_pages.py | 87 ++ .../recovery/pyt/test_016_min_consistency.py | 134 ++ src/test/recovery/pyt/test_017_shm.py | 265 ++++ .../recovery/pyt/test_018_wal_optimize.py | 416 ++++++ .../recovery/pyt/test_019_replslot_limit.py | 577 +++++++++ .../recovery/pyt/test_020_archive_status.py | 272 ++++ .../recovery/pyt/test_021_row_visibility.py | 122 ++ .../recovery/pyt/test_022_crash_temp_files.py | 189 +++ .../pyt/test_023_pitr_prepared_xact.py | 92 ++ .../recovery/pyt/test_024_archive_recovery.py | 109 ++ .../pyt/test_025_stuck_on_old_timeline.py | 123 ++ .../pyt/test_026_overwrite_contrecord.py | 98 ++ .../recovery/pyt/test_027_stream_regress.py | 233 ++++ .../recovery/pyt/test_028_pitr_timelines.py | 182 +++ .../recovery/pyt/test_029_stats_restart.py | 352 +++++ .../pyt/test_030_stats_cleanup_replica.py | 203 +++ .../pyt/test_031_recovery_conflict.py | 346 +++++ .../pyt/test_032_relfilenode_reuse.py | 181 +++ .../recovery/pyt/test_033_replay_tsp_drops.py | 153 +++ .../recovery/pyt/test_034_create_database.py | 40 + .../pyt/test_035_standby_logical_decoding.py | 1108 ++++++++++++++++ .../pyt/test_036_truncated_dropped.py | 108 ++ .../recovery/pyt/test_037_invalid_database.py | 144 +++ .../test_038_save_logical_slots_shutdown.py | 91 ++ src/test/recovery/pyt/test_039_end_of_wal.py | 431 +++++++ .../test_040_standby_failover_slots_sync.py | 1141 +++++++++++++++++ .../pyt/test_041_checkpoint_at_promote.py | 145 +++ .../recovery/pyt/test_042_low_level_backup.py | 130 ++ .../pyt/test_043_no_contrecord_switch.py | 121 ++ .../pyt/test_044_invalidate_inactive_slots.py | 100 ++ .../pyt/test_045_archive_restartpoint.py | 50 + .../pyt/test_046_checkpoint_logical_slot.py | 216 ++++ .../pyt/test_047_checkpoint_physical_slot.py | 122 ++ .../pyt/test_048_vacuum_horizon_floor.py | 317 +++++ .../recovery/pyt/test_049_wait_for_lsn.py | 1022 +++++++++++++++ .../pyt/test_050_redo_segment_missing.py | 114 ++ .../pyt/test_051_effective_wal_level.py | 494 +++++++ .../test_052_checkpoint_segment_missing.py | 64 + .../test_053_standby_login_event_trigger.py | 150 +++ 53 files changed, 13429 insertions(+) create mode 100644 src/test/recovery/pyt/test_001_stream_rep.py create mode 100644 src/test/recovery/pyt/test_002_archiving.py create mode 100644 src/test/recovery/pyt/test_003_recovery_targets.py create mode 100644 src/test/recovery/pyt/test_004_timeline_switch.py create mode 100644 src/test/recovery/pyt/test_005_replay_delay.py create mode 100644 src/test/recovery/pyt/test_006_logical_decoding.py create mode 100644 src/test/recovery/pyt/test_007_sync_rep.py create mode 100644 src/test/recovery/pyt/test_008_fsm_truncation.py create mode 100644 src/test/recovery/pyt/test_009_twophase.py create mode 100644 src/test/recovery/pyt/test_010_logical_decoding_timelines.py create mode 100644 src/test/recovery/pyt/test_012_subtransactions.py create mode 100644 src/test/recovery/pyt/test_013_crash_restart.py create mode 100644 src/test/recovery/pyt/test_014_unlogged_reinit.py create mode 100644 src/test/recovery/pyt/test_015_promotion_pages.py create mode 100644 src/test/recovery/pyt/test_016_min_consistency.py create mode 100644 src/test/recovery/pyt/test_017_shm.py create mode 100644 src/test/recovery/pyt/test_018_wal_optimize.py create mode 100644 src/test/recovery/pyt/test_019_replslot_limit.py create mode 100644 src/test/recovery/pyt/test_020_archive_status.py create mode 100644 src/test/recovery/pyt/test_021_row_visibility.py create mode 100644 src/test/recovery/pyt/test_022_crash_temp_files.py create mode 100644 src/test/recovery/pyt/test_023_pitr_prepared_xact.py create mode 100644 src/test/recovery/pyt/test_024_archive_recovery.py create mode 100644 src/test/recovery/pyt/test_025_stuck_on_old_timeline.py create mode 100644 src/test/recovery/pyt/test_026_overwrite_contrecord.py create mode 100644 src/test/recovery/pyt/test_027_stream_regress.py create mode 100644 src/test/recovery/pyt/test_028_pitr_timelines.py create mode 100644 src/test/recovery/pyt/test_029_stats_restart.py create mode 100644 src/test/recovery/pyt/test_030_stats_cleanup_replica.py create mode 100644 src/test/recovery/pyt/test_031_recovery_conflict.py create mode 100644 src/test/recovery/pyt/test_032_relfilenode_reuse.py create mode 100644 src/test/recovery/pyt/test_033_replay_tsp_drops.py create mode 100644 src/test/recovery/pyt/test_034_create_database.py create mode 100644 src/test/recovery/pyt/test_035_standby_logical_decoding.py create mode 100644 src/test/recovery/pyt/test_036_truncated_dropped.py create mode 100644 src/test/recovery/pyt/test_037_invalid_database.py create mode 100644 src/test/recovery/pyt/test_038_save_logical_slots_shutdown.py create mode 100644 src/test/recovery/pyt/test_039_end_of_wal.py create mode 100644 src/test/recovery/pyt/test_040_standby_failover_slots_sync.py create mode 100644 src/test/recovery/pyt/test_041_checkpoint_at_promote.py create mode 100644 src/test/recovery/pyt/test_042_low_level_backup.py create mode 100644 src/test/recovery/pyt/test_043_no_contrecord_switch.py create mode 100644 src/test/recovery/pyt/test_044_invalidate_inactive_slots.py create mode 100644 src/test/recovery/pyt/test_045_archive_restartpoint.py create mode 100644 src/test/recovery/pyt/test_046_checkpoint_logical_slot.py create mode 100644 src/test/recovery/pyt/test_047_checkpoint_physical_slot.py create mode 100644 src/test/recovery/pyt/test_048_vacuum_horizon_floor.py create mode 100644 src/test/recovery/pyt/test_049_wait_for_lsn.py create mode 100644 src/test/recovery/pyt/test_050_redo_segment_missing.py create mode 100644 src/test/recovery/pyt/test_051_effective_wal_level.py create mode 100644 src/test/recovery/pyt/test_052_checkpoint_segment_missing.py create mode 100644 src/test/recovery/pyt/test_053_standby_login_event_trigger.py diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build index 9eb8ed11425..2d516d6bb98 100644 --- a/src/test/recovery/meson.build +++ b/src/test/recovery/meson.build @@ -64,4 +64,64 @@ tests += { 't/053_standby_login_event_trigger.pl', ], }, + 'pytest': { + 'test_kwargs': {'priority': 40}, # recovery tests are slow, start early + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_stream_rep.py', + 'pyt/test_002_archiving.py', + 'pyt/test_003_recovery_targets.py', + 'pyt/test_004_timeline_switch.py', + 'pyt/test_005_replay_delay.py', + 'pyt/test_006_logical_decoding.py', + 'pyt/test_007_sync_rep.py', + 'pyt/test_008_fsm_truncation.py', + 'pyt/test_009_twophase.py', + 'pyt/test_010_logical_decoding_timelines.py', + 'pyt/test_012_subtransactions.py', + 'pyt/test_013_crash_restart.py', + 'pyt/test_014_unlogged_reinit.py', + 'pyt/test_015_promotion_pages.py', + 'pyt/test_016_min_consistency.py', + 'pyt/test_017_shm.py', + 'pyt/test_018_wal_optimize.py', + 'pyt/test_019_replslot_limit.py', + 'pyt/test_020_archive_status.py', + 'pyt/test_021_row_visibility.py', + 'pyt/test_022_crash_temp_files.py', + 'pyt/test_023_pitr_prepared_xact.py', + 'pyt/test_024_archive_recovery.py', + 'pyt/test_025_stuck_on_old_timeline.py', + 'pyt/test_026_overwrite_contrecord.py', + 'pyt/test_027_stream_regress.py', + 'pyt/test_028_pitr_timelines.py', + 'pyt/test_029_stats_restart.py', + 'pyt/test_030_stats_cleanup_replica.py', + 'pyt/test_031_recovery_conflict.py', + 'pyt/test_032_relfilenode_reuse.py', + 'pyt/test_033_replay_tsp_drops.py', + 'pyt/test_034_create_database.py', + 'pyt/test_035_standby_logical_decoding.py', + 'pyt/test_036_truncated_dropped.py', + 'pyt/test_037_invalid_database.py', + 'pyt/test_038_save_logical_slots_shutdown.py', + 'pyt/test_039_end_of_wal.py', + 'pyt/test_040_standby_failover_slots_sync.py', + 'pyt/test_041_checkpoint_at_promote.py', + 'pyt/test_042_low_level_backup.py', + 'pyt/test_043_no_contrecord_switch.py', + 'pyt/test_044_invalidate_inactive_slots.py', + 'pyt/test_045_archive_restartpoint.py', + 'pyt/test_046_checkpoint_logical_slot.py', + 'pyt/test_047_checkpoint_physical_slot.py', + 'pyt/test_048_vacuum_horizon_floor.py', + 'pyt/test_049_wait_for_lsn.py', + 'pyt/test_050_redo_segment_missing.py', + 'pyt/test_051_effective_wal_level.py', + 'pyt/test_052_checkpoint_segment_missing.py', + 'pyt/test_053_standby_login_event_trigger.py', + ], + }, } diff --git a/src/test/recovery/pyt/test_001_stream_rep.py b/src/test/recovery/pyt/test_001_stream_rep.py new file mode 100644 index 00000000000..19f4ccb4808 --- /dev/null +++ b/src/test/recovery/pyt/test_001_stream_rep.py @@ -0,0 +1,589 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Minimal test testing streaming replication.""" + +import os +import re + +from libpq import Session +from libpq.errors import PqConnectionError + + +def get_slot_xmins(node, slotname, check_expr): + """Fetch (xmin, catalog_xmin) of a slot once *check_expr* holds. + + Polls pg_replication_slots until + *check_expr* is true to reach a quiescent state, then returns the slot's + xmin and catalog_xmin (empty string for NULL, mirroring psql -At). + """ + assert node.poll_query_until( + f"SELECT {check_expr} " + "FROM pg_catalog.pg_replication_slots " + f"WHERE slot_name = '{slotname}'" + ), "Timed out waiting for slot xmins to advance" + + row = node.sql( + "SELECT xmin, catalog_xmin " + "FROM pg_catalog.pg_replication_slots " + f"WHERE slot_name = '{slotname}'" + ).rows[0] + xmin = "" if row[0] is None else row[0] + catalog_xmin = "" if row[1] is None else row[1] + return xmin, catalog_xmin + + +def advance_wal(node, num): + """Advance WAL by *num* segments.""" + for _ in range(num): + node.safe_sql("SELECT pg_logical_emit_message(false, '', 'foo')") + node.safe_sql("SELECT pg_switch_wal()") + + +def check_target_session_attrs(node1, node2, target_node, mode, status): + """Attempt to connect to node1,node2 with target_session_attrs=mode. + + Expect to connect to *target_node* (None for failure) with the given exit + *status* (0 success, 2 connection failure). + """ + connstr = ( + f"host={node1.host},{node2.host} " + f"port={node1.port},{node2.port} " + f"dbname=postgres " + f"target_session_attrs={mode}" + ) + + sess = None + ret = 0 + stdout = None + try: + sess = Session(connstr=connstr, libdir=node1.libdir) + stdout = sess.query_oneval("SHOW port") + except PqConnectionError: + ret = 2 + finally: + if sess is not None: + sess.close() + + if status == 0: + target_port = str(target_node.port) + assert status == ret and stdout == target_port, ( + f'connect to node {target_node.name} with mode "{mode}" and ' + f"{node1.name},{node2.name} listed" + ) + else: + assert status == ret and target_node is None, ( + f'fail to connect with mode "{mode}" and ' + f"{node1.name},{node2.name} listed" + ) + + +def test_001_stream_rep(create_pg): + # Initialize primary node + # + # A specific role is created to perform some tests related to replication, + # and it needs proper authentication configuration. Under the trust auth + # this framework uses, the repl_role role just has to exist, which the SQL + # below ensures. + node_primary = create_pg("primary", allows_streaming=True) + backup_name = "my_backup" + + # Take backup + node_primary.backup(backup_name) + + # Create streaming standby linking to primary + node_standby_1 = create_pg("standby_1", start=False) + node_standby_1.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby_1.start() + + # Take backup of standby 1 (not mandatory, but useful to check if + # pg_basebackup works on a standby). + node_standby_1.backup(backup_name) + + # Take a second backup of the standby while the primary is offline. + node_primary.stop() + node_standby_1.backup("my_backup_2") + node_primary.start() + + # Create second standby node linking to standby 1 + node_standby_2 = create_pg("standby_2", start=False) + node_standby_2.init_from_backup(node_standby_1, backup_name, has_streaming=True) + node_standby_2.start() + + # Reset IO statistics, for the WAL sender check with pg_stat_io. + node_primary.safe_sql("SELECT pg_stat_reset_shared('io')") + + # Create some content on primary and check its presence in standby nodes + node_primary.safe_sql("CREATE TABLE tab_int AS SELECT generate_series(1,1002) AS a") + + node_primary.safe_sql( + """ +CREATE TABLE user_logins(id serial, who text); + +CREATE FUNCTION on_login_proc() RETURNS EVENT_TRIGGER AS $$ +BEGIN + IF NOT pg_is_in_recovery() THEN + INSERT INTO user_logins (who) VALUES (session_user); + END IF; + IF session_user = 'regress_hacker' THEN + RAISE EXCEPTION 'You are not welcome!'; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE EVENT TRIGGER on_login_trigger ON login EXECUTE FUNCTION on_login_proc(); +ALTER EVENT TRIGGER on_login_trigger ENABLE ALWAYS; +""" + ) + + # Wait for standbys to catch up + node_primary.wait_for_replay_catchup(node_standby_1) + node_standby_1.wait_for_replay_catchup(node_standby_2, node_primary) + + result = node_standby_1.safe_sql("SELECT count(*) FROM tab_int") + print(f"standby 1: {result}") + assert result == "1002", "check streamed content on standby 1" + + result = node_standby_2.safe_sql("SELECT count(*) FROM tab_int") + print(f"standby 2: {result}") + assert result == "1002", "check streamed content on standby 2" + + result = node_standby_1.safe_sql( + "SELECT count(*) FROM pg_stat_recovery WHERE promote_triggered IS NOT NULL" + ) + assert result == "1", "check recovery state on standby 1" + + # Likewise, but for a sequence + node_primary.safe_sql("CREATE SEQUENCE seq1") + node_primary.safe_sql("SELECT nextval('seq1')") + + # Wait for standbys to catch up + node_primary.wait_for_replay_catchup(node_standby_1) + node_standby_1.wait_for_replay_catchup(node_standby_2, node_primary) + + result = node_standby_1.safe_sql("SELECT * FROM seq1") + print(f"standby 1: {result}") + assert result == "33|0|t", "check streamed sequence content on standby 1" + + result = node_standby_2.safe_sql("SELECT * FROM seq1") + print(f"standby 2: {result}") + assert result == "33|0|t", "check streamed sequence content on standby 2" + + # Check pg_sequence_last_value() returns NULL for unlogged sequence on standby + node_primary.safe_sql("CREATE UNLOGGED SEQUENCE ulseq") + node_primary.safe_sql("SELECT nextval('ulseq')") + node_primary.wait_for_replay_catchup(node_standby_1) + assert ( + node_standby_1.safe_sql( + "SELECT pg_sequence_last_value('ulseq'::regclass) IS NULL" + ) + == "t" + ), "pg_sequence_last_value() on unlogged sequence on standby 1" + + # Check that only READ-only queries can run on standbys + res = node_standby_1.sql("INSERT INTO tab_int VALUES (1)") + assert res.error_message is not None, "read-only queries on standby 1" + res = node_standby_2.sql("INSERT INTO tab_int VALUES (1)") + assert res.error_message is not None, "read-only queries on standby 2" + + # Tests for connection parameter target_session_attrs + print('# testing connection parameter "target_session_attrs"') + + # Connect to primary in "read-write" mode with primary,standby1 list. + check_target_session_attrs( + node_primary, node_standby_1, node_primary, "read-write", 0 + ) + + # Connect to primary in "read-write" mode with standby1,primary list. + check_target_session_attrs( + node_standby_1, node_primary, node_primary, "read-write", 0 + ) + + # Connect to primary in "any" mode with primary,standby1 list. + check_target_session_attrs(node_primary, node_standby_1, node_primary, "any", 0) + + # Connect to standby1 in "any" mode with standby1,primary list. + check_target_session_attrs(node_standby_1, node_primary, node_standby_1, "any", 0) + + # Connect to primary in "primary" mode with primary,standby1 list. + check_target_session_attrs(node_primary, node_standby_1, node_primary, "primary", 0) + + # Connect to primary in "primary" mode with standby1,primary list. + check_target_session_attrs(node_standby_1, node_primary, node_primary, "primary", 0) + + # Connect to standby1 in "read-only" mode with primary,standby1 list. + check_target_session_attrs( + node_primary, node_standby_1, node_standby_1, "read-only", 0 + ) + + # Connect to standby1 in "read-only" mode with standby1,primary list. + check_target_session_attrs( + node_standby_1, node_primary, node_standby_1, "read-only", 0 + ) + + # Connect to primary in "prefer-standby" mode with primary,primary list. + check_target_session_attrs( + node_primary, node_primary, node_primary, "prefer-standby", 0 + ) + + # Connect to standby1 in "prefer-standby" mode with primary,standby1 list. + check_target_session_attrs( + node_primary, node_standby_1, node_standby_1, "prefer-standby", 0 + ) + + # Connect to standby1 in "prefer-standby" mode with standby1,primary list. + check_target_session_attrs( + node_standby_1, node_primary, node_standby_1, "prefer-standby", 0 + ) + + # Connect to standby1 in "standby" mode with primary,standby1 list. + check_target_session_attrs( + node_primary, node_standby_1, node_standby_1, "standby", 0 + ) + + # Connect to standby1 in "standby" mode with standby1,primary list. + check_target_session_attrs( + node_standby_1, node_primary, node_standby_1, "standby", 0 + ) + + # Fail to connect in "read-write" mode with standby1,standby2 list. + check_target_session_attrs(node_standby_1, node_standby_2, None, "read-write", 2) + + # Fail to connect in "primary" mode with standby1,standby2 list. + check_target_session_attrs(node_standby_1, node_standby_2, None, "primary", 2) + + # Fail to connect in "read-only" mode with primary,primary list. + check_target_session_attrs(node_primary, node_primary, None, "read-only", 2) + + # Fail to connect in "standby" mode with primary,primary list. + check_target_session_attrs(node_primary, node_primary, None, "standby", 2) + + # Test for SHOW commands using a WAL sender connection with a replication + # role. + print("# testing SHOW commands for replication connection") + + node_primary.safe_sql( + """ +CREATE ROLE repl_role REPLICATION LOGIN; +GRANT pg_read_all_settings TO repl_role;""" + ) + primary_host = node_primary.host + primary_port = node_primary.port + connstr_common = f"host={primary_host} port={primary_port} user=repl_role" + connstr_rep = f"{connstr_common} replication=1" + connstr_db = f"{connstr_common} replication=database dbname=postgres" + + # Test SHOW ALL + with Session(connstr=connstr_rep, libdir=node_primary.libdir) as sess: + res = sess.query("SHOW ALL;") + assert ( + res.error_message is None + ), "SHOW ALL with replication role and physical replication" + with Session(connstr=connstr_db, libdir=node_primary.libdir) as sess: + res = sess.query("SHOW ALL;") + assert ( + res.error_message is None + ), "SHOW ALL with replication role and logical replication" + + # Test SHOW with a user-settable parameter + with Session(connstr=connstr_rep, libdir=node_primary.libdir) as sess: + res = sess.query("SHOW work_mem;") + assert res.error_message is None, ( + "SHOW with user-settable parameter, replication role and " + "physical replication" + ) + with Session(connstr=connstr_db, libdir=node_primary.libdir) as sess: + res = sess.query("SHOW work_mem;") + assert res.error_message is None, ( + "SHOW with user-settable parameter, replication role and " + "logical replication" + ) + + # Test SHOW with a superuser-settable parameter + with Session(connstr=connstr_rep, libdir=node_primary.libdir) as sess: + res = sess.query("SHOW primary_conninfo;") + assert res.error_message is None, ( + "SHOW with superuser-settable parameter, replication role and " + "physical replication" + ) + with Session(connstr=connstr_db, libdir=node_primary.libdir) as sess: + res = sess.query("SHOW primary_conninfo;") + assert res.error_message is None, ( + "SHOW with superuser-settable parameter, replication role and " + "logical replication" + ) + + print("# testing READ_REPLICATION_SLOT command for replication connection") + + slotname = "test_read_replication_slot_physical" + + with Session(connstr=connstr_rep, libdir=node_primary.libdir) as sess: + res = sess.query("READ_REPLICATION_SLOT non_existent_slot;") + assert res.error_message is None, "READ_REPLICATION_SLOT exit code 0 on success" + assert re.search( + r"^\|\|$", res.psqlout + ), "READ_REPLICATION_SLOT returns NULL values if slot does not exist" + + with Session(connstr=connstr_rep, libdir=node_primary.libdir) as sess: + sess.query(f"CREATE_REPLICATION_SLOT {slotname} PHYSICAL RESERVE_WAL;") + + with Session(connstr=connstr_rep, libdir=node_primary.libdir) as sess: + res = sess.query(f"READ_REPLICATION_SLOT {slotname};") + assert ( + res.error_message is None + ), "READ_REPLICATION_SLOT success with existing slot" + assert re.search( + r"^physical\|[^|]*\|1$", res.psqlout + ), "READ_REPLICATION_SLOT returns tuple with slot information" + + with Session(connstr=connstr_rep, libdir=node_primary.libdir) as sess: + sess.query(f"DROP_REPLICATION_SLOT {slotname};") + + print("# switching to physical replication slot") + + # Wait for the physical WAL sender to update its IO statistics. This is + # done before the next restart, which would force a flush of its stats, and + # far enough from the reset done above to not impact the run time. + assert node_primary.poll_query_until( + "SELECT sum(reads) > 0 " + "FROM pg_catalog.pg_stat_io " + "WHERE backend_type = 'walsender' " + "AND object = 'wal'" + ), "Timed out while waiting for the walsender to update its IO statistics" + + # Switch to using a physical replication slot. We can do this without a new + # backup since physical slots can go backwards if needed. Do so on both + # standbys. Since we're going to be testing things that affect the slot + # state, also increase the standby feedback interval to ensure timely + # updates. + slotname_1, slotname_2 = "standby_1", "standby_2" + node_primary.append_conf("max_replication_slots = 4") + node_primary.restart() + res = node_primary.sql( + f"SELECT pg_create_physical_replication_slot('{slotname_1}')" + ) + assert res.error_message is None, "physical slot created on primary" + node_standby_1.append_conf(f"primary_slot_name = {slotname_1}") + node_standby_1.append_conf("wal_receiver_status_interval = 1") + node_standby_1.append_conf("max_replication_slots = 4") + node_standby_1.restart() + res = node_standby_1.sql( + f"SELECT pg_create_physical_replication_slot('{slotname_2}')" + ) + assert res.error_message is None, "physical slot created on intermediate replica" + node_standby_2.append_conf(f"primary_slot_name = {slotname_2}") + node_standby_2.append_conf("wal_receiver_status_interval = 1") + # should be able change primary_slot_name without restart + # will wait effect in get_slot_xmins above + node_standby_2.reload() + + # There's no hot standby feedback and there are no logical slots on either + # peer so xmin and catalog_xmin should be null on both slots. + xmin, catalog_xmin = get_slot_xmins( + node_primary, slotname_1, "xmin IS NULL AND catalog_xmin IS NULL" + ) + assert xmin == "", "xmin of non-cascaded slot null with no hs_feedback" + assert ( + catalog_xmin == "" + ), "catalog xmin of non-cascaded slot null with no hs_feedback" + + xmin, catalog_xmin = get_slot_xmins( + node_standby_1, slotname_2, "xmin IS NULL AND catalog_xmin IS NULL" + ) + assert xmin == "", "xmin of cascaded slot null with no hs_feedback" + assert catalog_xmin == "", "catalog xmin of cascaded slot null with no hs_feedback" + + # Replication still works? + node_primary.safe_sql("CREATE TABLE replayed(val integer);") + + def replay_check(): + newval = node_primary.safe_sql( + "INSERT INTO replayed(val) SELECT coalesce(max(val),0) + 1 " + "AS newval FROM replayed RETURNING val" + ) + node_primary.wait_for_replay_catchup(node_standby_1) + node_standby_1.wait_for_replay_catchup(node_standby_2, node_primary) + + assert ( + node_standby_1.safe_sql(f"SELECT 1 FROM replayed WHERE val = {newval}") + == "1" + ), f"standby_1 didn't replay primary value {newval}" + assert ( + node_standby_2.safe_sql(f"SELECT 1 FROM replayed WHERE val = {newval}") + == "1" + ), f"standby_2 didn't replay standby_1 value {newval}" + + replay_check() + + evttrig = node_standby_1.safe_sql( + "SELECT evtname FROM pg_event_trigger WHERE evtevent = 'login'" + ) + assert evttrig == "on_login_trigger", "Name of login trigger" + evttrig = node_standby_2.safe_sql( + "SELECT evtname FROM pg_event_trigger WHERE evtevent = 'login'" + ) + assert evttrig == "on_login_trigger", "Name of login trigger" + + print("# enabling hot_standby_feedback") + + # Enable hs_feedback. The slot should gain an xmin. We set the status + # interval so we'll see the results promptly. + node_standby_1.safe_sql("ALTER SYSTEM SET hot_standby_feedback = on;") + node_standby_1.reload() + node_standby_2.safe_sql("ALTER SYSTEM SET hot_standby_feedback = on;") + node_standby_2.reload() + replay_check() + + xmin, catalog_xmin = get_slot_xmins( + node_primary, slotname_1, "xmin IS NOT NULL AND catalog_xmin IS NULL" + ) + assert xmin != "", "xmin of non-cascaded slot non-null with hs feedback" + assert ( + catalog_xmin == "" + ), "catalog xmin of non-cascaded slot still null with hs_feedback" + + xmin1, catalog_xmin1 = get_slot_xmins( + node_standby_1, slotname_2, "xmin IS NOT NULL AND catalog_xmin IS NULL" + ) + assert xmin1 != "", "xmin of cascaded slot non-null with hs feedback" + assert ( + catalog_xmin1 == "" + ), "catalog xmin of cascaded slot still null with hs_feedback" + + print("# doing some work to advance xmin") + node_primary.safe_sql( + """ +do $$ +begin + for i in 10000..11000 loop + -- use an exception block so that each iteration eats an XID + begin + insert into tab_int values (i); + exception + when division_by_zero then null; + end; + end loop; +end$$; +""" + ) + + node_primary.safe_sql("VACUUM;") + node_primary.safe_sql("CHECKPOINT;") + + xmin2, catalog_xmin2 = get_slot_xmins(node_primary, slotname_1, f"xmin <> '{xmin}'") + print(f"primary slot's new xmin {xmin2}, old xmin {xmin}") + assert xmin2 != xmin, "xmin of non-cascaded slot with hs feedback has changed" + assert ( + catalog_xmin2 == "" + ), "catalog xmin of non-cascaded slot still null with hs_feedback unchanged" + + xmin2, catalog_xmin2 = get_slot_xmins( + node_standby_1, slotname_2, f"xmin <> '{xmin1}'" + ) + print(f"standby_1 slot's new xmin {xmin2}, old xmin {xmin1}") + assert xmin2 != xmin1, "xmin of cascaded slot with hs feedback has changed" + assert ( + catalog_xmin2 == "" + ), "catalog xmin of cascaded slot still null with hs_feedback unchanged" + + print("# disabling hot_standby_feedback") + + # Disable hs_feedback. Xmin should be cleared. + node_standby_1.safe_sql("ALTER SYSTEM SET hot_standby_feedback = off;") + node_standby_1.reload() + node_standby_2.safe_sql("ALTER SYSTEM SET hot_standby_feedback = off;") + node_standby_2.reload() + replay_check() + + xmin, catalog_xmin = get_slot_xmins( + node_primary, slotname_1, "xmin IS NULL AND catalog_xmin IS NULL" + ) + assert xmin == "", "xmin of non-cascaded slot null with hs feedback reset" + assert ( + catalog_xmin == "" + ), "catalog xmin of non-cascaded slot still null with hs_feedback reset" + + xmin, catalog_xmin = get_slot_xmins( + node_standby_1, slotname_2, "xmin IS NULL AND catalog_xmin IS NULL" + ) + assert xmin == "", "xmin of cascaded slot null with hs feedback reset" + assert ( + catalog_xmin == "" + ), "catalog xmin of cascaded slot still null with hs_feedback reset" + + print("# check change primary_conninfo without restart") + node_standby_2.append_conf("primary_slot_name = ''") + node_standby_2.enable_streaming(node_primary) + node_standby_2.reload() + + # The WAL receiver should have generated some IO statistics. + stats_reads = node_standby_1.safe_sql( + "SELECT sum(writes) > 0 FROM pg_stat_io " + "WHERE backend_type = 'walreceiver' AND object = 'wal'" + ) + assert stats_reads == "t", "WAL receiver generates statistics for WAL writes" + + # be sure do not streaming from cascade + node_standby_1.stop() + + newval = node_primary.safe_sql( + "INSERT INTO replayed(val) SELECT coalesce(max(val),0) + 1 " + "AS newval FROM replayed RETURNING val" + ) + node_primary.wait_for_catchup(node_standby_2) + is_replayed = node_standby_2.safe_sql( + f"SELECT 1 FROM replayed WHERE val = {newval}" + ) + assert is_replayed == "1", f"standby_2 didn't replay primary value {newval}" + + # Drop any existing slots on the primary, for the follow-up tests. + node_primary.safe_sql( + "SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots;" + ) + + # Test physical slot advancing and its durability. Create a new slot on + # the primary, not used by any of the standbys. This reserves WAL at + # creation. + phys_slot = "phys_slot" + node_primary.safe_sql( + f"SELECT pg_create_physical_replication_slot('{phys_slot}', true);" + ) + # Generate some WAL, and switch to a new segment, used to check that + # the previous segment is correctly getting recycled as the slot advancing + # would recompute the minimum LSN calculated across all slots. + segment_removed = node_primary.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_lsn())" + ) + advance_wal(node_primary, 1) + current_lsn = node_primary.safe_sql("SELECT pg_current_wal_lsn();") + res = node_primary.sql( + f"SELECT pg_replication_slot_advance('{phys_slot}', " + f"'{current_lsn}'::pg_lsn);" + ) + assert res.error_message is None, "slot advancing with physical slot" + phys_restart_lsn_pre = node_primary.safe_sql( + "SELECT restart_lsn from pg_replication_slots " + f"WHERE slot_name = '{phys_slot}';" + ) + # Slot advance should persist across clean restarts. + node_primary.restart() + phys_restart_lsn_post = node_primary.safe_sql( + "SELECT restart_lsn from pg_replication_slots " + f"WHERE slot_name = '{phys_slot}';" + ) + assert ( + phys_restart_lsn_pre == phys_restart_lsn_post + ), "physical slot advance persists across restarts" + + # Check if the previous segment gets correctly recycled after the + # server stopped cleanly, causing a shutdown checkpoint to be generated. + primary_data = node_primary.data_dir + assert not os.path.isfile( + os.path.join(primary_data, "pg_wal", segment_removed) + ), f"WAL segment {segment_removed} recycled after physical slot advancing" + + # NOTE: the final "pg_backup_start() followed by BASE_BACKUP" and + # "BASE_BACKUP cancellation" checks are not implemented here. They drive + # the replication-protocol BASE_BACKUP command and consume/cancel its + # COPY_OUT stream, which needs libpq COPY-streaming support + # (PQgetCopyData) that the in-process Session does not provide yet. This + # section should be added once that framework support exists. diff --git a/src/test/recovery/pyt/test_002_archiving.py b/src/test/recovery/pyt/test_002_archiving.py new file mode 100644 index 00000000000..f0636c4b385 --- /dev/null +++ b/src/test/recovery/pyt/test_002_archiving.py @@ -0,0 +1,162 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test for archiving with hot standby.""" + +import os +import re + +from pypg.util import slurp_file + + +def test_002_archiving(create_pg): + # Initialize primary node, doing archives + node_primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + backup_name = "my_backup" + + # Start it + node_primary.start() + + # Take backup for standby + node_primary.backup(backup_name) + + # Initialize standby node from backup, fetching WAL from archives + node_standby = create_pg("standby", start=False) + # Note that this makes the standby store its contents on the archives + # of the primary. + node_standby.init_from_backup(node_primary, backup_name, has_restoring=True) + node_standby.append_conf("wal_retrieve_retry_interval = '100ms'") + + # Set archive_cleanup_command and recovery_end_command, checking their + # execution by the backend with dummy commands. + data_dir = node_standby.data_dir + archive_cleanup_command_file = "archive_cleanup_command.done" + recovery_end_command_file = "recovery_end_command.done" + node_standby.append_conf( + f""" +archive_cleanup_command = 'echo archive_cleanup_done > {archive_cleanup_command_file}' +recovery_end_command = 'echo recovery_ended_done > {recovery_end_command_file}' +""" + ) + node_standby.start() + + # Create some content on primary + node_primary.safe_sql("CREATE TABLE tab_int AS SELECT generate_series(1,1000) AS a") + + # Note the presence of this checkpoint for the archive_cleanup_command + # check done below, before switching to a new segment. + node_primary.safe_sql("CHECKPOINT") + + # Done after the checkpoint to ensure that it is replayed on the standby, + # for archive_cleanup_command. + current_lsn = node_primary.safe_sql("SELECT pg_current_wal_lsn();") + + # Force archiving of WAL file to make it present on primary + node_primary.safe_sql("SELECT pg_switch_wal()") + + # Add some more content, it should not be present on standby + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(1001,2000))") + + # Wait until necessary replay has been done on standby + caughtup_query = f"SELECT '{current_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + assert node_standby.poll_query_until( + caughtup_query + ), "Timed out while waiting for standby to catch up" + + result = node_standby.safe_sql("SELECT count(*) FROM tab_int") + assert result == "1000", "check content from archives" + + # archive_cleanup_command is executed after generating a restart point, + # with a checkpoint. + node_standby.safe_sql("CHECKPOINT") + assert os.path.isfile( + os.path.join(data_dir, archive_cleanup_command_file) + ), "archive_cleanup_command executed on checkpoint" + assert not os.path.isfile( + os.path.join(data_dir, recovery_end_command_file) + ), "recovery_end_command not executed yet" + + # Check the presence of temporary files specifically generated during + # archive recovery. To ensure the presence of the temporary history + # file, switch to a timeline large enough to allow a standby to recover + # a history file from an archive. As this requires at least two timeline + # switches, promote the existing standby first. Then create a second + # standby based on the primary, using its archives. Finally, the second + # standby is promoted. + node_standby.promote() + + # Wait until the history file has been stored on the archives of the + # primary once the promotion of the standby completes. This ensures that + # the second standby created below will be able to restore this file, + # creating a RECOVERYHISTORY. + primary_archive = node_primary.archive_dir + caughtup_query = ( + "SELECT size IS NOT NULL FROM " + f"pg_stat_file('{primary_archive}/00000002.history', true)" + ) + assert node_primary.poll_query_until( + caughtup_query + ), "Timed out while waiting for archiving of 00000002.history" + + # recovery_end_command should have been triggered on promotion. + assert os.path.isfile( + os.path.join(data_dir, recovery_end_command_file) + ), "recovery_end_command executed after promotion" + + node_standby2 = create_pg("standby2", start=False) + node_standby2.init_from_backup(node_primary, backup_name, has_restoring=True) + + # Make execution of recovery_end_command fail. This should not affect + # promotion, and its failure should be logged. + node_standby2.append_conf( + """ +recovery_end_command = 'echo recovery_end_failed > missing_dir/xyz.file' +""" + ) + + # Create recovery.signal and confirm that both signal files exist. + # This is necessary to test how recovery behaves when both files are present, + # i.e., standby.signal should take precedence and both files should be + # removed at the end of recovery. + node_standby2.set_recovery_mode() + node_standby2_data = node_standby2.data_dir + assert os.path.isfile( + os.path.join(node_standby2_data, "recovery.signal") + ), "recovery.signal is present at the beginning of recovery" + assert os.path.isfile( + os.path.join(node_standby2_data, "standby.signal") + ), "standby.signal is present at the beginning of recovery" + + node_standby2.start() + + # Save the log location, to see the failure of recovery_end_command. + log_location = node_standby2.log_position() + + # Now promote standby2, and check that temporary files specifically + # generated during archive recovery are removed by the end of recovery. + node_standby2.promote() + + # Check the logs of the standby to see that the commands have failed. + log_contents = slurp_file(node_standby2.logfile, log_location) + + assert ( + 'restored log file "00000002.history" from archive' in log_contents + ), "00000002.history retrieved from the archives" + assert not os.path.isfile( + os.path.join(node_standby2_data, "pg_wal", "RECOVERYHISTORY") + ), "RECOVERYHISTORY removed after promotion" + assert not os.path.isfile( + os.path.join(node_standby2_data, "pg_wal", "RECOVERYXLOG") + ), "RECOVERYXLOG removed after promotion" + assert re.search( + r"WARNING:.*recovery_end_command", log_contents, re.S + ), "recovery_end_command failure detected in logs after promotion" + + # Check that no signal files are present after promotion. + assert not os.path.isfile( + os.path.join(node_standby2_data, "recovery.signal") + ), "recovery.signal was left behind after promotion" + assert not os.path.isfile( + os.path.join(node_standby2_data, "standby.signal") + ), "standby.signal was left behind after promotion" diff --git a/src/test/recovery/pyt/test_003_recovery_targets.py b/src/test/recovery/pyt/test_003_recovery_targets.py new file mode 100644 index 00000000000..a74cabe88aa --- /dev/null +++ b/src/test/recovery/pyt/test_003_recovery_targets.py @@ -0,0 +1,212 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test for recovery targets: name, timestamp, XID.""" + +import os +import re + +from pypg.util import TIMEOUT_DEFAULT, poll_until, slurp_file + + +def test_003_recovery_targets(create_pg): + # Create and test a standby from given backup, with a certain recovery + # target. Choose until_lsn later than the transaction commit that causes + # the row count to reach num_rows, yet not later than the recovery target. + def test_recovery_standby( + test_name, node_name, node_primary, recovery_params, num_rows, until_lsn + ): + node_standby = create_pg(node_name, start=False) + node_standby.init_from_backup(node_primary, "my_backup", has_restoring=True) + + for param_item in recovery_params: + node_standby.append_conf(param_item) + + node_standby.start() + + # Wait until standby has replayed enough data + caughtup_query = f"SELECT '{until_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + assert node_standby.poll_query_until( + caughtup_query + ), "Timed out while waiting for standby to catch up" + + # Create some content on primary and check its presence in standby + result = node_standby.safe_sql("SELECT count(*) FROM tab_int") + assert result == str(num_rows), f"check standby content for {test_name}" + + # Stop standby node + node_standby.stop() + + # Initialize primary node + node_primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + + # Bump the transaction ID epoch. This is useful to stress the portability + # of recovery_target_xid parsing. + node_primary.pg_bin.command_ok( + ["pg_resetwal", "--epoch", "1", node_primary.data_dir] + ) + + # Start it + node_primary.start() + + # Create data before taking the backup, aimed at testing + # recovery_target = 'immediate' + node_primary.safe_sql("CREATE TABLE tab_int AS SELECT generate_series(1,1000) AS a") + lsn1 = node_primary.safe_sql("SELECT pg_current_wal_lsn();") + + # Take backup from which all operations will be run + node_primary.backup("my_backup") + + # Insert some data with used as a replay reference, with a recovery + # target TXID. + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(1001,2000))") + ret = node_primary.safe_sql("SELECT pg_current_wal_lsn(), pg_current_xact_id();") + lsn2, recovery_txid = ret.split("|") + + # More data, with recovery target timestamp + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(2001,3000))") + lsn3 = node_primary.safe_sql("SELECT pg_current_wal_lsn();") + recovery_time = node_primary.safe_sql("SELECT now()") + + # Even more data, this time with a recovery target name + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(3001,4000))") + recovery_name = "my_target" + lsn4 = node_primary.safe_sql("SELECT pg_current_wal_lsn();") + node_primary.safe_sql(f"SELECT pg_create_restore_point('{recovery_name}');") + + # And now for a recovery target LSN + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(4001,5000))") + lsn5 = recovery_lsn = node_primary.safe_sql("SELECT pg_current_wal_lsn()") + + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(5001,6000))") + + # Force archiving of WAL file + node_primary.safe_sql("SELECT pg_switch_wal()") + + # Test recovery targets + recovery_params = ["recovery_target = 'immediate'"] + test_recovery_standby( + "immediate target", "standby_1", node_primary, recovery_params, "1000", lsn1 + ) + recovery_params = [f"recovery_target_xid = '{recovery_txid}'"] + test_recovery_standby( + "XID", "standby_2", node_primary, recovery_params, "2000", lsn2 + ) + recovery_params = [f"recovery_target_time = '{recovery_time}'"] + test_recovery_standby( + "time", "standby_3", node_primary, recovery_params, "3000", lsn3 + ) + recovery_params = [f"recovery_target_name = '{recovery_name}'"] + test_recovery_standby( + "name", "standby_4", node_primary, recovery_params, "4000", lsn4 + ) + recovery_params = [f"recovery_target_lsn = '{recovery_lsn}'"] + test_recovery_standby( + "LSN", "standby_5", node_primary, recovery_params, "5000", lsn5 + ) + + # Multiple targets + # + # Multiple conflicting settings are not allowed, but setting the same + # parameter multiple times or unsetting a parameter and setting a + # different one is allowed. + recovery_params = [ + f"recovery_target_name = '{recovery_name}'", + "recovery_target_name = ''", + f"recovery_target_time = '{recovery_time}'", + ] + test_recovery_standby( + "multiple overriding settings", + "standby_6", + node_primary, + recovery_params, + "3000", + lsn3, + ) + + node_standby = create_pg("standby_7", start=False) + node_standby.init_from_backup(node_primary, "my_backup", has_restoring=True) + node_standby.append_conf( + f"recovery_target_name = '{recovery_name}'\n" + f"recovery_target_time = '{recovery_time}'" + ) + + # Start the server directly with pg_ctl (no -w wait) because this start is + # expected to fail, so a waited start would never report success. + res = node_standby.pg_bin.result( + [ + "pg_ctl", + "--pgdata", + node_standby.data_dir, + "--log", + node_standby.logfile, + "start", + ] + ) + assert res.returncode != 0, "invalid recovery startup fails" + + logfile = slurp_file(node_standby.logfile) + assert re.search( + r"multiple recovery targets specified", logfile + ), "multiple conflicting settings" + + # Check behavior when recovery ends before target is reached + node_standby = create_pg("standby_8", start=False) + node_standby.init_from_backup( + node_primary, "my_backup", has_restoring=True, standby=False + ) + node_standby.append_conf("recovery_target_name = 'does_not_exist'") + + node_standby.pg_bin.result( + [ + "pg_ctl", + "--pgdata", + node_standby.data_dir, + "--log", + node_standby.logfile, + "start", + ] + ) + + # wait for postgres to terminate + pidfile = os.path.join(node_standby.data_dir, "postmaster.pid") + poll_until(lambda: not os.path.isfile(pidfile), timeout=TIMEOUT_DEFAULT) + + logfile = slurp_file(node_standby.logfile) + assert re.search( + "FATAL: .* recovery ended before configured recovery target was reached", + logfile, + ), "recovery end before target reached is a fatal error" + + # Invalid recovery_target_timeline tests + res = node_primary.sql("ALTER SYSTEM SET recovery_target_timeline TO 'bogus'") + assert re.search( + r"is not a valid number", res.error_message or "" + ), "invalid recovery_target_timeline (bogus value)" + + res = node_primary.sql("ALTER SYSTEM SET recovery_target_timeline TO '0'") + assert re.search( + r"must be between 1 and 4294967295", res.error_message or "" + ), "invalid recovery_target_timeline (lower bound check)" + + res = node_primary.sql("ALTER SYSTEM SET recovery_target_timeline TO '4294967296'") + assert re.search( + r"must be between 1 and 4294967295", res.error_message or "" + ), "invalid recovery_target_timeline (upper bound check)" + + # Invalid recovery_target_xid tests + res = node_primary.sql("ALTER SYSTEM SET recovery_target_xid TO 'bogus'") + assert re.search( + r"is not a valid number", res.error_message or "" + ), "invalid recovery_target_xid (bogus value)" + + res = node_primary.sql("ALTER SYSTEM SET recovery_target_xid TO '-1'") + assert re.search( + r"is not a valid number", res.error_message or "" + ), "invalid recovery_target_xid (negative)" + + res = node_primary.sql("ALTER SYSTEM SET recovery_target_xid TO '0'") + assert re.search( + r"without epoch must be greater than or equal to 3", res.error_message or "" + ), "invalid recovery_target_xid (lower bound check)" diff --git a/src/test/recovery/pyt/test_004_timeline_switch.py b/src/test/recovery/pyt/test_004_timeline_switch.py new file mode 100644 index 00000000000..a656d28efbb --- /dev/null +++ b/src/test/recovery/pyt/test_004_timeline_switch.py @@ -0,0 +1,141 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test for timeline switch. + +Ensure that a cascading standby is able to follow a newly-promoted standby +on a new timeline. +""" + + +def test_004_timeline_switch(create_pg): + # Initialize primary node + node_primary = create_pg("primary", allows_streaming=True) + + # Take backup + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Create two standbys linking to it + node_standby_1 = create_pg("standby_1", start=False) + node_standby_1.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby_1.start() + node_standby_2 = create_pg("standby_2", start=False) + node_standby_2.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby_2.start() + + # Create some content on primary + node_primary.safe_sql("CREATE TABLE tab_int AS SELECT generate_series(1,1000) AS a") + + # Cleanly stop and remove primary. A clean stop is required so as all + # the records generated on the primary are received and flushed by the two + # standbys. + node_primary.stop() + + # promote standby 1 using "pg_promote", switching it to a new timeline + result = node_standby_1.sql("SELECT pg_promote(wait_seconds => 300)") + assert result.psqlout == "t", "promotion of standby with pg_promote" + + # Switch standby 2 to replay from standby 1. During the timeline switch, + # the WAL receiver process on standby 2 should not be stopped, and the + # new primary connection string should not be visible + # in pg_stat_wal_receiver. + secret = "dont_show_me" + # PostgresServer.connstr() returns a value with embedded single quotes and + # a dbname, which cannot be wrapped inside a single-quoted primary_conninfo + # GUC. Build the conninfo from the node's host/port directly, as an + # unquoted key=value string. + # Include application_name so wait_for_catchup can find this standby in + # pg_stat_replication on the newly-promoted node_standby_1. + connstr_1 = f"host={node_standby_1.host} port={node_standby_1.port}" + node_standby_2.append_conf( + f""" +primary_conninfo='{connstr_1} password={secret} application_name={node_standby_2.name}' +""" + ) + + # Rotate logfile before restarting, for the log checks done below. + # The framework uses a single log file, so capture the current log + # position to use as an offset for the post-restart log checks. + log_offset = node_standby_2.log_position() + node_standby_2.restart() + + # Wait for walreceiver to reconnect after the restart. We want to + # verify that after reconnection, the walreceiver stays alive during + # the timeline switch. + assert node_standby_2.poll_query_until( + "SELECT EXISTS(SELECT 1 FROM pg_stat_wal_receiver)" + ) + wr_pid_before_switch = node_standby_2.safe_sql( + "SELECT pid FROM pg_stat_wal_receiver" + ) + + # Insert some data in standby 1 and check its presence in standby 2 + # to ensure that the timeline switch has been done. + node_standby_1.safe_sql("INSERT INTO tab_int VALUES (generate_series(1001,2000))") + node_standby_1.wait_for_catchup(node_standby_2) + + result = node_standby_2.safe_sql("SELECT count(*) FROM tab_int") + assert result == "2000", "check content of standby 2" + + # Check the logs, WAL receiver should not have been stopped while + # transitioning to its new timeline. There is no need to rely on an + # offset in this check of the server logs: a new log file is used on + # node restart when primary_conninfo is updated above. + assert not node_standby_2.log_contains( + "FATAL: .* terminating walreceiver process due to administrator command", + offset=log_offset, + ), "WAL receiver should not be stopped across timeline jumps" + + # Verify that the walreceiver process stayed alive across the timeline + # switch, check its PID. + wr_pid_after_switch = node_standby_2.safe_sql( + "SELECT pid FROM pg_stat_wal_receiver" + ) + + assert ( + wr_pid_before_switch == wr_pid_after_switch + ), "WAL receiver PID matches across timeline jumps" + + raw_conninfo_count = node_standby_2.safe_sql( + f"SELECT count(*) FROM pg_stat_wal_receiver WHERE conninfo LIKE '%{secret}%'" + ) + + assert ( + raw_conninfo_count == "0" + ), "pg_stat_wal_receiver.conninfo not updated across timeline jumps" + + # Ensure that a standby is able to follow a primary on a newer timeline + # when WAL archiving is enabled. + + # Initialize primary node + node_primary_2 = create_pg( + "primary_2", start=False, allows_streaming=True, has_archiving=True + ) + node_primary_2.append_conf( + """ +wal_keep_size = 512MB +""" + ) + node_primary_2.start() + + # Take backup + node_primary_2.backup(backup_name) + + # Create standby node + node_standby_3 = create_pg("standby_3", start=False) + node_standby_3.init_from_backup(node_primary_2, backup_name, has_streaming=True) + + # Restart primary node in standby mode and promote it, switching it + # to a new timeline. + node_primary_2.set_standby_mode() + node_primary_2.restart() + node_primary_2.promote() + + # Start standby node, create some content on primary and check its presence + # in standby, to ensure that the timeline switch has been done. + node_standby_3.start() + node_primary_2.safe_sql("CREATE TABLE tab_int AS SELECT 1 AS a") + node_primary_2.wait_for_catchup(node_standby_3) + + result_2 = node_standby_3.safe_sql("SELECT count(*) FROM tab_int") + assert result_2 == "1", "check content of standby 3" diff --git a/src/test/recovery/pyt/test_005_replay_delay.py b/src/test/recovery/pyt/test_005_replay_delay.py new file mode 100644 index 00000000000..60204f57bf4 --- /dev/null +++ b/src/test/recovery/pyt/test_005_replay_delay.py @@ -0,0 +1,99 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Checks for recovery_min_apply_delay and recovery pause.""" + +import time + + +def test_005_replay_delay(create_pg): + # Initialize primary node + node_primary = create_pg("primary", allows_streaming=True) + + # And some content + node_primary.safe_sql("CREATE TABLE tab_int AS SELECT generate_series(1, 10) AS a") + + # Take backup + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Create streaming standby from backup + node_standby = create_pg("standby", start=False) + delay = 3 + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.append_conf( + f""" +recovery_min_apply_delay = '{delay}s' +""" + ) + node_standby.start() + + # Make new content on primary and check its presence in standby depending + # on the delay applied above. Before doing the insertion, get the + # current timestamp that will be used as a comparison base. Even on slow + # machines, this allows to have a predictable behavior when comparing the + # delay between data insertion moment on primary and replay time on standby. + primary_insert_time = time.time() + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(11, 20))") + + # Now wait for replay to complete on standby. We're done waiting when the + # standby has replayed up to the previously saved primary LSN. + until_lsn = node_primary.safe_sql("SELECT pg_current_wal_lsn()") + + assert node_standby.poll_query_until( + f"SELECT (pg_last_wal_replay_lsn() - '{until_lsn}'::pg_lsn) >= 0" + ), "standby never caught up" + + # This test is successful if and only if the LSN has been applied with at + # least the configured apply delay. + assert ( + time.time() - primary_insert_time >= delay + ), "standby applies WAL only after replication delay" + + # Check that recovery can be paused or resumed expectedly. + node_standby2 = create_pg("standby2", start=False) + node_standby2.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby2.start() + + # Recovery is not yet paused. + assert ( + node_standby2.safe_sql("SELECT pg_get_wal_replay_pause_state()") == "not paused" + ), "pg_get_wal_replay_pause_state() reports not paused" + + # Request to pause recovery and wait until it's actually paused. + node_standby2.safe_sql("SELECT pg_wal_replay_pause()") + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(21,30))") + assert node_standby2.poll_query_until( + "SELECT pg_get_wal_replay_pause_state() = 'paused'" + ), "Timed out while waiting for recovery to be paused" + + # Even if new WAL records are streamed from the primary, + # recovery in the paused state doesn't replay them. + receive_lsn = node_standby2.safe_sql("SELECT pg_last_wal_receive_lsn()") + replay_lsn = node_standby2.safe_sql("SELECT pg_last_wal_replay_lsn()") + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(31,40))") + assert node_standby2.poll_query_until( + f"SELECT '{receive_lsn}'::pg_lsn < pg_last_wal_receive_lsn()" + ), "Timed out while waiting for new WAL to be streamed" + assert ( + node_standby2.safe_sql("SELECT pg_last_wal_replay_lsn()") == replay_lsn + ), "no WAL is replayed in the paused state" + + # Request to resume recovery and wait until it's actually resumed. + node_standby2.safe_sql("SELECT pg_wal_replay_resume()") + assert node_standby2.poll_query_until( + "SELECT pg_get_wal_replay_pause_state() = 'not paused' AND " + f"pg_last_wal_replay_lsn() > '{replay_lsn}'::pg_lsn" + ), "Timed out while waiting for recovery to be resumed" + + # Check that the paused state ends and promotion continues if a promotion + # is triggered while recovery is paused. + node_standby2.safe_sql("SELECT pg_wal_replay_pause()") + node_primary.safe_sql("INSERT INTO tab_int VALUES (generate_series(41,50))") + assert node_standby2.poll_query_until( + "SELECT pg_get_wal_replay_pause_state() = 'paused'" + ), "Timed out while waiting for recovery to be paused" + + node_standby2.promote() + assert node_standby2.poll_query_until( + "SELECT NOT pg_is_in_recovery()" + ), "Timed out while waiting for promotion to finish" diff --git a/src/test/recovery/pyt/test_006_logical_decoding.py b/src/test/recovery/pyt/test_006_logical_decoding.py new file mode 100644 index 00000000000..bb4c3617cee --- /dev/null +++ b/src/test/recovery/pyt/test_006_logical_decoding.py @@ -0,0 +1,328 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Testing of logical decoding using SQL interface and/or pg_recvlogical.""" + +# Most logical decoding tests are in contrib/test_decoding. This module +# is for work that doesn't fit well there, like where server restarts +# are required. + +import re +import subprocess +import sys + +from libpq import Session +from pypg.util import TIMEOUT_DEFAULT + + +def test_006_logical_decoding(create_pg): + # Initialize primary node + node_primary = create_pg("primary", allows_streaming=True, start=False) + node_primary.append_conf("wal_level = logical") + node_primary.start() + + node_primary.safe_sql("CREATE TABLE decoding_test(x integer, y text);") + + node_primary.safe_sql( + "SELECT pg_create_logical_replication_slot('test_slot', 'test_decoding');" + ) + + libdir = node_primary.libdir + connstr_db = node_primary.connstr("template1") + " replication=database" + connstr_phys = node_primary.connstr("template1") + " replication=true" + + # Cover walsender error shutdown code + with Session(connstr=connstr_db, libdir=libdir) as sess: + res = sess.query("START_REPLICATION SLOT test_slot LOGICAL 0/0") + assert res.error_message is not None and re.search( + r'replication slot "test_slot" was not created in this database', + res.error_message, + ), "Logical decoding correctly fails to start" + + with Session(connstr=connstr_db, libdir=libdir) as sess: + res = sess.query("READ_REPLICATION_SLOT test_slot;") + assert res.error_message is not None and re.search( + r"cannot use READ_REPLICATION_SLOT with a logical replication slot", + res.error_message, + ), "READ_REPLICATION_SLOT not supported for logical slots" + + # Check case of walsender not using a database connection. Logical + # decoding should not be allowed. + with Session(connstr=connstr_phys, libdir=libdir) as sess: + res = sess.query("START_REPLICATION SLOT s1 LOGICAL 0/1") + assert res.error_message is not None and re.search( + r"ERROR: logical decoding requires a database connection", + res.error_message, + ), "Logical decoding fails on non-database connection" + + node_primary.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT s, s::text " + "FROM generate_series(1,10) s;" + ) + + # Basic decoding works + result = node_primary.safe_sql( + "SELECT pg_logical_slot_get_changes('test_slot', NULL, NULL);" + ) + assert len(result.splitlines()) == 12, "Decoding produced 12 rows inc BEGIN/COMMIT" + + # If we immediately crash the server we might lose the progress we just made + # and replay the same changes again. But a clean shutdown should never repeat + # the same changes when we use the SQL decoding interface. + node_primary.restart() + + # There are no new writes, so the result should be empty. + result = node_primary.safe_sql( + "SELECT pg_logical_slot_get_changes('test_slot', NULL, NULL);" + ) + assert result == "", "Decoding after fast restart repeats no rows" + + # Insert some rows and verify that we get the same results from pg_recvlogical + # and the SQL interface. + node_primary.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT s, s::text " + "FROM generate_series(1,4) s;" + ) + + expected = ( + "BEGIN\n" + "table public.decoding_test: INSERT: x[integer]:1 y[text]:'1'\n" + "table public.decoding_test: INSERT: x[integer]:2 y[text]:'2'\n" + "table public.decoding_test: INSERT: x[integer]:3 y[text]:'3'\n" + "table public.decoding_test: INSERT: x[integer]:4 y[text]:'4'\n" + "COMMIT" + ) + + stdout_sql = node_primary.safe_sql( + "SELECT data FROM pg_logical_slot_peek_changes('test_slot', NULL, NULL, " + "'include-xids', '0', 'skip-empty-xacts', '1');" + ) + assert stdout_sql == expected, "got expected output from SQL decoding session" + + endpos = node_primary.safe_sql( + "SELECT lsn FROM pg_logical_slot_peek_changes('test_slot', NULL, NULL) " + "ORDER BY lsn DESC LIMIT 1;" + ) + print(f"waiting to replay {endpos}") + + # Insert some rows after $endpos, which we won't read. + node_primary.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT s, s::text " + "FROM generate_series(5,50) s;" + ) + + stdout_recv = node_primary.pg_recvlogical_upto( + "postgres", + "test_slot", + endpos, + TIMEOUT_DEFAULT, + **{"include-xids": "0", "skip-empty-xacts": "1"}, + ).stdout + stdout_recv = stdout_recv.rstrip("\n") + assert ( + stdout_recv == expected + ), "got same expected output from pg_recvlogical decoding session" + + assert node_primary.poll_query_until( + "SELECT EXISTS (SELECT 1 FROM pg_replication_slots " + "WHERE slot_name = 'test_slot' AND active_pid IS NULL)" + ), "slot never became inactive" + + stdout_recv = node_primary.pg_recvlogical_upto( + "postgres", + "test_slot", + endpos, + TIMEOUT_DEFAULT, + **{"include-xids": "0", "skip-empty-xacts": "1"}, + ).stdout + stdout_recv = stdout_recv.rstrip("\n") + assert stdout_recv == "", "pg_recvlogical acknowledged changes" + + node_primary.safe_sql("CREATE DATABASE otherdb") + + assert ( + node_primary.sql( + "SELECT lsn FROM pg_logical_slot_peek_changes('test_slot', NULL, NULL) " + "ORDER BY lsn DESC LIMIT 1;", + "otherdb", + ).error_message + is not None + ), "replaying logical slot from another database fails" + + node_primary.safe_sql( + "SELECT pg_create_logical_replication_slot('otherdb_slot', 'test_decoding');", + "otherdb", + ) + + # make sure you can't drop a slot while active + if sys.platform == "win32": + # some Windows Perls at least don't like IPC::Run's start/kill_kill regime. + pass + else: + # Terminated explicitly by kill()/wait() in the finally block. + pg_recvlogical = subprocess.Popen( # pylint: disable=consider-using-with + [ + node_primary.resolve("pg_recvlogical"), + "--dbname", + node_primary.connstr("otherdb"), + "--slot", + "otherdb_slot", + "--file", + "-", + "--start", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + assert node_primary.poll_query_until( + "SELECT EXISTS (SELECT 1 FROM pg_replication_slots " + "WHERE slot_name = 'otherdb_slot' AND active_pid IS NOT NULL)", + dbname="otherdb", + ), "slot never became active" + assert ( + node_primary.sql("DROP DATABASE otherdb").error_message is not None + ), "dropping a DB with active logical slots fails" + finally: + pg_recvlogical.kill() + pg_recvlogical.wait() + assert ( + node_primary.slot("otherdb_slot")["plugin"] == "test_decoding" + ), "logical slot still exists" + + assert node_primary.poll_query_until( + "SELECT EXISTS (SELECT 1 FROM pg_replication_slots " + "WHERE slot_name = 'otherdb_slot' AND active_pid IS NULL)", + dbname="otherdb", + ), "slot never became inactive" + + # Wait out the killed walsender backend so DROP DATABASE is not blocked by + # lingering connections to otherdb. + assert node_primary.poll_query_until( + "SELECT NOT EXISTS (SELECT 1 FROM pg_stat_activity " + "WHERE datname = 'otherdb')" + ), "connections to otherdb never went away" + + assert ( + node_primary.sql("DROP DATABASE otherdb").error_message is None + ), "dropping a DB with inactive logical slots succeeds" + assert ( + node_primary.slot("otherdb_slot")["plugin"] == "" + ), "logical slot was actually dropped with DB" + + # Test logical slot advancing and its durability. + # Passing failover=true (last arg) should not have any impact on advancing. + logical_slot = "logical_slot" + node_primary.safe_sql( + f"SELECT pg_create_logical_replication_slot('{logical_slot}', " + "'test_decoding', false, false, true);" + ) + node_primary.safe_sql("CREATE TABLE tab_logical_slot (a int);") + node_primary.safe_sql( + "INSERT INTO tab_logical_slot VALUES (generate_series(1,10));" + ) + current_lsn = node_primary.safe_sql("SELECT pg_current_wal_lsn();") + assert ( + node_primary.sql( + f"SELECT pg_replication_slot_advance('{logical_slot}', " + f"'{current_lsn}'::pg_lsn);" + ).error_message + is None + ), "slot advancing with logical slot" + logical_restart_lsn_pre = node_primary.safe_sql( + "SELECT restart_lsn from pg_replication_slots " + f"WHERE slot_name = '{logical_slot}';" + ) + # Slot advance should persist across clean restarts. + node_primary.restart() + logical_restart_lsn_post = node_primary.safe_sql( + "SELECT restart_lsn from pg_replication_slots " + f"WHERE slot_name = '{logical_slot}';" + ) + assert ( + logical_restart_lsn_pre == logical_restart_lsn_post + ), "logical slot advance persists across restarts" + + stats_test_slot1 = "test_slot" + stats_test_slot2 = "logical_slot" + + # Test that reset works for pg_stat_replication_slots + + # Stats exist for stats test slot 1 + assert ( + node_primary.safe_sql( + "SELECT total_bytes > 0, stats_reset IS NULL " + "FROM pg_stat_replication_slots " + f"WHERE slot_name = '{stats_test_slot1}'" + ) + == "t|t" + ), f"Total bytes is > 0 and stats_reset is NULL for slot '{stats_test_slot1}'." + + # Do reset of stats for stats test slot 1 + node_primary.safe_sql( + f"SELECT pg_stat_reset_replication_slot('{stats_test_slot1}')" + ) + + # Get reset value after reset + reset1 = node_primary.safe_sql( + "SELECT stats_reset FROM pg_stat_replication_slots " + f"WHERE slot_name = '{stats_test_slot1}'" + ) + + # Do reset again + node_primary.safe_sql( + f"SELECT pg_stat_reset_replication_slot('{stats_test_slot1}')" + ) + + assert ( + node_primary.safe_sql( + f"SELECT stats_reset > '{reset1}'::timestamptz, total_bytes = 0 " + "FROM pg_stat_replication_slots " + f"WHERE slot_name = '{stats_test_slot1}'" + ) + == "t|t" + ), ( + "Check that reset timestamp is later after the second reset of stats " + f"for slot '{stats_test_slot1}' and confirm total_bytes was set to 0." + ) + + # Check that test slot 2 has NULL in reset timestamp + assert ( + node_primary.safe_sql( + "SELECT stats_reset IS NULL FROM pg_stat_replication_slots " + f"WHERE slot_name = '{stats_test_slot2}'" + ) + == "t" + ), f"Stats_reset is NULL for slot '{stats_test_slot2}' before reset." + + # Get reset value again for test slot 1 + reset1 = node_primary.safe_sql( + "SELECT stats_reset FROM pg_stat_replication_slots " + f"WHERE slot_name = '{stats_test_slot1}'" + ) + + # Reset stats for all replication slots + node_primary.safe_sql("SELECT pg_stat_reset_replication_slot(NULL)") + + # Check that test slot 2 reset timestamp is no longer NULL after reset + assert ( + node_primary.safe_sql( + "SELECT stats_reset IS NOT NULL FROM pg_stat_replication_slots " + f"WHERE slot_name = '{stats_test_slot2}'" + ) + == "t" + ), f"Stats_reset is not NULL for slot '{stats_test_slot2}' after reset all." + + assert ( + node_primary.safe_sql( + f"SELECT stats_reset > '{reset1}'::timestamptz " + "FROM pg_stat_replication_slots " + f"WHERE slot_name = '{stats_test_slot1}'" + ) + == "t" + ), ( + "Check that reset timestamp is later after resetting stats " + f"for slot '{stats_test_slot1}' again." + ) + + # done with the node + node_primary.stop() diff --git a/src/test/recovery/pyt/test_007_sync_rep.py b/src/test/recovery/pyt/test_007_sync_rep.py new file mode 100644 index 00000000000..0d6e1747a14 --- /dev/null +++ b/src/test/recovery/pyt/test_007_sync_rep.py @@ -0,0 +1,213 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Minimal test testing synchronous replication sync_state transition.""" + +# Query checking sync_priority and sync_state of each standby +CHECK_SQL = ( + "SELECT application_name, sync_priority, sync_state " + "FROM pg_stat_replication ORDER BY application_name;" +) + + +def check_sync_state(node, expected, msg, setting=None): + """Check that sync_state of each standby is expected (waiting till it is). + + If *setting* is given, synchronous_standby_names is set to it and the + configuration file is reloaded before the test. + """ + if setting is not None: + node.safe_sql(f"ALTER SYSTEM SET synchronous_standby_names = '{setting}';") + node.reload() + + assert node.poll_query_until(CHECK_SQL, expected), msg + + +def start_standby_and_wait(primary, standby): + """Start a standby and check that it is registered on the primary. + + Polls the primary's pg_stat_replication until the standby is confirmed as + registered within the WAL sender array of the given primary. + """ + standby_name = standby.name + query = ( + "SELECT count(1) = 1 FROM pg_stat_replication " + f"WHERE application_name = '{standby_name}'" + ) + + standby.start() + + print(f'### Waiting for standby "{standby_name}" on "{primary.name}"') + assert primary.poll_query_until(query) + + +def test_007_sync_rep(create_pg): + # Initialize primary node + node_primary = create_pg("primary", allows_streaming=True) + backup_name = "primary_backup" + + # Take backup + node_primary.backup(backup_name) + + # Create all the standbys. Their status on the primary is checked to + # ensure the ordering of each one of them in the WAL sender array of the + # primary. + + # Create standby1 linking to primary + node_standby_1 = create_pg("standby1", start=False) + node_standby_1.init_from_backup(node_primary, backup_name, has_streaming=True) + start_standby_and_wait(node_primary, node_standby_1) + + # Create standby2 linking to primary + node_standby_2 = create_pg("standby2", start=False) + node_standby_2.init_from_backup(node_primary, backup_name, has_streaming=True) + start_standby_and_wait(node_primary, node_standby_2) + + # Create standby3 linking to primary + node_standby_3 = create_pg("standby3", start=False) + node_standby_3.init_from_backup(node_primary, backup_name, has_streaming=True) + start_standby_and_wait(node_primary, node_standby_3) + + # Check that sync_state is determined correctly when + # synchronous_standby_names is specified in old syntax. + check_sync_state( + node_primary, + "standby1|1|sync\nstandby2|2|potential\nstandby3|0|async", + "old syntax of synchronous_standby_names", + "standby1,standby2", + ) + + # Check that all the standbys are considered as either sync or + # potential when * is specified in synchronous_standby_names. + # Note that standby1 is chosen as sync standby because + # it's stored in the head of WalSnd array which manages + # all the standbys though they have the same priority. + check_sync_state( + node_primary, + "standby1|1|sync\nstandby2|1|potential\nstandby3|1|potential", + "asterisk in synchronous_standby_names", + "*", + ) + + # Stop and start standbys to rearrange the order of standbys + # in WalSnd array. Now, if standbys have the same priority, + # standby2 is selected preferentially and standby3 is next. + node_standby_1.stop() + node_standby_2.stop() + node_standby_3.stop() + + # Make sure that each standby reports back to the primary in the wanted + # order. + start_standby_and_wait(node_primary, node_standby_2) + start_standby_and_wait(node_primary, node_standby_3) + + # Specify 2 as the number of sync standbys. + # Check that two standbys are in 'sync' state. + check_sync_state( + node_primary, + "standby2|2|sync\nstandby3|3|sync", + "2 synchronous standbys", + "2(standby1,standby2,standby3)", + ) + + # Start standby1 + start_standby_and_wait(node_primary, node_standby_1) + + # Create standby4 linking to primary + node_standby_4 = create_pg("standby4", start=False) + node_standby_4.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby_4.start() + + # Check that standby1 and standby2 whose names appear earlier in + # synchronous_standby_names are considered as sync. Also check that + # standby3 appearing later represents potential, and standby4 is + # in 'async' state because it's not in the list. + check_sync_state( + node_primary, + "standby1|1|sync\n" + "standby2|2|sync\n" + "standby3|3|potential\n" + "standby4|0|async", + "2 sync, 1 potential, and 1 async", + ) + + # Check that sync_state of each standby is determined correctly + # when num_sync exceeds the number of names of potential sync standbys + # specified in synchronous_standby_names. + check_sync_state( + node_primary, + "standby1|0|async\nstandby2|4|sync\nstandby3|3|sync\nstandby4|1|sync", + "num_sync exceeds the num of potential sync standbys", + "6(standby4,standby0,standby3,standby2)", + ) + + # The setting that * comes before another standby name is acceptable + # but does not make sense in most cases. Check that sync_state is + # chosen properly even in case of that setting. standby1 is selected + # as synchronous as it has the highest priority, and is followed by a + # second standby listed first in the WAL sender array, which is + # standby2 in this case. + check_sync_state( + node_primary, + "standby1|1|sync\n" + "standby2|2|sync\n" + "standby3|2|potential\n" + "standby4|2|potential", + "asterisk before another standby name", + "2(standby1,*,standby2)", + ) + + # Check that the setting of '2(*)' chooses standby2 and standby3 that are + # stored earlier in WalSnd array as sync standbys. + check_sync_state( + node_primary, + "standby1|1|potential\n" + "standby2|1|sync\n" + "standby3|1|sync\n" + "standby4|1|potential", + "multiple standbys having the same priority are chosen as sync", + "2(*)", + ) + + # Stop Standby3 which is considered in 'sync' state. + node_standby_3.stop() + + # Check that the state of standby1 stored earlier in WalSnd array than + # standby4 is transited from potential to sync. + check_sync_state( + node_primary, + "standby1|1|sync\nstandby2|1|sync\nstandby4|1|potential", + "potential standby found earlier in array is promoted to sync", + ) + + # Check that standby1 and standby2 are chosen as sync standbys + # based on their priorities. + check_sync_state( + node_primary, + "standby1|1|sync\nstandby2|2|sync\nstandby4|0|async", + "priority-based sync replication specified by FIRST keyword", + "FIRST 2(standby1, standby2)", + ) + + # Check that all the listed standbys are considered as candidates + # for sync standbys in a quorum-based sync replication. + check_sync_state( + node_primary, + "standby1|1|quorum\nstandby2|1|quorum\nstandby4|0|async", + "2 quorum and 1 async", + "ANY 2(standby1, standby2)", + ) + + # Start Standby3 which will be considered in 'quorum' state. + node_standby_3.start() + + # Check that the setting of 'ANY 2(*)' chooses all standbys as + # candidates for quorum sync standbys. + check_sync_state( + node_primary, + "standby1|1|quorum\n" + "standby2|1|quorum\n" + "standby3|1|quorum\n" + "standby4|1|quorum", + "all standbys are considered as candidates for quorum sync standbys", + "ANY 2(*)", + ) diff --git a/src/test/recovery/pyt/test_008_fsm_truncation.py b/src/test/recovery/pyt/test_008_fsm_truncation.py new file mode 100644 index 00000000000..f8917bc5bc0 --- /dev/null +++ b/src/test/recovery/pyt/test_008_fsm_truncation.py @@ -0,0 +1,88 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test FSM-driven INSERT just after truncation clears FSM slots indicating +free space in removed blocks. + +The FSM mustn't return a page that doesn't exist (anymore). +""" + + +def test_008_fsm_truncation(create_pg): + node_primary = create_pg("primary", start=False, allows_streaming=True) + + node_primary.append_conf( + """ +wal_log_hints = on +max_prepared_transactions = 5 +autovacuum = off +""" + ) + + # Create a primary node and its standby, initializing both with some data + # at the same time. + node_primary.start() + + node_primary.backup("primary_backup") + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, "primary_backup", has_streaming=True) + node_standby.start() + + node_primary.safe_sql( + """ +create table testtab (a int, b char(100)); +insert into testtab select generate_series(1,1000), 'foo'; +insert into testtab select generate_series(1,1000), 'foo'; +delete from testtab where ctid > '(8,0)'; +""" + ) + + # Take a lock on the table to prevent following vacuum from truncating it + node_primary.safe_sql( + """ +begin; +lock table testtab in row share mode; +prepare transaction 'p1'; +""" + ) + + # Vacuum, update FSM without truncation + node_primary.safe_sql("vacuum verbose testtab") + + # Force a checkpoint + node_primary.safe_sql("checkpoint") + + # Now do some more insert/deletes, another vacuum to ensure full-page writes + # are done + node_primary.safe_sql( + """ +insert into testtab select generate_series(1,1000), 'foo'; +delete from testtab where ctid > '(8,0)'; +""" + ) + node_primary.safe_sql("vacuum verbose testtab;") + + # Ensure all buffers are now clean on the standby + node_standby.safe_sql("checkpoint") + + # Release the lock, vacuum again which should lead to truncation + node_primary.safe_sql("rollback prepared 'p1';") + node_primary.safe_sql("vacuum verbose testtab;") + + node_primary.safe_sql("checkpoint") + until_lsn = node_primary.safe_sql("SELECT pg_current_wal_lsn();") + + # Wait long enough for standby to receive and apply all WAL + caughtup_query = f"SELECT '{until_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + assert node_standby.poll_query_until( + caughtup_query + ), "Timed out while waiting for standby to catch up" + + # Promote the standby + node_standby.promote() + node_standby.safe_sql("checkpoint") + + # Restart to discard in-memory copy of FSM + node_standby.restart() + + # Insert should work on standby + node_standby.safe_sql("insert into testtab select generate_series(1,1000), 'foo';") diff --git a/src/test/recovery/pyt/test_009_twophase.py b/src/test/recovery/pyt/test_009_twophase.py new file mode 100644 index 00000000000..2b0b6060932 --- /dev/null +++ b/src/test/recovery/pyt/test_009_twophase.py @@ -0,0 +1,544 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests dedicated to two-phase commit in recovery.""" + +import os + + +def _configure_and_reload(node, parameter): + """Append *parameter* to postgresql.conf and reload, asserting success.""" + node.append_conf(parameter + "\n") + psql_out = node.safe_sql("SELECT pg_reload_conf()") + assert psql_out == "t", f"reload node {node.name} with {parameter}" + + +def test_009_twophase(create_pg, tmp_path): + # Set up two nodes, which will alternately be primary and replication + # standby. + + # Setup london node + node_london = create_pg("london", start=False, allows_streaming=True) + node_london.append_conf( + """ + max_prepared_transactions = 10 + log_checkpoints = true +""" + ) + node_london.start() + node_london.backup("london_backup") + + # Setup paris node + node_paris = create_pg("paris", start=False) + node_paris.init_from_backup(node_london, "london_backup", has_streaming=True) + node_paris.append_conf( + """ + subtransaction_buffers = 32 +""" + ) + node_paris.start() + + # Switch to synchronous replication in both directions + _configure_and_reload(node_london, "synchronous_standby_names = 'paris'") + _configure_and_reload(node_paris, "synchronous_standby_names = 'london'") + + # Set up nonce names for current primary and standby nodes + # Initially, london is primary and paris is standby + cur_primary, cur_standby = node_london, node_paris + cur_primary_name = cur_primary.name + + # Create table we'll use in the test transactions + cur_primary.safe_sql("CREATE TABLE t_009_tbl (id int, msg text)") + + ########################################################################### + # Check that we can commit and abort transaction after soft restart. + # Here checkpoint happens before shutdown and no WAL replay will occur at + # next startup. In this case postgres re-creates shared-memory state from + # twophase files. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (1, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (2, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_1';""" + ) + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (3, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (4, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_2';""" + ) + cur_primary.stop() + cur_primary.start() + + # safe_sql raises on error, so a successful return mirrors psql_rc == 0 + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_1'") + cur_primary.safe_sql("ROLLBACK PREPARED 'xact_009_2'") + + ########################################################################### + # Check that we can commit and abort after a hard restart. + # At next startup, WAL replay will re-create shared memory state for + # prepared transaction using dedicated WAL records. + ########################################################################### + + cur_primary.safe_sql("CHECKPOINT") + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (5, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (6, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_3';""" + ) + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (7, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (8, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_4';""" + ) + cur_primary.stop("immediate") + cur_primary.start() + + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_3'") + cur_primary.safe_sql("ROLLBACK PREPARED 'xact_009_4'") + + ########################################################################### + # Check that WAL replay can handle several transactions with same GID name. + ########################################################################### + + cur_primary.safe_sql("CHECKPOINT") + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (9, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (10, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_5';""" + ) + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_5'") + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (11, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (12, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_5';""" + ) + cur_primary.stop("immediate") + cur_primary.start() + + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_5'") + + ########################################################################### + # Check that WAL replay cleans up its shared memory state and releases + # locks while replaying transaction commits. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (13, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (14, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_6';""" + ) + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_6'") + cur_primary.stop("immediate") + cur_primary.start() + # This prepare can fail due to conflicting GID or locks conflicts if + # replay did not fully cleanup its state on previous commit. + # safe_sql raises on error, so a successful return mirrors psql_rc == 0. + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (15, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (16, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_7';""" + ) + + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_7'") + + ########################################################################### + # Check that WAL replay will cleanup its shared memory state on running + # standby. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (17, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (18, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_8';""" + ) + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_8'") + psql_out = cur_standby.safe_sql("SELECT count(*) FROM pg_prepared_xacts") + assert ( + psql_out == "0" + ), "Cleanup of shared memory state on running standby without checkpoint" + + ########################################################################### + # Same as in previous case, but let's force checkpoint on standby between + # prepare and commit to use on-disk twophase files. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (19, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (20, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_9';""" + ) + cur_standby.safe_sql("CHECKPOINT") + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_9'") + psql_out = cur_standby.safe_sql("SELECT count(*) FROM pg_prepared_xacts") + assert ( + psql_out == "0" + ), "Cleanup of shared memory state on running standby after checkpoint" + + ########################################################################### + # Check that prepared transactions can be committed on promoted standby. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (21, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (22, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_10';""" + ) + cur_primary.stop() + cur_standby.promote() + + # change roles + # Now paris is primary and london is standby + cur_primary, cur_standby = node_paris, node_london + cur_primary_name = cur_primary.name + + # london is not running at this point, so we must not commit synchronously + # here (it would wait forever for the down standby). COMMIT PREPARED + # cannot run in a transaction block, and a multi-statement string is one + # implicit transaction, so set synchronous_commit and commit as separate + # statements on one persistent connection. + with cur_primary.connect() as sess: + sess.query_safe("SET synchronous_commit = off") + sess.query_safe("COMMIT PREPARED 'xact_009_10'") + + # restart old primary as new standby + cur_standby.enable_streaming(cur_primary) + cur_standby.start() + + ########################################################################### + # Check that prepared transactions are replayed after soft restart of + # standby while primary is down. Since standby knows that primary is down + # it uses a different code path on startup to ensure that the status of + # transactions is consistent. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (23, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (24, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_11';""" + ) + cur_primary.stop() + cur_standby.restart() + cur_standby.promote() + + # change roles + # Now london is primary and paris is standby + cur_primary, cur_standby = node_london, node_paris + cur_primary_name = cur_primary.name + + psql_out = cur_primary.safe_sql("SELECT count(*) FROM pg_prepared_xacts") + assert psql_out == "1", "Restore prepared transactions from files with primary down" + + # restart old primary as new standby + cur_standby.enable_streaming(cur_primary) + cur_standby.start() + + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_11'") + + ########################################################################### + # Check that prepared transactions are correctly replayed after standby + # hard restart while primary is down. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + INSERT INTO t_009_tbl VALUES (25, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl VALUES (26, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_12'; + """ + ) + cur_primary.stop() + cur_standby.stop("immediate") + cur_standby.start() + cur_standby.promote() + + # change roles + # Now paris is primary and london is standby + cur_primary, cur_standby = node_paris, node_london + cur_primary_name = cur_primary.name + + psql_out = cur_primary.safe_sql("SELECT count(*) FROM pg_prepared_xacts") + assert ( + psql_out == "1" + ), "Restore prepared transactions from records with primary down" + + # restart old primary as new standby + cur_standby.enable_streaming(cur_primary) + cur_standby.start() + + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_12'") + + ########################################################################### + # Check visibility of prepared transactions in standby after a restart + # while primary is down. + ########################################################################### + + # Set synchronous_commit='remote_apply' so the standby is caught up. The + # GUC must persist across the CREATE TABLE and the prepared transaction, + # and neither may share an implicit transaction with the SET, so run them + # as separate statements on one persistent connection. + with cur_primary.connect() as sess: + sess.query_safe("SET synchronous_commit='remote_apply'") + sess.query_safe("CREATE TABLE t_009_tbl_standby_mvcc (id int, msg text)") + sess.query_safe( + f""" + BEGIN; + INSERT INTO t_009_tbl_standby_mvcc VALUES (1, 'issued to {cur_primary_name}'); + SAVEPOINT s1; + INSERT INTO t_009_tbl_standby_mvcc VALUES (2, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_standby_mvcc'; + """ + ) + cur_primary.stop() + cur_standby.restart() + + # Acquire a snapshot in standby, before we commit the prepared transaction + standby_session = cur_standby.connect() + standby_session.do("BEGIN ISOLATION LEVEL REPEATABLE READ") + psql_out = standby_session.query_oneval( + "SELECT count(*) FROM t_009_tbl_standby_mvcc" + ) + assert psql_out == "0", "Prepared transaction not visible in standby before commit" + + # Commit the transaction in primary + cur_primary.start() + # Set synchronous_commit='remote_apply' so the standby is caught up. + # COMMIT PREPARED cannot run in a transaction block, so set the GUC and + # commit as separate statements on one persistent connection. + with cur_primary.connect() as sess: + sess.query_safe("SET synchronous_commit='remote_apply'") + sess.query_safe("COMMIT PREPARED 'xact_009_standby_mvcc'") + + # Still not visible to the old snapshot + psql_out = standby_session.query_oneval( + "SELECT count(*) FROM t_009_tbl_standby_mvcc" + ) + assert ( + psql_out == "0" + ), "Committed prepared transaction not visible to old snapshot in standby" + + # Is visible to a new snapshot + standby_session.do("COMMIT") + psql_out = standby_session.query_oneval( + "SELECT count(*) FROM t_009_tbl_standby_mvcc" + ) + assert ( + psql_out == "2" + ), "Committed prepared transaction is visible to new snapshot in standby" + standby_session.close() + + ########################################################################### + # Check for a lock conflict between prepared transaction with DDL inside + # and replay of XLOG_STANDBY_LOCK wal record. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + CREATE TABLE t_009_tbl2 (id int, msg text); + SAVEPOINT s1; + INSERT INTO t_009_tbl2 VALUES (27, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_13';""" + ) + # checkpoint will issue XLOG_STANDBY_LOCK that can conflict with lock + # held by 'create table' statement + cur_primary.safe_sql("CHECKPOINT") + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_13'") + + # Ensure that last transaction is replayed on standby. + cur_primary_lsn = cur_primary.safe_sql("SELECT pg_current_wal_lsn()") + caughtup_query = f"SELECT '{cur_primary_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + assert cur_standby.poll_query_until( + caughtup_query + ), "Timed out while waiting for standby to catch up" + + psql_out = cur_standby.safe_sql("SELECT count(*) FROM t_009_tbl2") + assert psql_out == "1", "Replay prepared transaction with DDL" + + ########################################################################### + # Check recovery of prepared transaction with DDL inside after a hard + # restart of the primary. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + CREATE TABLE t_009_tbl3 (id int, msg text); + SAVEPOINT s1; + INSERT INTO t_009_tbl3 VALUES (28, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_14';""" + ) + cur_primary.safe_sql( + f""" + BEGIN; + CREATE TABLE t_009_tbl4 (id int, msg text); + SAVEPOINT s1; + INSERT INTO t_009_tbl4 VALUES (29, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_15';""" + ) + + cur_primary.stop("immediate") + cur_primary.start() + + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_14'") + cur_primary.safe_sql("ROLLBACK PREPARED 'xact_009_15'") + + ########################################################################### + # Check recovery of prepared transaction with DDL inside after a soft + # restart of the primary. + ########################################################################### + + cur_primary.safe_sql( + f""" + BEGIN; + CREATE TABLE t_009_tbl5 (id int, msg text); + SAVEPOINT s1; + INSERT INTO t_009_tbl5 VALUES (30, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_16';""" + ) + cur_primary.safe_sql( + f""" + BEGIN; + CREATE TABLE t_009_tbl6 (id int, msg text); + SAVEPOINT s1; + INSERT INTO t_009_tbl6 VALUES (31, 'issued to {cur_primary_name}'); + PREPARE TRANSACTION 'xact_009_17';""" + ) + + cur_primary.stop() + cur_primary.start() + + cur_primary.safe_sql("COMMIT PREPARED 'xact_009_16'") + cur_primary.safe_sql("ROLLBACK PREPARED 'xact_009_17'") + + ########################################################################### + # Verify expected data appears on both servers. + ########################################################################### + + psql_out = cur_primary.safe_sql("SELECT count(*) FROM pg_prepared_xacts") + assert psql_out == "0", "No uncommitted prepared transactions on primary" + + expected_tbl = """1|issued to london +2|issued to london +5|issued to london +6|issued to london +9|issued to london +10|issued to london +11|issued to london +12|issued to london +13|issued to london +14|issued to london +15|issued to london +16|issued to london +17|issued to london +18|issued to london +19|issued to london +20|issued to london +21|issued to london +22|issued to london +23|issued to paris +24|issued to paris +25|issued to london +26|issued to london""" + + psql_out = cur_primary.safe_sql("SELECT * FROM t_009_tbl ORDER BY id") + assert psql_out == expected_tbl, "Check expected t_009_tbl data on primary" + + psql_out = cur_primary.safe_sql("SELECT * FROM t_009_tbl2") + assert psql_out == "27|issued to paris", "Check expected t_009_tbl2 data on primary" + + psql_out = cur_standby.safe_sql("SELECT count(*) FROM pg_prepared_xacts") + assert psql_out == "0", "No uncommitted prepared transactions on standby" + + psql_out = cur_standby.safe_sql("SELECT * FROM t_009_tbl ORDER BY id") + assert psql_out == expected_tbl, "Check expected t_009_tbl data on standby" + + psql_out = cur_standby.safe_sql("SELECT * FROM t_009_tbl2") + assert psql_out == "27|issued to paris", "Check expected t_009_tbl2 data on standby" + + # Exercise the 2PC recovery code in StartupSUBTRANS, which is concerned + # with ensuring that enough pg_subtrans pages exist on disk to cover the + # range of prepared transactions at server start time. There's not much we + # can verify directly, but let's at least get the code to run. + cur_standby.stop() + _configure_and_reload(cur_primary, "synchronous_standby_names = ''") + + cur_primary.safe_sql("CHECKPOINT") + + cur_primary.safe_sql("select pg_current_wal_insert_lsn()") + # "CREATE TABLE test()" autocommits on its own, then the BEGIN..PREPARE + # block prepares test1. As a single multi-statement string the whole + # batch would be one implicit transaction ending in PREPARE, so "test" + # would never commit; issue them as separate statements. + cur_primary.safe_sql("CREATE TABLE test()") + cur_primary.safe_sql("BEGIN; CREATE TABLE test1(); PREPARE TRANSACTION 'foo';") + osubtrans = cur_primary.safe_sql( + "select 'pg_subtrans/'||f, s.size from pg_ls_dir('pg_subtrans') f, " + "pg_stat_file('pg_subtrans/'||f) s" + ) + + # pgbench run to cause pg_subtrans traffic + pgb_script = os.path.join(str(tmp_path), "009_twophase.pgb") + with open(pgb_script, "w", encoding="utf-8") as fh: + fh.write("insert into test default values\n") + cur_primary.pg_bin.command_ok( + [ + "pgbench", + "--no-vacuum", + "--client=5", + "--transactions=1000", + "-f", + pgb_script, + "postgres", + ], + "pgbench run to cause pg_subtrans traffic", + ) + + # StartupSUBTRANS is exercised with a wide range of visible XIDs in this + # stop/start sequence, because we left a prepared transaction open above. + # Also, setting subtransaction_buffers to 32 above causes to switch SLRU + # bank, for additional code coverage. + cur_primary.stop() + cur_primary.start() + nsubtrans = cur_primary.safe_sql( + "select 'pg_subtrans/'||f, s.size from pg_ls_dir('pg_subtrans') f, " + "pg_stat_file('pg_subtrans/'||f) s" + ) + assert osubtrans != nsubtrans, "contents of pg_subtrans/ have changed" diff --git a/src/test/recovery/pyt/test_010_logical_decoding_timelines.py b/src/test/recovery/pyt/test_010_logical_decoding_timelines.py new file mode 100644 index 00000000000..b8a76016f61 --- /dev/null +++ b/src/test/recovery/pyt/test_010_logical_decoding_timelines.py @@ -0,0 +1,198 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Demonstrate that logical can follow timeline switches. + +Logical replication slots can follow timeline switches but it's normally not +possible to have a logical slot on a replica where promotion and a timeline +switch can occur. The only ways we can create that circumstance are: + +* By doing a filesystem-level copy of the DB, since pg_basebackup excludes + pg_replslot but we can copy it directly; or + +* by creating a slot directly at the C level on the replica and advancing it + as we go using the low level APIs. It can't be done from SQL since logical + decoding isn't allowed on replicas. + +This module uses the first approach to show that timeline following on a +logical slot works. + +(For convenience, it also tests some recovery-related operations on logical +slots). +""" + +import re + +from pypg.util import TIMEOUT_DEFAULT + + +def test_010_logical_decoding_timelines(create_pg): + # Initialize primary node + node_primary = create_pg( + "primary", allows_streaming="logical", has_archiving=True, start=False + ) + node_primary.append_conf( + """ +wal_level = 'logical' +max_replication_slots = 3 +max_wal_senders = 2 +log_min_messages = 'debug2' +hot_standby_feedback = on +wal_receiver_status_interval = 1 +""" + ) + node_primary.start() + + print("# testing logical timeline following with a filesystem-level copy") + + node_primary.safe_sql( + "SELECT pg_create_logical_replication_slot('before_basebackup', " + "'test_decoding');" + ) + node_primary.safe_sql("CREATE TABLE decoding(blah text);") + node_primary.safe_sql("INSERT INTO decoding(blah) VALUES ('beforebb');") + + # We also want to verify that DROP DATABASE on a standby with a logical + # slot works. This isn't strictly related to timeline following, but the + # only way to get a logical slot on a standby right now is to use the same + # physical copy trick, so: + node_primary.safe_sql("CREATE DATABASE dropme;") + node_primary.safe_sql( + "SELECT pg_create_logical_replication_slot('dropme_slot', 'test_decoding');", + dbname="dropme", + ) + + node_primary.safe_sql("CHECKPOINT;") + + backup_name = "b1" + node_primary.stop() + node_primary.backup_fs_cold(backup_name) + node_primary.start() + + node_primary.safe_sql("SELECT pg_create_physical_replication_slot('phys_slot');") + + node_replica = create_pg("replica", start=False) + node_replica.init_from_backup( + node_primary, backup_name, has_streaming=True, has_restoring=True + ) + node_replica.append_conf("primary_slot_name = 'phys_slot'") + + node_replica.start() + + # If we drop 'dropme' on the primary, the standby should drop the db and + # associated slot. + res = node_primary.sql("DROP DATABASE dropme") + assert res.error_message is None, "dropped DB with logical slot OK on primary" + node_primary.wait_for_catchup(node_replica) + assert ( + node_replica.safe_sql("SELECT 1 FROM pg_database WHERE datname = 'dropme'") + == "" + ), "dropped DB dropme on standby" + assert ( + node_replica.slot("dropme_slot")["plugin"] == "" + ), "logical slot was actually dropped on standby" + + # Back to testing failover... + node_primary.safe_sql( + "SELECT pg_create_logical_replication_slot('after_basebackup', " + "'test_decoding');" + ) + node_primary.safe_sql("INSERT INTO decoding(blah) VALUES ('afterbb');") + node_primary.safe_sql("CHECKPOINT;") + + # Verify that only the before base_backup slot is on the replica + stdout = node_replica.safe_sql( + "SELECT slot_name FROM pg_replication_slots ORDER BY slot_name" + ) + assert ( + stdout == "before_basebackup" + ), "Expected to find only slot before_basebackup on replica" + + # Examine the physical slot the replica uses to stream changes from the + # primary to make sure its hot_standby_feedback has locked in a + # catalog_xmin on the physical slot, and that any xmin is >= the + # catalog_xmin + assert node_primary.poll_query_until( + """ + SELECT catalog_xmin IS NOT NULL + FROM pg_replication_slots + WHERE slot_name = 'phys_slot' + """ + ), "slot's catalog_xmin never became set" + + phys_slot = node_primary.slot("phys_slot") + assert phys_slot["xmin"] != "", "xmin assigned on physical slot of primary" + assert ( + phys_slot["catalog_xmin"] != "" + ), "catalog_xmin assigned on physical slot of primary" + + # Ignore wrap-around here, we're on a new cluster: + assert int(phys_slot["xmin"]) >= int( + phys_slot["catalog_xmin"] + ), "xmin on physical slot must not be lower than catalog_xmin" + + node_primary.safe_sql("CHECKPOINT") + node_primary.wait_for_catchup(node_replica, "write") + + # Boom, crash + node_primary.stop("immediate") + + node_replica.promote() + + node_replica.safe_sql("INSERT INTO decoding(blah) VALUES ('after failover');") + + # Shouldn't be able to read from slot created after base backup + res = node_replica.sql( + "SELECT data FROM pg_logical_slot_peek_changes('after_basebackup', " + "NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');" + ) + assert res.error_message is not None, "replaying from after_basebackup slot fails" + assert re.search( + r'replication slot "after_basebackup" does not exist', res.error_message + ), "after_basebackup slot missing" + + # Should be able to read from slot created before base backup + res = node_replica.sql( + "SELECT data FROM pg_logical_slot_peek_changes('before_basebackup', " + "NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');" + ) + assert res.error_message is None, "replay from slot before_basebackup succeeds" + + final_expected_output_bb = ( + "BEGIN\n" + "table public.decoding: INSERT: blah[text]:'beforebb'\n" + "COMMIT\n" + "BEGIN\n" + "table public.decoding: INSERT: blah[text]:'afterbb'\n" + "COMMIT\n" + "BEGIN\n" + "table public.decoding: INSERT: blah[text]:'after failover'\n" + "COMMIT" + ) + stdout = "\n".join(row[0] for row in res.rows) + assert ( + stdout == final_expected_output_bb + ), "decoded expected data from slot before_basebackup" + + # So far we've peeked the slots, so when we fetch the same info over + # pg_recvlogical we should get complete results. First, find out the + # commit lsn of the last transaction. There's no max(pg_lsn), so: + endpos = node_replica.safe_sql( + "SELECT lsn FROM pg_logical_slot_peek_changes('before_basebackup', " + "NULL, NULL) ORDER BY lsn DESC LIMIT 1;" + ) + + # now use the walsender protocol to peek the slot changes and make sure we + # see the same results. + result = node_replica.pg_recvlogical_upto( + "postgres", + "before_basebackup", + endpos, + TIMEOUT_DEFAULT, + **{"include-xids": "0", "skip-empty-xacts": "1"} + ) + + # walsender likes to add a newline + stdout = result.stdout.rstrip("\n") + assert ( + stdout == final_expected_output_bb + ), "got same output from walsender via pg_recvlogical on before_basebackup" diff --git a/src/test/recovery/pyt/test_012_subtransactions.py b/src/test/recovery/pyt/test_012_subtransactions.py new file mode 100644 index 00000000000..a6819af1ebe --- /dev/null +++ b/src/test/recovery/pyt/test_012_subtransactions.py @@ -0,0 +1,170 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests dedicated to subtransactions in recovery.""" + +# Function borrowed from src/test/regress/sql/hs_primary_extremes.sql +_HS_SUBXIDS = """ + CREATE OR REPLACE FUNCTION hs_subxids (n integer) + RETURNS void + LANGUAGE plpgsql + AS $$ + BEGIN + IF n <= 0 THEN RETURN; END IF; + INSERT INTO t_012_tbl VALUES (n); + PERFORM hs_subxids(n - 1); + RETURN; + EXCEPTION WHEN raise_exception THEN NULL; END; + $$;""" + + +def test_012_subtransactions(create_pg): + # Setup primary node + node_primary = create_pg("primary", start=False, allows_streaming=True) + node_primary.append_conf( + """ + max_prepared_transactions = 10 + log_checkpoints = true +""" + ) + node_primary.start() + node_primary.backup("primary_backup") + node_primary.safe_sql("CREATE TABLE t_012_tbl (id int)") + + # Setup standby node + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, "primary_backup", has_streaming=True) + node_standby.start() + + # Switch to synchronous replication + node_primary.append_conf( + """ + synchronous_standby_names = '*' +""" + ) + node_primary.safe_sql("SELECT pg_reload_conf()") + + ########################################################################### + # Check that replay will correctly set SUBTRANS and properly advance + # nextXid so that it won't conflict with savepoint xids. + ########################################################################### + + node_primary.safe_sql( + """ + BEGIN; + DELETE FROM t_012_tbl; + INSERT INTO t_012_tbl VALUES (43); + SAVEPOINT s1; + INSERT INTO t_012_tbl VALUES (43); + SAVEPOINT s2; + INSERT INTO t_012_tbl VALUES (43); + SAVEPOINT s3; + INSERT INTO t_012_tbl VALUES (43); + SAVEPOINT s4; + INSERT INTO t_012_tbl VALUES (43); + SAVEPOINT s5; + INSERT INTO t_012_tbl VALUES (43); + PREPARE TRANSACTION 'xact_012_1'; + CHECKPOINT;""" + ) + + node_primary.stop() + node_primary.start() + # here we can get xid of previous savepoint if nextXid + # wasn't properly advanced. COMMIT PREPARED must run outside a + # transaction block, so it is issued as a separate query (libpq's + # simple query protocol wraps a multi-statement string in one implicit + # transaction, unlike psql which splits on ';'). + node_primary.safe_sql( + """ + BEGIN; + INSERT INTO t_012_tbl VALUES (142); + ROLLBACK;""" + ) + node_primary.safe_sql("COMMIT PREPARED 'xact_012_1';") + + psql_out = node_primary.safe_sql("SELECT count(*) FROM t_012_tbl") + assert psql_out == "6", "Check nextXid handling for prepared subtransactions" + + ########################################################################### + # Check that replay will correctly set 2PC with more than + # PGPROC_MAX_CACHED_SUBXIDS subtransactions and also show data properly + # on promotion + ########################################################################### + node_primary.safe_sql("DELETE FROM t_012_tbl") + + node_primary.safe_sql(_HS_SUBXIDS) + node_primary.safe_sql( + """ + BEGIN; + SELECT hs_subxids(127); + COMMIT;""" + ) + node_primary.wait_for_catchup(node_standby) + psql_out = node_standby.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "8128", "Visible" + node_primary.stop() + node_standby.promote() + + psql_out = node_standby.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "8128", "Visible" + + # restore state + node_primary, node_standby = node_standby, node_primary + node_standby.enable_streaming(node_primary) + node_standby.start() + psql_out = node_standby.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "8128", "Visible" + + node_primary.safe_sql("DELETE FROM t_012_tbl") + + node_primary.safe_sql(_HS_SUBXIDS) + node_primary.safe_sql( + """ + BEGIN; + SELECT hs_subxids(127); + PREPARE TRANSACTION 'xact_012_1';""" + ) + node_primary.wait_for_catchup(node_standby) + psql_out = node_standby.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "-1", "Not visible" + node_primary.stop() + node_standby.promote() + + psql_out = node_standby.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "-1", "Not visible" + + # restore state + node_primary, node_standby = node_standby, node_primary + node_standby.enable_streaming(node_primary) + node_standby.start() + # safe_sql raises on error, so a successful return mirrors psql_rc == 0 + node_primary.safe_sql("COMMIT PREPARED 'xact_012_1'") + + psql_out = node_primary.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "8128", "Visible" + + node_primary.safe_sql("DELETE FROM t_012_tbl") + node_primary.safe_sql( + """ + BEGIN; + SELECT hs_subxids(201); + PREPARE TRANSACTION 'xact_012_1';""" + ) + node_primary.wait_for_catchup(node_standby) + psql_out = node_standby.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "-1", "Not visible" + node_primary.stop() + node_standby.promote() + + psql_out = node_standby.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "-1", "Not visible" + + # restore state + node_primary, node_standby = node_standby, node_primary + node_standby.enable_streaming(node_primary) + node_standby.start() + # safe_sql raises on error, so a successful return mirrors psql_rc == 0 + node_primary.safe_sql("ROLLBACK PREPARED 'xact_012_1'") + + psql_out = node_primary.safe_sql("SELECT coalesce(sum(id),-1) FROM t_012_tbl") + assert psql_out == "-1", "Not visible" diff --git a/src/test/recovery/pyt/test_013_crash_restart.py b/src/test/recovery/pyt/test_013_crash_restart.py new file mode 100644 index 00000000000..8a49a85afec --- /dev/null +++ b/src/test/recovery/pyt/test_013_crash_restart.py @@ -0,0 +1,237 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests restarts of postgres due to crashes of a subprocess.""" + +# Two longer-running libpq sessions are used: One to kill a backend, +# triggering a crash-restart cycle, one to detect when postmaster +# noticed the backend died. The second backend is necessary because +# it's otherwise hard to determine if postmaster is still accepting new +# sessions (because it hasn't noticed that the backend died), or because +# it's already restarted. +# + +import re + +from libpq import ConnStatusType + +# Patterns matching how psql/libpq reports that the connection to the +# backend was lost. The first WARNING variant only appears on SIGQUIT, +# where the backend's signal handlers get a chance to run. +_SIGQUIT_DIED = re.compile( + r"terminating connection because of unexpected SIGQUIT signal" + r"|server closed the connection unexpectedly" + r"|connection to server was lost" + r"|could not send data to server" + r"|no connection to the server" +) + +_CRASH_DIED = re.compile( + r"terminating connection because of crash of another server process" + r"|server closed the connection unexpectedly" + r"|connection to server was lost" + r"|could not send data to server" + r"|no connection to the server" +) + + +def _async_died(session, pattern): + """Send a long sleep async and confirm the session dies due to the crash. + + Sends 'SELECT 1' / 'SELECT pg_sleep(3600)' on a psql session and waits + for the connection-loss / crash message on stderr. + """ + session.do_async("SELECT pg_sleep(3600)") + res = session.get_async_result() + if res is None: + # Connection dropped with no error result; confirm it is no longer OK. + assert ( + session.conn_status() != ConnStatusType.CONNECTION_OK + ), "session should have died after crash" + return + msg = (res.error_message or "") + (res.psqlout or "") + assert pattern.search(msg), f"session died successfully after crash; got: {msg!r}" + + +def test_013_crash_restart(create_pg): + node = create_pg("primary", start=False, allows_streaming=True) + + # Enable pg_stat_statements to test restart of shared_preload_libraries. + node.append_conf( + "shared_preload_libraries = 'pg_stat_statements'\n" + "pg_stat_statements.max = 50000\n" + "compute_query_id = 'regress'\n" + ) + + node.start() + + # by default the framework doesn't restart after a crash. ALTER SYSTEM + # cannot run inside a transaction block, so issue each statement + # separately. + node.safe_sql("ALTER SYSTEM SET restart_after_crash = 1") + node.safe_sql("ALTER SYSTEM SET log_connections = receipt") + node.safe_sql("SELECT pg_reload_conf()") + + # Remember the time that pg_stat_statements was reset. We'll use it later + # to verify that it gets re-initialized after crash. + node.safe_sql("CREATE EXTENSION pg_stat_statements") + stats_reset = node.safe_sql("SELECT stats_reset FROM pg_stat_statements_info") + + # libpq session, keeping it alive, so we have an alive backend to kill. + killme = node.connect("postgres") + # Need a second session to check if crash-restart happened. + monitor = node.connect("postgres") + + try: + # create table, insert row that should survive + res = killme.query( + "CREATE TABLE alive(status text);\n" + "INSERT INTO alive VALUES($$committed-before-sigquit$$);\n" + "SELECT pg_backend_pid();" + ) + assert res.error_message is None, res.error_message + pid = int(res.psqlout.strip().splitlines()[-1]) + + # insert a row that should *not* survive, due to in-progress xact + res = killme.query( + "BEGIN;\n" + "INSERT INTO alive VALUES($$in-progress-before-sigquit$$)" + " RETURNING status;" + ) + assert res.error_message is None, res.error_message + assert re.search("in-progress-before-sigquit", res.psqlout), res.psqlout + + # Start longrunning query in second session; its failure will signal + # that crash-restart has occurred. The initial trivial select is to + # be sure that the session successfully connected to the backend. + marker = monitor.query_oneval("SELECT $$psql-connected$$") + assert marker == "psql-connected", marker + + # kill once with QUIT - we expect the backend to exit, while emitting + # an error message first. + node.signal_backend(pid, "QUIT") + + # Check that the killme session sees the killed backend as having been + # terminated. + _async_died(killme, _SIGQUIT_DIED) + killme.close() + + # Wait till server restarts - we should get the WARNING here, but + # sometimes the server is unable to send that, if interrupted while + # sending. + _async_died(monitor, _CRASH_DIED) + monitor.close() + finally: + try: + killme.close() + except Exception: # pylint: disable=broad-exception-caught + pass + try: + monitor.close() + except Exception: # pylint: disable=broad-exception-caught + pass + + # Wait till server restarts + assert node.poll_query_until("SELECT 1", expected="1"), "reconnected after SIGQUIT" + + # restart sessions, now that the crash cycle finished + killme = node.connect("postgres") + monitor = node.connect("postgres") + + try: + # Verify that pg_stat_statements, loaded via shared_preload_libraries, + # was re-initialized at the crash. + stats_reset_after = node.safe_sql( + "SELECT stats_reset FROM pg_stat_statements_info" + ) + assert ( + stats_reset != stats_reset_after + ), "pg_stat_statements was reset by restart" + + # Acquire pid of new backend + pid = int(killme.query_oneval("SELECT pg_backend_pid()")) + + # Insert test rows. The committed row must land in its own + # transaction (it must be committed before the BEGIN); + # the in-process Session wraps a multi-statement query in a single + # implicit transaction, so issue the committed INSERT separately or it + # would be rolled back together with the in-progress one at SIGKILL. + res = killme.query( + "INSERT INTO alive VALUES($$committed-before-sigkill$$)" + " RETURNING status;" + ) + assert res.error_message is None, res.error_message + res = killme.query( + "BEGIN;\n" + "INSERT INTO alive VALUES($$in-progress-before-sigkill$$)" + " RETURNING status;" + ) + assert res.error_message is None, res.error_message + assert re.search("in-progress-before-sigkill", res.psqlout), res.psqlout + + # Re-start longrunning query in second session; its failure will + # signal that crash-restart has occurred. + marker = monitor.query_oneval("SELECT $$psql-connected$$") + assert marker == "psql-connected", marker + + # kill with SIGKILL this time - we expect the backend to exit, without + # being able to emit an error message. + node.signal_backend(pid, "KILL") + + # Check that the killme session sees the server as being terminated. + # No WARNING, because signal handlers aren't being run on SIGKILL. + _async_died(killme, _SIGQUIT_DIED) + killme.close() + + # Wait till server restarts. + _async_died(monitor, _CRASH_DIED) + monitor.close() + finally: + try: + killme.close() + except Exception: # pylint: disable=broad-exception-caught + pass + try: + monitor.close() + except Exception: # pylint: disable=broad-exception-caught + pass + + # Wait till server restarts + assert node.poll_query_until("SELECT 1", expected="1"), "reconnected after SIGKILL" + + # Make sure the committed rows survived, in-progress ones not + assert ( + node.safe_sql("SELECT * FROM alive") + == "committed-before-sigquit\ncommitted-before-sigkill" + ), "data survived" + + assert ( + node.safe_sql( + "INSERT INTO alive VALUES($$before-orderly-restart$$) RETURNING status" + ) + == "before-orderly-restart" + ), "can still write after crash restart" + + # Confirm that the logical replication launcher, a background worker + # without the never-restart flag, has also restarted successfully. + assert node.poll_query_until( + "SELECT count(*) = 1 FROM pg_stat_activity" + " WHERE backend_type = 'logical replication launcher'" + ), "logical replication launcher restarted after crash" + + # Just to be sure, check that an orderly restart now still works + node.restart() + + assert ( + node.safe_sql("SELECT * FROM alive") + == "committed-before-sigquit\ncommitted-before-sigkill\n" + "before-orderly-restart" + ), "data survived" + + assert ( + node.safe_sql( + "INSERT INTO alive VALUES($$after-orderly-restart$$) RETURNING status" + ) + == "after-orderly-restart" + ), "can still write after orderly restart" + + node.stop() diff --git a/src/test/recovery/pyt/test_014_unlogged_reinit.py b/src/test/recovery/pyt/test_014_unlogged_reinit.py new file mode 100644 index 00000000000..b9ed22b4789 --- /dev/null +++ b/src/test/recovery/pyt/test_014_unlogged_reinit.py @@ -0,0 +1,146 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests that unlogged tables are properly reinitialized after a crash. + +The behavior should be the same when restoring from a backup, but +that is not tested here. +""" + +import os + +from pypg.util import append_to_file + + +def test_014_unlogged_reinit(create_pg, tmp_path): + node = create_pg("main") + pgdata = node.data_dir + + # Create an unlogged table and an unlogged sequence to test that forks + # other than init are not copied. + node.safe_sql("CREATE UNLOGGED TABLE base_unlogged (id int)") + node.safe_sql("CREATE UNLOGGED SEQUENCE seq_unlogged") + + base_unlogged_path = node.safe_sql("select pg_relation_filepath('base_unlogged')") + seq_unlogged_path = node.safe_sql("select pg_relation_filepath('seq_unlogged')") + + # Test that main and init forks exist. + assert os.path.isfile( + f"{pgdata}/{base_unlogged_path}_init" + ), "table init fork exists" + assert os.path.isfile(f"{pgdata}/{base_unlogged_path}"), "table main fork exists" + assert os.path.isfile( + f"{pgdata}/{seq_unlogged_path}_init" + ), "sequence init fork exists" + assert os.path.isfile(f"{pgdata}/{seq_unlogged_path}"), "sequence main fork exists" + + # Test the sequence + assert node.safe_sql("SELECT nextval('seq_unlogged')") == "1", "sequence nextval" + assert ( + node.safe_sql("SELECT nextval('seq_unlogged')") == "2" + ), "sequence nextval again" + + # Create an unlogged table in a tablespace. + tablespace_dir = str(tmp_path / "ts1") + os.mkdir(tablespace_dir) + + node.safe_sql(f"CREATE TABLESPACE ts1 LOCATION '{tablespace_dir}'") + node.safe_sql("CREATE UNLOGGED TABLE ts1_unlogged (id int) TABLESPACE ts1") + + ts1_unlogged_path = node.safe_sql("select pg_relation_filepath('ts1_unlogged')") + + # Test that main and init forks exist. + assert os.path.isfile( + f"{pgdata}/{ts1_unlogged_path}_init" + ), "init fork in tablespace exists" + assert os.path.isfile( + f"{pgdata}/{ts1_unlogged_path}" + ), "main fork in tablespace exists" + + # Create more unlogged sequences for testing. + node.safe_sql("CREATE UNLOGGED SEQUENCE seq_unlogged2") + # This rewrites the sequence relation in AlterSequence(). + node.safe_sql("ALTER SEQUENCE seq_unlogged2 INCREMENT 2") + node.safe_sql("SELECT nextval('seq_unlogged2')") + + node.safe_sql( + "CREATE UNLOGGED TABLE tab_seq_unlogged3 " + "(a int GENERATED ALWAYS AS IDENTITY)" + ) + # This rewrites the sequence relation in ResetSequence(). + node.safe_sql("TRUNCATE tab_seq_unlogged3 RESTART IDENTITY") + node.safe_sql("INSERT INTO tab_seq_unlogged3 DEFAULT VALUES") + + # Crash the postmaster. + node.stop("immediate") + + # Write fake forks to test that they are removed during recovery. + append_to_file(f"{pgdata}/{base_unlogged_path}_vm", "TEST_VM") + append_to_file(f"{pgdata}/{base_unlogged_path}_fsm", "TEST_FSM") + + # Remove main fork to test that it is recopied from init. + os.unlink(f"{pgdata}/{base_unlogged_path}") + os.unlink(f"{pgdata}/{seq_unlogged_path}") + + # the same for the tablespace + append_to_file(f"{pgdata}/{ts1_unlogged_path}_vm", "TEST_VM") + append_to_file(f"{pgdata}/{ts1_unlogged_path}_fsm", "TEST_FSM") + os.unlink(f"{pgdata}/{ts1_unlogged_path}") + + node.start() + + # check unlogged table in base + assert os.path.isfile( + f"{pgdata}/{base_unlogged_path}_init" + ), "table init fork in base still exists" + assert os.path.isfile( + f"{pgdata}/{base_unlogged_path}" + ), "table main fork in base recreated at startup" + assert not os.path.isfile( + f"{pgdata}/{base_unlogged_path}_vm" + ), "vm fork in base removed at startup" + assert not os.path.isfile( + f"{pgdata}/{base_unlogged_path}_fsm" + ), "fsm fork in base removed at startup" + + # check unlogged sequence + assert os.path.isfile( + f"{pgdata}/{seq_unlogged_path}_init" + ), "sequence init fork still exists" + assert os.path.isfile( + f"{pgdata}/{seq_unlogged_path}" + ), "sequence main fork recreated at startup" + + # Test the sequence after restart + assert ( + node.safe_sql("SELECT nextval('seq_unlogged')") == "1" + ), "sequence nextval after restart" + assert ( + node.safe_sql("SELECT nextval('seq_unlogged')") == "2" + ), "sequence nextval after restart again" + + # check unlogged table in tablespace + assert os.path.isfile( + f"{pgdata}/{ts1_unlogged_path}_init" + ), "init fork still exists in tablespace" + assert os.path.isfile( + f"{pgdata}/{ts1_unlogged_path}" + ), "main fork in tablespace recreated at startup" + assert not os.path.isfile( + f"{pgdata}/{ts1_unlogged_path}_vm" + ), "vm fork in tablespace removed at startup" + assert not os.path.isfile( + f"{pgdata}/{ts1_unlogged_path}_fsm" + ), "fsm fork in tablespace removed at startup" + + # Test other sequences + assert ( + node.safe_sql("SELECT nextval('seq_unlogged2')") == "1" + ), "altered sequence nextval after restart" + assert ( + node.safe_sql("SELECT nextval('seq_unlogged2')") == "3" + ), "altered sequence nextval after restart again" + + node.safe_sql("INSERT INTO tab_seq_unlogged3 VALUES (DEFAULT), (DEFAULT)") + assert ( + node.safe_sql("SELECT * FROM tab_seq_unlogged3") == "1\n2" + ), "reset sequence nextval after restart" diff --git a/src/test/recovery/pyt/test_015_promotion_pages.py b/src/test/recovery/pyt/test_015_promotion_pages.py new file mode 100644 index 00000000000..65937ca2fcb --- /dev/null +++ b/src/test/recovery/pyt/test_015_promotion_pages.py @@ -0,0 +1,87 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test for promotion handling with WAL records generated post-promotion +before the first checkpoint is generated. This test case checks for +invalid page references at replay based on the minimum consistent +recovery point defined. +""" + + +def test_015_promotion_pages(create_pg): + # Initialize primary node + alpha = create_pg("alpha", start=False, allows_streaming=True) + # Setting wal_log_hints to off is important to get invalid page + # references. + alpha.append_conf( + """ +wal_log_hints = off +""" + ) + + # Start the primary + alpha.start() + + # setup/start a standby + alpha.backup("bkp") + bravo = create_pg("bravo", start=False) + bravo.init_from_backup(alpha, "bkp", has_streaming=True) + bravo.append_conf( + """ +checkpoint_timeout=1h +""" + ) + bravo.start() + + # Dummy table for the upcoming tests. + alpha.safe_sql("create table test1 (a int)") + alpha.safe_sql("insert into test1 select generate_series(1, 10000)") + + # take a checkpoint + alpha.safe_sql("checkpoint") + + # The following vacuum will set visibility map bits and create + # problematic WAL records. + alpha.safe_sql("vacuum verbose test1") + # Wait for last record to have been replayed on the standby. + alpha.wait_for_catchup(bravo) + + # Now force a checkpoint on the standby. This seems unnecessary but for + # "some" reason, the previous checkpoint on the primary does not reflect on + # the standby and without an explicit checkpoint, it may start redo + # recovery from a much older point, which includes even create table and + # initial page additions. + bravo.safe_sql("checkpoint") + + # Now just use a dummy table and run some operations to move + # minRecoveryPoint beyond the previous vacuum. + alpha.safe_sql("create table test2 (a int, b bytea)") + alpha.safe_sql( + "insert into test2 select generate_series(1,10000), " + "sha256(random()::text::bytea)" + ) + alpha.safe_sql("truncate test2") + + # Wait again for all records to be replayed. + alpha.wait_for_catchup(bravo) + + # Do the promotion, which reinitializes minRecoveryPoint in the control + # file so as WAL is replayed up to the end. + bravo.promote() + + # Truncate the table on the promoted standby, vacuum and extend it + # again to create new page references. The first post-recovery checkpoint + # has not happened yet. + bravo.safe_sql("truncate test1") + bravo.safe_sql("vacuum verbose test1") + bravo.safe_sql("insert into test1 select generate_series(1,1000)") + + # Now crash-stop the promoted standby and restart. This makes sure that + # replay does not see invalid page references because of an invalid + # minimum consistent recovery point. + bravo.stop("immediate") + bravo.start() + + # Check state of the table after full crash recovery. All its data should + # be here. + psql_out = bravo.safe_sql("SELECT count(*) FROM test1") + assert psql_out == "1000", "Check that table state is correct" diff --git a/src/test/recovery/pyt/test_016_min_consistency.py b/src/test/recovery/pyt/test_016_min_consistency.py new file mode 100644 index 00000000000..ad2887489e4 --- /dev/null +++ b/src/test/recovery/pyt/test_016_min_consistency.py @@ -0,0 +1,134 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test for checking consistency of on-disk pages for a cluster with +the minimum recovery LSN, ensuring that the updates happen across +all processes. In this test, the updates from the startup process +and the checkpointer (which triggers non-startup code paths) are +both checked. +""" + +import os +import re +import struct + + +def find_largest_lsn(blocksize, filename): + """Find the largest LSN in the set of pages part of the given relation file. + + This is used for offline checks of page consistency. The LSN is + historically stored as a set of two numbers of 4 byte-length located at + the beginning of each page. + """ + blocksize = int(blocksize) + max_hi, max_lo = 0, 0 + with open(filename, "rb") as fh: + while True: + buf = fh.read(blocksize) + if not buf: + break + assert ( + len(buf) == blocksize + ), f"read only {len(buf)} of {blocksize} bytes from {filename}" + hi, lo = struct.unpack(" max_hi or (hi == max_hi and lo > max_lo): + max_hi, max_lo = hi, lo + + return "%X/%08X" % (max_hi, max_lo) + + +def test_016_min_consistency(create_pg, pg_bin): + # Initialize primary node + primary = create_pg("primary", allows_streaming=True, start=False) + + # Set shared_buffers to a very low value to enforce discard and flush + # of PostgreSQL buffers on standby, enforcing other processes than the + # startup process to update the minimum recovery LSN in the control + # file. Autovacuum is disabled so as there is no risk of having other + # processes than the checkpointer doing page flushes. + primary.append_conf( + """ +shared_buffers = 128kB +autovacuum = off +""" + ) + + # Start the primary + primary.start() + + # setup/start a standby + primary.backup("bkp") + standby = create_pg("standby", start=False) + standby.init_from_backup(primary, "bkp", has_streaming=True) + standby.start() + + # Create base table whose data consistency is checked. + primary.safe_sql( + """ +CREATE TABLE test1 (a int) WITH (fillfactor = 10); +INSERT INTO test1 SELECT generate_series(1, 10000);""" + ) + + # Take a checkpoint and enforce post-checkpoint full page writes + # which makes the startup process replay those pages, updating + # minRecoveryPoint. + primary.safe_sql("CHECKPOINT;") + primary.safe_sql("UPDATE test1 SET a = a + 1;") + + # Wait for last record to have been replayed on the standby. + primary.wait_for_catchup(standby) + + # Fill in the standby's shared buffers with the data filled in + # previously. + standby.safe_sql("SELECT count(*) FROM test1;") + + # Update the table again, this does not generate full page writes so + # the standby will replay records associated with it, but the startup + # process will not flush those pages. + primary.safe_sql("UPDATE test1 SET a = a + 1;") + + # Extract from the relation the last block created and its relation + # file, this will be used at the end of the test for sanity checks. + blocksize = primary.safe_sql( + "SELECT setting::int FROM pg_settings WHERE name = 'block_size';" + ) + primary.safe_sql(f"SELECT pg_relation_size('test1')::int / {blocksize} - 1;") + relfilenode = primary.safe_sql("SELECT pg_relation_filepath('test1'::regclass);") + + # Wait for last record to have been replayed on the standby. + primary.wait_for_catchup(standby) + + # Issue a restart point on the standby now, which makes the checkpointer + # update minRecoveryPoint. + standby.safe_sql("CHECKPOINT;") + + # Now shut down the primary violently so as the standby does not + # receive the shutdown checkpoint, making sure that the startup + # process does not flush any pages on its side. The standby is + # cleanly stopped, which makes the checkpointer update minRecoveryPoint + # with the restart point created at shutdown. + primary.stop("immediate") + standby.stop("fast") + + # Check the data consistency of the instance while offline. This is + # done by directly scanning the on-disk relation blocks and what + # pg_controldata lets know. + standby_data = standby.data_dir + offline_max_lsn = find_largest_lsn( + blocksize, os.path.join(standby_data, relfilenode) + ) + + # Fetch minRecoveryPoint from the control file itself + res = pg_bin.result(["pg_controldata", standby_data]) + offline_recovery_lsn = None + for line in res.stdout.split("\n"): + m = re.match(r"^Minimum recovery ending location:\s*(.*)$", line) + if m: + offline_recovery_lsn = m.group(1) + break + assert offline_recovery_lsn is not None, "No minRecoveryPoint in control file found" + + # minRecoveryPoint should never be older than the maximum LSN for all + # the pages on disk. + assert ( + offline_recovery_lsn >= offline_max_lsn + ), "Check offline that table data is consistent with minRecoveryPoint" diff --git a/src/test/recovery/pyt/test_017_shm.py b/src/test/recovery/pyt/test_017_shm.py new file mode 100644 index 00000000000..d71d6f3567c --- /dev/null +++ b/src/test/recovery/pyt/test_017_shm.py @@ -0,0 +1,265 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests of pg_shmem.h functions.""" + +# PostgreSQL keys its main System V shared memory segment on the data +# directory's inode. These tests create a conflicting segment at that key, +# then exercise the postmaster's detection of pre-existing segments across +# restarts, crashes, and a stuck live backend. We drive shmget/shmctl through +# ctypes so the suite keeps pytest as its only third-party dependency. + +import ctypes +import ctypes.util +import os +import re +import signal +import subprocess +import sys +import time + +import pytest + +from pypg.util import TIMEOUT_DEFAULT + +# -- System V shared memory via libc (the IPC::SharedMem stand-in) ----------- + +IPC_CREAT = 0o1000 +IPC_EXCL = 0o2000 +IPC_RMID = 0 +# S_IRUSR | S_IWUSR +SHM_MODE = 0o600 + +try: + _libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6", use_errno=True) + # key_t is int; size is size_t; flags/cmd are int. + _libc.shmget.argtypes = [ctypes.c_int, ctypes.c_size_t, ctypes.c_int] + _libc.shmget.restype = ctypes.c_int + _libc.shmctl.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p] + _libc.shmctl.restype = ctypes.c_int + _SYSV_AVAILABLE = True +except (OSError, AttributeError): + _SYSV_AVAILABLE = False + + +def _key_t(value): + """Truncate an inode to a signed 32-bit C key_t, matching the backend. + + PostgreSQL keys its main segment on the datadir inode via + ``NextShmemSegID = statbuf.st_ino`` (src/backend/port/sysv_shmem.c), an + implicit cast of ino_t to key_t (int). We reproduce that two's-complement + truncation so our conflicting segment lands on the same key the postmaster + will pick. This is correct on every platform PostgreSQL's SysV path runs + on; the one assumption to revisit is an exotic filesystem handing out + inodes wide enough that the truncation diverges from the backend's cast. + """ + value &= 0xFFFFFFFF + if value >= 0x80000000: + value -= 0x100000000 + return value + + +class SysVSharedMem: + """A System V shared memory segment, mirroring the bits of IPC::SharedMem + that 017_shm needs: create-exclusive and remove.""" + + def __init__(self, key, size, flags): + ctypes.set_errno(0) + shmid = _libc.shmget(_key_t(key), size, flags) + if shmid == -1: + err = ctypes.get_errno() + raise OSError(err, os.strerror(err)) + self.shmid = shmid + + def remove(self): + _libc.shmctl(self.shmid, IPC_RMID, None) + + +def make_conflict_shm(key): + """Create a 1024-byte segment at *key*, or return None if the key is taken. + + Creates the segment with IPC_CREAT|IPC_EXCL|S_IRUSR|S_IWUSR; a key + collision is fine and just exercises a different scenario. + """ + try: + return SysVSharedMem(key, 1024, IPC_CREAT | IPC_EXCL | SHM_MODE) + except OSError: + return None + + +# -- ipcs diff logging (best effort, purely diagnostic) ---------------------- + + +def _ipcs(): + try: + return subprocess.run( + ["ipcs", "-am"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ).stdout + except OSError: + return None + + +def _log_ipcs(baseline): + """Print the diff of current `ipcs -am` against *baseline*, swallowing any + error (the platform may lack ipcs).""" + current = _ipcs() + if current is None or baseline is None: + return + if current != baseline: + print("# ipcs -am diff:\n" + current) + + +# -- start with retries (poll_start) ----------------------------------------- + + +def poll_start(node): + """Start *node*, retrying for the reasons Cluster::start can transiently + fail after a kill9 (slow SIGKILL delivery, slow child exit, etc.).""" + max_attempts = 10 * TIMEOUT_DEFAULT + for _ in range(max_attempts): + if node.start(fail_ok=True): + return True + time.sleep(0.1) + # Clean up in case the attempt timed out with a postmaster half-up. + node.stop("fast", fail_ok=True) + # One last try that raises on failure. + return node.start() + + +_PRE_EXISTING = re.compile(r"pre-existing shared memory block") + + +def test_017_shm(create_pg): + if sys.platform == "win32" or not _SYSV_AVAILABLE: + pytest.skip("SysV shared memory not supported by this platform") + + baseline = _ipcs() + + # Node setup (not started yet; we create the conflicting segment first). + gnat = create_pg("gnat", start=False) + + # Create a shmem segment that will conflict with gnat's first choice of + # shmem key. (If something else already holds that key, that's fine, + # though the test then exercises a different scenario than usual.) + gnat_inode = os.stat(gnat.data_dir).st_ino + print(f"# gnat's datadir inode = {gnat_inode}") + + conflict = make_conflict_shm(gnat_inode) + if conflict is None: + print("# could not create conflicting shmem") + _log_ipcs(baseline) + + gnat.start() + _log_ipcs(baseline) + + gnat.restart() # should keep same shmem key + _log_ipcs(baseline) + + # Upon postmaster death, postmaster children exit automatically. + gnat.kill9() + _log_ipcs(baseline) + poll_start(gnat) # gnat recycles its former shm key. + _log_ipcs(baseline) + + print("# removing the conflicting shmem ...") + if conflict: + conflict.remove() + _log_ipcs(baseline) + + # Upon postmaster death, postmaster children exit automatically. + gnat.kill9() + _log_ipcs(baseline) + + # In this start, gnat uses its normal shmem key, and fails to remove the + # higher-keyed segment that the previous postmaster was using. That's not + # great, but key collisions should be rare enough not to matter much. + poll_start(gnat) + _log_ipcs(baseline) + gnat.stop() + _log_ipcs(baseline) + + # Re-create the conflicting segment, and start/stop normally, just so this + # test doesn't leak the higher-keyed segment. + print("# re-creating conflicting shmem ...") + conflict = make_conflict_shm(gnat_inode) + if conflict is None: + print("# could not create conflicting shmem") + _log_ipcs(baseline) + + gnat.start() + _log_ipcs(baseline) + gnat.stop() + _log_ipcs(baseline) + + print("# removing the conflicting shmem ...") + if conflict: + conflict.remove() + _log_ipcs(baseline) + + # Scenarios involving no postmaster.pid, a dead postmaster, and a live + # backend. Use the regress.c wait_pid() function to emulate the + # responsiveness of a backend working through a CPU-intensive task. + gnat.start() + _log_ipcs(baseline) + + regress_shlib = os.environ.get("REGRESS_SHLIB") + if not regress_shlib: + pytest.skip("REGRESS_SHLIB not set in environment") + gnat.safe_sql( + "CREATE FUNCTION wait_pid(int) " + "RETURNS void " + f"AS '{regress_shlib}' " + "LANGUAGE C STRICT" + ) + + # Start a slow backend stuck in wait_pid() on its own PID; it spins until + # signalled and keeps its shared memory attachment after postmaster death. + slow_client = gnat.connect() + slow_pid = int(slow_client.query_oneval("SELECT pg_backend_pid()")) + slow_query = f"SELECT wait_pid({slow_pid})" + assert slow_client.do_async(slow_query) + assert gnat.poll_query_until( + f"SELECT 1 FROM pg_stat_activity WHERE pid = {slow_pid} AND state = 'active'", + "1", + ), "slow query started" + + gnat.kill9() + os.unlink(gnat.pidfile) + gnat.rotate_logfile() # on Windows, can't open old log for writing + _log_ipcs(baseline) + + # Reject ordinary startup. Retry for the same reasons poll_start() does, + # every 0.1s for at least TIMEOUT_DEFAULT seconds. + max_attempts = 10 * TIMEOUT_DEFAULT + for _ in range(max_attempts): + if gnat.start(fail_ok=True) or _PRE_EXISTING.search(gnat.log_content()): + break + time.sleep(0.1) + assert _PRE_EXISTING.search( + gnat.log_content() + ), "detected live backend via shared memory" + + # Reject single-user startup. + gnat.command_fails_like( + ["postgres", "--single", "-D", gnat.data_dir, "template1"], + _PRE_EXISTING, + "single-user mode detected live backend via shared memory", + ) + _log_ipcs(baseline) + + # Cleanup slow backend: SIGQUIT it (cf 'pg_ctl kill QUIT '), then the + # client detects the backend's termination. + os.kill(slow_pid, signal.SIGQUIT) + slow_client.close() + _log_ipcs(baseline) + + # Now startup should work. + poll_start(gnat) + _log_ipcs(baseline) + + # Finish testing. + gnat.stop() + _log_ipcs(baseline) diff --git a/src/test/recovery/pyt/test_018_wal_optimize.py b/src/test/recovery/pyt/test_018_wal_optimize.py new file mode 100644 index 00000000000..9a5c4f79730 --- /dev/null +++ b/src/test/recovery/pyt/test_018_wal_optimize.py @@ -0,0 +1,416 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test WAL replay when some operation has skipped WAL.""" + +# These tests exercise code that once violated the mandate described in +# src/backend/access/transam/README section "Skipping WAL for New +# RelFileLocator". The tests work by committing some transactions, initiating +# an immediate shutdown, and confirming that the expected data survives +# recovery. For many years, individual commands made the decision to skip WAL, +# hence the frequent appearance of COPY in these tests. + +import os +import re + +import pytest + + +def check_orphan_relfilenodes(node, test_name): + """Assert the data dir contains exactly the relfilenodes pg_class expects.""" + db_oid = node.safe_sql("SELECT oid FROM pg_database WHERE datname = 'postgres'") + prefix = f"base/{db_oid}/" + filepaths_referenced = node.safe_sql( + """ + SELECT pg_relation_filepath(oid) FROM pg_class + WHERE reltablespace = 0 AND relpersistence <> 't' AND + pg_relation_filepath(oid) IS NOT NULL;""" + ) + + dir_path = os.path.join(node.data_dir, prefix) + on_disk = sorted( + prefix + name for name in os.listdir(dir_path) if re.fullmatch(r"[0-9]+", name) + ) + referenced = sorted(filepaths_referenced.split("\n")) + assert on_disk == referenced, test_name + + +@pytest.mark.parametrize("wal_level", ["minimal", "replica"]) +def test_018_wal_optimize(create_pg, wal_level): + # We run this same test suite for both wal_level=minimal and replica. + node = create_pg(f"node_{wal_level}", start=False) + # The default (non-streaming) init sets wal_level = minimal and + # max_wal_senders = 0; mirror that, overriding wal_level for the + # replica case. + node.append_conf( + f""" +wal_level = {wal_level} +max_wal_senders = 0 +max_prepared_transactions = 1 +wal_log_hints = on +wal_skip_threshold = 0 +#wal_debug = on +""" + ) + node.start() + + # Setup + tablespace_dir = node.basedir + "/tablespace_other" + os.mkdir(tablespace_dir) + + # Test redo of CREATE TABLESPACE. + # + # The leading statements are run individually rather than as one + # multi-statement string: psql sends each top-level statement + # separately, so CREATE TABLESPACE runs outside any + # transaction block, whereas an in-process multi-statement PQexec would + # wrap them in one implicit transaction (CREATE TABLESPACE forbids that). + # The trailing BEGIN..COMMIT is a single deliberately-grouped transaction, + # so it is kept as one statement. + node.safe_sql("CREATE TABLE moved (id int);") + node.safe_sql("INSERT INTO moved VALUES (1);") + node.safe_sql(f"CREATE TABLESPACE other LOCATION '{tablespace_dir}';") + node.safe_sql( + """ + BEGIN; + ALTER TABLE moved SET TABLESPACE other; + CREATE TABLE originated (id int); + INSERT INTO originated VALUES (1); + CREATE UNIQUE INDEX ON originated(id) TABLESPACE other; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM moved;") + assert result == "1", f"wal_level = {wal_level}, CREATE+SET TABLESPACE" + result = node.safe_sql( + """ + INSERT INTO originated VALUES (1) ON CONFLICT (id) + DO UPDATE set id = originated.id + 1 + RETURNING id;""" + ) + assert result == "2", f"wal_level = {wal_level}, CREATE TABLESPACE, CREATE INDEX" + + # Test direct truncation optimization. No tuples. + node.safe_sql( + """ + BEGIN; + CREATE TABLE trunc (id serial PRIMARY KEY); + TRUNCATE trunc; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM trunc;") + assert result == "0", f"wal_level = {wal_level}, TRUNCATE with empty table" + + # Test truncation with inserted tuples within the same transaction. + # Tuples inserted after the truncation should be seen. + node.safe_sql( + """ + BEGIN; + CREATE TABLE trunc_ins (id serial PRIMARY KEY); + INSERT INTO trunc_ins VALUES (DEFAULT); + TRUNCATE trunc_ins; + INSERT INTO trunc_ins VALUES (DEFAULT); + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*), min(id) FROM trunc_ins;") + assert result == "1|2", f"wal_level = {wal_level}, TRUNCATE INSERT" + + # Same for prepared transaction. + # Tuples inserted after the truncation should be seen. + # PREPARE TRANSACTION ends the explicit transaction; COMMIT PREPARED must + # then run outside any transaction block, so it is a separate statement. + node.safe_sql( + """ + BEGIN; + CREATE TABLE twophase (id serial PRIMARY KEY); + INSERT INTO twophase VALUES (DEFAULT); + TRUNCATE twophase; + INSERT INTO twophase VALUES (DEFAULT); + PREPARE TRANSACTION 't';""" + ) + node.safe_sql("COMMIT PREPARED 't';") + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*), min(id) FROM trunc_ins;") + assert result == "1|2", f"wal_level = {wal_level}, TRUNCATE INSERT PREPARE" + + # Writing WAL at end of xact, instead of syncing. + node.safe_sql( + """ + SET wal_skip_threshold = '1GB'; + BEGIN; + CREATE TABLE noskip (id serial PRIMARY KEY); + INSERT INTO noskip (SELECT FROM generate_series(1, 20000) a) ; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM noskip;") + assert result == "20000", f"wal_level = {wal_level}, end-of-xact WAL" + + # Data file for COPY query in subsequent tests + basedir = node.basedir + copy_file = f"{basedir}/copy_data.txt" + with open(copy_file, "a", encoding="utf-8") as fh: + fh.write("20000,30000\n20001,30001\n20002,30002") + + # Test truncation with inserted tuples using both INSERT and COPY. Tuples + # inserted after the truncation should be seen. + node.safe_sql( + f""" + BEGIN; + CREATE TABLE ins_trunc (id serial PRIMARY KEY, id2 int); + INSERT INTO ins_trunc VALUES (DEFAULT, generate_series(1,10000)); + TRUNCATE ins_trunc; + INSERT INTO ins_trunc (id, id2) VALUES (DEFAULT, 10000); + COPY ins_trunc FROM '{copy_file}' DELIMITER ','; + INSERT INTO ins_trunc (id, id2) VALUES (DEFAULT, 10000); + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM ins_trunc;") + assert result == "5", f"wal_level = {wal_level}, TRUNCATE COPY INSERT" + + # Test truncation with inserted tuples using COPY. Tuples copied after + # the truncation should be seen. + node.safe_sql( + f""" + BEGIN; + CREATE TABLE trunc_copy (id serial PRIMARY KEY, id2 int); + INSERT INTO trunc_copy VALUES (DEFAULT, generate_series(1,3000)); + TRUNCATE trunc_copy; + COPY trunc_copy FROM '{copy_file}' DELIMITER ','; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM trunc_copy;") + assert result == "3", f"wal_level = {wal_level}, TRUNCATE COPY" + + # Like previous test, but rollback SET TABLESPACE in a subtransaction. + node.safe_sql( + f""" + BEGIN; + CREATE TABLE spc_abort (id serial PRIMARY KEY, id2 int); + INSERT INTO spc_abort VALUES (DEFAULT, generate_series(1,3000)); + TRUNCATE spc_abort; + SAVEPOINT s; + ALTER TABLE spc_abort SET TABLESPACE other; ROLLBACK TO s; + COPY spc_abort FROM '{copy_file}' DELIMITER ','; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM spc_abort;") + assert ( + result == "3" + ), f"wal_level = {wal_level}, SET TABLESPACE abort subtransaction" + + # in different subtransaction patterns + node.safe_sql( + f""" + BEGIN; + CREATE TABLE spc_commit (id serial PRIMARY KEY, id2 int); + INSERT INTO spc_commit VALUES (DEFAULT, generate_series(1,3000)); + TRUNCATE spc_commit; + SAVEPOINT s; ALTER TABLE spc_commit SET TABLESPACE other; RELEASE s; + COPY spc_commit FROM '{copy_file}' DELIMITER ','; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM spc_commit;") + assert ( + result == "3" + ), f"wal_level = {wal_level}, SET TABLESPACE commit subtransaction" + + node.safe_sql( + f""" + BEGIN; + CREATE TABLE spc_nest (id serial PRIMARY KEY, id2 int); + INSERT INTO spc_nest VALUES (DEFAULT, generate_series(1,3000)); + TRUNCATE spc_nest; + SAVEPOINT s; + ALTER TABLE spc_nest SET TABLESPACE other; + SAVEPOINT s2; + ALTER TABLE spc_nest SET TABLESPACE pg_default; + ROLLBACK TO s2; + SAVEPOINT s2; + ALTER TABLE spc_nest SET TABLESPACE pg_default; + RELEASE s2; + ROLLBACK TO s; + COPY spc_nest FROM '{copy_file}' DELIMITER ','; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM spc_nest;") + assert ( + result == "3" + ), f"wal_level = {wal_level}, SET TABLESPACE nested subtransaction" + + # Leading statements run individually (see CREATE TABLESPACE note above); + # the BEGIN..COMMIT is one grouped transaction. + node.safe_sql("CREATE TABLE spc_hint (id int);") + node.safe_sql("INSERT INTO spc_hint VALUES (1);") + node.safe_sql( + """ + BEGIN; + ALTER TABLE spc_hint SET TABLESPACE other; + CHECKPOINT; + SELECT * FROM spc_hint; -- set hint bit + INSERT INTO spc_hint VALUES (2); + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM spc_hint;") + assert result == "2", f"wal_level = {wal_level}, SET TABLESPACE, hint bit" + + node.safe_sql( + """ + BEGIN; + CREATE TABLE idx_hint (c int PRIMARY KEY); + SAVEPOINT q; INSERT INTO idx_hint VALUES (1); ROLLBACK TO q; + CHECKPOINT; + INSERT INTO idx_hint VALUES (1); -- set index hint bit + INSERT INTO idx_hint VALUES (2); + COMMIT;""" + ) + node.stop("immediate") + node.start() + # Run the conflicting INSERT in-process; it must fail with a unique + # violation. + res = node.sql("INSERT INTO idx_hint VALUES (2);") + assert ( + res.error_message is not None + ), f"wal_level = {wal_level}, unique index LP_DEAD" + assert re.search( + r"violates unique", res.error_message + ), f"wal_level = {wal_level}, unique index LP_DEAD message" + + # UPDATE touches two buffers for one row. + node.safe_sql( + f""" + BEGIN; + CREATE TABLE upd (id serial PRIMARY KEY, id2 int); + INSERT INTO upd (id, id2) VALUES (DEFAULT, generate_series(1,10000)); + COPY upd FROM '{copy_file}' DELIMITER ','; + UPDATE upd SET id2 = id2 + 1; + DELETE FROM upd; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM upd;") + assert ( + result == "0" + ), f"wal_level = {wal_level}, UPDATE touches two buffers for one row" + + # Test consistency of COPY with INSERT for table created in the same + # transaction. + node.safe_sql( + f""" + BEGIN; + CREATE TABLE ins_copy (id serial PRIMARY KEY, id2 int); + INSERT INTO ins_copy VALUES (DEFAULT, 1); + COPY ins_copy FROM '{copy_file}' DELIMITER ','; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM ins_copy;") + assert result == "4", f"wal_level = {wal_level}, INSERT COPY" + + # Test consistency of COPY that inserts more to the same table using + # triggers. If the INSERTS from the trigger go to the same block data + # is copied to, and the INSERTs are WAL-logged, WAL replay will fail when + # it tries to replay the WAL record but the "before" image doesn't match, + # because not all changes were WAL-logged. + node.safe_sql( + f""" + BEGIN; + CREATE TABLE ins_trig (id serial PRIMARY KEY, id2 text); + CREATE FUNCTION ins_trig_before_row_trig() RETURNS trigger + LANGUAGE plpgsql as $$ + BEGIN + IF new.id2 NOT LIKE 'triggered%' THEN + INSERT INTO ins_trig + VALUES (DEFAULT, 'triggered row before' || NEW.id2); + END IF; + RETURN NEW; + END; $$; + CREATE FUNCTION ins_trig_after_row_trig() RETURNS trigger + LANGUAGE plpgsql as $$ + BEGIN + IF new.id2 NOT LIKE 'triggered%' THEN + INSERT INTO ins_trig + VALUES (DEFAULT, 'triggered row after' || NEW.id2); + END IF; + RETURN NEW; + END; $$; + CREATE TRIGGER ins_trig_before_row_insert + BEFORE INSERT ON ins_trig + FOR EACH ROW EXECUTE PROCEDURE ins_trig_before_row_trig(); + CREATE TRIGGER ins_trig_after_row_insert + AFTER INSERT ON ins_trig + FOR EACH ROW EXECUTE PROCEDURE ins_trig_after_row_trig(); + COPY ins_trig FROM '{copy_file}' DELIMITER ','; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM ins_trig;") + assert result == "9", f"wal_level = {wal_level}, COPY with INSERT triggers" + + # Test consistency of INSERT, COPY and TRUNCATE in same transaction block + # with TRUNCATE triggers. + node.safe_sql( + f""" + BEGIN; + CREATE TABLE trunc_trig (id serial PRIMARY KEY, id2 text); + CREATE FUNCTION trunc_trig_before_stat_trig() RETURNS trigger + LANGUAGE plpgsql as $$ + BEGIN + INSERT INTO trunc_trig VALUES (DEFAULT, 'triggered stat before'); + RETURN NULL; + END; $$; + CREATE FUNCTION trunc_trig_after_stat_trig() RETURNS trigger + LANGUAGE plpgsql as $$ + BEGIN + INSERT INTO trunc_trig VALUES (DEFAULT, 'triggered stat before'); + RETURN NULL; + END; $$; + CREATE TRIGGER trunc_trig_before_stat_truncate + BEFORE TRUNCATE ON trunc_trig + FOR EACH STATEMENT EXECUTE PROCEDURE trunc_trig_before_stat_trig(); + CREATE TRIGGER trunc_trig_after_stat_truncate + AFTER TRUNCATE ON trunc_trig + FOR EACH STATEMENT EXECUTE PROCEDURE trunc_trig_after_stat_trig(); + INSERT INTO trunc_trig VALUES (DEFAULT, 1); + TRUNCATE trunc_trig; + COPY trunc_trig FROM '{copy_file}' DELIMITER ','; + COMMIT;""" + ) + node.stop("immediate") + node.start() + result = node.safe_sql("SELECT count(*) FROM trunc_trig;") + assert ( + result == "4" + ), f"wal_level = {wal_level}, TRUNCATE COPY with TRUNCATE triggers" + + # Test redo of temp table creation. + node.safe_sql( + """ + CREATE TEMP TABLE temp (id serial PRIMARY KEY, id2 text);""" + ) + node.stop("immediate") + node.start() + check_orphan_relfilenodes( + node, f"wal_level = {wal_level}, no orphan relfilenode remains" + ) diff --git a/src/test/recovery/pyt/test_019_replslot_limit.py b/src/test/recovery/pyt/test_019_replslot_limit.py new file mode 100644 index 00000000000..d741fab83ed --- /dev/null +++ b/src/test/recovery/pyt/test_019_replslot_limit.py @@ -0,0 +1,577 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test for replication slot limit. + +Ensure that max_slot_wal_keep_size limits the number of WAL files to +be kept by replication slots. +""" + +import os +import re +import signal +import time + +from pypg.util import TIMEOUT_DEFAULT, WINDOWS_OS + + +def wait_for_slot_catchup(node, slot_name, mode, target_lsn): + """Poll pg_replication_slots until the slot's _lsn reaches + *target_lsn*. + """ + assert mode in ( + "restart", + "confirmed_flush", + ), "valid modes are restart, confirmed_flush" + assert target_lsn is not None, "target lsn must be specified" + print( + f"Waiting for replication slot {slot_name}'s {mode}_lsn to pass " + f"{target_lsn} on {node.name}" + ) + query = ( + f"SELECT '{target_lsn}' <= {mode}_lsn " + "FROM pg_catalog.pg_replication_slots " + f"WHERE slot_name = '{slot_name}'" + ) + if not node.poll_query_until(query): + details = node.safe_sql("SELECT * FROM pg_catalog.pg_replication_slots") + raise TimeoutError( + "timed out waiting for catchup\n" + f"Last pg_replication_slots contents:\n{details}" + ) + print("done") + + +def validate_slot_inactive_since(node, slot_name, reference_time): + """Return the slot's inactive_since, asserting it is sane (later than the + epoch and than *reference_time*). + """ + inactive_since = node.safe_sql( + "SELECT inactive_since FROM pg_replication_slots " + f"WHERE slot_name = '{slot_name}' AND inactive_since IS NOT NULL" + ) + assert ( + node.safe_sql( + f"SELECT '{inactive_since}'::timestamptz > to_timestamp(0) AND " + f"'{inactive_since}'::timestamptz > '{reference_time}'::timestamptz" + ) + == "t" + ), f"last inactive time for slot {slot_name} is valid on node {node.name}" + return inactive_since + + +def test_019_replslot_limit(create_pg): + # Initialize primary node, setting wal-segsize to 1MB + node_primary = create_pg( + "primary", allows_streaming=True, initdb_extra=["--wal-segsize=1"] + ) + node_primary.append_conf( + """ +min_wal_size = 2MB +max_wal_size = 4MB +log_checkpoints = yes +""" + ) + node_primary.restart() + node_primary.safe_sql("SELECT pg_create_physical_replication_slot('rep1')") + + # The slot state and remain should be null before the first connection + result = node_primary.safe_sql( + "SELECT restart_lsn IS NULL, wal_status is NULL, " + "safe_wal_size is NULL FROM pg_replication_slots " + "WHERE slot_name = 'rep1'" + ) + assert result == "t|t|t", 'check the state of non-reserved slot is "unknown"' + + # Take backup + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Create a standby linking to it using the replication slot + node_standby = create_pg("standby_1", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.append_conf("primary_slot_name = 'rep1'") + + node_standby.start() + + # Wait until the primary has processed standby feedback and advanced the + # slot's restart_lsn. For a physical slot, restart_lsn is updated from + # the standby's reported flush position, so this waits for the primary-side + # slot state that the following wal_status checks depend on. + wait_for_slot_catchup(node_primary, "rep1", "restart", node_primary.lsn("write")) + + # Stop standby + node_standby.stop() + + # Preparation done, the slot is the state "reserved" now + result = node_primary.safe_sql( + "SELECT wal_status, safe_wal_size IS NULL FROM pg_replication_slots " + "WHERE slot_name = 'rep1'" + ) + assert result == "reserved|t", "check the catching-up state" + + # Advance WAL by one segment (= 1MB) on primary + node_primary.advance_wal(1) + node_primary.safe_sql("CHECKPOINT;") + + # The slot is always "safe" when fitting max_wal_size + result = node_primary.safe_sql( + "SELECT wal_status, safe_wal_size IS NULL FROM pg_replication_slots " + "WHERE slot_name = 'rep1'" + ) + assert result == "reserved|t", "check that it is safe if WAL fits in max_wal_size" + + node_primary.advance_wal(4) + node_primary.safe_sql("CHECKPOINT;") + + # The slot is always "safe" when max_slot_wal_keep_size is not set + result = node_primary.safe_sql( + "SELECT wal_status, safe_wal_size IS NULL FROM pg_replication_slots " + "WHERE slot_name = 'rep1'" + ) + assert result == "reserved|t", "check that slot is working" + + # The standby can reconnect to primary + node_standby.start() + + wait_for_slot_catchup(node_primary, "rep1", "restart", node_primary.lsn("write")) + + node_standby.stop() + + # Set max_slot_wal_keep_size on primary + max_slot_wal_keep_size_mb = 6 + node_primary.append_conf( + f""" +max_slot_wal_keep_size = {max_slot_wal_keep_size_mb}MB +""" + ) + node_primary.reload() + + # The slot is in safe state. + result = node_primary.safe_sql( + "SELECT wal_status FROM pg_replication_slots WHERE slot_name = 'rep1'" + ) + assert result == "reserved", "check that max_slot_wal_keep_size is working" + + # Advance WAL again then checkpoint, reducing remain by 2 MB. + node_primary.advance_wal(2) + node_primary.safe_sql("CHECKPOINT;") + + # The slot is still working + result = node_primary.safe_sql( + "SELECT wal_status FROM pg_replication_slots WHERE slot_name = 'rep1'" + ) + assert result == "reserved", "check that slot remains reserved after advancing WAL" + + # The standby can reconnect to primary + node_standby.start() + wait_for_slot_catchup(node_primary, "rep1", "restart", node_primary.lsn("write")) + node_standby.stop() + + # wal_keep_size overrides max_slot_wal_keep_size + node_primary.safe_sql("ALTER SYSTEM SET wal_keep_size to '8MB'") + node_primary.safe_sql("SELECT pg_reload_conf()") + # Advance WAL again, reducing remain by 6 MB. + node_primary.advance_wal(6) + result = node_primary.safe_sql( + "SELECT wal_status as remain FROM pg_replication_slots " + "WHERE slot_name = 'rep1'" + ) + assert ( + result == "extended" + ), "check that wal_keep_size overrides max_slot_wal_keep_size" + # restore wal_keep_size + node_primary.safe_sql("ALTER SYSTEM SET wal_keep_size to 0") + node_primary.safe_sql("SELECT pg_reload_conf()") + + # The standby can reconnect to primary + node_standby.start() + wait_for_slot_catchup(node_primary, "rep1", "restart", node_primary.lsn("write")) + node_standby.stop() + + # Advance WAL again without checkpoint, reducing remain by 6 MB. + node_primary.advance_wal(6) + + # Slot gets into 'extended' state + result = node_primary.safe_sql( + "SELECT wal_status FROM pg_replication_slots WHERE slot_name = 'rep1'" + ) + assert result == "extended", 'check that the slot state changes to "extended"' + + # do checkpoint so that the next checkpoint runs too early + node_primary.safe_sql("CHECKPOINT;") + + # Advance WAL again without checkpoint; remain goes to 0. + node_primary.advance_wal(1) + + # Slot gets into 'unreserved' state and safe_wal_size is negative + result = node_primary.safe_sql( + "SELECT wal_status, safe_wal_size <= 0 FROM pg_replication_slots " + "WHERE slot_name = 'rep1'" + ) + assert result == "unreserved|t", 'check that the slot state changes to "unreserved"' + + # The standby still can connect to primary before a checkpoint + node_standby.start() + + wait_for_slot_catchup(node_primary, "rep1", "restart", node_primary.lsn("write")) + + node_standby.stop() + + assert not node_standby.log_contains( + "requested WAL segment [0-9A-F]+ has already been removed" + ), "check that required WAL segments are still available" + + # Create one checkpoint, to improve stability of the next steps + node_primary.safe_sql("CHECKPOINT;") + + # Prevent other checkpoints from occurring while advancing WAL segments + node_primary.safe_sql("ALTER SYSTEM SET max_wal_size='40MB'") + node_primary.safe_sql("SELECT pg_reload_conf()") + + # Advance WAL again. The slot loses the oldest segment by the next checkpoint + logstart = node_primary.log_position() + node_primary.advance_wal(7) + + # Now create another checkpoint and wait until the WARNING is issued + node_primary.safe_sql("ALTER SYSTEM RESET max_wal_size") + node_primary.safe_sql("SELECT pg_reload_conf()") + node_primary.safe_sql("CHECKPOINT;") + invalidated = False + for _ in range(10 * TIMEOUT_DEFAULT): + if node_primary.log_contains( + 'invalidating obsolete replication slot "rep1"', logstart + ): + invalidated = True + break + time.sleep(0.1) + assert invalidated, "check that slot invalidation has been logged" + + result = node_primary.safe_sql( + "SELECT slot_name, active, restart_lsn IS NULL, wal_status, " + "safe_wal_size FROM pg_replication_slots WHERE slot_name = 'rep1'" + ) + assert ( + result == "rep1|f|t|lost|" + ), 'check that the slot became inactive and the state "lost" persists' + + # Wait until current checkpoint ends + checkpoint_ended = False + for _ in range(10 * TIMEOUT_DEFAULT): + if node_primary.log_contains("checkpoint complete: ", logstart): + checkpoint_ended = True + break + time.sleep(0.1) + assert checkpoint_ended, "waited for checkpoint to end" + + # The invalidated slot shouldn't keep the old-segment horizon back; + # see bug #17103: https://postgr.es/m/17103-004130e8f27782c9@postgresql.org + # Test for this by creating a new slot and comparing its restart LSN + # to the oldest existing file. + redoseg = node_primary.safe_sql( + "SELECT pg_walfile_name(lsn) FROM " + "pg_create_physical_replication_slot('s2', true)" + ) + oldestseg = node_primary.safe_sql( + "SELECT pg_ls_dir AS f FROM pg_ls_dir('pg_wal') " + "WHERE pg_ls_dir ~ '^[0-9A-F]{24}$' ORDER BY 1 LIMIT 1" + ) + node_primary.safe_sql("SELECT pg_drop_replication_slot('s2')") + assert oldestseg == redoseg, "check that segments have been removed" + + # The standby no longer can connect to the primary + logstart = node_standby.log_position() + node_standby.start() + + failed = False + for _ in range(10 * TIMEOUT_DEFAULT): + if node_standby.log_contains( + 'This replication slot has been invalidated due to "wal_removed".', + logstart, + ): + failed = True + break + time.sleep(0.1) + assert failed, "check that replication has been broken" + + node_primary.stop() + node_standby.stop() + + node_primary2 = create_pg("primary2", allows_streaming=True) + node_primary2.append_conf( + """ +min_wal_size = 32MB +max_wal_size = 32MB +log_checkpoints = yes +""" + ) + node_primary2.restart() + node_primary2.safe_sql("SELECT pg_create_physical_replication_slot('rep1')") + backup_name = "my_backup2" + node_primary2.backup(backup_name) + + node_primary2.stop() + node_primary2.append_conf( + """ +max_slot_wal_keep_size = 0 +""" + ) + node_primary2.start() + + node_standby = create_pg("standby_2", start=False) + node_standby.init_from_backup(node_primary2, backup_name, has_streaming=True) + node_standby.append_conf("primary_slot_name = 'rep1'") + node_standby.start() + node_primary2.advance_wal(1) + node_primary2.safe_sql("CHECKPOINT;") + result = node_primary2.safe_sql("SELECT 'finished';") + assert result == "finished", "check if checkpoint command is not blocked" + + node_primary2.stop() + node_standby.stop() + + # The remaining cases freeze the walsender/walreceiver with SIGSTOP/SIGCONT, + # which is not portable to Windows; end the test here on that platform. + if WINDOWS_OS: + return + + # Get a slot terminated while the walsender is active + # We do this by sending SIGSTOP to the walsender. + node_primary3 = create_pg( + "primary3", allows_streaming=True, initdb_extra=["--wal-segsize=1"] + ) + node_primary3.append_conf( + """ +min_wal_size = 2MB +max_wal_size = 2MB +log_checkpoints = yes +max_slot_wal_keep_size = 1MB +""" + ) + node_primary3.restart() + node_primary3.safe_sql("SELECT pg_create_physical_replication_slot('rep3')") + # Take backup + backup_name = "my_backup" + node_primary3.backup(backup_name) + # Create standby + node_standby3 = create_pg("standby_3", start=False) + node_standby3.init_from_backup(node_primary3, backup_name, has_streaming=True) + node_standby3.append_conf("primary_slot_name = 'rep3'") + node_standby3.start() + node_primary3.wait_for_catchup(node_standby3) + + senderpid = None + + # We've seen occasional cases where multiple walsender pids are still + # active at this point, apparently just due to process shutdown being slow. + # To avoid spurious failures, retry a couple times. + i = 0 + while True: + senderpid = node_primary3.safe_sql( + "SELECT pid FROM pg_stat_activity WHERE backend_type = 'walsender'" + ) + + if re.fullmatch(r"[0-9]+", senderpid): + break + + print(f"multiple walsenders active in iteration {i}") + + # show information about all active connections + stdout = node_primary3.safe_sql("SELECT * FROM pg_stat_activity") + print(stdout) + + if i == 10 * TIMEOUT_DEFAULT: + # An immediate shutdown may hide evidence of a locking bug. If + # retrying didn't resolve the issue, shut down in fast mode. + node_primary3.stop("fast") + node_standby3.stop("fast") + raise RuntimeError("could not determine walsender pid, can't continue") + i += 1 + + time.sleep(0.1) + + assert re.fullmatch(r"[0-9]+", senderpid), f"have walsender pid {senderpid}" + + receiverpid = node_standby3.safe_sql( + "SELECT pid FROM pg_stat_activity WHERE backend_type = 'walreceiver'" + ) + assert re.fullmatch(r"[0-9]+", receiverpid), f"have walreceiver pid {receiverpid}" + + logstart = node_primary3.log_position() + # freeze walsender and walreceiver. Slot will still be active, but + # walreceiver won't get anything anymore. + os.kill(int(senderpid), signal.SIGSTOP) + os.kill(int(receiverpid), signal.SIGSTOP) + node_primary3.advance_wal(2) + + msg_logged = False + max_attempts = TIMEOUT_DEFAULT + while max_attempts >= 0: + if node_primary3.log_contains( + f"terminating process {senderpid} to release " 'replication slot "rep3"', + logstart, + ): + msg_logged = True + break + time.sleep(1) + max_attempts -= 1 + assert msg_logged, "walsender termination logged" + + # Now let the walsender continue; slot should be killed now. + # (Must not let walreceiver run yet; otherwise the standby could start + # another one before the slot can be killed) + os.kill(int(senderpid), signal.SIGCONT) + assert node_primary3.poll_query_until( + "SELECT wal_status FROM pg_replication_slots WHERE slot_name = 'rep3'", "lost" + ), "timed out waiting for slot to be lost" + + msg_logged = False + max_attempts = TIMEOUT_DEFAULT + while max_attempts >= 0: + if node_primary3.log_contains( + 'invalidating obsolete replication slot "rep3"', logstart + ): + msg_logged = True + break + time.sleep(1) + max_attempts -= 1 + assert msg_logged, "slot invalidation logged" + + # Now let the walreceiver continue, so that the node can be stopped cleanly + os.kill(int(receiverpid), signal.SIGCONT) + + node_primary3.stop() + node_standby3.stop() + + # ========================================================================= + # Testcase start: Check inactive_since property of the streaming standby's + # slot + # + + # Initialize primary node + primary4 = create_pg("primary4", allows_streaming="logical") + + # Take backup + backup_name = "my_backup4" + primary4.backup(backup_name) + + # Create a standby linking to the primary using the replication slot + standby4 = create_pg("standby4", start=False) + standby4.init_from_backup(primary4, backup_name, has_streaming=True) + + sb4_slot = "sb4_slot" + standby4.append_conf(f"primary_slot_name = '{sb4_slot}'") + + slot_creation_time = primary4.safe_sql("SELECT current_timestamp") + + primary4.safe_sql( + f"SELECT pg_create_physical_replication_slot(slot_name := '{sb4_slot}')" + ) + + # Get inactive_since value after the slot's creation. Note that the slot is + # still inactive till it's used by the standby below. + inactive_since = validate_slot_inactive_since( + primary4, sb4_slot, slot_creation_time + ) + + standby4.start() + + # Wait until standby has replayed enough data + primary4.wait_for_catchup(standby4) + + # Now the slot is active so inactive_since value must be NULL + assert ( + primary4.safe_sql( + "SELECT inactive_since IS NULL FROM pg_replication_slots " + f"WHERE slot_name = '{sb4_slot}'" + ) + == "t" + ), "last inactive time for an active physical slot is NULL" + + # Stop the standby to check its inactive_since value is updated + standby4.stop() + + # Let's restart the primary so that the inactive_since is set upon loading + # the slot from the disk. + primary4.restart() + + assert ( + primary4.safe_sql( + f"SELECT inactive_since > '{inactive_since}'::timestamptz " + "FROM pg_replication_slots " + f"WHERE slot_name = '{sb4_slot}' AND inactive_since IS NOT NULL" + ) + == "t" + ), "last inactive time for an inactive physical slot is updated correctly" + + # Testcase end: Check inactive_since property of the streaming standby's slot + # ========================================================================= + + # ========================================================================= + # Testcase start: Check inactive_since property of the logical + # subscriber's slot + publisher4 = primary4 + + # Create subscriber node + subscriber4 = create_pg("subscriber4", start=False) + + # Setup logical replication + publisher4_connstr = ( + f"host={publisher4.host} port={publisher4.port} dbname=postgres" + ) + publisher4.safe_sql("CREATE PUBLICATION pub FOR ALL TABLES") + + slot_creation_time = publisher4.safe_sql("SELECT current_timestamp") + + lsub4_slot = "lsub4_slot" + publisher4.safe_sql( + f"SELECT pg_create_logical_replication_slot(slot_name := '{lsub4_slot}', " + "plugin := 'pgoutput')" + ) + + # Get inactive_since value after the slot's creation. Note that the slot is + # still inactive till it's used by the subscriber below. + inactive_since = validate_slot_inactive_since( + publisher4, lsub4_slot, slot_creation_time + ) + + subscriber4.start() + subscriber4.safe_sql( + f"CREATE SUBSCRIPTION sub CONNECTION '{publisher4_connstr}' " + f"PUBLICATION pub WITH (slot_name = '{lsub4_slot}', create_slot = false)" + ) + + # Wait until subscriber has caught up + subscriber4.wait_for_subscription_sync(publisher4, "sub") + + # Now the slot is active so inactive_since value must be NULL + assert ( + publisher4.safe_sql( + "SELECT inactive_since IS NULL FROM pg_replication_slots " + f"WHERE slot_name = '{lsub4_slot}'" + ) + == "t" + ), "last inactive time for an active logical slot is NULL" + + # Stop the subscriber to check its inactive_since value is updated + subscriber4.stop() + + # Let's restart the publisher so that the inactive_since is set upon + # loading the slot from the disk. + publisher4.restart() + + assert ( + publisher4.safe_sql( + f"SELECT inactive_since > '{inactive_since}'::timestamptz " + "FROM pg_replication_slots " + f"WHERE slot_name = '{lsub4_slot}' " + "AND inactive_since IS NOT NULL" + ) + == "t" + ), "last inactive time for an inactive logical slot is updated correctly" + + # Testcase end: Check inactive_since property of the logical subscriber's + # slot + # ========================================================================= + + publisher4.stop() + subscriber4.stop() diff --git a/src/test/recovery/pyt/test_020_archive_status.py b/src/test/recovery/pyt/test_020_archive_status.py new file mode 100644 index 00000000000..d53b23dcc9a --- /dev/null +++ b/src/test/recovery/pyt/test_020_archive_status.py @@ -0,0 +1,272 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests related to WAL archiving and recovery.""" + +import os +import re + +from libpq.errors import QueryError +from pypg.util import slurp_file + + +def test_020_archive_status(create_pg): + primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + primary.append_conf("autovacuum = off") + primary.start() + primary_data = primary.data_dir + + # Temporarily use an archive_command value to make the archiver fail, + # knowing that archiving is enabled. Note that we cannot use a command + # that does not exist as in this case the archiver process would just exit + # without reporting the failure to pg_stat_archiver. This also cannot + # use a plain "false" as that's unportable on Windows. So, instead, as + # a portable solution, use an archive command based on a command known to + # work but will fail: copy with an incorrect original path. + incorrect_command = 'cp "%p_does_not_exist" "%f_does_not_exist"' + primary.safe_sql(f"ALTER SYSTEM SET archive_command TO '{incorrect_command}'") + primary.safe_sql("SELECT pg_reload_conf()") + + # Save the WAL segment currently in use and switch to a new segment. + # This will be used to track the activity of the archiver. + segment_name_1 = primary.safe_sql("SELECT pg_walfile_name(pg_current_wal_lsn())") + segment_path_1 = f"pg_wal/archive_status/{segment_name_1}" + segment_path_1_ready = f"{segment_path_1}.ready" + segment_path_1_done = f"{segment_path_1}.done" + primary.safe_sql( + """ + CREATE TABLE mine AS SELECT generate_series(1,10) AS x; + SELECT pg_switch_wal(); + CHECKPOINT; + """ + ) + + # Wait for an archive failure. + assert primary.poll_query_until( + "SELECT failed_count > 0 FROM pg_stat_archiver" + ), "Timed out while waiting for archiving to fail" + assert os.path.isfile(os.path.join(primary_data, segment_path_1_ready)), ( + f".ready file exists for WAL segment {segment_name_1} " "waiting to be archived" + ) + assert not os.path.isfile(os.path.join(primary_data, segment_path_1_done)), ( + f".done file does not exist for WAL segment {segment_name_1} " + "waiting to be archived" + ) + + assert ( + primary.safe_sql( + """ + SELECT archived_count, last_failed_wal + FROM pg_stat_archiver + """ + ) + == f"0|{segment_name_1}" + ), f"pg_stat_archiver failed to archive {segment_name_1}" + + # Crash the cluster for the next test in charge of checking that + # non-archived WAL segments are not removed. + primary.stop("immediate") + + # Recovery tests for the archiving with a standby partially check + # the recovery behavior when restoring a backup taken using a + # snapshot with no pg_backup_start/stop. In this situation, + # the recovered standby should enter first crash recovery then + # switch to regular archive recovery. Note that the base backup + # is taken here so as archive_command will fail. This is necessary + # for the assumptions of the tests done with the standbys below. + primary.backup_fs_cold("backup") + + primary.start() + assert os.path.isfile(os.path.join(primary_data, segment_path_1_ready)), ( + f".ready file for WAL segment {segment_name_1} still exists " + "after crash recovery on primary" + ) + + # Allow WAL archiving again and wait for a success. + primary.safe_sql("ALTER SYSTEM RESET archive_command") + primary.safe_sql("SELECT pg_reload_conf()") + + assert primary.poll_query_until( + "SELECT archived_count FROM pg_stat_archiver", "1" + ), "Timed out while waiting for archiving to finish" + + assert not os.path.isfile( + os.path.join(primary_data, segment_path_1_ready) + ), f".ready file for archived WAL segment {segment_name_1} removed" + + assert os.path.isfile( + os.path.join(primary_data, segment_path_1_done) + ), f".done file for archived WAL segment {segment_name_1} exists" + + assert ( + primary.safe_sql("SELECT last_archived_wal FROM pg_stat_archiver") + == segment_name_1 + ), ( + "archive success reported in pg_stat_archiver for WAL segment " + f"{segment_name_1}" + ) + + # Create some WAL activity and a new checkpoint so as the next standby can + # create a restartpoint. As this standby starts in crash recovery because + # of the cold backup taken previously, it needs a clean restartpoint to + # deal with existing status files. + segment_name_2 = primary.safe_sql("SELECT pg_walfile_name(pg_current_wal_lsn())") + segment_path_2 = f"pg_wal/archive_status/{segment_name_2}" + segment_path_2_ready = f"{segment_path_2}.ready" + segment_path_2_done = f"{segment_path_2}.done" + primary.safe_sql( + """ + INSERT INTO mine SELECT generate_series(10,20) AS x; + CHECKPOINT; + """ + ) + + # Switch to a new segment and use the returned LSN to make sure that + # standbys have caught up to this point. + primary_lsn = primary.safe_sql( + """ + SELECT pg_switch_wal(); + """ + ) + + assert primary.poll_query_until( + "SELECT last_archived_wal FROM pg_stat_archiver", segment_name_2 + ), "Timed out while waiting for archiving to finish" + + # Test standby with archive_mode = on. + standby1 = create_pg("standby", start=False) + standby1.init_from_backup(primary, "backup", has_restoring=True) + standby1.append_conf("archive_mode = on") + standby1_data = standby1.data_dir + standby1.start() + + # Wait for the replay of the segment switch done previously, ensuring + # that all segments needed are restored from the archives. + assert standby1.poll_query_until( + f"SELECT pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '{primary_lsn}') >= 0" + ), "Timed out while waiting for xlog replay on standby1" + + standby1.safe_sql("CHECKPOINT") + + # Recovery with archive_mode=on does not keep .ready signal files inherited + # from backup. Note that this WAL segment existed in the backup. + assert not os.path.isfile(os.path.join(standby1_data, segment_path_1_ready)), ( + f".ready file for WAL segment {segment_name_1} present in backup got " + "removed with archive_mode=on on standby" + ) + + # Recovery with archive_mode=on should not create .ready files. + # Note that this segment did not exist in the backup. + assert not os.path.isfile(os.path.join(standby1_data, segment_path_2_ready)), ( + f".ready file for WAL segment {segment_name_2} not created on standby " + "when archive_mode=on on standby" + ) + + # Recovery with archive_mode = on creates .done files. + assert os.path.isfile(os.path.join(standby1_data, segment_path_2_done)), ( + f".done file for WAL segment {segment_name_2} created when " + "archive_mode=on on standby" + ) + + # Test recovery with archive_mode = always, which should always keep + # .ready files if archiving is enabled, though here we want the archive + # command to fail to persist the .ready files. Note that this node + # has inherited the archive command of the previous cold backup that + # will cause archiving failures. + standby2 = create_pg("standby2", start=False) + standby2.init_from_backup(primary, "backup", has_restoring=True) + standby2.append_conf("archive_mode = always") + standby2_data = standby2.data_dir + standby2.start() + + # Wait for the replay of the segment switch done previously, ensuring + # that all segments needed are restored from the archives. + assert standby2.poll_query_until( + f"SELECT pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '{primary_lsn}') >= 0" + ), "Timed out while waiting for xlog replay on standby2" + + standby2.safe_sql("CHECKPOINT") + + assert os.path.isfile(os.path.join(standby2_data, segment_path_1_ready)), ( + f".ready file for WAL segment {segment_name_1} existing in backup is " + "kept with archive_mode=always on standby" + ) + + assert os.path.isfile(os.path.join(standby2_data, segment_path_2_ready)), ( + f".ready file for WAL segment {segment_name_2} created with " + "archive_mode=always on standby" + ) + + # Reset statistics of the archiver for the next checks. + standby2.safe_sql("SELECT pg_stat_reset_shared('archiver')") + + # Now crash the cluster to check that recovery step does not + # remove non-archived WAL segments on a standby where archiving + # is enabled. + standby2.stop("immediate") + standby2.start() + + assert os.path.isfile(os.path.join(standby2_data, segment_path_1_ready)), ( + "WAL segment still ready to archive after crash recovery on standby " + "with archive_mode=always" + ) + + # Allow WAL archiving again, and wait for the segments to be archived. + standby2.safe_sql("ALTER SYSTEM RESET archive_command") + standby2.safe_sql("SELECT pg_reload_conf()") + assert standby2.poll_query_until( + "SELECT last_archived_wal FROM pg_stat_archiver", segment_name_2 + ), "Timed out while waiting for archiving to finish" + + assert ( + standby2.safe_sql("SELECT archived_count FROM pg_stat_archiver") == "2" + ), "correct number of WAL segments archived from standby" + + assert not os.path.isfile( + os.path.join(standby2_data, segment_path_1_ready) + ) and not os.path.isfile( + os.path.join(standby2_data, segment_path_2_ready) + ), ".ready files removed after archive success with archive_mode=always on standby" + + assert os.path.isfile( + os.path.join(standby2_data, segment_path_1_done) + ) and os.path.isfile( + os.path.join(standby2_data, segment_path_2_done) + ), ".done files created after archive success with archive_mode=always on standby" + + # Check that the archiver process calls the shell archive module's shutdown + # callback. + standby2.append_conf("log_min_messages = debug1") + standby2.reload() + + # Run a query to make sure that the reload has taken effect. + standby2.safe_sql("SELECT 1") + log_location = standby2.log_position() + + standby2.stop() + logfile = slurp_file(standby2.logfile, log_location) + assert re.search( + r"archiver process shutting down", logfile + ), "check shutdown callback of shell archive module" + + # Test that we can enter and leave backup mode without crashes. + # + # The third statement, with an oversized backup label, must fail + # gracefully. Here the equivalent is a + # single in-process query whose final statement errors with "backup label + # too long". + try: + primary.safe_sql( + "SELECT pg_backup_start('onebackup'); " + "SELECT pg_backup_stop(); " + "SELECT pg_backup_start(repeat('x', 1026))" + ) + raise AssertionError("psql fails correctly") + except QueryError as exc: + assert re.search( + r"backup label too long", str(exc) + ), "pg_backup_start fails gracefully" + + primary.safe_sql("SELECT pg_backup_start('onebackup'); SELECT pg_backup_stop();") + primary.safe_sql("SELECT pg_backup_start('twobackup')") diff --git a/src/test/recovery/pyt/test_021_row_visibility.py b/src/test/recovery/pyt/test_021_row_visibility.py new file mode 100644 index 00000000000..ef8a8d8c6fa --- /dev/null +++ b/src/test/recovery/pyt/test_021_row_visibility.py @@ -0,0 +1,122 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Checks that snapshots on standbys behave in a minimally reasonable way.""" + + +def _standby_rows(session): + """Return the rows of "SELECT * FROM test_visibility ORDER BY data". + + Runs the standby query repeatedly and returns a list of single-column + values (the "data" column). + """ + res = session.query("SELECT * FROM test_visibility ORDER BY data") + assert res.error_message is None, res.error_message + return [row[0] for row in res.rows] + + +def test_021_row_visibility(create_pg): + # Initialize primary node + node_primary = create_pg("primary", start=False, allows_streaming=True) + node_primary.append_conf("max_prepared_transactions=10") + node_primary.start() + + # Initialize with empty test table + node_primary.safe_sql("CREATE TABLE public.test_visibility (data text not null)") + + # Take backup + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Create streaming standby from backup + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.append_conf("max_prepared_transactions=10") + node_standby.start() + + # One libpq session to primary and standby each, for all queries. That + # allows to check uncommitted changes being replicated and such. These + # long-lived connections carry the transaction control + # (BEGIN/COMMIT/PREPARE TRANSACTION) issued by the test. + psql_primary = node_primary.connect("postgres") + psql_standby = node_standby.connect("postgres") + try: + # + # 1. Check initial data is the same + # + assert _standby_rows(psql_standby) == [], "data not visible" + + # + # 2. Check if an INSERT is replayed and visible + # + node_primary.safe_sql("INSERT INTO test_visibility VALUES ('first insert')") + node_primary.wait_for_catchup(node_standby) + + assert _standby_rows(psql_standby) == ["first insert"], "insert visible" + + # + # 3. Verify that uncommitted changes aren't visible. + # + res = psql_primary.query( + "BEGIN;\n" + "UPDATE test_visibility SET data = 'first update' RETURNING data;" + ) + assert res.error_message is None, res.error_message + assert res.psqlout == "first update", "UPDATE" + + node_primary.safe_sql("SELECT txid_current()") # ensure WAL flush + node_primary.wait_for_catchup(node_standby) + + assert _standby_rows(psql_standby) == [ + "first insert" + ], "uncommitted update invisible" + + # + # 4. That a commit turns 3. visible + # + assert psql_primary.do("COMMIT") is not None, "COMMIT" + + node_primary.wait_for_catchup(node_standby) + + assert _standby_rows(psql_standby) == [ + "first update" + ], "committed update visible" + + # + # 5. Check that changes in prepared xacts is invisible + # + # delete old data, so we start with clean slate + assert psql_primary.do("DELETE from test_visibility") is not None, "DELETE" + res = psql_primary.query( + "BEGIN;" + "\nINSERT INTO test_visibility" + " VALUES('inserted in prepared will_commit');" + "\nPREPARE TRANSACTION 'will_commit';" + ) + assert res.error_message is None, res.error_message + + res = psql_primary.query( + "BEGIN;" + "\nINSERT INTO test_visibility" + " VALUES('inserted in prepared will_abort');" + "\nPREPARE TRANSACTION 'will_abort';" + ) + assert res.error_message is None, res.error_message + + node_primary.wait_for_catchup(node_standby) + + assert _standby_rows(psql_standby) == [], "uncommitted prepared invisible" + + # For some variation, finish prepared xacts via separate connections + node_primary.safe_sql("COMMIT PREPARED 'will_commit';") + node_primary.safe_sql("ROLLBACK PREPARED 'will_abort';") + node_primary.wait_for_catchup(node_standby) + + assert _standby_rows(psql_standby) == [ + "inserted in prepared will_commit" + ], "finished prepared visible" + finally: + psql_primary.close() + psql_standby.close() + + node_primary.stop() + node_standby.stop() diff --git a/src/test/recovery/pyt/test_022_crash_temp_files.py b/src/test/recovery/pyt/test_022_crash_temp_files.py new file mode 100644 index 00000000000..c4155e219ae --- /dev/null +++ b/src/test/recovery/pyt/test_022_crash_temp_files.py @@ -0,0 +1,189 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test remove of temporary files after a crash.""" + +import re + +from pypg.util import poll_until + + +def _kill_backend(node, pid): + """Kill a specific backend via 'pg_ctl kill KILL '.""" + node.signal_backend(pid, "KILL") + + +def _wait_backend_blocked_on_lock(session, pid): + """Run the server-side loop that waits until backend *pid* is stuck on a + not-granted lock, then returns the 'insert-tuple-lock-waiting' marker. + + Sends the DO block + SELECT on the 2nd session. + """ + res = session.query( + "DO $c$\n" + "DECLARE\n" + " c INT;\n" + "BEGIN\n" + " LOOP\n" + " SELECT COUNT(*) INTO c FROM pg_locks WHERE pid = " + + str(pid) + + " AND NOT granted;\n" + " IF c > 0 THEN\n" + " EXIT;\n" + " END IF;\n" + " END LOOP;\n" + "END; $c$;\n" + "SELECT $$insert-tuple-lock-waiting$$;" + ) + assert res.error_message is None, res.error_message + assert re.search("insert-tuple-lock-waiting", res.psqlout), res.psqlout + + +def _detect_session_died(session): + """Send a long sleep async and confirm the session dies due to the crash. + + Sends 'SELECT pg_sleep()' on the 2nd session and waits + for the connection-loss / crash WARNING on stderr. + """ + # Send a sleep that would block; the postmaster's crash handling will + # terminate this backend instead, so the async result reports failure. + session.do_async("SELECT pg_sleep(180)") + res = session.get_async_result() + # On a crash the backend is terminated: either an error result comes back + # (FATAL: terminating connection because of crash of another server + # process) or the connection is simply lost (result is None / error set). + pattern = re.compile( + r"terminating connection because of crash of another server process" + r"|server closed the connection unexpectedly" + r"|connection to server was lost" + r"|could not send data to server" + r"|terminating connection due to" + r"|no connection to the server" + ) + if res is None: + # Connection dropped with no error result; confirm it is no longer OK. + from libpq import ConnStatusType + + assert ( + session.conn_status() != ConnStatusType.CONNECTION_OK + ), "second session should have died after SIGKILL" + return + msg = (res.error_message or "") + (res.psqlout or "") + assert pattern.search( + msg + ), f"second session died successfully after SIGKILL; got: {msg!r}" + + +def _ls_tmp_count(node): + """Return COUNT(1) of files in base/pgsql_tmp as an int.""" + return int(node.safe_sql("SELECT COUNT(1) FROM pg_ls_dir($$base/pgsql_tmp$$)")) + + +def _crash_cycle(node, remove_temp_files): + """Run one full open-sessions / block / SIGKILL / restart cycle. + + Returns once the server has finished restarting and is reachable again. + The two concurrent backends are opened with node.connect() so the crash + drops them; they are explicitly closed afterwards. + """ + # Session to be killed: keep it alive so we have a backend to SIGKILL. + killme = node.connect("postgres") + # 2nd session that blocks the 1st via the UNIQUE constraint, preventing + # removal of the temp file created by the 1st session. + killme2 = node.connect("postgres") + try: + # Get backend pid of the session to be killed. + pid = int(killme.query_oneval("SELECT pg_backend_pid()")) + + # Insert one tuple and leave the transaction open on the 2nd session. + res = killme2.query( + "BEGIN;\n" + "INSERT INTO tab_crash (a) VALUES(1);\n" + "SELECT $$insert-tuple-to-lock-next-insert$$;" + ) + assert res.error_message is None, res.error_message + assert re.search("insert-tuple-to-lock-next-insert", res.psqlout), res.psqlout + + # On the 1st session, open a transaction and fire the INSERT that + # generates a temp file. It will block on the UNIQUE lock held by the + # 2nd session, so it must be sent asynchronously: it does not return + # before the backend is killed. + assert killme.do("BEGIN") is not None + marker = killme.query_oneval("SELECT $$in-progress-before-sigkill$$") + assert marker == "in-progress-before-sigkill", marker + assert killme.do_async( + "INSERT INTO tab_crash (a) SELECT i FROM generate_series(1, 5000) s(i)" + ), "failed to send blocking insert" + + # Wait until that batch insert gets stuck on the lock. + _wait_backend_blocked_on_lock(killme2, pid) + + # Kill the 1st backend with SIGKILL. + _kill_backend(node, pid) + + # The 1st psql session is now dead; close it. + killme.close() + + # Wait till the other session reports failure, ensuring the postmaster + # has noticed its dead child and begun a restart cycle. + _detect_session_died(killme2) + killme2.close() + finally: + # In case of an assertion failure mid-cycle, make sure we do not leak + # the connections (they may already be closed). + try: + killme.close() + except Exception: # pylint: disable=broad-exception-caught + pass + try: + killme2.close() + except Exception: # pylint: disable=broad-exception-caught + pass + + # Wait till the server finishes restarting and is reachable again. + assert poll_until( + lambda: node.poll_query_until("SELECT 1", expected="1") + ), "server never finished restarting" + + +def test_022_crash_temp_files(create_pg): + node = create_pg("node_crash") + + # By default the server doesn't restart after crash; turn that on. Reduce + # work_mem to generate a temporary file with a small number of rows. + # ALTER SYSTEM cannot run inside a transaction block, and the in-process + # Session wraps a multi-statement query in one implicit transaction, so + # each statement is issued separately. + node.safe_sql("ALTER SYSTEM SET remove_temp_files_after_crash = on") + node.safe_sql("ALTER SYSTEM SET log_connections = receipt") + node.safe_sql("ALTER SYSTEM SET work_mem = '64kB'") + node.safe_sql("ALTER SYSTEM SET restart_after_crash = on") + node.safe_sql("SELECT pg_reload_conf()") + + # create table, insert rows + node.safe_sql("CREATE TABLE tab_crash (a integer UNIQUE);") + + # First cycle: remove_temp_files_after_crash = on. + _crash_cycle(node, remove_temp_files=True) + + # Check for temporary files -- should be gone. + assert _ls_tmp_count(node) == 0, "no temporary files" + + # + # Test old behavior (don't remove temporary files after crash) + # + node.safe_sql("ALTER SYSTEM SET remove_temp_files_after_crash = off") + node.safe_sql("SELECT pg_reload_conf()") + + # Second cycle: remove_temp_files_after_crash = off. + _crash_cycle(node, remove_temp_files=False) + + # Check for temporary files -- should be there. + assert _ls_tmp_count(node) == 1, "one temporary file" + + # Restart should remove the temporary files. + node.restart() + + # Check the temporary files -- should be gone. + assert _ls_tmp_count(node) == 0, "temporary file was removed" + + node.stop() diff --git a/src/test/recovery/pyt/test_023_pitr_prepared_xact.py b/src/test/recovery/pyt/test_023_pitr_prepared_xact.py new file mode 100644 index 00000000000..357c50c9184 --- /dev/null +++ b/src/test/recovery/pyt/test_023_pitr_prepared_xact.py @@ -0,0 +1,92 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test for point-in-time recovery (PITR) with prepared transactions.""" + + +def test_023_pitr_prepared_xact(create_pg): + # Initialize and start primary node with WAL archiving + node_primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + node_primary.append_conf( + """ +max_prepared_transactions = 10""" + ) + node_primary.start() + + # Take backup + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Initialize node for PITR targeting a very specific restore point, just + # after a PREPARE TRANSACTION is issued so as we finish with a promoted + # node where this 2PC transaction needs an explicit COMMIT PREPARED. + node_pitr = create_pg("node_pitr", start=False) + node_pitr.init_from_backup( + node_primary, backup_name, standby=False, has_restoring=True + ) + node_pitr.append_conf( + """ +recovery_target_name = 'rp' +recovery_target_action = 'promote'""" + ) + + # Workload with a prepared transaction and the target restore point. + # The prepared transaction is issued as its own command because libpq + # wraps a multi-statement query in a single implicit transaction. + node_primary.safe_sql("CREATE TABLE foo(i int)") + node_primary.safe_sql( + """ +BEGIN; +INSERT INTO foo VALUES(1); +PREPARE TRANSACTION 'fooinsert';""" + ) + node_primary.safe_sql("SELECT pg_create_restore_point('rp')") + node_primary.safe_sql("INSERT INTO foo VALUES(2)") + + # Find next WAL segment to be archived + walfile_to_be_archived = node_primary.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_lsn());" + ) + + # Make WAL segment eligible for archival + node_primary.safe_sql("SELECT pg_switch_wal()") + + # Wait until the WAL segment has been archived. + archive_wait_query = ( + f"SELECT '{walfile_to_be_archived}' <= last_archived_wal " + "FROM pg_stat_archiver;" + ) + assert node_primary.poll_query_until( + archive_wait_query + ), "Timed out while waiting for WAL segment to be archived" + + # Now start the PITR node. + node_pitr.start() + + # Wait until the PITR node exits recovery. + assert node_pitr.poll_query_until( + "SELECT pg_is_in_recovery() = 'f';" + ), "Timed out while waiting for PITR promotion" + + # Commit the prepared transaction in the latest timeline and check its + # result. There should only be one row in the table, coming from the + # prepared transaction. The row from the INSERT after the restore point + # should not show up, since our recovery target was older than the second + # INSERT done. + node_pitr.safe_sql("COMMIT PREPARED 'fooinsert';") + result = node_pitr.safe_sql("SELECT * FROM foo;") + assert result == "1", "check table contents after COMMIT PREPARED" + + # Insert more data and do a checkpoint. These should be generated on the + # timeline chosen after the PITR promotion. + node_pitr.safe_sql( + """ +INSERT INTO foo VALUES(3); +CHECKPOINT;""" + ) + + # Enforce recovery, the checkpoint record generated previously should + # still be found. + node_pitr.stop("immediate") + node_pitr.start() diff --git a/src/test/recovery/pyt/test_024_archive_recovery.py b/src/test/recovery/pyt/test_024_archive_recovery.py new file mode 100644 index 00000000000..b51bb71f17d --- /dev/null +++ b/src/test/recovery/pyt/test_024_archive_recovery.py @@ -0,0 +1,109 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test for archive recovery of WAL generated with wal_level=minimal.""" + +import os +import re + +from pypg.util import TIMEOUT_DEFAULT, poll_until, slurp_file + +REPLICA_CONFIG = """ +wal_level = replica +archive_mode = on +max_wal_senders = 10 +hot_standby = off +""" + + +def test_024_archive_recovery(create_pg): + # Initialize and start node with wal_level = replica and WAL archiving + # enabled. + node = create_pg("orig", start=False, has_archiving=True, allows_streaming=True) + node.append_conf(REPLICA_CONFIG) + node.start() + + # Take backup + backup_name = "my_backup" + node.backup(backup_name) + + # Restart node with wal_level = minimal and WAL archiving disabled + # to generate WAL with that setting. Note that such WAL has not been + # archived yet at this moment because WAL archiving is not enabled. + node.append_conf( + """ +wal_level = minimal +archive_mode = off +max_wal_senders = 0 +""" + ) + node.restart() + + # Restart node with wal_level = replica and WAL archiving enabled + # to archive WAL previously generated with wal_level = minimal. + # We ensure the WAL file containing the record indicating the change + # of wal_level to minimal is archived by checking pg_stat_archiver. + node.append_conf(REPLICA_CONFIG) + node.restart() + + # Find next WAL segment to be archived + walfile_to_be_archived = node.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_lsn());" + ) + + # Make WAL segment eligible for archival + node.safe_sql("SELECT pg_switch_wal()") + archive_wait_query = ( + f"SELECT '{walfile_to_be_archived}' <= last_archived_wal " + "FROM pg_stat_archiver;" + ) + + # Wait until the WAL segment has been archived. + assert node.poll_query_until( + archive_wait_query + ), "Timed out while waiting for WAL segment to be archived" + + node.stop() + + # Initialize new node from backup, and start archive recovery. Check that + # archive recovery fails with an error when it detects the WAL record + # indicating the change of wal_level to minimal and node stops. + def test_recovery_wal_level_minimal(node_name, node_text, standby_setting): + recovery_node = create_pg(node_name, start=False) + recovery_node.init_from_backup( + node, backup_name, has_restoring=True, standby=standby_setting + ) + + # Start the server directly with pg_ctl (no -w wait) because this test + # expects that the server ends with an error during recovery, so a + # waited start would never report success. + recovery_node.pg_bin.result( + [ + "pg_ctl", + "--pgdata", + recovery_node.data_dir, + "--log", + recovery_node.logfile, + "start", + ] + ) + + # wait for postgres to terminate + pidfile = os.path.join(recovery_node.data_dir, "postmaster.pid") + poll_until(lambda: not os.path.isfile(pidfile), timeout=TIMEOUT_DEFAULT) + + # Confirm that the archive recovery fails with an expected error + logfile = slurp_file(recovery_node.logfile) + assert re.search( + r'FATAL: .* WAL was generated with "wal_level=minimal", ' + r"cannot continue recovering", + logfile, + ), ( + f"{node_text} ends with an error because it finds WAL generated " + 'with "wal_level=minimal"' + ) + + # Test for archive recovery + test_recovery_wal_level_minimal("archive_recovery", "archive recovery", False) + + # Test for standby server + test_recovery_wal_level_minimal("standby", "standby", True) diff --git a/src/test/recovery/pyt/test_025_stuck_on_old_timeline.py b/src/test/recovery/pyt/test_025_stuck_on_old_timeline.py new file mode 100644 index 00000000000..59a6c6a9a01 --- /dev/null +++ b/src/test/recovery/pyt/test_025_stuck_on_old_timeline.py @@ -0,0 +1,123 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Testing streaming replication where standby is promoted and a new cascading +standby (without WAL) is connected to the promoted standby. Both archiving +and streaming are enabled, but only the history file is available from the +archive, so the WAL files all have to be streamed. Test that the cascading +standby can follow the new primary (promoted standby). +""" + +import os +import sys +import tempfile + + +def test_025_stuck_on_old_timeline(create_pg): + # Initialize primary node. + # + # Set up an archive command that will copy the history file but not the WAL + # files. No real archive command should behave this way; the point is to + # simulate a race condition where the new cascading standby starts up after + # the timeline history file reaches the archive but before any of the WAL + # files get there. + node_primary = create_pg( + "primary", start=False, allows_streaming=True, has_archiving=True + ) + + # Write a small script for cp_history_files: it copies the source to the + # target only when the source path contains "history" (i.e. timeline + # history files), dropping everything else. Use a Python script run by the + # interpreter, so the archive_command works on every platform -- a + # "#!/bin/sh" script would not run on Windows. + archivedir_primary = node_primary.archive_dir.replace("\\", "/") + fd, cp_history_files = tempfile.mkstemp(prefix="cp_history_files", suffix=".py") + os.write( + fd, + b"""\ +import shutil +import sys + +src, dst = sys.argv[1], sys.argv[2] +if "history" in src: + shutil.copyfile(src, dst) +""", + ) + os.close(fd) + + # Override the default archive_command with our history-only copy script. + # Forward slashes keep the command valid in postgresql.conf on Windows. + python = sys.executable.replace("\\", "/") + script = cp_history_files.replace("\\", "/") + node_primary.append_conf( + f""" +archive_command = '"{python}" "{script}" "%p" "{archivedir_primary}/%f"' +wal_keep_size=128MB +""" + ) + node_primary.start() + + # Take backup from primary + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Create streaming standby linking to primary + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.start() + + # Take backup of standby, use -Xnone so that pg_wal is empty. + node_standby.backup(backup_name, backup_options=["-Xnone"]) + + # Create cascading standby but don't start it yet. + # Must set up both streaming and archiving. + node_cascade = create_pg("cascade", start=False) + node_cascade.init_from_backup(node_standby, backup_name, has_streaming=True) + node_cascade.enable_restoring(node_primary) + node_cascade.append_conf( + """ +recovery_target_timeline='latest' +""" + ) + + # Promote the standby. + node_standby.promote() + + # Wait for promotion to complete + assert node_standby.poll_query_until( + "SELECT NOT pg_is_in_recovery();" + ), "Timed out while waiting for promotion" + + # Find next WAL segment to be archived + walfile_to_be_archived = node_standby.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_lsn());" + ) + + # Make WAL segment eligible for archival + node_standby.safe_sql("SELECT pg_switch_wal()") + + # Wait until the WAL segment has been archived. + # Since the history file gets created on promotion and is archived before any + # WAL segment, this is enough to guarantee that the history file was + # archived. + archive_wait_query = ( + f"SELECT '{walfile_to_be_archived}' <= last_archived_wal " + "FROM pg_stat_archiver" + ) + assert node_standby.poll_query_until( + archive_wait_query + ), "Timed out while waiting for WAL segment to be archived" + + # Start cascade node + node_cascade.start() + + # Create some content on promoted standby and check its presence on the + # cascading standby. + node_standby.safe_sql("CREATE TABLE tab_int AS SELECT 1 AS a") + + # Wait for the replication to catch up + node_standby.wait_for_catchup(node_cascade) + + # Check that cascading standby has the new content + result = node_cascade.safe_sql("SELECT count(*) FROM tab_int") + print(f"cascade: {result}") + assert result == "1", "check streamed content on cascade standby" diff --git a/src/test/recovery/pyt/test_026_overwrite_contrecord.py b/src/test/recovery/pyt/test_026_overwrite_contrecord.py new file mode 100644 index 00000000000..1cf011ec6a2 --- /dev/null +++ b/src/test/recovery/pyt/test_026_overwrite_contrecord.py @@ -0,0 +1,98 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for already-propagated WAL segments ending in incomplete WAL records.""" + +import os + + +def test_026_overwrite_contrecord(create_pg): + # Test: Create a physical replica that's missing the last WAL file, + # then restart the primary to create a divergent WAL file and observe + # that the replica replays the "overwrite contrecord" from that new + # file and the standby promotes successfully. + + node = create_pg("primary", start=False, allows_streaming=True) + # We need these settings for stability of WAL behavior. + node.append_conf( + """ +autovacuum = off +wal_keep_size = 1GB +""" + ) + node.start() + + node.safe_sql("create table filler (a int, b text)") + + # Now consume all remaining room in the current WAL segment, leaving + # space enough only for the start of a largish record. + node.safe_sql( + r""" +DO $$ +DECLARE + wal_segsize int := setting::int FROM pg_settings WHERE name = 'wal_segment_size'; + remain int; + iters int := 0; +BEGIN + LOOP + INSERT into filler + select g, repeat(encode(sha256(g::text::bytea), 'hex'), (random() * 15 + 1)::int) + from generate_series(1, 10) g; + + remain := wal_segsize - (pg_current_wal_insert_lsn() - '0/0') % wal_segsize; + IF remain < 2 * setting::int from pg_settings where name = 'block_size' THEN + RAISE log 'exiting after % iterations, % bytes to end of WAL segment', iters, remain; + EXIT; + END IF; + iters := iters + 1; + END LOOP; +END +$$; +""" + ) + + initfile = node.safe_sql("SELECT pg_walfile_name(pg_current_wal_insert_lsn())") + node.safe_sql( + "SELECT pg_logical_emit_message(true, 'test 026', repeat('xyzxz', 123456))" + ) + + endfile = node.safe_sql("SELECT pg_walfile_name(pg_current_wal_insert_lsn())") + assert initfile != endfile, f"{initfile} differs from {endfile}" + + # Now stop abruptly, to avoid a stop checkpoint. We can remove the tail + # file afterwards, and on startup the large message should be overwritten + # with new contents + node.stop("immediate") + + os.unlink(os.path.join(node.data_dir, "pg_wal", endfile)) + + # OK, create a standby at this spot. + node.backup_fs_cold("backup") + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node, "backup", has_streaming=True) + + node_standby.start() + node.start() + + node.safe_sql("create table foo (a text); insert into foo values ('hello')") + node.safe_sql("SELECT pg_logical_emit_message(true, 'test 026', 'AABBCC')") + + until_lsn = node.safe_sql("SELECT pg_current_wal_lsn()") + caughtup_query = f"SELECT '{until_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + assert node_standby.poll_query_until( + caughtup_query + ), "Timed out while waiting for standby to catch up" + + assert ( + node_standby.safe_sql("select * from foo") == "hello" + ), "standby replays past overwritten contrecord" + + # Verify message appears in standby's log + assert node_standby.log_contains( + r"successfully skipped missing contrecord at" + ), "found log line in standby" + + # Verify promotion is successful + node_standby.promote() + + node.stop() + node_standby.stop() diff --git a/src/test/recovery/pyt/test_027_stream_regress.py b/src/test/recovery/pyt/test_027_stream_regress.py new file mode 100644 index 00000000000..010b66d6737 --- /dev/null +++ b/src/test/recovery/pyt/test_027_stream_regress.py @@ -0,0 +1,233 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Run the standard regression tests with streaming replication, then verify +that primary and standby produce identical logical and catalog dumps and +that pg_stat_statements gathered the expected query jumbling data. + +pg_regress, pg_dumpall and pg_dump are binaries under test, so they are run +as subprocesses (pg_regress via the run_pg_regress helper; the dump tools via +the node-scoped pg_bin, which sets PGHOST/PGPORT). All SQL the test issues on +its own behalf goes through the in-process Session (safe_sql / sql). +""" + +import os + +import pytest + +from pypg.regress import pg_regress_available, run_pg_regress +from pypg.util import slurp_file + +# This test exercises the full regression suite via pg_regress, which is only +# available under the meson/make harness (PG_REGRESS in the environment). +pytestmark = pytest.mark.skipif( + not pg_regress_available(), + reason="pg_regress is not available (PG_REGRESS not set)", +) + +# Repository root: this file lives at src/test/recovery/pyt, four levels up. +_REPO_ROOT = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") +) +_REGRESS_DIR = os.path.join(_REPO_ROOT, "src", "test", "regress") + + +def _compare_files(a, b, msg): + """Assert that files *a* and *b* have identical contents. + + On mismatch, show a small unified-ish diff to make the failure + diagnosable. + """ + content_a = slurp_file(a) + content_b = slurp_file(b) + if content_a == content_b: + return + + import difflib + + diff = list( + difflib.unified_diff( + content_a.splitlines(keepends=True), + content_b.splitlines(keepends=True), + fromfile=a, + tofile=b, + ) + ) + # Keep the reported diff bounded so failures stay readable. + snippet = "".join(diff[:200]) + raise AssertionError(f"{msg}\n{snippet}") + + +def test_027_stream_regress(create_pg, tmp_path): + # Initialize primary node + node_primary = create_pg("primary", start=False, allows_streaming=True) + + # Increase some settings that init makes too low by default. A later + # setting in postgresql.conf overrides an earlier one, so appending + # max_connections = 25 raises the default of 10. + node_primary.append_conf("max_connections = 25") + node_primary.append_conf("max_prepared_transactions = 10") + + # Enable pg_stat_statements to force tests to do query jumbling. + # pg_stat_statements.max should be large enough to hold all the entries + # of the regression database. + node_primary.append_conf( + "shared_preload_libraries = 'pg_stat_statements'\n" + "pg_stat_statements.max = 50000\n" + "compute_query_id = 'regress'\n" + ) + + # We'll stick with the small default shared_buffers, but since that makes + # synchronized seqscans more probable, it risks changing the results of + # some test queries. Disable synchronized seqscans to prevent that. + node_primary.append_conf("synchronize_seqscans = off") + + # WAL consistency checking is resource intensive so require opt-in with the + # PG_TEST_EXTRA environment variable. + pg_test_extra = os.environ.get("PG_TEST_EXTRA", "") + if "wal_consistency_checking" in pg_test_extra.split(): + node_primary.append_conf("wal_consistency_checking = all") + + node_primary.start() + res = node_primary.sql("SELECT pg_create_physical_replication_slot('standby_1')") + assert res.error_message is None, "physical slot created on primary" + + backup_name = "my_backup" + + # Take backup + node_primary.backup(backup_name) + + # Create streaming standby linking to primary + node_standby_1 = create_pg("standby_1", start=False) + node_standby_1.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby_1.append_conf("primary_slot_name = standby_1") + node_standby_1.append_conf("max_standby_streaming_delay = 600s") + node_standby_1.start() + + outputdir = str(tmp_path) + + # Run the regression tests against the primary. + result = run_pg_regress( + node_primary, + inputdir=_REGRESS_DIR, + outputdir=outputdir, + schedule=os.path.join(_REGRESS_DIR, "parallel_schedule"), + max_concurrent_tests=20, + ) + assert result.returncode == 0, ( + "regression tests pass\n" f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + + assert node_primary.postmaster_alive(), "primary alive after regression test run" + assert node_standby_1.postmaster_alive(), "standby alive after regression test run" + + # Clobber all sequences with their next value, so that we don't have + # differences between nodes due to caching. + node_primary.safe_sql( + "select setval(seqrelid, nextval(seqrelid)) from pg_sequence", + dbname="regression", + ) + + # Wait for standby to catch up + node_primary.wait_for_replay_catchup(node_standby_1) + + # Perform a logical dump of primary and standby, and check that they match. + primary_dump = os.path.join(outputdir, "primary.dump") + standby_dump = os.path.join(outputdir, "standby.dump") + node_primary.command_ok( + [ + "pg_dumpall", + "--file", + primary_dump, + "--no-sync", + "--no-statistics", + "--restrict-key", + "test", + "--port", + str(node_primary.port), + "--no-unlogged-table-data", # if unlogged, standby has schema only + ], + "dump primary server", + ) + node_standby_1.command_ok( + [ + "pg_dumpall", + "--file", + standby_dump, + "--no-sync", + "--no-statistics", + "--restrict-key", + "test", + "--port", + str(node_standby_1.port), + ], + "dump standby server", + ) + _compare_files(primary_dump, standby_dump, "compare primary and standby dumps") + + # Likewise for the catalogs of the regression database, after disabling + # autovacuum to make fields like relpages stop changing. + node_primary.append_conf("autovacuum = off") + node_primary.restart() + node_primary.wait_for_replay_catchup(node_standby_1) + + primary_catalogs = os.path.join(outputdir, "catalogs_primary.dump") + standby_catalogs = os.path.join(outputdir, "catalogs_standby.dump") + node_primary.command_ok( + [ + "pg_dump", + "--schema", + "pg_catalog", + "--file", + primary_catalogs, + "--no-sync", + "--restrict-key", + "test", + "--port", + str(node_primary.port), + "--no-unlogged-table-data", + "regression", + ], + "dump catalogs of primary server", + ) + node_standby_1.command_ok( + [ + "pg_dump", + "--schema", + "pg_catalog", + "--file", + standby_catalogs, + "--no-sync", + "--restrict-key", + "test", + "--port", + str(node_standby_1.port), + "regression", + ], + "dump catalogs of standby server", + ) + _compare_files( + primary_catalogs, standby_catalogs, "compare primary and standby catalog dumps" + ) + + # Check some data from pg_stat_statements. + node_primary.safe_sql("CREATE EXTENSION pg_stat_statements") + # This gathers data based on the first characters for some common query + # types, checking that reports are generated for SELECT, DMLs, and DDL + # queries with CREATE. + result = node_primary.safe_sql( + """WITH select_stats AS + (SELECT upper(substr(query, 1, 6)) AS select_query + FROM pg_stat_statements + WHERE upper(substr(query, 1, 6)) IN ('SELECT', 'UPDATE', + 'INSERT', 'DELETE', + 'CREATE')) + SELECT select_query, count(select_query) > 1 AS some_rows + FROM select_stats + GROUP BY select_query ORDER BY select_query;""" + ) + assert ( + result == "CREATE|t\nDELETE|t\nINSERT|t\nSELECT|t\nUPDATE|t" + ), "check contents of pg_stat_statements on regression database" + + node_standby_1.stop() + node_primary.stop() diff --git a/src/test/recovery/pyt/test_028_pitr_timelines.py b/src/test/recovery/pyt/test_028_pitr_timelines.py new file mode 100644 index 00000000000..ee37a6a8562 --- /dev/null +++ b/src/test/recovery/pyt/test_028_pitr_timelines.py @@ -0,0 +1,182 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Test PITR recovery where the target point is physically in a WAL segment +with a higher TLI than the target point's TLI. +""" + +# Test recovering to a point-in-time using WAL archive, such that the +# target point is physically in a WAL segment with a higher TLI than +# the target point's TLI. For example, imagine that the following WAL +# segments exist in the WAL archive: +# +# 000000010000000000000001 +# 000000010000000000000002 +# 000000020000000000000003 +# +# The timeline switch happened in the middle of WAL segment 3, but it +# was never archived on timeline 1. The first half of +# 000000020000000000000003 contains the WAL from timeline 1 up to the +# point where the timeline switch happened. If you now perform +# archive recovery with recovery target point in that first half of +# segment 3, archive recovery will find the WAL up to that point in +# segment 000000020000000000000003, but it will not follow the +# timeline switch to timeline 2, and creates a timeline switching +# end-of-recovery record with TLI 1 -> 3. That's what this test case +# tests. +# +# The comments below contain lists of WAL segments at different points +# in the tests, to make it easier to follow along. They are correct +# as of this writing, but the exact WAL segment numbers could change +# if the backend logic for when it switches to a new segment changes. +# The actual checks are not sensitive to that. + + +def test_028_pitr_timelines(create_pg): + # Initialize and start primary node with WAL archiving + node_primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + node_primary.start() + + # Take a backup. + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Workload with some transactions, and the target restore point. + # + # Each statement must auto-commit separately, but our in-process + # safe_sql runs a multi-statement string as a + # single implicit transaction, which would leave the whole workload + # uncommitted at restore point 'rp' (so 'foo' would not yet exist on PITR). + # Issue each statement separately to reproduce psql's per-statement + # autocommit, so 'foo' with row 1 is committed before 'rp'. + node_primary.safe_sql("CREATE TABLE foo(i int);") + node_primary.safe_sql("INSERT INTO foo VALUES(1);") + node_primary.safe_sql("SELECT pg_create_restore_point('rp');") + node_primary.safe_sql("INSERT INTO foo VALUES(2);") + + # Contents of the WAL archive at this point: + # + # 000000010000000000000001 + # 000000010000000000000002 + # 000000010000000000000002.00000028.backup + # + # The operations on the test table and the restore point went into WAL + # segment 3, but it hasn't been archived yet. + + # Start a standby node, and wait for it to catch up. + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup( + node_primary, backup_name, standby=True, has_streaming=True, has_restoring=False + ) + # Archiving is a no-op in init_from_backup, which only acts on + # has_streaming/has_restoring. The standby inherits + # the primary's archive_command -- which targets the primary's archive_dir + # -- from the postgresql.conf copied by pg_basebackup. We only override + # archive_mode to 'always' so the standby keeps archiving into the + # primary's archive_dir, where the PITR nodes below restore from. + node_standby.append_conf("archive_mode = always") + node_standby.start() + node_primary.wait_for_catchup(node_standby) + + # Check that it's really caught up. + result = node_standby.safe_sql("SELECT max(i) FROM foo;") + assert result == "2", "check table contents after archive recovery" + + # Kill the old primary, before it archives the most recent WAL segment that + # contains all the INSERTs. + node_primary.stop("immediate") + + # Promote the standby, and switch WAL so that it archives a WAL segment + # that contains all the INSERTs, on a new timeline. + node_standby.promote() + + # Find next WAL segment to be archived. + node_standby.safe_sql("SELECT pg_walfile_name(pg_current_wal_lsn());") + + # Make WAL segment eligible for archival + node_standby.safe_sql("SELECT pg_switch_wal()") + + # We don't need the standby anymore, request shutdown. The server will + # finish archiving all the WAL on timeline 2 before it exits. + node_standby.stop() + + # Contents of the WAL archive at this point: + # + # 000000010000000000000001 + # 000000010000000000000002 + # 000000010000000000000002.00000028.backup + # 000000010000000000000003.partial + # 000000020000000000000003 + # 00000002.history + # + # The operations on the test table and the restore point are in + # segment 3. They are part of timeline 1, but were not archived by + # the primary yet. However, they were copied into the beginning of + # segment 000000020000000000000003, before the timeline switching + # record. (They are also present in the + # 000000010000000000000003.partial file, but .partial files are not + # used automatically.) + + # Now test PITR to the recovery target. It should find the WAL in + # segment 000000020000000000000003, but not follow the timeline switch + # to timeline 2. + node_pitr = create_pg("node_pitr", start=False) + node_pitr.init_from_backup( + node_primary, backup_name, standby=False, has_restoring=True + ) + node_pitr.append_conf( + """ +recovery_target_name = 'rp' +recovery_target_action = 'promote' +""" + ) + + node_pitr.start() + + # Wait until recovery finishes. + assert node_pitr.poll_query_until( + "SELECT pg_is_in_recovery() = 'f';" + ), "Timed out while waiting for PITR promotion" + + # Check that we see the data we expect. + result = node_pitr.safe_sql("SELECT max(i) FROM foo;") + assert result == "1", "check table contents after point-in-time recovery" + + # Insert a row so that we can check later that we successfully recover + # back to this timeline. + node_pitr.safe_sql("INSERT INTO foo VALUES(3);") + + # Wait for the archiver to be running. The startup process might have yet + # to exit, in which case the postmaster has not started the archiver. If + # we stop() without an archiver, the archive will be incomplete. + assert node_pitr.poll_query_until( + "SELECT true FROM pg_stat_activity WHERE backend_type = 'archiver';" + ), "Timed out while waiting for archiver to start" + + # Stop the node. This archives the last segment. + node_pitr.stop() + + # Test archive recovery on the timeline created by the PITR. This + # replays the end-of-recovery record that switches from timeline 1 to + # 3. + node_pitr2 = create_pg("node_pitr2", start=False) + node_pitr2.init_from_backup( + node_primary, backup_name, standby=False, has_restoring=True + ) + node_pitr2.append_conf( + """ +recovery_target_action = 'promote' +""" + ) + + node_pitr2.start() + + # Wait until recovery finishes. + assert node_pitr2.poll_query_until( + "SELECT pg_is_in_recovery() = 'f';" + ), "Timed out while waiting for PITR promotion" + + # Verify that we can see the row inserted after the PITR. + result = node_pitr2.safe_sql("SELECT max(i) FROM foo;") + assert result == "3", "check table contents after point-in-time recovery" diff --git a/src/test/recovery/pyt/test_029_stats_restart.py b/src/test/recovery/pyt/test_029_stats_restart.py new file mode 100644 index 00000000000..fc8a8efc734 --- /dev/null +++ b/src/test/recovery/pyt/test_029_stats_restart.py @@ -0,0 +1,352 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests statistics handling around restarts, including handling of crashes and +invalid stats files, as well as restoring stats after "normal" restarts. +""" + +import os +import shutil + +CONNECT_DB = "postgres" +DB_UNDER_TEST = "test" + + +def _trigger_funcrel_stat(node): + node.safe_sql( + """ + SELECT * FROM tab_stats_crash_discard_test1; + SELECT func_stats_crash_discard1(); + SELECT pg_stat_force_next_flush();""", + DB_UNDER_TEST, + ) + + +def _have_stats(node, kind, dboid, objid): + return node.safe_sql( + f"SELECT pg_stat_have_stats('{kind}', {dboid}, {objid})", CONNECT_DB + ) + + +def _overwrite_file(filename, text): + with open(filename, "w", encoding="utf-8") as fh: + fh.write(text) + + +def _append_file(filename, text): + with open(filename, "a", encoding="utf-8") as fh: + fh.write(text) + + +def _checkpoint_stats(node): + results = {} + results["count"] = node.safe_sql( + "SELECT num_timed + num_requested FROM pg_stat_checkpointer", CONNECT_DB + ) + results["reset"] = node.safe_sql( + "SELECT stats_reset FROM pg_stat_checkpointer", CONNECT_DB + ) + return results + + +def _wal_stats(node): + results = {} + results["records"] = node.safe_sql( + "SELECT wal_records FROM pg_stat_wal", CONNECT_DB + ) + results["bytes"] = node.safe_sql("SELECT wal_bytes FROM pg_stat_wal", CONNECT_DB) + results["reset"] = node.safe_sql("SELECT stats_reset FROM pg_stat_wal", CONNECT_DB) + return results + + +def _io_stats(node, context, obj, backend_type): + results = {} + results["writes"] = node.safe_sql( + f"""SELECT writes FROM pg_stat_io + WHERE context = '{context}' AND object = '{obj}' AND + backend_type = '{backend_type}'""", + CONNECT_DB, + ) + results["reads"] = node.safe_sql( + f"""SELECT reads FROM pg_stat_io + WHERE context = '{context}' AND object = '{obj}' AND + backend_type = '{backend_type}'""", + CONNECT_DB, + ) + return results + + +def test_029_stats_restart(create_pg, tmp_path): + node = create_pg("primary", start=False, allows_streaming=True) + node.append_conf("track_functions = 'all'") + node.start() + + sect = "startup" + + # Check some WAL statistics after a fresh startup. The startup process + # should have done WAL reads, and initialization some WAL writes. + standalone_io_stats = _io_stats(node, "init", "wal", "standalone backend") + startup_io_stats = _io_stats(node, "normal", "wal", "startup") + assert int("0") < int( + standalone_io_stats["writes"] + ), f"{sect}: increased standalone backend IO writes" + assert int("0") < int( + startup_io_stats["reads"] + ), f"{sect}: increased startup IO reads" + + # create test objects + node.safe_sql(f"CREATE DATABASE {DB_UNDER_TEST}", CONNECT_DB) + node.safe_sql( + "CREATE TABLE tab_stats_crash_discard_test1 AS " + "SELECT generate_series(1,100) AS a", + DB_UNDER_TEST, + ) + node.safe_sql( + "CREATE FUNCTION func_stats_crash_discard1() RETURNS VOID AS " + "'select 2;' LANGUAGE SQL IMMUTABLE", + DB_UNDER_TEST, + ) + + # collect object oids + dboid = node.safe_sql( + f"SELECT oid FROM pg_database WHERE datname = '{DB_UNDER_TEST}'", + DB_UNDER_TEST, + ) + funcoid = node.safe_sql( + "SELECT 'func_stats_crash_discard1()'::regprocedure::oid", DB_UNDER_TEST + ) + tableoid = node.safe_sql( + "SELECT 'tab_stats_crash_discard_test1'::regclass::oid", DB_UNDER_TEST + ) + + # generate stats and flush them + _trigger_funcrel_stat(node) + + # verify stats objects exist + sect = "initial" + assert _have_stats(node, "database", dboid, 0) == "t", f"{sect}: db stats do exist" + assert ( + _have_stats(node, "function", dboid, funcoid) == "t" + ), f"{sect}: function stats do exist" + assert ( + _have_stats(node, "relation", dboid, tableoid) == "t" + ), f"{sect}: relation stats do exist" + + # regular shutdown + node.stop() + + # backup stats files + statsfile = os.path.join(str(tmp_path), "discard_stats1") + assert not os.path.isfile(statsfile), "backup statsfile cannot already exist" + + datadir = node.data_dir + og_stats = os.path.join(datadir, "pg_stat", "pgstat.stat") + assert os.path.isfile(og_stats), "origin stats file must exist" + shutil.copy(og_stats, statsfile) + + # test discarding of stats file after crash etc + + node.start() + + sect = "copy" + assert _have_stats(node, "database", dboid, 0) == "t", f"{sect}: db stats do exist" + assert ( + _have_stats(node, "function", dboid, funcoid) == "t" + ), f"{sect}: function stats do exist" + assert ( + _have_stats(node, "relation", dboid, tableoid) == "t" + ), f"{sect}: relation stats do exist" + + node.stop("immediate") + + assert not os.path.isfile( + og_stats + ), "no stats file should exist after immediate shutdown" + + # copy the old stats back to test we discard stats after crash restart + shutil.copy(statsfile, og_stats) + + node.start() + + # stats should have been discarded + sect = "post immediate" + assert ( + _have_stats(node, "database", dboid, 0) == "f" + ), f"{sect}: db stats do not exist" + assert ( + _have_stats(node, "function", dboid, funcoid) == "f" + ), f"{sect}: function stats do exist" + assert ( + _have_stats(node, "relation", dboid, tableoid) == "f" + ), f"{sect}: relation stats do not exist" + + # get rid of backup statsfile + os.unlink(statsfile) + + # generate new stats and flush them + _trigger_funcrel_stat(node) + + sect = "post immediate, new" + assert _have_stats(node, "database", dboid, 0) == "t", f"{sect}: db stats do exist" + assert ( + _have_stats(node, "function", dboid, funcoid) == "t" + ), f"{sect}: function stats do exist" + assert ( + _have_stats(node, "relation", dboid, tableoid) == "t" + ), f"{sect}: relation stats do exist" + + # regular shutdown + node.stop() + + # check an invalid stats file is handled + + _overwrite_file(og_stats, "ZZZZZZZZZZZZZ") + + # normal startup and no issues despite invalid stats file + node.start() + + # no stats present due to invalid stats file + sect = "invalid_overwrite" + assert ( + _have_stats(node, "database", dboid, 0) == "f" + ), f"{sect}: db stats do not exist" + assert ( + _have_stats(node, "function", dboid, funcoid) == "f" + ), f"{sect}: function stats do not exist" + assert ( + _have_stats(node, "relation", dboid, tableoid) == "f" + ), f"{sect}: relation stats do not exist" + + # check invalid stats file starting with valid contents, but followed by + # invalid content is handled. + + _trigger_funcrel_stat(node) + node.stop() + _append_file(og_stats, "XYZ") + node.start() + + sect = "invalid_append" + assert ( + _have_stats(node, "database", dboid, 0) == "f" + ), f"{sect}: db stats do not exist" + assert ( + _have_stats(node, "function", dboid, funcoid) == "f" + ), f"{sect}: function stats do not exist" + assert ( + _have_stats(node, "relation", dboid, tableoid) == "f" + ), f"{sect}: relation stats do not exist" + + # checks related to stats persistency around restarts and resets + + # Ensure enough checkpoints to protect against races for test after reset, + # even on very slow machines. + node.safe_sql("CHECKPOINT; CHECKPOINT;", CONNECT_DB) + + # check checkpoint and wal stats are incremented due to restart + + ckpt_start = _checkpoint_stats(node) + wal_start = _wal_stats(node) + node.restart() + + sect = "post restart" + ckpt_restart = _checkpoint_stats(node) + wal_restart = _wal_stats(node) + + assert int(ckpt_start["count"]) < int( + ckpt_restart["count"] + ), f"{sect}: increased checkpoint count" + assert int(wal_start["records"]) < int( + wal_restart["records"] + ), f"{sect}: increased wal record count" + assert int(wal_start["bytes"]) < int( + wal_restart["bytes"] + ), f"{sect}: increased wal bytes" + assert ( + ckpt_start["reset"] == ckpt_restart["reset"] + ), f"{sect}: checkpoint stats_reset equal" + assert wal_start["reset"] == wal_restart["reset"], f"{sect}: wal stats_reset equal" + + # Check that checkpoint stats are reset, WAL stats aren't affected + + node.safe_sql("SELECT pg_stat_reset_shared('checkpointer')", CONNECT_DB) + + sect = "post ckpt reset" + ckpt_reset = _checkpoint_stats(node) + wal_ckpt_reset = _wal_stats(node) + + assert int(ckpt_restart["count"]) > int( + ckpt_reset["count"] + ), f"{sect}: checkpoint count smaller" + assert ckpt_start["reset"] < ckpt_reset["reset"], f"{sect}: stats_reset newer" + + assert int(wal_restart["records"]) <= int( + wal_ckpt_reset["records"] + ), f"{sect}: wal record count not affected by reset" + assert ( + wal_start["reset"] == wal_ckpt_reset["reset"] + ), f"{sect}: wal stats_reset equal" + + # check that checkpoint stats stay reset after restart + + node.restart() + + sect = "post ckpt reset & restart" + ckpt_restart_reset = _checkpoint_stats(node) + wal_restart2 = _wal_stats(node) + + # made sure above there's enough checkpoints that this will be stable even + # on slow machines + assert int(ckpt_restart_reset["count"]) < int( + ckpt_restart["count"] + ), f"{sect}: checkpoint still reset" + assert ( + ckpt_restart_reset["reset"] == ckpt_reset["reset"] + ), f"{sect}: stats_reset same" + + assert int(wal_ckpt_reset["records"]) < int( + wal_restart2["records"] + ), f"{sect}: increased wal record count" + assert int(wal_ckpt_reset["bytes"]) < int( + wal_restart2["bytes"] + ), f"{sect}: increased wal bytes" + assert wal_start["reset"] == wal_restart2["reset"], f"{sect}: wal stats_reset equal" + + # check WAL stats stay reset + + node.safe_sql("SELECT pg_stat_reset_shared('wal')", CONNECT_DB) + + sect = "post wal reset" + wal_reset = _wal_stats(node) + + assert int(wal_reset["records"]) < int( + wal_restart2["records"] + ), f"{sect}: smaller record count" + assert int(wal_reset["bytes"]) < int( + wal_restart2["bytes"] + ), f"{sect}: smaller bytes" + assert wal_reset["reset"] > wal_restart2["reset"], f"{sect}: newer stats_reset" + + node.restart() + + sect = "post wal reset & restart" + wal_reset_restart = _wal_stats(node) + + # enough WAL generated during prior tests and initdb to make this not racy + assert int(wal_reset_restart["records"]) < int( + wal_restart2["records"] + ), f"{sect}: smaller record count" + assert int(wal_reset["bytes"]) < int( + wal_restart2["bytes"] + ), f"{sect}: smaller bytes" + assert wal_reset["reset"] > wal_restart2["reset"], f"{sect}: newer stats_reset" + + node.stop("immediate") + node.start() + + sect = "post immediate restart" + wal_restart_immediate = _wal_stats(node) + + assert ( + wal_reset_restart["reset"] < wal_restart_immediate["reset"] + ), f"{sect}: reset timestamp is new" + + node.stop() diff --git a/src/test/recovery/pyt/test_030_stats_cleanup_replica.py b/src/test/recovery/pyt/test_030_stats_cleanup_replica.py new file mode 100644 index 00000000000..e12b7274352 --- /dev/null +++ b/src/test/recovery/pyt/test_030_stats_cleanup_replica.py @@ -0,0 +1,203 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests that standbys: + +- drop stats for objects when the those records are replayed +- persist stats across graceful restarts +- discard stats after immediate / crash restarts +""" + + +def _populate_standby_stats(node_primary, node_standby, connect_db, schema): + # create objects on primary + node_primary.safe_sql( + f"CREATE TABLE {schema}.drop_tab_test1 AS " + "SELECT generate_series(1,100) AS a", + connect_db, + ) + node_primary.safe_sql( + f"CREATE FUNCTION {schema}.drop_func_test1() RETURNS VOID AS " + "'select 2;' LANGUAGE SQL IMMUTABLE", + connect_db, + ) + node_primary.wait_for_replay_catchup(node_standby) + + # collect object oids + dboid = node_standby.safe_sql( + f"SELECT oid FROM pg_database WHERE datname = '{connect_db}'", + connect_db, + ) + tableoid = node_standby.safe_sql( + f"SELECT '{schema}.drop_tab_test1'::regclass::oid", connect_db + ) + funcoid = node_standby.safe_sql( + f"SELECT '{schema}.drop_func_test1()'::regprocedure::oid", connect_db + ) + + # Generate stats on standby. This framework keeps one long-lived + # backend per database. A backend that + # has referenced an object pins its stats entry, so a later replayed drop + # can only mark the entry dropped (not free it) and pg_stat_have_stats() + # on that same backend would still report the object as present. Run the + # stats-generating queries on a throwaway connection that is closed + # immediately, mirroring the per-statement fresh connection semantics. + with node_standby.connect(connect_db) as gen: + gen.query_safe(f"SELECT * FROM {schema}.drop_tab_test1") + gen.query_safe(f"SELECT {schema}.drop_func_test1()") + + return dboid, tableoid, funcoid + + +def _drop_function_by_oid(node_primary, connect_db, funcoid): + # Get function name from returned oid + func_name = node_primary.safe_sql(f"SELECT '{funcoid}'::regprocedure", connect_db) + node_primary.safe_sql(f"DROP FUNCTION {func_name}", connect_db) + + +def _drop_table_by_oid(node_primary, connect_db, tableoid): + # Get table name from returned oid + table_name = node_primary.safe_sql(f"SELECT '{tableoid}'::regclass", connect_db) + node_primary.safe_sql(f"DROP TABLE {table_name}", connect_db) + + +def _test_standby_func_tab_stats_status( + node_standby, sect, connect_db, dboid, tableoid, funcoid, present +): + expected = {"rel": present, "func": present} + stats = {} + + stats["rel"] = node_standby.safe_sql( + f"SELECT pg_stat_have_stats('relation', {dboid}, {tableoid})", connect_db + ) + stats["func"] = node_standby.safe_sql( + f"SELECT pg_stat_have_stats('function', {dboid}, {funcoid})", connect_db + ) + + assert stats == expected, f"{sect}: standby stats as expected" + + +def _test_standby_db_stats_status(node_standby, sect, connect_db, dboid, present): + assert ( + node_standby.safe_sql( + f"SELECT pg_stat_have_stats('database', {dboid}, 0)", connect_db + ) + == present + ), f"{sect}: standby db stats as expected" + + +def test_030_stats_cleanup_replica(create_pg): + node_primary = create_pg("primary", start=False, allows_streaming=True) + node_primary.append_conf("track_functions = 'all'") + node_primary.start() + + backup_name = "my_backup" + node_primary.backup(backup_name) + + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.start() + + # Test that stats are cleaned up on standby after dropping table or function + + sect = "initial" + + dboid, tableoid, funcoid = _populate_standby_stats( + node_primary, node_standby, "postgres", "public" + ) + _test_standby_func_tab_stats_status( + node_standby, sect, "postgres", dboid, tableoid, funcoid, "t" + ) + + _drop_table_by_oid(node_primary, "postgres", tableoid) + _drop_function_by_oid(node_primary, "postgres", funcoid) + + sect = "post drop" + node_primary.wait_for_replay_catchup(node_standby) + _test_standby_func_tab_stats_status( + node_standby, sect, "postgres", dboid, tableoid, funcoid, "f" + ) + + # Test that stats are cleaned up on standby after dropping indirectly + + sect = "schema creation" + + node_primary.safe_sql("CREATE SCHEMA drop_schema_test1", "postgres") + node_primary.wait_for_replay_catchup(node_standby) + + dboid, tableoid, funcoid = _populate_standby_stats( + node_primary, node_standby, "postgres", "drop_schema_test1" + ) + + _test_standby_func_tab_stats_status( + node_standby, sect, "postgres", dboid, tableoid, funcoid, "t" + ) + node_primary.safe_sql("DROP SCHEMA drop_schema_test1 CASCADE", "postgres") + + sect = "post schema drop" + + node_primary.wait_for_replay_catchup(node_standby) + + # verify table and function stats removed from standby + _test_standby_func_tab_stats_status( + node_standby, sect, "postgres", dboid, tableoid, funcoid, "f" + ) + + # Test that stats are cleaned up on standby after dropping database + + sect = "createdb" + + node_primary.safe_sql("CREATE DATABASE test", "postgres") + node_primary.wait_for_replay_catchup(node_standby) + + dboid, tableoid, funcoid = _populate_standby_stats( + node_primary, node_standby, "test", "public" + ) + + # verify stats are present + _test_standby_func_tab_stats_status( + node_standby, sect, "test", dboid, tableoid, funcoid, "t" + ) + _test_standby_db_stats_status(node_standby, sect, "test", dboid, "t") + + node_primary.safe_sql("DROP DATABASE test", "postgres") + sect = "post dropdb" + node_primary.wait_for_replay_catchup(node_standby) + + # Test that the stats were cleaned up on standby + # Note that this connects to 'postgres' but provides the dboid of dropped db + # 'test' which we acquired previously + _test_standby_func_tab_stats_status( + node_standby, sect, "postgres", dboid, tableoid, funcoid, "f" + ) + + _test_standby_db_stats_status(node_standby, sect, "postgres", dboid, "f") + + # verify that stats persist across graceful restarts on a replica + + # NB: Can't test database stats, they're immediately repopulated when + # reconnecting... + sect = "pre restart" + dboid, tableoid, funcoid = _populate_standby_stats( + node_primary, node_standby, "postgres", "public" + ) + _test_standby_func_tab_stats_status( + node_standby, sect, "postgres", dboid, tableoid, funcoid, "t" + ) + + node_standby.restart() + + sect = "post non-immediate" + + _test_standby_func_tab_stats_status( + node_standby, sect, "postgres", dboid, tableoid, funcoid, "t" + ) + + # but gone after an immediate restart + node_standby.stop("immediate") + node_standby.start() + + sect = "post immediate restart" + + _test_standby_func_tab_stats_status( + node_standby, sect, "postgres", dboid, tableoid, funcoid, "f" + ) diff --git a/src/test/recovery/pyt/test_031_recovery_conflict.py b/src/test/recovery/pyt/test_031_recovery_conflict.py new file mode 100644 index 00000000000..e13fe278d1e --- /dev/null +++ b/src/test/recovery/pyt/test_031_recovery_conflict.py @@ -0,0 +1,346 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that connections to a hot standby are correctly canceled when a +recovery conflict is detected. Also, test that statistics in +pg_stat_database_conflicts are populated correctly. +""" + +from pypg.util import TIMEOUT_DEFAULT + + +def test_031_recovery_conflict(create_pg): + # Set up nodes + node_primary = create_pg("primary", start=False, allows_streaming=True) + + tablespace1 = "test_recovery_conflict_tblspc" + + node_primary.append_conf( + f""" +allow_in_place_tablespaces = on +log_temp_files = 0 + +# for deadlock test +max_prepared_transactions = 10 + +# wait some to test the wait paths as well, but not long for obvious reasons +max_standby_streaming_delay = 50ms + +temp_tablespaces = {tablespace1} +# Some of the recovery conflict logging code only gets exercised after +# deadlock_timeout. The test doesn't rely on that additional output, but it's +# nice to get some minimal coverage of that code. +log_recovery_conflict_waits = on +deadlock_timeout = 10ms +""" + ) + node_primary.start() + + backup_name = "my_backup" + + node_primary.safe_sql(f"CREATE TABLESPACE {tablespace1} LOCATION ''") + + node_primary.backup(backup_name) + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + + node_standby.start() + + test_db = "test_db" + + # use a new database, to trigger database recovery conflict + node_primary.safe_sql(f"CREATE DATABASE {test_db}") + + # test schema / data + table1 = "test_recovery_conflict_table1" + table2 = "test_recovery_conflict_table2" + node_primary.safe_sql( + f""" +CREATE TABLE {table1}(a int, b int); +INSERT INTO {table1} SELECT i % 3, 0 FROM generate_series(1,20) i; +CREATE TABLE {table2}(a int, b int); +""", + dbname=test_db, + ) + node_primary.wait_for_replay_catchup(node_standby) + + # a longrunning session that we can use to trigger conflicts + psql_standby = node_standby.connect(test_db) + expected_conflicts = 0 + + cursor1 = "test_recovery_conflict_cursor" + + # Mutable holder for the running log offset, mirroring $log_location. + log_location = [0] + + def check_conflict_log(message, sect): + old_log_location = log_location[0] + log_location[0] = node_standby.wait_for_log(message, old_log_location) + assert log_location[0] > old_log_location, ( + f"{sect}: logfile contains terminated connection due to " + "recovery conflict" + ) + + def check_conflict_stat(conflict_type, sect): + count = node_standby.safe_sql( + f"SELECT confl_{conflict_type} FROM pg_stat_database_conflicts " + f"WHERE datname='{test_db}'", + dbname=test_db, + ) + assert count == "1", f"{sect}: stats show conflict on standby" + + try: + ## RECOVERY CONFLICT 1: Buffer pin conflict + sect = "buffer pin conflict" + expected_conflicts += 1 + + # Aborted INSERT on primary that will be cleaned up by vacuum. Has to be + # old enough so that there's not a snapshot conflict before the buffer + # pin conflict. + node_primary.safe_sql( + f""" + BEGIN; + INSERT INTO {table1} VALUES (1,0); + ROLLBACK; + -- ensure flush, rollback doesn't do so + BEGIN; LOCK {table1}; COMMIT; + """, + dbname=test_db, + ) + + node_primary.wait_for_replay_catchup(node_standby) + + # DECLARE and use a cursor on standby, causing buffer with the only + # block of the relation to be pinned on the standby + res = psql_standby.query_oneval( + f""" + BEGIN; + DECLARE {cursor1} CURSOR FOR SELECT b FROM {table1}; + FETCH FORWARD FROM {cursor1}; + """ + ) + # FETCH FORWARD should have returned a 0 since all values of b in the + # table are 0 + assert res == "0", f"{sect}: cursor with conflicting pin established" + + # to check the log starting now for recovery conflict messages + log_location[0] = node_standby.log_position() + + # VACUUM FREEZE on the primary + node_primary.safe_sql(f"VACUUM FREEZE {table1};", dbname=test_db) + + # Wait for catchup. Existing connection will be terminated before replay + # is finished, so waiting for catchup ensures that there is no race + # between encountering the recovery conflict which causes the disconnect + # and checking the logfile for the terminated connection. + node_primary.wait_for_replay_catchup(node_standby) + + check_conflict_log("User was holding shared buffer pin for too long", sect) + psql_standby.reconnect() + check_conflict_stat("bufferpin", sect) + + ## RECOVERY CONFLICT 2: Snapshot conflict + sect = "snapshot conflict" + expected_conflicts += 1 + + node_primary.safe_sql( + f"INSERT INTO {table1} SELECT i, 0 FROM generate_series(1,20) i", + dbname=test_db, + ) + node_primary.wait_for_replay_catchup(node_standby) + + # DECLARE and FETCH from cursor on the standby + res = psql_standby.query_oneval( + f""" + BEGIN; + DECLARE {cursor1} CURSOR FOR SELECT b FROM {table1}; + FETCH FORWARD FROM {cursor1}; + """ + ) + assert res == "0", f"{sect}: cursor with conflicting snapshot established" + + # Do some HOT updates + node_primary.safe_sql( + f"UPDATE {table1} SET a = a + 1 WHERE a > 2;", dbname=test_db + ) + + # VACUUM FREEZE, pruning those dead tuples + node_primary.safe_sql(f"VACUUM FREEZE {table1};", dbname=test_db) + + # Wait for attempted replay of PRUNE records + node_primary.wait_for_replay_catchup(node_standby) + + check_conflict_log( + "User query might have needed to see row versions that must be removed", + sect, + ) + psql_standby.reconnect() + check_conflict_stat("snapshot", sect) + + ## RECOVERY CONFLICT 3: Lock conflict + sect = "lock conflict" + expected_conflicts += 1 + + # acquire lock to conflict with + res = psql_standby.query_oneval( + f""" + BEGIN; + LOCK TABLE {table1} IN ACCESS SHARE MODE; + SELECT 1; + """ + ) + assert res == "1", f"{sect}: conflicting lock acquired" + + # DROP TABLE containing block which standby has in a pinned buffer + node_primary.safe_sql(f"DROP TABLE {table1};", dbname=test_db) + + node_primary.wait_for_replay_catchup(node_standby) + + check_conflict_log("User was holding a relation lock for too long", sect) + psql_standby.reconnect() + check_conflict_stat("lock", sect) + + ## RECOVERY CONFLICT 4: Tablespace conflict + sect = "tablespace conflict" + expected_conflicts += 1 + + # DECLARE a cursor for a query which, with sufficiently low work_mem, + # will spill tuples into temp files in the temporary tablespace created + # during setup. + res = psql_standby.query_oneval( + f""" + BEGIN; + SET work_mem = '64kB'; + DECLARE {cursor1} CURSOR FOR + SELECT count(*) FROM generate_series(1,6000); + FETCH FORWARD FROM {cursor1}; + """ + ) + assert res == "6000", f"{sect}: cursor with conflicting temp file established" + + # Drop the tablespace currently containing spill files for the query on + # the standby + node_primary.safe_sql(f"DROP TABLESPACE {tablespace1};", dbname=test_db) + + node_primary.wait_for_replay_catchup(node_standby) + + check_conflict_log( + "User was or might have been using tablespace that must be dropped", sect + ) + psql_standby.reconnect() + check_conflict_stat("tablespace", sect) + + ## RECOVERY CONFLICT 5: Deadlock + sect = "startup deadlock" + expected_conflicts += 1 + + # Want to test recovery deadlock conflicts, not buffer pin conflicts. + # Without changing max_standby_streaming_delay it'd be timing dependent + # what we hit first + node_standby.append_conf(f"max_standby_streaming_delay = {TIMEOUT_DEFAULT}s") + psql_standby.close() + node_standby.restart() + psql_standby.reconnect() + + # Generate a few dead rows, to later be cleaned up by vacuum. Then + # acquire a lock on another relation in a prepared xact, so it's held + # continuously by the startup process. The standby session will block + # acquiring that lock while holding a pin that vacuum needs, triggering + # the deadlock. + # psql runs each statement in its own implicit transaction, so the + # in-process Session (which wraps a multi-statement string in one + # implicit transaction) cannot run PREPARE TRANSACTION inside the same + # string; issue the statements one at a time to match psql semantics. + node_primary.safe_sql(f"CREATE TABLE {table1}(a int, b int);", dbname=test_db) + node_primary.safe_sql(f"INSERT INTO {table1} VALUES (1);", dbname=test_db) + node_primary.safe_sql( + f""" +BEGIN; +INSERT INTO {table1}(a) SELECT generate_series(1, 100) i; +ROLLBACK; +""", + dbname=test_db, + ) + node_primary.safe_sql( + f""" +BEGIN; +LOCK TABLE {table2}; +PREPARE TRANSACTION 'lock'; +""", + dbname=test_db, + ) + node_primary.safe_sql(f"INSERT INTO {table1}(a) VALUES (170);", dbname=test_db) + node_primary.safe_sql("SELECT txid_current();", dbname=test_db) + + node_primary.wait_for_replay_catchup(node_standby) + + res = psql_standby.query_oneval( + f""" + BEGIN; + -- hold pin + DECLARE {cursor1} CURSOR FOR SELECT a FROM {table1}; + FETCH FORWARD FROM {cursor1}; + """ + ) + assert res == "1", "pin held" + assert psql_standby.do_async( + f""" + -- wait for lock held by prepared transaction + SELECT * FROM {table2}; + """ + ), ( + f"{sect}: cursor holding conflicting pin, also waiting for lock, " + "established" + ) + + # just to make sure we're waiting for lock already + assert node_standby.poll_query_until( + "SELECT 'waiting' FROM pg_locks WHERE locktype = 'relation' AND " + "NOT granted;", + expected="waiting", + ), f"{sect}: lock acquisition is waiting" + + # VACUUM FREEZE will prune away rows, causing a buffer pin conflict, + # while standby session is waiting on lock + node_primary.safe_sql(f"VACUUM FREEZE {table1};", dbname=test_db) + node_primary.wait_for_replay_catchup(node_standby) + + check_conflict_log( + "User transaction caused buffer deadlock with recovery.", sect + ) + psql_standby.reconnect() + check_conflict_stat("deadlock", sect) + + # clean up for next tests + node_primary.safe_sql("ROLLBACK PREPARED 'lock';", dbname=test_db) + node_standby.append_conf("max_standby_streaming_delay = 50ms") + psql_standby.close() + node_standby.restart() + psql_standby.reconnect() + + # Check that expected number of conflicts show in pg_stat_database. + # Needs to be tested before database is dropped, for obvious reasons. + assert node_standby.safe_sql( + "SELECT conflicts FROM pg_stat_database " f"WHERE datname='{test_db}';", + dbname=test_db, + ) == str( + expected_conflicts + ), f"{expected_conflicts} recovery conflicts shown in pg_stat_database" + + ## RECOVERY CONFLICT 6: Database conflict + sect = "database conflict" + + # The standby's psql_standby session must stay connected to test_db: + # that is the backend the database recovery conflict cancels. + node_primary.safe_sql(f"DROP DATABASE {test_db};") + + node_primary.wait_for_replay_catchup(node_standby) + + check_conflict_log( + "User was connected to a database that must be dropped", sect + ) + finally: + # explicitly close session gracefully + psql_standby.close() + + node_standby.stop() + node_primary.stop() diff --git a/src/test/recovery/pyt/test_032_relfilenode_reuse.py b/src/test/recovery/pyt/test_032_relfilenode_reuse.py new file mode 100644 index 00000000000..af5e8c1ad5b --- /dev/null +++ b/src/test/recovery/pyt/test_032_relfilenode_reuse.py @@ -0,0 +1,181 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test that a standby correctly handles relfilenode reuse after a database +is dropped and recreated. +""" + +import re + + +def _verify(primary, standby, counter, message): + """Check that the primary and (after catchup) the standby both report the + expected single grouped row for the "datab" column. + """ + query = "SELECT datab, count(*) FROM large GROUP BY 1 ORDER BY 1 LIMIT 10" + assert ( + primary.safe_sql(query, dbname="conflict_db") == f"{counter}|4000" + ), f"primary: {message}" + + primary.wait_for_catchup(standby) + assert ( + standby.safe_sql(query, dbname="conflict_db") == f"{counter}|4000" + ), f"standby: {message}" + + +def _cause_eviction(psql_primary, psql_standby): + """Run pg_prewarm work on the long-lived primary and standby sessions to + write back dirty data and (re)open the relevant file descriptors. + """ + prewarm = ( + "SELECT SUM(pg_prewarm(oid)) warmed_buffers FROM pg_class " + "WHERE pg_relation_filenode(oid) != 0;" + ) + res = psql_primary.query(prewarm) + assert res.error_message is None, res.error_message + res = psql_standby.query(prewarm) + assert res.error_message is None, res.error_message + + +def test_032_relfilenode_reuse(create_pg, pg_bin): + node_primary = create_pg("primary", start=False, allows_streaming=True) + node_primary.append_conf( + """ +allow_in_place_tablespaces = true +log_connections=receipt +# to avoid "repairing" corruption +full_page_writes=off +log_min_messages=debug2 +shared_buffers=1MB +""" + ) + node_primary.start() + + # Create streaming standby linking to primary + backup_name = "my_backup" + node_primary.backup(backup_name) + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.start() + + # Create template database with a table that we'll update, to trigger dirty + # rows. Using a template database + preexisting rows makes it a bit easier + # to reproduce, because there's no cache invalidations generated. + node_primary.safe_sql("CREATE DATABASE conflict_db_template OID = 50000;") + # Split non-transactional statements (each is one implicit txn). + node_primary.safe_sql( + "CREATE TABLE large(id serial primary key, dataa text, datab text);", + dbname="conflict_db_template", + ) + node_primary.safe_sql( + "INSERT INTO large(dataa, datab) SELECT g.i::text, 1 " + "FROM generate_series(1, 4000) g(i);", + dbname="conflict_db_template", + ) + node_primary.safe_sql( + "CREATE DATABASE conflict_db TEMPLATE conflict_db_template OID = 50001;" + ) + + node_primary.safe_sql("CREATE EXTENSION pg_prewarm;") + node_primary.safe_sql("CREATE TABLE replace_sb(data text);") + node_primary.safe_sql( + "INSERT INTO replace_sb(data) SELECT random()::text " + "FROM generate_series(1, 15000);" + ) + + node_primary.wait_for_catchup(node_standby) + + # Long-lived sessions to primary and standby; using longrunning + # transactions means AtEOXact_SMgr doesn't close files. + psql_primary = node_primary.connect("postgres") + psql_standby = node_standby.connect("postgres") + try: + assert psql_primary.do("BEGIN") is not None, "BEGIN" + assert psql_standby.do("BEGIN") is not None, "BEGIN" + + # Cause lots of dirty rows in shared_buffers + node_primary.safe_sql("UPDATE large SET datab = 1;", dbname="conflict_db") + + # Now do a bunch of work in another database. That will end up needing + # to write back dirty data from the previous step, opening the relevant + # file descriptors + _cause_eviction(psql_primary, psql_standby) + + # drop and recreate database + node_primary.safe_sql("DROP DATABASE conflict_db;") + node_primary.safe_sql( + "CREATE DATABASE conflict_db TEMPLATE conflict_db_template OID = 50001;" + ) + + _verify(node_primary, node_standby, 1, "initial contents as expected") + + # Again cause lots of dirty rows in shared_buffers, but use a different + # update value so we can check everything is OK + node_primary.safe_sql("UPDATE large SET datab = 2;", dbname="conflict_db") + + # Again cause a lot of IO. That'll again write back dirty data, but + # uses newly opened file descriptors, so we don't confuse old files + # with new files despite recycling relfilenodes. + _cause_eviction(psql_primary, psql_standby) + + _verify( + node_primary, + node_standby, + 2, + "update to reused relfilenode (due to DB oid conflict) is not lost", + ) + + node_primary.safe_sql("VACUUM FULL large;", dbname="conflict_db") + node_primary.safe_sql("UPDATE large SET datab = 3;", dbname="conflict_db") + + _verify(node_primary, node_standby, 3, "restored contents as expected") + + # Test for old filehandles after moving a database in / out of + # tablespace + node_primary.safe_sql("CREATE TABLESPACE test_tablespace LOCATION ''") + + # cause dirty buffers + node_primary.safe_sql("UPDATE large SET datab = 4;", dbname="conflict_db") + # cause files to be opened in backend in other database + _cause_eviction(psql_primary, psql_standby) + + # move database back / forth (ALTER DATABASE SET TABLESPACE needs no + # connection to the target database) + node_primary.safe_sql( + "ALTER DATABASE conflict_db SET TABLESPACE test_tablespace" + ) + node_primary.safe_sql("ALTER DATABASE conflict_db SET TABLESPACE pg_default") + + # cause dirty buffers + node_primary.safe_sql("UPDATE large SET datab = 5;", dbname="conflict_db") + _cause_eviction(psql_primary, psql_standby) + + _verify(node_primary, node_standby, 5, "post move contents as expected") + + node_primary.safe_sql( + "ALTER DATABASE conflict_db SET TABLESPACE test_tablespace" + ) + + node_primary.safe_sql("UPDATE large SET datab = 7;", dbname="conflict_db") + _cause_eviction(psql_primary, psql_standby) + node_primary.safe_sql("UPDATE large SET datab = 8;", dbname="conflict_db") + node_primary.safe_sql("DROP DATABASE conflict_db") + node_primary.safe_sql("DROP TABLESPACE test_tablespace") + + node_primary.safe_sql("REINDEX TABLE pg_database") + finally: + # explicitly close the long-lived sessions gracefully + psql_primary.close() + psql_standby.close() + + node_primary.stop() + node_standby.stop() + + # Make sure that there weren't crashes during shutdown + res = pg_bin.result(["pg_controldata", node_primary.data_dir]) + assert re.search( + r"Database cluster state:\s+shut down\n", res.stdout + ), "primary shut down ok" + res = pg_bin.result(["pg_controldata", node_standby.data_dir]) + assert re.search( + r"Database cluster state:\s+shut down in recovery\n", res.stdout + ), "standby shut down ok" diff --git a/src/test/recovery/pyt/test_033_replay_tsp_drops.py b/src/test/recovery/pyt/test_033_replay_tsp_drops.py new file mode 100644 index 00000000000..bd1b7a00900 --- /dev/null +++ b/src/test/recovery/pyt/test_033_replay_tsp_drops.py @@ -0,0 +1,153 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test replay of tablespace/database creation/drop.""" + +import shutil +import time + +from pypg.util import TIMEOUT_DEFAULT + + +def _run_script(node, script): + """Run a multi-statement SQL script as separate statements. + + A multi-statement string is sent as a single implicit transaction, which + CREATE DATABASE / CREATE TABLESPACE reject, so split on ';' and run each + statement on its own. They share one persistent session so that session + GUCs (e.g. allow_in_place_tablespaces) persist across statements. + """ + with node.connect() as sess: + for stmt in script.split(";"): + stmt = stmt.strip() + if stmt: + sess.query_safe(stmt) + + +def _test_tablespace(create_pg, strategy): + node_primary = create_pg(f"primary1_{strategy}", allows_streaming=True) + _run_script( + node_primary, + """ + SET allow_in_place_tablespaces=on; + CREATE TABLESPACE dropme_ts1 LOCATION ''; + CREATE TABLESPACE dropme_ts2 LOCATION ''; + CREATE TABLESPACE source_ts LOCATION ''; + CREATE TABLESPACE target_ts LOCATION ''; + CREATE DATABASE template_db IS_TEMPLATE = true; + SELECT pg_create_physical_replication_slot('slot', true); + """, + ) + backup_name = "my_backup" + node_primary.backup(backup_name) + + node_standby = create_pg(f"standby2_{strategy}", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.append_conf("allow_in_place_tablespaces = on") + node_standby.append_conf("primary_slot_name = slot") + node_standby.start() + + # Make sure the connection is made + node_primary.wait_for_catchup(node_standby, "write") + + # Do immediate shutdown just after a sequence of CREATE DATABASE / DROP + # DATABASE / DROP TABLESPACE. This causes CREATE DATABASE WAL records + # to be applied to already-removed directories. + query = """ + CREATE DATABASE dropme_db1 WITH TABLESPACE dropme_ts1 STRATEGY=; + CREATE TABLE t (a int) TABLESPACE dropme_ts2; + CREATE DATABASE dropme_db2 WITH TABLESPACE dropme_ts2 STRATEGY=; + CREATE DATABASE moveme_db TABLESPACE source_ts STRATEGY=; + ALTER DATABASE moveme_db SET TABLESPACE target_ts; + CREATE DATABASE newdb TEMPLATE template_db STRATEGY=; + ALTER DATABASE template_db IS_TEMPLATE = false; + DROP DATABASE dropme_db1; + DROP TABLE t; + DROP DATABASE dropme_db2; DROP TABLESPACE dropme_ts2; + DROP TABLESPACE source_ts; + DROP DATABASE template_db; + """ + query = query.replace("", strategy) + + _run_script(node_primary, query) + node_primary.wait_for_catchup(node_standby, "write") + + # show "create missing directory" log message + node_standby.safe_sql("ALTER SYSTEM SET log_min_messages TO debug1;") + node_standby.stop("immediate") + # Should restart ignoring directory creation error. + started = True + try: + node_standby.start() + except Exception: # pylint: disable=broad-exception-caught + started = False + assert started, f"standby node started for {strategy}" + node_standby.stop("immediate") + + +def test_033_replay_tsp_drops(create_pg): + _test_tablespace(create_pg, "FILE_COPY") + _test_tablespace(create_pg, "WAL_LOG") + + # Ensure that a missing tablespace directory during create database + # replay immediately causes panic if the standby has already reached + # consistent state (archive recovery is in progress). This is + # effective only for CREATE DATABASE WITH STRATEGY=FILE_COPY. + + node_primary = create_pg("primary2", allows_streaming=True) + + # Create tablespace + _run_script( + node_primary, + """ + SET allow_in_place_tablespaces=on; + CREATE TABLESPACE ts1 LOCATION '' + """, + ) + node_primary.safe_sql("CREATE DATABASE db1 WITH TABLESPACE ts1 STRATEGY=FILE_COPY") + + # Take backup + backup_name = "my_backup" + node_primary.backup(backup_name) + node_standby = create_pg("standby3", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.append_conf("allow_in_place_tablespaces = on") + node_standby.start() + + # Make sure standby reached consistency and starts accepting connections + assert node_standby.poll_query_until("SELECT 1", "1") + + # Remove standby tablespace directory so it will be missing when + # replay resumes. + tspoid = node_standby.safe_sql( + "SELECT oid FROM pg_tablespace WHERE spcname = 'ts1';" + ) + tspdir = node_standby.data_dir + f"/pg_tblspc/{tspoid}" + shutil.rmtree(tspdir) + + logstart = node_standby.log_position() + + # Create a database in the tablespace and a table in default tablespace + _run_script( + node_primary, + """ + CREATE TABLE should_not_replay_insertion(a int); + CREATE DATABASE db2 WITH TABLESPACE ts1 STRATEGY=FILE_COPY; + INSERT INTO should_not_replay_insertion VALUES (1); + """, + ) + + # Standby should fail and should not silently skip replaying the wal + # In this test, PANIC turns into WARNING by allow_in_place_tablespaces. + # Check the log messages instead of confirming standby failure. + max_attempts = TIMEOUT_DEFAULT * 10 + detected = False + while max_attempts >= 0: + if node_standby.log_contains( + r"WARNING: ( [A-Z0-9]+:)? creating missing directory: pg_tblspc/", + offset=logstart, + ): + detected = True + break + max_attempts -= 1 + time.sleep(0.1) + assert detected, "invalid directory creation is detected" diff --git a/src/test/recovery/pyt/test_034_create_database.py b/src/test/recovery/pyt/test_034_create_database.py new file mode 100644 index 00000000000..27088ed122a --- /dev/null +++ b/src/test/recovery/pyt/test_034_create_database.py @@ -0,0 +1,40 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test WAL replay for CREATE DATABASE .. STRATEGY WAL_LOG.""" + + +def test_034_create_database(create_pg): + node = create_pg("node") + + # This checks that any DDLs run on the template database that modify + # pg_class are persisted after creating a database from it using the + # WAL_LOG strategy, as a direct copy of the template database's pg_class is + # used in this case. + db_template = "template1" + db_new = "test_db_1" + + # Create table. It should persist on the template database. + node.safe_sql(f"CREATE DATABASE {db_new} STRATEGY WAL_LOG TEMPLATE {db_template};") + + node.safe_sql("CREATE TABLE tab_db_after_create_1 (a INT);", dbname=db_template) + + # Flush the changes affecting the template database, then replay them. + node.safe_sql("CHECKPOINT;") + + node.stop("immediate") + node.start() + result = node.safe_sql( + "SELECT count(*) FROM pg_class WHERE relname LIKE 'tab_db_%';", + dbname=db_template, + ) + assert ( + result == "1" + ), "check that table exists on template after crash, with checkpoint" + + # The new database should have no tables. + result = node.safe_sql( + "SELECT count(*) FROM pg_class WHERE relname LIKE 'tab_db_%';", dbname=db_new + ) + assert ( + result == "0" + ), "check that there are no tables from template on new database after crash" diff --git a/src/test/recovery/pyt/test_035_standby_logical_decoding.py b/src/test/recovery/pyt/test_035_standby_logical_decoding.py new file mode 100644 index 00000000000..2076fb97179 --- /dev/null +++ b/src/test/recovery/pyt/test_035_standby_logical_decoding.py @@ -0,0 +1,1108 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Logical decoding on a standby: tests logical decoding, recovery conflict and +standby promotion. Logical slots are created on a standby; when the primary +does something that conflicts with standby decoding (row removal / VACUUM, +incorrect wal_level, DROP DATABASE, ...) the slots get invalidated with the +right conflict reason. Injection points are used to keep xl_running_xacts from +advancing an active slot's catalog_xmin. +""" + +import os +import subprocess + +import pytest + +from libpq import Session +from pypg.util import TIMEOUT_DEFAULT, poll_until + +DEFAULT_TIMEOUT = TIMEOUT_DEFAULT + +# Name for the physical slot on primary +PRIMARY_SLOTNAME = "primary_physical" +STANDBY_PHYSICAL_SLOTNAME = "standby_physical" + + +# --------------------------------------------------------------------------- +# A backgrounded pg_recvlogical --start session, returned by +# make_slot_active(). +# --------------------------------------------------------------------------- +class RecvLogicalHandle: + """A long-running ``pg_recvlogical --start --no-loop`` subprocess. + + Background reader threads drain stdout and stderr into buffers as the data + arrives. pg_recvlogical writes each decoded record to its output fd with a + raw write() (no stdio buffering), so the threads see complete lines + promptly; this also avoids mixing select() with a buffered readline(), which + can strand data in Python's TextIOWrapper buffer where select() never + reports it as readable. + """ + + def __init__(self, argv): + import threading + + print("# Running (background): " + " ".join(argv)) + # Lifetime is managed by finish()/kill(), not a with block. + self._proc = subprocess.Popen( # pylint: disable=consider-using-with + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + self.stdout = "" + self.stderr = "" + self._finished = False + self._lock = threading.Lock() + + def _drain(stream, attr): + while True: + line = stream.readline() + if line == "": + break + with self._lock: + setattr(self, attr, getattr(self, attr) + line) + + self._out_thread = threading.Thread( + target=_drain, args=(self._proc.stdout, "stdout"), daemon=True + ) + self._err_thread = threading.Thread( + target=_drain, args=(self._proc.stderr, "stderr"), daemon=True + ) + self._out_thread.start() + self._err_thread.start() + + def finish(self): + """Wait for the process to exit, capturing its output. + + Returns the exit status (the value of $? equivalent, i.e. returncode). + """ + if not self._finished: + self._proc.wait(timeout=DEFAULT_TIMEOUT) + self._out_thread.join(timeout=DEFAULT_TIMEOUT) + self._err_thread.join(timeout=DEFAULT_TIMEOUT) + self._finished = True + return self._proc.returncode + + def pump_until(self, pattern, timeout=DEFAULT_TIMEOUT): + """Wait until *pattern* matches the accumulated stdout. + + The reader thread keeps appending to self.stdout, so we just poll the + buffer until the pattern matches or we time out / the process exits. + """ + import re + import time + + regex = re.compile(pattern, re.S) + deadline = time.monotonic() + timeout if timeout is not None else None + while True: + with self._lock: + buf = self.stdout + if regex.search(buf): + return True + if self._proc.poll() is not None and not self._out_thread.is_alive(): + # Process exited and all output has been drained. + with self._lock: + return bool(regex.search(self.stdout)) + if deadline is not None and time.monotonic() > deadline: + with self._lock: + return bool(regex.search(self.stdout)) + time.sleep(0.05) + + def kill(self): + if not self._finished: + try: + self._proc.kill() + except Exception: # pylint: disable=broad-exception-caught + pass + + +# --------------------------------------------------------------------------- +# Helpers. +# --------------------------------------------------------------------------- +def wait_for_xmins(node, slotname, check_expr): + """Wait until *check_expr* holds for the named slot's xmin/catalog_xmin.""" + assert node.poll_query_until( + f"SELECT {check_expr} " + "FROM pg_catalog.pg_replication_slots " + f"WHERE slot_name = '{slotname}'" + ), "Timed out waiting for slot xmins to advance" + + +def create_logical_slots(node, primary, slot_prefix): + """Create the inactive/active logical slots on the standby.""" + active_slot = slot_prefix + "activeslot" + inactive_slot = slot_prefix + "inactiveslot" + node.create_logical_slot_on_standby(primary, inactive_slot, "testdb") + node.create_logical_slot_on_standby(primary, active_slot, "testdb") + + +def drop_logical_slots(standby, slot_prefix): + """Drop the inactive/active logical slots on the standby.""" + active_slot = slot_prefix + "activeslot" + inactive_slot = slot_prefix + "inactiveslot" + standby.sql(f"SELECT pg_drop_replication_slot('{inactive_slot}')") + standby.sql(f"SELECT pg_drop_replication_slot('{active_slot}')") + + +def make_slot_active(node, slot_prefix, wait): + """Launch pg_recvlogical against the 'activeslot' slot in the background. + + When *wait* is true, wait until the slot shows an active_pid (a known-good + scenario); otherwise we are testing a known failure scenario. + """ + active_slot = slot_prefix + "activeslot" + argv = [ + os.path.join(node.bindir, "pg_recvlogical"), + "--dbname", + node.connstr("testdb"), + "--slot", + active_slot, + "--option", + "include-xids=0", + "--option", + "skip-empty-xacts=1", + "--file", + "-", + "--no-loop", + "--start", + ] + handle = RecvLogicalHandle(argv) + if wait: + # make sure activeslot is in use + assert node.poll_query_until( + "SELECT EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = " + f"'{active_slot}' AND active_pid IS NOT NULL)" + ), "slot never became active" + return handle + + +def check_pg_recvlogical_stderr(handle, check_stderr): + """Assert pg_recvlogical terminated in response to the walsender error.""" + import re + + ret = handle.finish() + assert ret != 0, "pg_recvlogical exited non-zero" + assert re.search( + check_stderr, handle.stderr + ), f"slot has been invalidated (stderr={handle.stderr!r})" + + +def check_slots_dropped(standby, slot_prefix, handle): + """Assert both slots have been dropped on the standby.""" + assert ( + standby.slot(slot_prefix + "inactiveslot")["slot_type"] == "" + ), "inactiveslot on standby dropped" + assert ( + standby.slot(slot_prefix + "activeslot")["slot_type"] == "" + ), "activeslot on standby dropped" + check_pg_recvlogical_stderr(handle, "conflict with recovery") + + +def change_hot_standby_feedback_and_wait_for_xmins(standby, primary, hsf, invalidated): + """Set hot_standby_feedback on the standby and wait for primary xmins.""" + standby.append_conf(f"\nhot_standby_feedback = {hsf}\n") + standby.reload() + + if hsf and invalidated: + # With hot_standby_feedback on, xmin should advance, but catalog_xmin + # should still remain NULL since there is no logical slot. + wait_for_xmins( + primary, PRIMARY_SLOTNAME, "xmin IS NOT NULL AND catalog_xmin IS NULL" + ) + elif hsf: + # With hot_standby_feedback on, xmin and catalog_xmin should advance. + wait_for_xmins( + primary, PRIMARY_SLOTNAME, "xmin IS NOT NULL AND catalog_xmin IS NOT NULL" + ) + else: + # Both should be NULL since hs_feedback is off + wait_for_xmins( + primary, PRIMARY_SLOTNAME, "xmin IS NULL AND catalog_xmin IS NULL" + ) + + +def check_slots_conflict_reason(standby, slot_prefix, reason): + """Assert both slots are conflicting with the expected invalidation reason.""" + active_slot = slot_prefix + "activeslot" + inactive_slot = slot_prefix + "inactiveslot" + + res = standby.safe_sql( + "select invalidation_reason from pg_replication_slots where slot_name = " + f"'{active_slot}' and conflicting;" + ) + assert res == reason, f"{active_slot} reason for conflict is {reason}" + + res = standby.safe_sql( + "select invalidation_reason from pg_replication_slots where slot_name = " + f"'{inactive_slot}' and conflicting;" + ) + assert res == reason, f"{inactive_slot} reason for conflict is {reason}" + + +def reactive_slots_change_hfs_and_wait_for_xmins( + standby, primary, previous_slot_prefix, slot_prefix, hsf, invalidated +): + """Recreate the slots, change hot_standby_feedback, and wait for xmins. + + Returns the new active-slot handle. + """ + drop_logical_slots(standby, previous_slot_prefix) + create_logical_slots(standby, primary, slot_prefix) + change_hot_standby_feedback_and_wait_for_xmins(standby, primary, hsf, invalidated) + handle = make_slot_active(standby, slot_prefix, True) + # reset stat: easier to check for confl_active_logicalslot in + # pg_stat_database_conflicts + standby.sql("select pg_stat_reset();", dbname="testdb") + return handle + + +def check_for_invalidation(standby, slot_prefix, log_start, test_name): + """Assert both slots were invalidated and the conflict stat was updated.""" + active_slot = slot_prefix + "activeslot" + inactive_slot = slot_prefix + "inactiveslot" + + # message should be issued. Use wait_for_log rather than an immediate + # log_contains check: the invalidation is logged during the standby's replay + # of the conflicting record, which may land slightly after + # wait_for_replay_catchup returns. + standby.wait_for_log( + f'invalidating obsolete replication slot "{inactive_slot}"', log_start + ) # inactiveslot slot invalidation is logged + + standby.wait_for_log( + f'invalidating obsolete replication slot "{active_slot}"', log_start + ) # activeslot slot invalidation is logged + + # Verify that pg_stat_database_conflicts.confl_active_logicalslot has been + # updated + assert standby.poll_query_until( + "select (confl_active_logicalslot = 1) from pg_stat_database_conflicts " + "where datname = 'testdb'" + ), "Timed out waiting confl_active_logicalslot to be updated" + + +def wait_until_vacuum_can_remove(primary, standby, vac_option, sql, to_vac): + """Run *sql* then VACUUM so dead rows can be removed past the slot horizon. + + The injection_point avoids seeing a xl_running_xacts that could advance an + active replication slot's catalog_xmin. + """ + # From this point the checkpointer and bgwriter will skip writing + # xl_running_xacts record. + primary.safe_sql( + "SELECT injection_points_attach('skip-log-running-xacts', 'error');", + dbname="testdb", + ) + + # Get the current xid horizon. + xid_horizon = primary.safe_sql( + "select pg_snapshot_xmin(pg_current_snapshot());", dbname="testdb" + ) + + # Launch our sql. Run each statement as its own transaction (autocommit). + # Sending the whole thing as one multi-command string would wrap it in a + # single implicit transaction, which changes the xids stamped on the catalog + # tuples and prevents the row-removal conflict from firing. + for stmt in (s.strip() for s in sql.split(";")): + if stmt: + primary.safe_sql(stmt, dbname="testdb") + + # Wait until we get a newer horizon. + assert primary.poll_query_until( + "SELECT (select pg_snapshot_xmin(pg_current_snapshot())::text::int - " + f"{xid_horizon}) > 0", + dbname="testdb", + ), "new snapshot does not have a newer horizon" + + # Launch the vacuum command and insert into flush_wal (see CREATE TABLE + # flush_wal for the reason). VACUUM cannot run inside a transaction block, + # so the two statements are sent separately rather than as one multi-command + # string. + primary.safe_sql(f"VACUUM {vac_option} verbose {to_vac};", dbname="testdb") + primary.safe_sql("INSERT INTO flush_wal DEFAULT VALUES;", dbname="testdb") + + primary.wait_for_replay_catchup(standby) + + # Resume generating the xl_running_xacts record + primary.safe_sql( + "SELECT injection_points_detach('skip-log-running-xacts');", + dbname="testdb", + ) + + +# --------------------------------------------------------------------------- +# The test. +# --------------------------------------------------------------------------- +def test_035_standby_logical_decoding(create_pg): + # Skip unless built with injection points. + if os.environ.get("enable_injection_points", "no") != "yes": + pytest.skip("Injection points not supported by this build") + + ######################## + # Initialize primary node + ######################## + node_primary = create_pg( + "primary", start=False, allows_streaming="logical", has_archiving=True + ) + node_primary.append_conf( + "\n".join( + [ + "wal_level = 'logical'", + "max_replication_slots = 4", + "max_wal_senders = 4", + "autovacuum = off", + "", + ] + ) + ) + node_primary.start() + + # Check if the extension injection_points is available. + if ( + node_primary.safe_sql( + "SELECT count(*) FROM pg_available_extensions WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node_primary.sql("CREATE DATABASE testdb") + + node_primary.safe_sql( + f"SELECT * FROM pg_create_physical_replication_slot('{PRIMARY_SLOTNAME}');", + dbname="testdb", + ) + + # Check conflicting is NULL for physical slot + res = node_primary.safe_sql( + "SELECT conflicting is null FROM pg_replication_slots where slot_name = " + f"'{PRIMARY_SLOTNAME}';" + ) + assert res == "t", "Physical slot reports conflicting as NULL" + + backup_name = "b1" + node_primary.backup(backup_name) + + # Some tests need to wait for VACUUM to be replayed. But vacuum does not + # flush WAL. An insert into flush_wal outside transaction does guarantee a + # flush. + node_primary.sql("CREATE TABLE flush_wal();", dbname="testdb") + + ####################### + # Initialize standby node + ####################### + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup( + node_primary, backup_name, has_streaming=True, has_restoring=True + ) + node_standby.append_conf( + f"primary_slot_name = '{PRIMARY_SLOTNAME}'\nmax_replication_slots = 5\n" + ) + node_standby.start() + node_primary.wait_for_replay_catchup(node_standby) + + ####################### + # Initialize subscriber node + ####################### + node_subscriber = create_pg("subscriber", start=False) + node_subscriber.start() + + ################################################## + # Test that the standby requires hot_standby to be enabled for pre-existing + # logical slots. + ################################################## + node_standby.create_logical_slot_on_standby(node_primary, "restart_test") + node_standby.stop() + node_standby.append_conf("hot_standby = off\n") + + # Use pg_ctl directly because this test expects the server to fail during + # startup. + subprocess.run( + [ + os.path.join(node_standby.bindir, "pg_ctl"), + "--pgdata", + node_standby.data_dir, + "--log", + node_standby.logfile, + "start", + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + + # wait for postgres to terminate + poll_until( + lambda: not os.path.exists(node_standby.pidfile), + timeout=10 * DEFAULT_TIMEOUT, + ) + + # Confirm that the server startup fails with an expected error + import re + + logfile = node_standby.log_content() + assert re.search( + r'FATAL: .* logical replication slot ".*" exists on the standby, but ' + r'"hot_standby" = "off"', + logfile, + ), "the standby ends with an error during startup because hot_standby was disabled" + + # adjust_conf(hot_standby => on): later value wins. + node_standby.append_conf("hot_standby = on\n") + node_standby.start() + node_standby.safe_sql("SELECT pg_drop_replication_slot('restart_test')") + + ################################################## + # Test that logical decoding on the standby behaves correctly. + ################################################## + create_logical_slots(node_standby, node_primary, "behaves_ok_") + + node_primary.safe_sql( + "CREATE TABLE decoding_test(x integer, y text);", dbname="testdb" + ) + node_primary.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT s, s::text FROM generate_series(1,10) s;", + dbname="testdb", + ) + + node_primary.wait_for_replay_catchup(node_standby) + + result = node_standby.safe_sql( + "SELECT pg_logical_slot_get_changes('behaves_ok_activeslot', NULL, NULL);", + dbname="testdb", + ) + + # test if basic decoding works (2 BEGIN/COMMIT and 10 rows = 14 lines) + assert ( + len(result.splitlines()) == 14 + ), "Decoding produced 14 rows (2 BEGIN/COMMIT and 10 rows)" + + # Insert some rows and verify that we get the same results from + # pg_recvlogical and the SQL interface. + node_primary.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT s, s::text FROM generate_series(1,4) s;", + dbname="testdb", + ) + + expected = ( + "BEGIN\n" + "table public.decoding_test: INSERT: x[integer]:1 y[text]:'1'\n" + "table public.decoding_test: INSERT: x[integer]:2 y[text]:'2'\n" + "table public.decoding_test: INSERT: x[integer]:3 y[text]:'3'\n" + "table public.decoding_test: INSERT: x[integer]:4 y[text]:'4'\n" + "COMMIT" + ) + + node_primary.wait_for_replay_catchup(node_standby) + + stdout_sql = node_standby.safe_sql( + "SELECT data FROM pg_logical_slot_peek_changes('behaves_ok_activeslot', " + "NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');", + dbname="testdb", + ) + assert stdout_sql == expected, "got expected output from SQL decoding session" + + endpos = node_standby.safe_sql( + "SELECT lsn FROM pg_logical_slot_peek_changes('behaves_ok_activeslot', " + "NULL, NULL) ORDER BY lsn DESC LIMIT 1;", + dbname="testdb", + ) + + # Insert some rows after $endpos, which we won't read. + node_primary.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT s, s::text FROM generate_series(5,50) s;", + dbname="testdb", + ) + + node_primary.wait_for_replay_catchup(node_standby) + + recv = node_standby.pg_recvlogical_upto( + "testdb", + "behaves_ok_activeslot", + endpos, + DEFAULT_TIMEOUT, + **{"include-xids": "0", "skip-empty-xacts": "1"}, + ) + assert ( + recv.stdout.rstrip("\n") == expected + ), "got same expected output from pg_recvlogical decoding session" + + assert node_standby.poll_query_until( + "SELECT EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = " + "'behaves_ok_activeslot' AND active_pid IS NULL)", + dbname="testdb", + ), "slot never became inactive" + + recv = node_standby.pg_recvlogical_upto( + "testdb", + "behaves_ok_activeslot", + endpos, + DEFAULT_TIMEOUT, + **{"include-xids": "0", "skip-empty-xacts": "1"}, + ) + assert recv.stdout.rstrip("\n") == "", "pg_recvlogical acknowledged changes" + + node_primary.safe_sql("CREATE DATABASE otherdb") + + # Wait for catchup to ensure that the new database is visible to other + # sessions on the standby. + node_primary.wait_for_replay_catchup(node_standby) + + res = node_standby.sql( + "SELECT lsn FROM pg_logical_slot_peek_changes('behaves_ok_activeslot', " + "NULL, NULL) ORDER BY lsn DESC LIMIT 1;", + dbname="otherdb", + ) + assert res.error_message is not None and re.search( + r'replication slot "behaves_ok_activeslot" was not created in this database', + res.error_message, + ), "replaying logical slot from another database fails" + + ################################################## + # Test that we can subscribe on the standby with the publication created on + # the primary. + ################################################## + # Create a table on the primary + node_primary.safe_sql("CREATE TABLE tab_rep (a int primary key)") + # Create a table (same structure) on the subscriber node + node_subscriber.safe_sql("CREATE TABLE tab_rep (a int primary key)") + # Create a publication on the primary + node_primary.safe_sql("CREATE PUBLICATION tap_pub for table tab_rep") + + node_primary.wait_for_replay_catchup(node_standby) + + # Subscribe on the standby + standby_connstr = ( + f"host={node_standby.host} port={node_standby.port} dbname=postgres" + ) + + # Use an async session here: a synchronous CREATE SUBSCRIPTION would wait for + # activity on the primary and we wouldn't be able to run + # pg_log_standby_snapshot() on the primary while waiting. + sub_sess = node_subscriber.connect() + try: + assert sub_sess.do_async( + "CREATE SUBSCRIPTION tap_sub " + f"CONNECTION '{standby_connstr}' " + "PUBLICATION tap_pub " + "WITH (copy_data = off);" + ) + + # Log the standby snapshot to speed up the subscription creation + node_primary.log_standby_snapshot(node_standby, "tap_sub") + + # Collect the CREATE SUBSCRIPTION result. + assert sub_sess.get_async_result().error_message is None + finally: + sub_sess.close() + + node_subscriber.wait_for_subscription_sync(node_standby, "tap_sub") + + # Insert some rows on the primary + node_primary.safe_sql("INSERT INTO tab_rep select generate_series(1,10);") + + node_primary.wait_for_replay_catchup(node_standby) + node_standby.wait_for_catchup("tap_sub") + + # Check that the subscriber can see the rows inserted in the primary + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_rep") + assert result == "10", "check replicated inserts after subscription on standby" + + # We do not need the subscription and the subscriber anymore + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + node_subscriber.stop() + + # Create the injection_points extension + node_primary.safe_sql("CREATE EXTENSION injection_points;", dbname="testdb") + + ################################################## + # Recovery conflict scenario 1: hot_standby_feedback off and vacuum FULL. + ################################################## + handle = reactive_slots_change_hfs_and_wait_for_xmins( + node_standby, node_primary, "behaves_ok_", "vacuum_full_", 0, 1 + ) + + # Ensure that replication slot stats are not empty before the conflict. + node_primary.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT 100,'100';", dbname="testdb" + ) + + assert node_standby.poll_query_until( + "SELECT total_txns > 0 FROM pg_stat_replication_slots WHERE slot_name = " + "'vacuum_full_activeslot'", + dbname="testdb", + ), "replication slot stats of vacuum_full_activeslot not updated" + + # This should trigger the conflict + wait_until_vacuum_can_remove( + node_primary, + node_standby, + "full", + "CREATE TABLE conflict_test(x integer, y text); DROP TABLE conflict_test;", + "pg_class", + ) + + # Check invalidation in the logfile and in pg_stat_database_conflicts + check_for_invalidation( + node_standby, "vacuum_full_", 1, "with vacuum FULL on pg_class" + ) + + # Verify reason for conflict is 'rows_removed' + check_slots_conflict_reason(node_standby, "vacuum_full_", "rows_removed") + + # Attempting to alter an invalidated slot should result in an error + rep_sess = Session( + connstr=node_standby.connstr("postgres") + " replication=database", + libdir=node_standby.libdir, + ) + try: + alter_res = rep_sess.query( + "ALTER_REPLICATION_SLOT vacuum_full_inactiveslot (failover);" + ) + assert ( + alter_res.error_message is not None + and re.search( + r'can no longer access replication slot "vacuum_full_inactiveslot"', + alter_res.error_message, + ) + and re.search( + r'This replication slot has been invalidated due to "rows_removed"\.', + alter_res.error_message, + ) + ), "invalidated slot cannot be altered" + + # Ensure that replication slot stats are not removed after invalidation. + assert ( + node_standby.safe_sql( + "SELECT total_txns > 0 FROM pg_stat_replication_slots WHERE slot_name = " + "'vacuum_full_activeslot'", + dbname="testdb", + ) + == "t" + ), "replication slot stats not removed after invalidation" + + handle = make_slot_active(node_standby, "vacuum_full_", False) + # We are not able to read from the slot as it has been invalidated + check_pg_recvlogical_stderr( + handle, + 'can no longer access replication slot "vacuum_full_activeslot"', + ) + + # Attempt to copy an invalidated logical replication slot + copy_res = rep_sess.query( + "select pg_copy_logical_replication_slot('vacuum_full_inactiveslot', " + "'vacuum_full_inactiveslot_copy');" + ) + assert copy_res.error_message is not None and re.search( + r'cannot copy invalidated replication slot "vacuum_full_inactiveslot"', + copy_res.error_message, + ), "invalidated slot cannot be copied" + finally: + rep_sess.close() + + # Set hot_standby_feedback to on + change_hot_standby_feedback_and_wait_for_xmins(node_standby, node_primary, 1, 1) + + ################################################## + # Verify that invalidated logical slots stay invalidated across a restart. + ################################################## + node_standby.restart() + + # Verify reason for conflict is retained across a restart. + check_slots_conflict_reason(node_standby, "vacuum_full_", "rows_removed") + + ################################################## + # Verify that invalidated logical slots do not lead to retaining WAL. + ################################################## + restart_lsn = node_standby.safe_sql( + "SELECT restart_lsn FROM pg_replication_slots " + "WHERE slot_name = 'vacuum_full_activeslot' AND conflicting;" + ) + + # As pg_walfile_name() can not be executed on the standby, get the WAL file + # name associated to this lsn from the primary. + walfile_name = node_primary.safe_sql(f"SELECT pg_walfile_name('{restart_lsn}')") + + # Generate some activity and switch WAL file on the primary + node_primary.advance_wal(1) + node_primary.safe_sql("checkpoint;") + + # Wait for the standby to catch up + node_primary.wait_for_replay_catchup(node_standby) + + # Request a checkpoint on the standby to trigger the WAL file(s) removal + node_standby.safe_sql("checkpoint;") + + # Verify that the WAL file has not been retained on the standby + standby_walfile = os.path.join(node_standby.data_dir, "pg_wal", walfile_name) + assert not os.path.isfile( + standby_walfile + ), "invalidated logical slots do not lead to retaining WAL" + + ################################################## + # Recovery conflict scenario 2: conflict due to row removal with + # hot_standby_feedback off. + ################################################## + logstart = node_standby.log_position() + + handle = reactive_slots_change_hfs_and_wait_for_xmins( + node_standby, node_primary, "vacuum_full_", "row_removal_", 0, 1 + ) + + # This should trigger the conflict + wait_until_vacuum_can_remove( + node_primary, + node_standby, + "", + "CREATE TABLE conflict_test(x integer, y text); DROP TABLE conflict_test;", + "pg_class", + ) + + check_for_invalidation( + node_standby, "row_removal_", logstart, "with vacuum on pg_class" + ) + check_slots_conflict_reason(node_standby, "row_removal_", "rows_removed") + + handle = make_slot_active(node_standby, "row_removal_", False) + check_pg_recvlogical_stderr( + handle, 'can no longer access replication slot "row_removal_activeslot"' + ) + + ################################################## + # Recovery conflict scenario 3: same as 2 but on a shared catalog table. + ################################################## + logstart = node_standby.log_position() + + handle = reactive_slots_change_hfs_and_wait_for_xmins( + node_standby, node_primary, "row_removal_", "shared_row_removal_", 0, 1 + ) + + # Trigger the conflict (create/drop a role and vacuum pg_authid) + wait_until_vacuum_can_remove( + node_primary, + node_standby, + "", + "CREATE ROLE create_trash; DROP ROLE create_trash;", + "pg_authid", + ) + + check_for_invalidation( + node_standby, "shared_row_removal_", logstart, "with vacuum on pg_authid" + ) + check_slots_conflict_reason(node_standby, "shared_row_removal_", "rows_removed") + + handle = make_slot_active(node_standby, "shared_row_removal_", False) + check_pg_recvlogical_stderr( + handle, + 'can no longer access replication slot "shared_row_removal_activeslot"', + ) + + ################################################## + # Recovery conflict scenario 4: same as 2 but on a non catalog table. + # No conflict expected. + ################################################## + logstart = node_standby.log_position() + + handle = reactive_slots_change_hfs_and_wait_for_xmins( + node_standby, node_primary, "shared_row_removal_", "no_conflict_", 0, 1 + ) + + # This should not trigger a conflict + wait_until_vacuum_can_remove( + node_primary, + node_standby, + "", + "CREATE TABLE conflict_test(x integer, y text); " + "INSERT INTO conflict_test(x,y) SELECT s, s::text FROM generate_series(1,4) s; " + "UPDATE conflict_test set x=1, y=1;", + "conflict_test", + ) + + # message should not be issued + assert not node_standby.log_contains( + 'invalidating obsolete replication slot "no_conflict_inactiveslot"', logstart + ), "inactiveslot slot invalidation is not logged with vacuum on conflict_test" + + assert not node_standby.log_contains( + 'invalidating obsolete replication slot "no_conflict_activeslot"', logstart + ), "activeslot slot invalidation is not logged with vacuum on conflict_test" + + # Verify that confl_active_logicalslot has not been updated + assert node_standby.poll_query_until( + "select (confl_active_logicalslot = 0) from pg_stat_database_conflicts " + "where datname = 'testdb'" + ), "Timed out waiting confl_active_logicalslot to be updated" + + # Verify slots are reported as non conflicting in pg_replication_slots + assert ( + node_standby.safe_sql( + "select bool_or(conflicting) from " + "(select conflicting from pg_replication_slots where slot_type = 'logical')" + ) + == "f" + ), "Logical slots are reported as non conflicting" + + # Turn hot_standby_feedback back on + change_hot_standby_feedback_and_wait_for_xmins(node_standby, node_primary, 1, 0) + + # Restart the standby node to ensure no slots are still active + node_standby.restart() + + ################################################## + # Recovery conflict scenario 5: conflict due to on-access pruning. + ################################################## + logstart = node_standby.log_position() + + handle = reactive_slots_change_hfs_and_wait_for_xmins( + node_standby, node_primary, "no_conflict_", "pruning_", 0, 0 + ) + + # Injection point avoids seeing a xl_running_xacts. + node_primary.safe_sql( + "SELECT injection_points_attach('skip-log-running-xacts', 'error');", + dbname="testdb", + ) + + # This should trigger the conflict + node_primary.safe_sql( + "CREATE TABLE prun(id integer, s char(2000)) " + "WITH (fillfactor = 75, user_catalog_table = true);", + dbname="testdb", + ) + node_primary.safe_sql("INSERT INTO prun VALUES (1, 'A');", dbname="testdb") + node_primary.safe_sql("UPDATE prun SET s = 'B';", dbname="testdb") + node_primary.safe_sql("UPDATE prun SET s = 'C';", dbname="testdb") + node_primary.safe_sql("UPDATE prun SET s = 'D';", dbname="testdb") + node_primary.safe_sql("UPDATE prun SET s = 'E';", dbname="testdb") + + node_primary.wait_for_replay_catchup(node_standby) + + # Resume generating the xl_running_xacts record + node_primary.safe_sql( + "SELECT injection_points_detach('skip-log-running-xacts');", + dbname="testdb", + ) + + check_for_invalidation(node_standby, "pruning_", logstart, "with on-access pruning") + check_slots_conflict_reason(node_standby, "pruning_", "rows_removed") + + handle = make_slot_active(node_standby, "pruning_", False) + check_pg_recvlogical_stderr( + handle, 'can no longer access replication slot "pruning_activeslot"' + ) + + # Turn hot_standby_feedback back on + change_hot_standby_feedback_and_wait_for_xmins(node_standby, node_primary, 1, 1) + + ################################################## + # Recovery conflict scenario 6: incorrect wal_level on primary. + ################################################## + logstart = node_standby.log_position() + + drop_logical_slots(node_standby, "pruning_") + create_logical_slots(node_standby, node_primary, "wal_level_") + + handle = make_slot_active(node_standby, "wal_level_", True) + + # reset stat + node_standby.sql("select pg_stat_reset();", dbname="testdb") + + # Make primary wal_level replica. This will trigger slot conflict. + node_primary.append_conf("\nwal_level = 'replica'\n") + node_primary.restart() + + node_primary.wait_for_replay_catchup(node_standby) + + check_for_invalidation(node_standby, "wal_level_", logstart, "due to wal_level") + check_slots_conflict_reason(node_standby, "wal_level_", "wal_level_insufficient") + + handle = make_slot_active(node_standby, "wal_level_", False) + # We are not able to read from the slot as it requires + # effective_wal_level >= logical on the primary server + check_pg_recvlogical_stderr( + handle, + 'logical decoding on standby requires "effective_wal_level" >= ' + '"logical" on the primary', + ) + + # Restore primary wal_level + node_primary.append_conf("\nwal_level = 'logical'\n") + node_primary.restart() + node_primary.wait_for_replay_catchup(node_standby) + + handle = make_slot_active(node_standby, "wal_level_", False) + # as the slot has been invalidated we should not be able to read + check_pg_recvlogical_stderr( + handle, 'can no longer access replication slot "wal_level_activeslot"' + ) + + ################################################## + # DROP DATABASE should drop its slots, including active slots. + ################################################## + drop_logical_slots(node_standby, "wal_level_") + create_logical_slots(node_standby, node_primary, "drop_db_") + + handle = make_slot_active(node_standby, "drop_db_", True) + + # Create a slot on a database that would not be dropped. This slot should + # not get dropped. + node_standby.create_logical_slot_on_standby(node_primary, "otherslot", "postgres") + + # dropdb on the primary to verify slots are dropped on standby + node_primary.safe_sql("DROP DATABASE testdb") + + node_primary.wait_for_replay_catchup(node_standby) + + assert ( + node_standby.safe_sql( + "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = 'testdb')" + ) + == "f" + ), "database dropped on standby" + + check_slots_dropped(node_standby, "drop_db_", handle) + + assert ( + node_standby.slot("otherslot")["slot_type"] == "logical" + ), "otherslot on standby not dropped" + + # Cleanup: manually drop the slot that was not dropped. + node_standby.sql("SELECT pg_drop_replication_slot('otherslot')") + + ################################################## + # Test standby promotion and logical decoding behavior after the standby + # gets promoted. + ################################################## + node_standby.reload() + + node_primary.sql("CREATE DATABASE testdb") + node_primary.safe_sql( + "CREATE TABLE decoding_test(x integer, y text);", dbname="testdb" + ) + + # Wait for the standby to catchup before initializing the cascading standby + node_primary.wait_for_replay_catchup(node_standby) + + # The standby's cached testdb session was connected to the testdb that has + # just been dropped and recreated; its backend was terminated by the DROP + # DATABASE replay. Discard it so the next query reconnects to the new + # database. + node_standby.session("testdb").close() + + # Create a physical replication slot on the standby. + node_standby.safe_sql( + "SELECT * FROM pg_create_physical_replication_slot(" + f"'{STANDBY_PHYSICAL_SLOTNAME}');", + dbname="testdb", + ) + + # Initialize cascading standby node + node_cascading_standby = create_pg("cascading_standby", start=False) + node_standby.backup(backup_name) + node_cascading_standby.init_from_backup( + node_standby, backup_name, has_streaming=True, has_restoring=True + ) + node_cascading_standby.append_conf( + f"primary_slot_name = '{STANDBY_PHYSICAL_SLOTNAME}'\n" + "hot_standby_feedback = on\n" + ) + node_cascading_standby.start() + + # create the logical slots + create_logical_slots(node_standby, node_primary, "promotion_") + + # Wait for the cascading standby to catchup before creating the slots + node_standby.wait_for_replay_catchup(node_cascading_standby, node_primary) + + # create the logical slots on the cascading standby too + create_logical_slots(node_cascading_standby, node_primary, "promotion_") + + # Make slots active + handle = make_slot_active(node_standby, "promotion_", True) + cascading_handle = make_slot_active(node_cascading_standby, "promotion_", True) + + try: + # Insert some rows before the promotion + node_primary.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT s, s::text FROM " + "generate_series(1,4) s;", + dbname="testdb", + ) + + # Wait for both standbys to catchup + node_primary.wait_for_replay_catchup(node_standby) + node_standby.wait_for_replay_catchup(node_cascading_standby, node_primary) + + # promote + node_standby.promote() + + # insert some rows on promoted standby + node_standby.safe_sql( + "INSERT INTO decoding_test(x,y) SELECT s, s::text FROM " + "generate_series(5,7) s;", + dbname="testdb", + ) + + # Wait for the cascading standby to catchup + node_standby.wait_for_replay_catchup(node_cascading_standby) + + expected = ( + "BEGIN\n" + "table public.decoding_test: INSERT: x[integer]:1 y[text]:'1'\n" + "table public.decoding_test: INSERT: x[integer]:2 y[text]:'2'\n" + "table public.decoding_test: INSERT: x[integer]:3 y[text]:'3'\n" + "table public.decoding_test: INSERT: x[integer]:4 y[text]:'4'\n" + "COMMIT\n" + "BEGIN\n" + "table public.decoding_test: INSERT: x[integer]:5 y[text]:'5'\n" + "table public.decoding_test: INSERT: x[integer]:6 y[text]:'6'\n" + "table public.decoding_test: INSERT: x[integer]:7 y[text]:'7'\n" + "COMMIT" + ) + + # check that we are decoding pre and post promotion inserted rows + stdout_sql = node_standby.safe_sql( + "SELECT data FROM pg_logical_slot_peek_changes('promotion_inactiveslot', " + "NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');", + dbname="testdb", + ) + assert ( + stdout_sql == expected + ), "got expected output from SQL decoding session on promoted standby" + + # check that we are decoding pre and post promotion inserted rows with + # pg_recvlogical that has started before the promotion + assert handle.pump_until( + r"^.*COMMIT.*COMMIT$" + ), "got 2 COMMIT from pg_recvlogical output" + assert ( + handle.stdout.rstrip("\n") == expected + ), "got same expected output from pg_recvlogical decoding session" + + # check that we are decoding pre and post promotion inserted rows on the + # cascading standby + stdout_sql = node_cascading_standby.safe_sql( + "SELECT data FROM pg_logical_slot_peek_changes('promotion_inactiveslot', " + "NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');", + dbname="testdb", + ) + assert ( + stdout_sql == expected + ), "got expected output from SQL decoding session on cascading standby" + + # check that we are decoding pre and post promotion inserted rows with + # pg_recvlogical that has started before the promotion on the cascading + # standby + assert cascading_handle.pump_until( + r"^.*COMMIT.*COMMIT$" + ), "got 2 COMMIT from pg_recvlogical output" + assert cascading_handle.stdout.rstrip("\n") == expected, ( + "got same expected output from pg_recvlogical decoding session on " + "cascading standby" + ) + finally: + handle.kill() + cascading_handle.kill() diff --git a/src/test/recovery/pyt/test_036_truncated_dropped.py b/src/test/recovery/pyt/test_036_truncated_dropped.py new file mode 100644 index 00000000000..251b56e411c --- /dev/null +++ b/src/test/recovery/pyt/test_036_truncated_dropped.py @@ -0,0 +1,108 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests recovery scenarios where the files are shorter than in the common +cases, e.g. due to replaying WAL records of a relation that was subsequently +truncated or dropped. +""" + + +def test_036_truncated_dropped(create_pg): + node = create_pg("n1", start=False) + + # Disable autovacuum to guarantee VACUUM can remove rows / truncate + # relations + node.append_conf( + """ +wal_level = 'replica' +autovacuum = off +""" + ) + + node.start() + + # Test: Replay replay of PRUNE records for a pre-existing, then dropped, + # relation. + # + # Statements are issued one at a time because the in-process libpq Session + # runs a multi-statement string as a single implicit transaction, and + # VACUUM / CHECKPOINT cannot run inside a transaction block. + node.safe_sql("CREATE TABLE truncme(i int) WITH (fillfactor = 50)") + node.safe_sql("INSERT INTO truncme SELECT generate_series(1, 1000)") + node.safe_sql("UPDATE truncme SET i = 1") + node.safe_sql("CHECKPOINT") # ensure relation exists at start of recovery + node.safe_sql("VACUUM truncme") # generate prune records + node.safe_sql("DROP TABLE truncme") + + node.stop("immediate") + + node.start() # replay of PRUNE records for a pre-existing, then dropped, relation + + # Test: Replay of PRUNE records for a newly created, then dropped, relation + node.safe_sql("CREATE TABLE truncme(i int) WITH (fillfactor = 50)") + node.safe_sql("INSERT INTO truncme SELECT generate_series(1, 1000)") + node.safe_sql("UPDATE truncme SET i = 1") + node.safe_sql("VACUUM truncme") # generate prune records + node.safe_sql("DROP TABLE truncme") + + node.stop("immediate") + + node.start() # replay of PRUNE records for a newly created, then dropped, relation + + # Test: Replay of PRUNE records affecting truncated block. With FPIs used + # for PRUNE. + node.safe_sql("CREATE TABLE truncme(i int) WITH (fillfactor = 50)") + node.safe_sql("INSERT INTO truncme SELECT generate_series(1, 1000)") + node.safe_sql("UPDATE truncme SET i = 1") + node.safe_sql("CHECKPOINT") # generate FPIs + node.safe_sql("VACUUM truncme") # generate prune records + node.safe_sql("TRUNCATE truncme") # make blocks non-existing + node.safe_sql("INSERT INTO truncme SELECT generate_series(1, 10)") + + node.stop("immediate") + + node.start() # replay of PRUNE records affecting truncated block (FPIs) + + assert ( + node.safe_sql("select count(*), sum(i) FROM truncme") == "10|55" + ), "table contents as expected after recovery" + node.safe_sql("DROP TABLE truncme") + + # Test replay of PRUNE records for blocks that are later truncated. Without + # FPIs used for PRUNE. + node.safe_sql("CREATE TABLE truncme(i int) WITH (fillfactor = 50)") + node.safe_sql("INSERT INTO truncme SELECT generate_series(1, 1000)") + node.safe_sql("UPDATE truncme SET i = 1") + node.safe_sql("VACUUM truncme") # generate prune records + node.safe_sql("TRUNCATE truncme") # make blocks non-existing + node.safe_sql("INSERT INTO truncme SELECT generate_series(1, 10)") + + node.stop("immediate") + + node.start() # replay of PRUNE records affecting truncated block (no FPIs) + + assert ( + node.safe_sql("select count(*), sum(i) FROM truncme") == "10|55" + ), "table contents as expected after recovery" + node.safe_sql("DROP TABLE truncme") + + # Test: Replay of partial truncation via VACUUM + node.safe_sql("CREATE TABLE truncme(i int) WITH (fillfactor = 50)") + node.safe_sql("INSERT INTO truncme SELECT generate_series(1, 1000)") + node.safe_sql("UPDATE truncme SET i = i + 1") + # ensure a mix of pre/post truncation rows + node.safe_sql("DELETE FROM truncme WHERE i > 500") + + node.safe_sql("VACUUM truncme") # should truncate relation + + # rows at TIDs that previously existed + node.safe_sql("INSERT INTO truncme SELECT generate_series(1000, 1010)") + + node.stop("immediate") + + node.start() # replay of partial truncation via VACUUM + + assert ( + node.safe_sql("select count(*), sum(i), min(i), max(i) FROM truncme") + == "510|136304|2|1010" + ), "table contents as expected after recovery" + node.safe_sql("DROP TABLE truncme") diff --git a/src/test/recovery/pyt/test_037_invalid_database.py b/src/test/recovery/pyt/test_037_invalid_database.py new file mode 100644 index 00000000000..ddbf439b8c9 --- /dev/null +++ b/src/test/recovery/pyt/test_037_invalid_database.py @@ -0,0 +1,144 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test we handle interrupted DROP DATABASE correctly.""" + +import re + +from libpq import ExecStatusType +from libpq.errors import PqConnectionError + + +def test_037_invalid_database(create_pg): + node = create_pg("node") + node.append_conf( + """ +autovacuum = off +max_prepared_transactions=5 +log_min_duration_statement=0 +log_connections=receipt +log_disconnections=on +""" + ) + node.restart() + + # First verify that we can't connect to or ALTER an invalid database. Just + # mark the database as invalid ourselves, that's more reliable than hitting + # the required race conditions (see testing further down)... + + # CREATE DATABASE cannot run inside a transaction block, so it must be a + # statement of its own (the in-process Session runs a multi-statement + # string as a single implicit transaction). + node.safe_sql("CREATE DATABASE regression_invalid;") + node.safe_sql( + "UPDATE pg_database SET datconnlimit = -2 " + "WHERE datname = 'regression_invalid';" + ) + + # can't connect to invalid database - error code / error message + try: + sess = node.connect("regression_invalid") + sess.close() + connect_err = None + except PqConnectionError as exc: + connect_err = str(exc) + assert connect_err is not None, "can't connect to invalid database - error code" + assert re.search( + r'FATAL:\s+cannot connect to invalid database "regression_invalid"', connect_err + ), "can't connect to invalid database - error message" + + # can't ALTER invalid database + res = node.sql("ALTER DATABASE regression_invalid CONNECTION LIMIT 10") + assert res.error_message is not None, "can't ALTER invalid database" + + # check invalid database can't be used as a template + res = node.sql("CREATE DATABASE copy_invalid TEMPLATE regression_invalid") + assert res.error_message is not None, "can't use invalid database as template" + + # Verify that VACUUM ignores an invalid database when computing how much of + # the clog is needed (vac_truncate_clog()). For that we modify the + # pg_database row of the invalid database to have an outdated datfrozenxid. + sess = node.session() + sess.clear_notices() + sess.query_safe( + "UPDATE pg_database SET datfrozenxid = '123456' " + "WHERE datname = 'regression_invalid';" + "DROP TABLE IF EXISTS foo_tbl; CREATE TABLE foo_tbl();" + ) + # VACUUM cannot run inside a transaction block; run it separately. + sess.query_safe("VACUUM FREEZE;") + notices = sess.get_notices_str() + assert not re.search( + r"some databases have not been vacuumed in over 2 billion transactions", notices + ), "invalid databases are ignored by vac_truncate_clog" + + # But we need to be able to drop an invalid database. + res = node.sql("DROP DATABASE regression_invalid") + assert res.error_message is None, "can DROP invalid database" + + # Ensure database is gone + res = node.sql("DROP DATABASE regression_invalid") + assert res.error_message is not None, "can't drop already dropped database" + + # Test that interruption of DROP DATABASE is handled properly. To ensure the + # interruption happens at the appropriate moment, we lock pg_tablespace. + # DROP DATABASE scans pg_tablespace once it has reached the "irreversible" + # part of dropping the database, making it a suitable point to wait. Since + # relcache init reads pg_tablespace, establish each connection before + # locking. This avoids a connection-time hang with debug_discard_caches. + cancel = node.connect() + bgpsql = node.connect() + pid = bgpsql.query_oneval("SELECT pg_backend_pid()") + + # create the database, prevent drop database via lock held by a 2PC + # transaction + assert ( + bgpsql.do( + "CREATE DATABASE regression_invalid_interrupt;", + "BEGIN;\nLOCK pg_tablespace;\nPREPARE TRANSACTION 'lock_tblspc';", + ) + == 1 + ) + + # Try to drop. This will wait due to the still held lock. + bgpsql.do_async("DROP DATABASE regression_invalid_interrupt;") + + # Once the DROP DATABASE is waiting for the lock, interrupt it. + cancel_res = cancel.query( + f""" + DO $$ + BEGIN + WHILE NOT EXISTS(SELECT * FROM pg_locks WHERE NOT granted AND relation = 'pg_tablespace'::regclass AND mode = 'AccessShareLock') LOOP + PERFORM pg_sleep(.1); + END LOOP; + END$$; + SELECT pg_cancel_backend({pid})""" + ) + assert ( + cancel_res.status == ExecStatusType.PGRES_TUPLES_OK + ), "canceling DROP DATABASE" # COMMAND_TUPLES_OK + cancel.close() + + bgpsql.wait_for_completion() + # wait for cancellation to be processed + # pass("cancel processed") + + # Verify that connections to the database aren't allowed. The backend + # checks this before relcache init, so the lock won't interfere. + try: + sess = node.connect("regression_invalid_interrupt") + sess.close() + ii_connect_err = None + except PqConnectionError as exc: + ii_connect_err = str(exc) + assert ii_connect_err is not None, "can't connect to invalid_interrupt database" + + # To properly drop the database, we need to release the lock previously + # preventing doing so. + assert ( + bgpsql.do("ROLLBACK PREPARED 'lock_tblspc'") == ExecStatusType.PGRES_COMMAND_OK + ), "unblock DROP DATABASE" + + res = bgpsql.query("DROP DATABASE regression_invalid_interrupt") + assert res.error_message is None, "DROP DATABASE invalid_interrupt" + + bgpsql.close() diff --git a/src/test/recovery/pyt/test_038_save_logical_slots_shutdown.py b/src/test/recovery/pyt/test_038_save_logical_slots_shutdown.py new file mode 100644 index 00000000000..b808d8816ac --- /dev/null +++ b/src/test/recovery/pyt/test_038_save_logical_slots_shutdown.py @@ -0,0 +1,91 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test logical replication slots are always flushed to disk during a shutdown +checkpoint. +""" + +import re + + +def compare_confirmed_flush(node, confirmed_flush_from_log): + # Fetch Latest checkpoint location from the control file + res = node.pg_bin.result(["pg_controldata", node.data_dir]) + latest_checkpoint = None + for line in res.stdout.splitlines(): + m = re.match(r"^Latest checkpoint location:\s*(.*)$", line) + if m: + latest_checkpoint = m.group(1) + break + assert ( + latest_checkpoint is not None + ), "Latest checkpoint location not found in control file" + + # Is it same as the value read from log? + assert latest_checkpoint == confirmed_flush_from_log, ( + "Check that the slot's confirmed_flush LSN is the same as the " + "latest_checkpoint location" + ) + + +def test_038_save_logical_slots_shutdown(create_pg): + # Initialize publisher node + node_publisher = create_pg("pub", start=False, allows_streaming="logical") + # Avoid checkpoint during the test, otherwise, the latest checkpoint + # location will change. + node_publisher.append_conf("checkpoint_timeout = 1h\nautovacuum = off\n") + node_publisher.start() + + # Create subscriber node + node_subscriber = create_pg("sub") + + # Create tables + node_publisher.safe_sql("CREATE TABLE test_tbl (id int)") + node_subscriber.safe_sql("CREATE TABLE test_tbl (id int)") + + # To avoid a shutdown checkpoint WAL record (that gets generated as part of + # the publisher restart below) falling into a new page, advance the WAL + # segment. Otherwise, the confirmed_flush_lsn and shutdown_checkpoint + # location won't match. + node_publisher.advance_wal(1) + + # Insert some data + node_publisher.safe_sql("INSERT INTO test_tbl VALUES (generate_series(1, 5));") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION pub FOR ALL TABLES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub CONNECTION '{publisher_connstr}' " "PUBLICATION pub" + ) + + node_subscriber.wait_for_subscription_sync(node_publisher, "sub") + + result = node_subscriber.safe_sql("SELECT count(*) FROM test_tbl") + assert result == "5", "check initial copy was done" + + offset = node_publisher.log_position() + + # Note: Don't insert any data on the publisher that may cause the shutdown + # checkpoint to fall into a new WAL file. See the comments atop + # advance_wal() above. + + # Restart the publisher to ensure that the slot will be flushed if required + node_publisher.restart() + + # Wait until the walsender creates decoding context + pattern = ( + r"Streaming transactions committing after ([A-F0-9]+/[A-F0-9]+), " + r"reading WAL from ([A-F0-9]+/[A-F0-9]+)\." + ) + node_publisher.wait_for_log(pattern, offset) + + # Extract confirmed_flush from the logfile + log_contents = node_publisher.log_content()[offset:] + m = re.search(pattern, log_contents) + assert m is not None, "could not get confirmed_flush_lsn" + + # Ensure that the slot's confirmed_flush LSN is the same as the + # latest_checkpoint location. + compare_confirmed_flush(node_publisher, m.group(1)) diff --git a/src/test/recovery/pyt/test_039_end_of_wal.py b/src/test/recovery/pyt/test_039_end_of_wal.py new file mode 100644 index 00000000000..738b2a57f73 --- /dev/null +++ b/src/test/recovery/pyt/test_039_end_of_wal.py @@ -0,0 +1,431 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test detecting end-of-WAL conditions. This test suite generates +fake defective page and record headers to trigger various failure +scenarios. +""" + +import os +import re +import struct +import subprocess +import sys + +# Is this a big-endian system ("network" byte order)? We can't use 'Q' in +# struct calls in a way that's portable across all interpreters, so we +# break 64 bit LSN values into two 'I' values. +# Fortunately we don't need to deal with high values, so we can just write 0 +# for the high order 32 bits, but we need to know the endianness to do that. +BIG_ENDIAN = sys.byteorder == "big" + + +def scan_server_header(bindir, header_path, regexp): + """Return the first regex match within the installed server header. + + The regex is anchored at the start of each line and the first match's + capture groups are returned. + """ + includedir = subprocess.run( + [os.path.join(bindir, "pg_config"), "--includedir-server"], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout.strip() + + pattern = re.compile("^" + regexp) + with open(os.path.join(includedir, header_path), encoding="utf-8") as fh: + for line in fh: + m = pattern.match(line) + if m: + return list(m.groups()) + raise RuntimeError(f"could not find match in header {header_path}") + + +# Get GUC value, converted to an int. +def get_int_setting(node, name): + return int(node.safe_sql(f"SELECT setting FROM pg_settings WHERE name = '{name}'")) + + +def start_of_page(lsn, wal_block_size): + return lsn & ~(wal_block_size - 1) + + +def start_of_next_page(lsn, wal_block_size): + return start_of_page(lsn, wal_block_size) + wal_block_size + + +# Build a fake WAL record header based on the data given by the caller. +# This needs to follow the format of the C structure XLogRecord. To +# be inserted with write_wal(). +def build_record_header( + xl_tot_len, xl_xid=0, xl_prev=0, xl_info=0, xl_rmid=0, xl_crc=0 +): + # This needs to follow the structure XLogRecord: + # I for xl_tot_len + # I for xl_xid + # II for xl_prev + # C for xl_info + # C for xl_rmid + # BB for two bytes of padding + # I for xl_crc + return struct.pack( + " to_timestamp(0) AND " + f"'{inactive_since}'::timestamptz > '{reference_time}'::timestamptz" + ) + == "t" + ), f"last inactive time for slot {slot_name} is valid on node {node.name}" + return inactive_since + + +def expect_error(node, sql, pattern, msg, dbname="postgres", replication=None): + """Run *sql* expecting it to fail; assert *pattern* matches the error. + + Uses an in-process libpq Session. *replication* may be 'database' (or + True) to open a replication connection. + """ + connstr = node.connstr(dbname) + if replication == "database": + connstr += " replication=database" + elif replication: + connstr += " replication=true" + with Session(connstr=connstr, libdir=node.libdir) as sess: + res = sess.query(sql) + assert res.error_message is not None and re.search( + pattern, res.error_message + ), f"{msg}: expected /{pattern}/, got: {res.error_message!r}" + + +def test_040_standby_failover_slots_sync(create_pg): + ################################################## + # Test that when a subscription with failover enabled is created, it will + # alter the failover property of the corresponding slot on the publisher. + ################################################## + + # Create publisher + publisher = create_pg("publisher", allows_streaming="logical", start=False) + # Disable autovacuum to avoid generating xid during stats update as + # otherwise the new XID could then be replicated to standby at some random + # point making slots at primary lag behind standby during slot sync. + publisher.append_conf("autovacuum = off\nmax_prepared_transactions = 1\n") + publisher.start() + + publisher.safe_sql("CREATE PUBLICATION regress_mypub FOR ALL TABLES;") + + publisher_connstr = f"host={publisher.host} port={publisher.port} dbname=postgres" + + # Create a subscriber node, wait for sync to complete + subscriber1 = create_pg("subscriber1", start=False) + subscriber1.append_conf("max_prepared_transactions = 1\n") + subscriber1.start() + + # Capture the time before the logical failover slot is created on the + # primary. We later call this publisher as primary anyway. + slot_creation_time_on_primary = publisher.safe_sql("SELECT current_timestamp;") + + # Create a subscription that enables failover. + subscriber1.safe_sql( + f"CREATE SUBSCRIPTION regress_mysub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION regress_mypub WITH (slot_name = lsub1_slot, " + "copy_data = false, failover = true, enabled = false);" + ) + + # Confirm that the failover flag on the slot is turned on + assert ( + publisher.safe_sql( + "SELECT failover from pg_replication_slots WHERE slot_name = 'lsub1_slot';" + ) + == "t" + ), "logical slot has failover true on the publisher" + + ################################################## + # Test that changing the failover property of a subscription updates the + # corresponding failover property of the slot. + ################################################## + + # Disable failover + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 SET (failover = false)") + + # Confirm that the failover flag on the slot has now been turned off + assert ( + publisher.safe_sql( + "SELECT failover from pg_replication_slots WHERE slot_name = 'lsub1_slot';" + ) + == "f" + ), "logical slot has failover false on the publisher" + + # Enable failover + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 SET (failover = true)") + + # Confirm that the failover flag on the slot has now been turned on + assert ( + publisher.safe_sql( + "SELECT failover from pg_replication_slots WHERE slot_name = 'lsub1_slot';" + ) + == "t" + ), "logical slot has failover true on the publisher" + + ################################################## + # Test that the failover option cannot be changed for enabled subscriptions. + ################################################## + + # Enable subscription + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 ENABLE") + + # Disable failover for enabled subscription + expect_error( + subscriber1, + "ALTER SUBSCRIPTION regress_mysub1 SET (failover = false)", + r'ERROR: cannot set option "failover" for enabled subscription', + "altering failover is not allowed for enabled subscription", + ) + + ################################################## + # Test that pg_sync_replication_slots() cannot be executed on a non-standby + # server. + ################################################## + + expect_error( + publisher, + "SELECT pg_sync_replication_slots();", + r"ERROR: replication slots can only be synchronized to a standby server", + "cannot sync slots on a non-standby server", + ) + + ################################################## + # Test logical failover slots corresponding to different plugins can be + # synced to the standby. + # + # failover slot lsub1_slot | output_plugin: pgoutput + # failover slot lsub2_slot | output_plugin: test_decoding + # physical slot sb1_slot ----> standby1 (lsub1_slot, lsub2_slot synced) + ################################################## + + primary = publisher + primary.backup("backup") + + # Create a standby + standby1 = create_pg("standby1", start=False) + standby1.init_from_backup(primary, "backup", has_streaming=1, has_restoring=1) + + # Increase the log_min_messages setting to DEBUG2 on both the standby and + # primary to debug test failures, if any. + # + # Build an unquoted conninfo; PostgresServer.connstr() quotes values and + # adds dbname, which would break embedding inside primary_conninfo = '...'. + connstr_1 = f"port={primary.port} host={primary.host}" + + # A primary_conninfo with no application_name shows up as 'walreceiver' in + # pg_stat_replication. wait_for_catchup polls the upstream's + # pg_stat_replication matching application_name IN (name, 'walreceiver'), + # which would match two physical standbys at once when one is unnamed. Pin + # an explicit application_name on standby1 to keep each catchup wait + # matching exactly one row. + standby1_appname = " application_name=standby1" + standby1.append_conf( + "hot_standby_feedback = on\n" + "primary_slot_name = 'sb1_slot'\n" + f"primary_conninfo = '{connstr_1} dbname=postgres{standby1_appname}'\n" + "log_min_messages = 'debug2'\n" + ) + + primary.append_conf("log_min_messages = 'debug2'\n") + primary.reload() + + # Drop the subscription to prevent further advancement of the restart_lsn + # for the lsub1_slot. + subscriber1.safe_sql("DROP SUBSCRIPTION regress_mysub1;") + + # To ensure that restart_lsn has moved to a recent WAL position, we + # re-create the lsub1_slot. + primary.safe_sql( + "SELECT pg_create_logical_replication_slot('lsub1_slot', 'pgoutput', false, false, true);" + ) + primary.safe_sql( + "SELECT pg_create_logical_replication_slot('lsub2_slot', 'test_decoding', false, false, true);" + ) + primary.safe_sql("SELECT pg_create_physical_replication_slot('sb1_slot');") + + # Start the standby so that slot syncing can begin + standby1.start() + + # Capture the inactive_since of the slot from the primary. Note that the + # slot will be inactive since the corresponding subscription was dropped. + inactive_since_on_primary = validate_slot_inactive_since( + primary, "lsub1_slot", slot_creation_time_on_primary + ) + + # Wait for the standby to catch up so that the standby is not lagging behind + # the failover slots. + primary.wait_for_replay_catchup(standby1) + + # Synchronize the primary server slots to the standby. + standby1.safe_sql("SELECT pg_sync_replication_slots();") + + # Confirm that the logical failover slots are created on the standby and are + # flagged as 'synced' + assert ( + standby1.safe_sql( + "SELECT count(*) = 2 FROM pg_replication_slots WHERE slot_name IN " + "('lsub1_slot', 'lsub2_slot') AND synced AND NOT temporary;" + ) + == "t" + ), "logical slots have synced as true on standby" + + # Capture the inactive_since of the synced slot on the standby + inactive_since_on_standby = validate_slot_inactive_since( + standby1, "lsub1_slot", slot_creation_time_on_primary + ) + + # Synced slot on the standby must get its own inactive_since + assert ( + standby1.safe_sql( + f"SELECT '{inactive_since_on_primary}'::timestamptz < " + f"'{inactive_since_on_standby}'::timestamptz;" + ) + == "t" + ), "synchronized slot has got its own inactive_since" + + ################################################## + # Test that the synchronized slot will be dropped if the corresponding + # remote slot on the primary server has been dropped. + ################################################## + + primary.safe_sql("SELECT pg_drop_replication_slot('lsub2_slot');") + + standby1.safe_sql("SELECT pg_sync_replication_slots();") + + assert ( + standby1.safe_sql( + "SELECT count(*) = 0 FROM pg_replication_slots WHERE slot_name = 'lsub2_slot';" + ) + == "t" + ), "synchronized slot has been dropped" + + ################################################## + # Test that if the synchronized slot is invalidated while the remote slot is + # still valid, the slot will be dropped and re-created on the standby by + # executing pg_sync_replication_slots() again. + ################################################## + + # Configure the max_slot_wal_keep_size so that the synced slot can be + # invalidated due to wal removal. + standby1.append_conf("max_slot_wal_keep_size = 64kB\n") + standby1.reload() + + # Generate some activity and switch WAL file on the primary + primary.advance_wal(1) + primary.safe_sql("CHECKPOINT") + primary.wait_for_replay_catchup(standby1) + + # Request a checkpoint on the standby to trigger the WAL file(s) removal + standby1.safe_sql("CHECKPOINT") + + # Check if the synced slot is invalidated + assert ( + standby1.safe_sql( + "SELECT invalidation_reason = 'wal_removed' FROM pg_replication_slots " + "WHERE slot_name = 'lsub1_slot';" + ) + == "t" + ), "synchronized slot has been invalidated" + + # Reset max_slot_wal_keep_size to avoid further wal removal + standby1.append_conf("max_slot_wal_keep_size = -1\n") + standby1.reload() + + # Capture the time before the logical failover slot is created on the + # primary. + slot_creation_time_on_primary = publisher.safe_sql("SELECT current_timestamp;") + + # To ensure that restart_lsn has moved to a recent WAL position, we + # re-create the lsub1_slot. + primary.safe_sql("SELECT pg_drop_replication_slot('lsub1_slot');") + primary.safe_sql( + "SELECT pg_create_logical_replication_slot('lsub1_slot', 'pgoutput', false, false, true);" + ) + + # Capture the inactive_since of the slot from the primary. Note that the + # slot will be inactive since the corresponding subscription was dropped. + inactive_since_on_primary = validate_slot_inactive_since( + primary, "lsub1_slot", slot_creation_time_on_primary + ) + + # Wait for the standby to catch up so that the standby is not lagging behind + # the failover slots. + primary.wait_for_replay_catchup(standby1) + + log_offset = standby1.log_position() + + # Synchronize the primary server slots to the standby. + standby1.safe_sql("SELECT pg_sync_replication_slots();") + + # Confirm that the invalidated slot has been dropped. + standby1.wait_for_log( + r'dropped replication slot "lsub1_slot" of database with OID [0-9]+', + log_offset, + ) + + # Confirm that the logical slot has been re-created on the standby and is + # flagged as 'synced' + assert ( + standby1.safe_sql( + "SELECT invalidation_reason IS NULL AND synced AND NOT temporary FROM " + "pg_replication_slots WHERE slot_name = 'lsub1_slot';" + ) + == "t" + ), "logical slot is re-synced" + + # Reset the log_min_messages to the default value. + primary.append_conf("log_min_messages = 'warning'\n") + primary.reload() + + standby1.append_conf("log_min_messages = 'warning'\n") + standby1.reload() + + ################################################## + # Test that a synchronized slot can not be decoded, altered or dropped by + # the user + ################################################## + + # Attempting to perform logical decoding on a synced slot should error + expect_error( + standby1, + "select * from pg_logical_slot_get_changes('lsub1_slot', NULL, NULL);", + r'ERROR: cannot use replication slot "lsub1_slot" for logical decoding', + "logical decoding is not allowed on synced slot", + ) + + # Attempting to alter a synced slot should result in an error + expect_error( + standby1, + "ALTER_REPLICATION_SLOT lsub1_slot (failover);", + r'ERROR: cannot alter replication slot "lsub1_slot"', + "synced slot on standby cannot be altered", + replication="database", + ) + + # Attempting to drop a synced slot should result in an error + expect_error( + standby1, + "SELECT pg_drop_replication_slot('lsub1_slot');", + r'ERROR: cannot drop replication slot "lsub1_slot"', + "synced slot on standby cannot be dropped", + ) + + ################################################## + # Test that we cannot synchronize slots if dbname is not specified in the + # primary_conninfo. + ################################################## + + standby1.append_conf(f"primary_conninfo = '{connstr_1}{standby1_appname}'\n") + + # Capture the log position before reload to check for walreceiver + # termination. + log_offset = standby1.log_position() + + standby1.reload() + + # Wait for the walreceiver to be stopped and restarted after a + # configuration reload. When primary_conninfo changes, the walreceiver + # should be terminated and a new one spawned. + standby1.wait_for_log( + r"FATAL: .* terminating walreceiver process due to administrator command", + log_offset, + ) + + expect_error( + standby1, + "SELECT pg_sync_replication_slots();", + r'ERROR: replication slot synchronization requires "dbname" to be specified in "primary_conninfo"', + "cannot sync slots if dbname is not specified in primary_conninfo", + ) + + # Add the dbname back to the primary_conninfo for further tests + standby1.append_conf( + f"primary_conninfo = '{connstr_1} dbname=postgres{standby1_appname}'\n" + ) + standby1.reload() + + ################################################## + # Test that we cannot synchronize slots to a cascading standby server. + ################################################## + + # Create a cascading standby + standby1.backup("backup2") + + cascading_standby = create_pg("cascading_standby", start=False) + cascading_standby.init_from_backup( + standby1, "backup2", has_streaming=1, has_restoring=1 + ) + + cascading_connstr = f"port={standby1.port} host={standby1.host}" + cascading_standby.append_conf( + "hot_standby_feedback = on\n" + "primary_slot_name = 'cascading_sb_slot'\n" + f"primary_conninfo = '{cascading_connstr} dbname=postgres'\n" + ) + + standby1.safe_sql( + "SELECT pg_create_physical_replication_slot('cascading_sb_slot');" + ) + + cascading_standby.start() + + expect_error( + cascading_standby, + "SELECT pg_sync_replication_slots();", + r"ERROR: cannot synchronize replication slots from a standby server", + "cannot sync slots to a cascading standby server", + ) + + cascading_standby.stop() + + ################################################## + # Create a failover slot and advance the restart_lsn to a position where a + # running transaction exists. This setup is for testing that the synced + # slots can achieve the consistent snapshot state starting from the + # restart_lsn after promotion without losing any data that otherwise would + # have been received from the primary. + ################################################## + + primary.safe_sql( + "SELECT pg_create_logical_replication_slot('snap_test_slot', 'test_decoding', false, false, true);" + ) + + # Wait for the standby to catch up so that the standby is not lagging behind + # the failover slots. + primary.wait_for_replay_catchup(standby1) + + standby1.safe_sql("SELECT pg_sync_replication_slots();") + + # Two xl_running_xacts logs are generated here. When decoding the first + # log, it only serializes the snapshot, without advancing the restart_lsn + # to the latest position. This is because if a transaction is running, the + # restart_lsn can only move to a position before that transaction. Hence, + # the second xl_running_xacts log is needed, the decoding for which allows + # the restart_lsn to advance to the last serialized snapshot's position + # (the first log). + primary.safe_sql( + """ + BEGIN; + SELECT txid_current(); + SELECT pg_log_standby_snapshot(); + COMMIT; + BEGIN; + SELECT txid_current(); + SELECT pg_log_standby_snapshot(); + COMMIT; + """ + ) + + # Advance the restart_lsn to the position of the first xl_running_xacts log + # generated above. Note that there might be concurrent xl_running_xacts + # logs written by the bgwriter, which could cause the position to be + # advanced to an unexpected point, but that would be a rare scenario and + # doesn't affect the test results. + primary.safe_sql( + "SELECT pg_replication_slot_advance('snap_test_slot', pg_current_wal_lsn());" + ) + + # Wait for the standby to catch up so that the standby is not lagging behind + # the failover slots. + primary.wait_for_replay_catchup(standby1) + + # Log a message that will be consumed on the standby after promotion using + # the synced slot. See the test where we promote standby (Promote the + # standby1 to primary.) + primary.safe_sql("SELECT pg_logical_emit_message(false, 'test', 'test');") + + # Get the confirmed_flush_lsn for the logical slot snap_test_slot on primary + confirmed_flush_lsn = primary.safe_sql( + "SELECT confirmed_flush_lsn from pg_replication_slots WHERE slot_name = 'snap_test_slot';" + ) + + standby1.safe_sql("SELECT pg_sync_replication_slots();") + + # Verify that confirmed_flush_lsn of snap_test_slot is synced to the standby + assert standby1.poll_query_until( + f"SELECT '{confirmed_flush_lsn}' = confirmed_flush_lsn from " + "pg_replication_slots WHERE slot_name = 'snap_test_slot' AND synced AND " + "NOT temporary;" + ), "confirmed_flush_lsn of slot snap_test_slot synced to standby" + + ################################################## + # Test to confirm that the slot synchronization is protected from malicious + # users. + ################################################## + + primary.safe_sql("CREATE DATABASE slotsync_test_db") + primary.wait_for_replay_catchup(standby1) + + standby1.stop() + + # On the primary server, create '=' operator in another schema mapped to + # inequality function and redirect the queries to use new operator by + # setting search_path. The new '=' operator is created with leftarg as + # 'bigint' and right arg as 'int' to redirect 'count(*) = 1' in slot sync's + # query to use new '=' operator. + # Use a one-shot connection (closed immediately) so the primary has no + # lingering session on slotsync_test_db that would block the later DROP + # DATABASE. + setup_sess = primary.connect(dbname="slotsync_test_db") + try: + setup_sess.query_safe( + """ + +CREATE ROLE repl_role REPLICATION LOGIN; +CREATE SCHEMA myschema; + +CREATE FUNCTION myschema.myintne(bigint, int) RETURNS bool as $$ + BEGIN + RETURN $1 <> $2; + END; + $$ LANGUAGE plpgsql immutable; + +CREATE OPERATOR myschema.= ( + leftarg = bigint, + rightarg = int, + procedure = myschema.myintne); + +ALTER DATABASE slotsync_test_db SET SEARCH_PATH TO myschema,pg_catalog; +GRANT USAGE on SCHEMA myschema TO repl_role; +""" + ) + finally: + setup_sess.close() + + # Start the standby with changed primary_conninfo. + standby1.append_conf( + f"primary_conninfo = '{connstr_1} dbname=slotsync_test_db user=repl_role{standby1_appname}'\n" + ) + standby1.start() + + # Run the synchronization function. If the sync flow was not prepared to + # handle such attacks, it would have failed during the validation of the + # primary_slot_name itself resulting in + # ERROR: slot synchronization requires valid primary_slot_name + # Use a one-shot connection so standby1 keeps no cached session on the + # database we are about to drop. + sync_sess = standby1.connect(dbname="slotsync_test_db") + try: + sync_sess.query_safe("SELECT pg_sync_replication_slots();") + finally: + sync_sess.close() + + # Reset the dbname and user in primary_conninfo to the earlier values. + standby1.append_conf( + f"primary_conninfo = '{connstr_1} dbname=postgres{standby1_appname}'\n" + ) + standby1.reload() + + # Drop the newly created database. Wait for the standby's walreceiver to + # reconnect to the postgres database (after the reload above) so it no + # longer holds a connection to slotsync_test_db. + assert primary.poll_query_until( + "SELECT count(*) = 0 FROM pg_stat_activity WHERE datname = 'slotsync_test_db'" + ) + primary.safe_sql("DROP DATABASE slotsync_test_db;") + + ################################################## + # Test to confirm that the slot sync worker exits on invalid GUC(s) and + # get started again on valid GUC(s). + ################################################## + + log_offset = standby1.log_position() + + # Enable slot sync worker. + standby1.append_conf("sync_replication_slots = on\n") + standby1.reload() + + # Confirm that the slot sync worker is able to start. + standby1.wait_for_log(r"slot sync worker started", log_offset) + + log_offset = standby1.log_position() + + # Disable another GUC required for slot sync. + standby1.append_conf("hot_standby_feedback = off\n") + standby1.reload() + + # Confirm that slot sync worker acknowledge the GUC change and logs the msg + # about wrong configuration. + standby1.wait_for_log( + r"slot synchronization worker will restart because of a parameter change", + log_offset, + ) + standby1.wait_for_log( + r'slot synchronization requires "hot_standby_feedback" to be enabled', + log_offset, + ) + + log_offset = standby1.log_position() + + # Re-enable the required GUC + standby1.append_conf("hot_standby_feedback = on\n") + standby1.reload() + + # Confirm that the slot sync worker is able to start now. + standby1.wait_for_log(r"slot sync worker started", log_offset) + + ################################################## + # Test to confirm that confirmed_flush_lsn of the logical slot on the + # primary is synced to the standby via the slot sync worker. + ################################################## + + # Insert data on the primary + primary.safe_sql( + "CREATE TABLE tab_int (a int PRIMARY KEY);\n" + "INSERT INTO tab_int SELECT generate_series(1, 10);" + ) + + # Subscribe to the new table data and wait for it to arrive + subscriber1.safe_sql("CREATE TABLE tab_int (a int PRIMARY KEY);") + subscriber1.safe_sql( + f"CREATE SUBSCRIPTION regress_mysub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION regress_mypub WITH (slot_name = lsub1_slot, failover = true, " + "create_slot = false);" + ) + + subscriber1.wait_for_subscription_sync() + + # Do not allow any further advancement of the confirmed_flush_lsn for the + # lsub1_slot. + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 DISABLE") + + # Wait for the replication slot to become inactive on the publisher + assert primary.poll_query_until( + "SELECT COUNT(*) FROM pg_catalog.pg_replication_slots WHERE " + "slot_name = 'lsub1_slot' AND active='f'", + "1", + ) + + # Get the confirmed_flush_lsn for the logical slot lsub1_slot on the primary + primary_flush_lsn = primary.safe_sql( + "SELECT confirmed_flush_lsn from pg_replication_slots WHERE slot_name = 'lsub1_slot';" + ) + + # Confirm that confirmed_flush_lsn of lsub1_slot is synced to the standby + assert standby1.poll_query_until( + f"SELECT '{primary_flush_lsn}' = confirmed_flush_lsn from " + "pg_replication_slots WHERE slot_name = 'lsub1_slot' AND synced AND NOT " + "temporary;" + ), "confirmed_flush_lsn of slot lsub1_slot synced to standby" + + ################################################## + # Test that logical failover replication slots wait for the specified + # physical replication slots to receive the changes first. + # + # primary --(physical)--> standby1 (primary_slot_name = sb1_slot) + # --(physical)--> standby2 (primary_slot_name = sb2_slot) + # --(logical) --> subscriber1 (failover = true, lsub1_slot) + # --(logical) --> subscriber2 (failover = false, lsub2_slot) + # + # synchronized_standby_slots = 'sb1_slot' + ################################################## + + primary.safe_sql("SELECT pg_create_physical_replication_slot('sb2_slot');") + + primary.backup("backup3") + + # Create another standby + standby2 = create_pg("standby2", start=False) + standby2.init_from_backup(primary, "backup3", has_streaming=1, has_restoring=1) + standby2.append_conf("primary_slot_name = 'sb2_slot'\n") + standby2.start() + primary.wait_for_replay_catchup(standby2) + + # Configure primary to disallow any logical slots that have enabled failover + # from getting ahead of the specified physical replication slot (sb1_slot). + primary.append_conf("synchronized_standby_slots = 'sb1_slot'\n") + primary.reload() + + # Create another subscriber node without enabling failover, wait for sync + subscriber2 = create_pg("subscriber2") + subscriber2.safe_sql("CREATE TABLE tab_int (a int PRIMARY KEY);") + subscriber2.safe_sql( + f"CREATE SUBSCRIPTION regress_mysub2 CONNECTION '{publisher_connstr}' " + "PUBLICATION regress_mypub WITH (slot_name = lsub2_slot);" + ) + + subscriber2.wait_for_subscription_sync() + + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 ENABLE") + + offset = primary.log_position() + + # Stop the standby associated with the specified physical replication slot + # (sb1_slot) so that the logical replication slot (lsub1_slot) won't receive + # changes until the standby comes up. + standby1.stop() + + # Create some data on the primary + primary_row_count = 20 + primary.safe_sql( + f"INSERT INTO tab_int SELECT generate_series(11, {primary_row_count});" + ) + + # Wait until the standby2 that's still running gets the data from the primary + primary.wait_for_replay_catchup(standby2) + assert ( + standby2.safe_sql(f"SELECT count(*) = {primary_row_count} FROM tab_int;") == "t" + ), "standby2 gets data from primary" + + # Wait for regress_mysub2 to get the data from the primary. This + # subscription was not enabled for failover so it gets the data without + # waiting for any standbys. + primary.wait_for_catchup("regress_mysub2") + assert ( + subscriber2.safe_sql(f"SELECT count(*) = {primary_row_count} FROM tab_int;") + == "t" + ), "subscriber2 gets data from primary" + + # Wait until the primary server logs a warning indicating that it is waiting + # for the sb1_slot to catch up. + primary.wait_for_log( + r'replication slot "sb1_slot" specified in parameter "synchronized_standby_slots" does not have active_pid', + offset, + ) + + # The regress_mysub1 was enabled for failover so it doesn't get the data + # from primary and keeps waiting for the standby specified in + # synchronized_standby_slots (sb1_slot aka standby1). + assert ( + subscriber1.safe_sql(f"SELECT count(*) <> {primary_row_count} FROM tab_int;") + == "t" + ), "subscriber1 doesn't get data from primary until standby1 acknowledges changes" + + # Start the standby specified in synchronized_standby_slots (sb1_slot aka + # standby1) and wait for it to catch up with the primary. + standby1.start() + primary.wait_for_replay_catchup(standby1) + assert ( + standby1.safe_sql(f"SELECT count(*) = {primary_row_count} FROM tab_int;") == "t" + ), "standby1 gets data from primary" + + # Now that the standby specified in synchronized_standby_slots is up and + # running, the primary can send the decoded changes to the subscription + # enabled for failover (i.e. regress_mysub1). While the standby was down, + # regress_mysub1 didn't receive any data from the primary. i.e. the primary + # didn't allow it to go ahead of standby. + primary.wait_for_catchup("regress_mysub1") + assert ( + subscriber1.safe_sql(f"SELECT count(*) = {primary_row_count} FROM tab_int;") + == "t" + ), "subscriber1 gets data from primary after standby1 acknowledges changes" + + ################################################## + # Verify that when using pg_logical_slot_get_changes to consume changes from + # a logical failover slot, it will also wait for the slots specified in + # synchronized_standby_slots to catch up. + ################################################## + + # Stop the standby associated with the specified physical replication slot + # so that the logical replication slot won't receive changes until the + # standby slot's restart_lsn is advanced or the slot is removed from the + # synchronized_standby_slots list. + primary.safe_sql("TRUNCATE tab_int;") + primary.wait_for_catchup("regress_mysub1") + standby1.stop() + + # Disable the regress_mysub1 to prevent the logical walsender from + # generating more warnings. + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 DISABLE") + + # Wait for the replication slot to become inactive on the publisher + assert primary.poll_query_until( + "SELECT COUNT(*) FROM pg_catalog.pg_replication_slots WHERE " + "slot_name = 'lsub1_slot' AND active = 'f'", + "1", + ) + + # Create a logical 'test_decoding' replication slot with failover enabled + primary.safe_sql( + "SELECT pg_create_logical_replication_slot('test_slot', 'test_decoding', false, false, true);" + ) + + back_q = Session(connstr=primary.connstr(), libdir=primary.libdir) + + # pg_logical_slot_get_changes will be blocked until the standby catches up, + # hence it needs to be executed in a background session. + offset = primary.log_position() + assert back_q.do_async( + "SELECT pg_logical_slot_get_changes('test_slot', NULL, NULL);" + ) + + # Wait until the primary server logs a warning indicating that it is waiting + # for the sb1_slot to catch up. + primary.wait_for_log( + r'replication slot "sb1_slot" specified in parameter "synchronized_standby_slots" does not have active_pid', + offset, + ) + + # Remove the standby from the synchronized_standby_slots list and reload the + # configuration. + primary.append_conf("synchronized_standby_slots = ''\n") + primary.reload() + + # Since there are no slots in synchronized_standby_slots, the function + # pg_logical_slot_get_changes should now return, and the session can be + # stopped. + back_q.wait_for_completion() + back_q.close() + + primary.safe_sql("SELECT pg_drop_replication_slot('test_slot');") + + # Add the physical slot (sb1_slot) back to the synchronized_standby_slots + # for further tests. + primary.append_conf("synchronized_standby_slots = 'sb1_slot'\n") + primary.reload() + + # Enable the regress_mysub1 for further tests + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 ENABLE") + + ################################################## + # Test that logical replication will wait for the user-created inactive + # physical slot to catch up until we remove the slot from + # synchronized_standby_slots. + ################################################## + + offset = primary.log_position() + + # Create some data on the primary + primary_row_count = 10 + primary.safe_sql( + f"INSERT INTO tab_int SELECT generate_series(1, {primary_row_count});" + ) + + # Wait until the primary server logs a warning indicating that it is waiting + # for the sb1_slot to catch up. + primary.wait_for_log( + r'replication slot "sb1_slot" specified in parameter "synchronized_standby_slots" does not have active_pid', + offset, + ) + + # The regress_mysub1 doesn't get the data from primary because the specified + # standby slot (sb1_slot) in synchronized_standby_slots is inactive. + assert ( + subscriber1.safe_sql("SELECT count(*) = 0 FROM tab_int;") == "t" + ), "subscriber1 doesn't get data as the sb1_slot doesn't catch up" + + # Remove the standby from the synchronized_standby_slots list and reload the + # configuration. + primary.append_conf("synchronized_standby_slots = ''\n") + primary.reload() + + # Since there are no slots in synchronized_standby_slots, the primary server + # should now send the decoded changes to the subscription. + primary.wait_for_catchup("regress_mysub1") + assert ( + subscriber1.safe_sql(f"SELECT count(*) = {primary_row_count} FROM tab_int;") + == "t" + ), "subscriber1 gets data from primary after standby1 is removed from the synchronized_standby_slots list" + + # Add the physical slot (sb1_slot) back to the synchronized_standby_slots + # for further tests. + primary.append_conf("synchronized_standby_slots = 'sb1_slot'\n") + primary.reload() + + ################################################## + # Test the synchronization of the two_phase setting for a subscription with + # the standby. Additionally, prepare a transaction before enabling the + # two_phase option; subsequent tests will verify if it can be correctly + # replicated to the subscriber after committing it on the promoted standby. + ################################################## + + standby1.start() + + # Prepare a transaction + primary.safe_sql( + """ + BEGIN; + INSERT INTO tab_int values(0); + PREPARE TRANSACTION 'test_twophase_slotsync'; + """ + ) + + primary.wait_for_replay_catchup(standby1) + primary.wait_for_catchup("regress_mysub1") + + # Disable the subscription to allow changing the two_phase option. + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 DISABLE") + + # Wait for the replication slot to become inactive on the publisher + assert primary.poll_query_until( + "SELECT COUNT(*) FROM pg_catalog.pg_replication_slots WHERE " + "slot_name = 'lsub1_slot' AND active='f'", + "1", + ) + + # Set two_phase to true and enable the subscription + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 SET (two_phase = true);") + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 ENABLE;") + + primary.wait_for_catchup("regress_mysub1") + + two_phase_at = primary.safe_sql( + "SELECT two_phase_at from pg_replication_slots WHERE slot_name = 'lsub1_slot';" + ) + + # Confirm that two_phase setting of lsub1_slot is synced to the standby + assert standby1.poll_query_until( + f"SELECT two_phase AND '{two_phase_at}' = two_phase_at from " + "pg_replication_slots WHERE slot_name = 'lsub1_slot' AND synced AND NOT " + "temporary;" + ), "two_phase setting of slot lsub1_slot synced to standby" + + # Confirm that the prepared transaction is not yet replicated to the + # subscriber. + assert ( + subscriber1.safe_sql("SELECT count(*) = 0 FROM pg_prepared_xacts;") == "t" + ), "the prepared transaction is not replicated to the subscriber" + + ################################################## + # Promote the standby1 to primary. Confirm that: + # a) the slot 'lsub1_slot' and 'snap_test_slot' are retained on the new + # primary + # b) logical replication for regress_mysub1 is resumed after failover + # c) changes from the transaction prepared 'test_twophase_slotsync' can be + # consumed from the synced slot once committed on the new primary + # d) changes can be consumed from the synced slot 'snap_test_slot' + ################################################## + primary.wait_for_replay_catchup(standby1) + + # Capture the time before the standby is promoted + promotion_time_on_primary = standby1.safe_sql("SELECT current_timestamp;") + + standby1.promote() + + # Capture the inactive_since of the synced slot after the promotion. The + # expectation here is that the slot gets its inactive_since as part of the + # promotion. We do this check before the slot is enabled on the new primary + # below, otherwise, the slot gets active setting inactive_since to NULL. + inactive_since_on_new_primary = validate_slot_inactive_since( + standby1, "lsub1_slot", promotion_time_on_primary + ) + + assert ( + standby1.safe_sql( + f"SELECT '{inactive_since_on_new_primary}'::timestamptz > " + f"'{inactive_since_on_primary}'::timestamptz" + ) + == "t" + ), "synchronized slot has got its own inactive_since on the new primary after promotion" + + # Update subscription with the new primary's connection info + standby1_conninfo = f"host={standby1.host} port={standby1.port} dbname=postgres" + subscriber1.safe_sql( + f"ALTER SUBSCRIPTION regress_mysub1 CONNECTION '{standby1_conninfo}';" + ) + + # Confirm the synced slot 'lsub1_slot' is retained on the new primary + assert ( + standby1.safe_sql( + "SELECT count(*) = 2 FROM pg_replication_slots WHERE slot_name IN " + "('lsub1_slot', 'snap_test_slot') AND synced AND NOT temporary;" + ) + == "t" + ), "synced slot retained on the new primary" + + # Commit the prepared transaction + standby1.safe_sql("COMMIT PREPARED 'test_twophase_slotsync';") + standby1.wait_for_catchup("regress_mysub1") + + # Confirm that the prepared transaction is replicated to the subscriber + assert ( + subscriber1.safe_sql("SELECT count(*) FROM tab_int;") == "11" + ), "prepared data replicated from the new primary" + + # Insert data on the new primary + standby1.safe_sql("INSERT INTO tab_int SELECT generate_series(11, 20);") + standby1.wait_for_catchup("regress_mysub1") + + # Confirm that data in tab_int replicated on the subscriber + assert ( + subscriber1.safe_sql("SELECT count(*) FROM tab_int;") == "21" + ), "data replicated from the new primary" + + # Consume the data from the snap_test_slot. The synced slot should reach a + # consistent point by restoring the snapshot at the restart_lsn serialized + # during slot synchronization. + assert ( + standby1.safe_sql( + "SELECT count(*) FROM pg_logical_slot_get_changes('snap_test_slot', NULL, NULL) " + "WHERE data ~ 'message*';" + ) + == "1" + ), "data can be consumed using snap_test_slot" + + ################################################## + # Remove any unnecessary replication slots and clear pending transactions on + # the primary server to ensure a clean environment. + ################################################## + + primary.safe_sql("SELECT pg_drop_replication_slot('sb1_slot');") + primary.safe_sql("SELECT pg_drop_replication_slot('lsub1_slot');") + primary.safe_sql("SELECT pg_drop_replication_slot('snap_test_slot');") + + subscriber2.safe_sql("DROP SUBSCRIPTION regress_mysub2;") + subscriber1.safe_sql("DROP SUBSCRIPTION regress_mysub1;") + subscriber1.safe_sql("TRUNCATE tab_int;") + + # Remove the dropped sb1_slot from the synchronized_standby_slots list and + # reload the configuration. + primary.append_conf("synchronized_standby_slots = ''\n") + primary.reload() + + # Verify that all slots have been removed except the one necessary for + # standby2, which is needed for further testing. + assert ( + primary.safe_sql( + "SELECT count(*) = 0 FROM pg_replication_slots WHERE slot_name != 'sb2_slot';" + ) + == "t" + ), "all replication slots have been dropped except the physical slot used by standby2" + + # Commit the pending prepared transaction + primary.safe_sql("COMMIT PREPARED 'test_twophase_slotsync';") + primary.wait_for_replay_catchup(standby2) + + ################################################## + # Test that pg_sync_replication_slots() on the standby skips and retries + # until the slot becomes sync-ready (when the remote slot catches up with + # the locally reserved position). + # Also verify that slotsync skip statistics are correctly updated when the + # slotsync operation is skipped. + ################################################## + + # Recreate the slot by creating a subscription on the subscriber, keep it + # disabled. + subscriber1.safe_sql("CREATE TABLE push_wal (a int);") + subscriber1.safe_sql( + f"CREATE SUBSCRIPTION regress_mysub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION regress_mypub WITH (slot_name = lsub1_slot, failover = true, " + "enabled = false);" + ) + + # Create some DDL on the primary so that the slot lags behind the standby + primary.safe_sql("CREATE TABLE push_wal (a int);") + + # Make sure the DDL changes are synced to the standby + primary.wait_for_replay_catchup(standby2) + + log_offset = standby2.log_position() + + # Enable standby for slot synchronization + standby2.append_conf( + "hot_standby_feedback = on\n" + f"primary_conninfo = '{connstr_1} dbname=postgres application_name=standby2'\n" + "log_min_messages = 'debug2'\n" + ) + + standby2.reload() + + # Attempt to synchronize slots using API. The API will continue retrying + # synchronization until the remote slot catches up. The API will not return + # until this happens, to be able to make further calls, call the API in a + # background process. + h = Session(connstr=standby2.connstr(), libdir=standby2.libdir) + assert h.do_async("SELECT pg_sync_replication_slots();") + + # Confirm that the slot sync is skipped due to the remote slot lagging behind + standby2.wait_for_log( + r'could not synchronize replication slot "lsub1_slot"', log_offset + ) + + # Confirm that the slotsync skip reason is updated + assert ( + standby2.safe_sql( + "SELECT slotsync_skip_reason FROM pg_replication_slots WHERE slot_name = 'lsub1_slot'" + ) + == "wal_or_rows_removed" + ), "check slot sync skip reason" + + # Confirm that the slotsync skip statistics is updated + assert ( + standby2.safe_sql( + "SELECT slotsync_skip_count > 0 FROM pg_stat_replication_slots WHERE slot_name = 'lsub1_slot'" + ) + == "t" + ), "check slot sync skip count increments" + + # Configure primary to disallow any logical slots that have enabled failover + # from getting ahead of the specified physical replication slot (sb2_slot). + primary.append_conf("synchronized_standby_slots = 'sb2_slot'\n") + primary.reload() + + # Enable the Subscription, so that the remote slot catches up + subscriber1.safe_sql("ALTER SUBSCRIPTION regress_mysub1 ENABLE") + subscriber1.wait_for_subscription_sync() + + # Create xl_running_xacts on the primary to speed up restart_lsn advancement. + primary.safe_sql("SELECT pg_log_standby_snapshot();") + + # Confirm from the log that the slot is sync-ready now. + standby2.wait_for_log( + r'newly created replication slot "lsub1_slot" is sync-ready now', + log_offset, + ) + + h.wait_for_completion() + h.close() diff --git a/src/test/recovery/pyt/test_041_checkpoint_at_promote.py b/src/test/recovery/pyt/test_041_checkpoint_at_promote.py new file mode 100644 index 00000000000..1efc4a7b4aa --- /dev/null +++ b/src/test/recovery/pyt/test_041_checkpoint_at_promote.py @@ -0,0 +1,145 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test race condition when a restart point is running during a promotion, +checking that WAL segments are correctly removed in the restart point +while the promotion finishes. + +This test relies on an injection point that causes the checkpointer to +wait in the middle of a restart point on a standby. The checkpointer +is awaken to finish its restart point only once the promotion of the +standby is completed, and the node should be able to restart properly. +""" + +import os +import time + +import pytest + +from pypg.util import TIMEOUT_DEFAULT + + +def test_041_checkpoint_at_promote(create_pg): + # This test is gated on the enable_injection_points build flag. When that + # variable is unset, fall back to the actual capability: an + # injection-points build installs the injection_points extension, which + # check_extension below independently confirms. Either signal being + # present means injection points are usable. + node_primary = create_pg("master", allows_streaming=True) + node_primary.append_conf( + """ +log_checkpoints = on +restart_after_crash = on +""" + ) + node_primary.restart() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + injection_points_available = ( + node_primary.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + != "0" + ) + if ( + os.environ.get("enable_injection_points", "no") != "yes" + and not injection_points_available + ): + pytest.skip("Injection points not supported by this build") + if not injection_points_available: + pytest.skip("Extension injection_points not installed") + + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Setup a standby. + node_standby = create_pg("standby1", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.start() + + # Dummy table for the upcoming tests. + node_primary.safe_sql("checkpoint") + node_primary.safe_sql("CREATE TABLE prim_tab (a int);") + + # Register an injection point on the standby so as the follow-up + # restart point will wait on it. + node_primary.safe_sql("CREATE EXTENSION injection_points;") + # Wait until the extension has been created on the standby + node_primary.wait_for_replay_catchup(node_standby) + + # Note that from this point the checkpointer will wait in the middle of + # a restart point on the standby. + node_standby.safe_sql( + "SELECT injection_points_attach('create-restart-point', 'wait');" + ) + + # Execute a restart point on the standby, that we will now be waiting on. + # This needs to be in the background. + logstart = node_standby.log_position() + psql_session = node_standby.connect("postgres") + assert psql_session.do_async("CHECKPOINT;"), "failed to send CHECKPOINT" + + # Switch one WAL segment to make the previous restart point remove the + # segment once the restart point completes. + node_primary.safe_sql("INSERT INTO prim_tab VALUES (1);") + node_primary.safe_sql("SELECT pg_switch_wal();") + node_primary.wait_for_replay_catchup(node_standby) + + # Wait until the checkpointer is in the middle of the restart point + # processing. + node_standby.wait_for_event("checkpointer", "create-restart-point") + + # Check the logs that the restart point has started on standby. This is + # optional, but let's be sure. + assert node_standby.log_contains( + "restartpoint starting: fast wait", logstart + ), "restartpoint has started" + + # Trigger promotion during the restart point. + node_primary.stop() + node_standby.promote() + + # Update the start position before waking up the checkpointer! + logstart = node_standby.log_position() + + # Now wake up the checkpointer. + node_standby.safe_sql("SELECT injection_points_wakeup('create-restart-point');") + + # Wait until the previous restart point completes on the newly-promoted + # standby, checking the logs for that. + checkpoint_complete = False + for _ in range(10 * TIMEOUT_DEFAULT): + if node_standby.log_contains("restartpoint complete", logstart): + checkpoint_complete = True + break + time.sleep(0.1) + assert checkpoint_complete, "restart point has completed" + + # Done with the async CHECKPOINT session. + psql_session.close() + + # Kill a backend with SIGKILL, forcing all the backends to restart. + crash_logpos = node_standby.log_position() + killme = node_standby.connect("postgres") + pid = int(killme.query_oneval("SELECT pg_backend_pid()")) + node_standby.signal_backend(pid, "KILL") + killme.close() + + # Confirm the crash restart actually began before waiting for readiness: a + # single "SELECT 1" can be served by the postmaster before it notices the + # crash, so polling for one success can return while the server is about to + # (re-)enter recovery. "all server processes terminated; reinitializing" + # appears only on a crash restart, so it cannot match the pre-crash server. + node_standby.wait_for_log( + "all server processes terminated; reinitializing", crash_logpos + ) + + # Now wait until crash recovery finishes and the server accepts queries + # again. poll_query_until retries past the transient connection rejections + # during recovery ("the database system is not yet accepting connections", + # "... is in recovery mode"). + assert node_standby.poll_query_until( + "SELECT 1", expected="1" + ), "server did not finish restarting after crash" diff --git a/src/test/recovery/pyt/test_042_low_level_backup.py b/src/test/recovery/pyt/test_042_low_level_backup.py new file mode 100644 index 00000000000..b1cdfa2fd43 --- /dev/null +++ b/src/test/recovery/pyt/test_042_low_level_backup.py @@ -0,0 +1,130 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test low-level backup method by using pg_backup_start() and pg_backup_stop() +to create backups. +""" + +import os +import shutil + +from pypg.util import append_to_file, copy_live_tree + + +def test_042_low_level_backup(create_pg): + # Start primary node with archiving. + node_primary = create_pg( + "primary", start=False, has_archiving=True, allows_streaming=True + ) + node_primary.start() + + # Start backup. + backup_name = "backup1" + # The backup state is per-connection, so pg_backup_start() and + # pg_backup_stop() must run on the same persistent libpq session, which is + # kept open between the two calls. + psql = node_primary.connect() + + psql.do("SET client_min_messages TO WARNING") + psql.query_safe("select pg_backup_start('test label')") + + # Copy files. + backup_dir = os.path.join(node_primary.backup_dir, backup_name) + + # Copying a running primary's data dir races with the server (e.g. WAL + # archive_status flags come and go), so use a copy that tolerates files + # that disappear mid-copy. + copy_live_tree(node_primary.data_dir, backup_dir) + + # Cleanup some files/paths that should not be in the backup. There is no + # attempt to handle all the exclusions done by pg_basebackup here, in part + # because these are not required, but also to keep the test simple. + # + # Also remove pg_control because it needs to be copied later. + os.unlink(os.path.join(backup_dir, "postmaster.pid")) + os.unlink(os.path.join(backup_dir, "postmaster.opts")) + os.unlink(os.path.join(backup_dir, "global", "pg_control")) + + shutil.rmtree(os.path.join(backup_dir, "pg_wal")) + os.mkdir(os.path.join(backup_dir, "pg_wal")) + + # Create a table that will be used to verify that recovery started at the + # correct location, rather than a location recorded in the control file. + node_primary.safe_sql("create table canary (id int)") + + # Advance the checkpoint location in pg_control past the location where the + # backup started. Switch WAL to make it really clear that the location is + # different and to put the checkpoint in a new WAL segment. + segment_name = node_primary.safe_sql("select pg_walfile_name(pg_switch_wal())") + + # Ensure that the segment just switched from is archived. The follow-up + # tests depend on its presence to begin recovery. + assert node_primary.poll_query_until( + "SELECT last_archived_wal FROM pg_stat_archiver", segment_name + ), "Timed out while waiting for archiving of switched segment to finish" + + node_primary.safe_sql("checkpoint") + + # Copy pg_control last so it contains the new checkpoint. + shutil.copy( + os.path.join(node_primary.data_dir, "global", "pg_control"), + os.path.join(backup_dir, "global", "pg_control"), + ) + + # Save the name segment that will be archived by pg_backup_stop(). + # This is copied to the pg_wal directory of the node whose recovery + # is done without a backup_label. + stop_segment_name = node_primary.safe_sql( + "SELECT pg_walfile_name(pg_current_wal_lsn())" + ) + + # Stop backup and get backup_label, the last segment is archived. + backup_label = psql.query_oneval("select labelfile from pg_backup_stop()") + + psql.close() + + # Rather than writing out backup_label, try to recover the backup without + # backup_label to demonstrate that recovery will not work correctly without + # it, i.e. the canary table will be missing and the cluster will be + # corrupted. Provide only the WAL segment that recovery will think it + # needs. + # + # The point of this test is to explicitly demonstrate that backup_label is + # being used in a later test to get the correct recovery info. + node_replica = create_pg("replica_fail", start=False) + node_replica.init_from_backup(node_primary, backup_name) + node_replica.append_conf("archive_mode = off") + + canary_query = "select count(*) from pg_class where relname = 'canary'" + + shutil.copy( + os.path.join(node_primary.archive_dir, stop_segment_name), + os.path.join(node_replica.data_dir, "pg_wal", stop_segment_name), + ) + + node_replica.start() + + assert node_replica.safe_sql(canary_query) == "0", "canary is missing" + + # Check log to ensure that crash recovery was used as there is no + # backup_label. + assert node_replica.log_contains( + "database system was not properly shut down; automatic recovery in progress" + ), "verify backup recovery performed with crash recovery" + + node_replica.teardown() + + # Save backup_label into the backup directory and recover using the + # primary's archive. This time recovery will succeed and the canary table + # will be present. + append_to_file(os.path.join(backup_dir, "backup_label"), backup_label) + + node_replica = create_pg("replica_success", start=False) + node_replica.init_from_backup(node_primary, backup_name, has_restoring=True) + node_replica.start() + + assert node_replica.safe_sql(canary_query) == "1", "canary is present" + + # Check log to ensure that backup_label was used for recovery. + assert node_replica.log_contains( + "starting backup recovery with redo LSN" + ), "verify backup recovery performed with backup_label" diff --git a/src/test/recovery/pyt/test_043_no_contrecord_switch.py b/src/test/recovery/pyt/test_043_no_contrecord_switch.py new file mode 100644 index 00000000000..52367df9a62 --- /dev/null +++ b/src/test/recovery/pyt/test_043_no_contrecord_switch.py @@ -0,0 +1,121 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for already-propagated WAL segments ending in incomplete WAL records.""" + +import shutil + + +# Build name of a WAL segment, used when filtering the contents of the server +# logs. +def wal_segment_name(tli, segment): + return "%08X%08X%08X" % (tli, 0, segment) + + +# Calculate from a LSN (in bytes) its segment number and its offset, used +# when filtering the contents of the server logs. +def lsn_to_segment_and_offset(lsn, wal_segment_size): + return (lsn // wal_segment_size, lsn % wal_segment_size) + + +# Get GUC value, converted to an int. +def get_int_setting(node, name): + return int(node.safe_sql(f"SELECT setting FROM pg_settings WHERE name = '{name}'")) + + +# Find the start of a WAL page, based on an LSN in bytes. +def start_of_page(lsn, wal_block_size): + return lsn & ~(wal_block_size - 1) + + +def test_043_no_contrecord_switch(create_pg): + primary = create_pg( + "primary", start=False, allows_streaming=True, has_archiving=True + ) + + # The configuration is chosen here to minimize the friction with + # concurrent WAL activity. checkpoint_timeout avoids noise with + # checkpoint activity, and autovacuum is disabled to avoid any + # WAL activity generated by it. + primary.append_conf( + """ +autovacuum = off +checkpoint_timeout = '30min' +wal_keep_size = 1GB +""" + ) + + primary.start() + primary.backup("backup") + + primary.safe_sql("CREATE TABLE t AS SELECT 0") + + wal_segment_size = get_int_setting(primary, "wal_segment_size") + wal_block_size = get_int_setting(primary, "wal_block_size") + tli = int(primary.safe_sql("SELECT timeline_id FROM pg_control_checkpoint()")) + + # Get close to the end of the current WAL page, enough to fit the + # beginning of a record that spans on two pages, generating a + # continuation record. + primary.emit_wal(0) + end_lsn = primary.advance_wal_out_of_record_splitting_zone(wal_block_size) + + # Do some math to find the record size that will overflow the page, and + # write it. + overflow_size = wal_block_size - (end_lsn % wal_block_size) + end_lsn = primary.emit_wal(overflow_size) + primary.stop("immediate") + + # Find the beginning of the page with the continuation record and fill + # the entire page with zero bytes to simulate broken replication. + start_page = start_of_page(end_lsn, wal_block_size) + wal_file = primary.write_wal( + tli, start_page, wal_segment_size, b"\x00" * wal_block_size + ) + + # Copy the file we just "hacked" to the archives. + shutil.copy(wal_file, primary.archive_dir) + + # Start standby nodes and make sure they replay the file "hacked" from + # the archives of the primary. + standby1 = create_pg("standby1", start=False) + standby1.init_from_backup(primary, "backup", standby=True, has_restoring=True) + + standby2 = create_pg("standby2", start=False) + standby2.init_from_backup(primary, "backup", standby=True, has_restoring=True) + + log_size1 = standby1.log_position() + log_size2 = standby2.log_position() + + standby1.start() + standby2.start() + + segment, offset = lsn_to_segment_and_offset(start_page, wal_segment_size) + segment_name = wal_segment_name(tli, segment) + pattern = rf"invalid magic number 0000 .* segment {segment_name}.* offset {offset}" + + # We expect both standby nodes to complain about an empty page when trying + # to assemble the record that spans over two pages, so wait for such + # reports in their logs. + standby1.wait_for_log(pattern, log_size1) + standby2.wait_for_log(pattern, log_size2) + + # Now check the case of a promotion with a timeline jump handled at + # page boundary with a continuation record. + standby1.promote() + + # This command forces standby2 to read a continuation record from the page + # that is filled with zero bytes. + standby1.safe_sql("SELECT pg_switch_wal()") + + # Make sure WAL moves forward. + standby1.safe_sql("INSERT INTO t SELECT * FROM generate_series(1, 1000)") + + # Configure standby2 to stream from just promoted standby1 (it also pulls + # WAL files from the archive). It should be able to catch up. + standby2.enable_streaming(standby1) + standby2.reload() + standby1.wait_for_replay_catchup(standby2) + + result = standby2.safe_sql("SELECT count(*) FROM t") + print(f"standby2: {result}") + assert result == "1001", "check streamed content on standby2" diff --git a/src/test/recovery/pyt/test_044_invalidate_inactive_slots.py b/src/test/recovery/pyt/test_044_invalidate_inactive_slots.py new file mode 100644 index 00000000000..d03317f233d --- /dev/null +++ b/src/test/recovery/pyt/test_044_invalidate_inactive_slots.py @@ -0,0 +1,100 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test for replication slots invalidation due to idle_timeout.""" + +import pytest + +from libpq.errors import QueryError + + +def wait_for_slot_invalidation(node, slot_name, offset): + """Wait for slot to first become idle and then get invalidated.""" + # The slot's invalidation should be logged + node.wait_for_log(rf'invalidating obsolete replication slot "{slot_name}"', offset) + + # Check that the invalidation reason is 'idle_timeout' + if not node.poll_query_until( + f""" + SELECT COUNT(slot_name) = 1 FROM pg_replication_slots + WHERE slot_name = '{slot_name}' AND + invalidation_reason = 'idle_timeout'; + """ + ): + raise TimeoutError( + "Timed out while waiting for invalidation reason of slot " + f"{slot_name} to be set on node {node.name}" + ) + + +def test_044_invalidate_inactive_slots(create_pg): + # ==================================================================== + # Testcase start + # + # Test invalidation of physical replication slot and logical replication + # slot due to idle timeout. + + # Initialize the node + node = create_pg("node", allows_streaming="logical", start=False) + + # Avoid unpredictability + node.append_conf( + """ +checkpoint_timeout = 1h +idle_replication_slot_timeout = 1min +""" + ) + node.start() + + # This test depends on injection point that forces slot invalidation + # due to idle_timeout. Check if the 'injection_points' extension is + # available, as it may be possible that this script is run with + # installcheck, where the module would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + # Create both physical and logical replication slots + node.safe_sql( + """ + SELECT pg_create_physical_replication_slot(slot_name := 'physical_slot', immediately_reserve := true); + SELECT pg_create_logical_replication_slot('logical_slot', 'test_decoding'); +""" + ) + + log_offset = node.log_position() + + # Register an injection point on the node to forcibly cause a slot + # invalidation due to idle_timeout + node.safe_sql("CREATE EXTENSION injection_points;") + + node.safe_sql("SELECT injection_points_attach('slot-timeout-inval', 'error');") + + # Slot invalidation occurs during a checkpoint, so perform a checkpoint to + # invalidate the slots. + node.safe_sql("CHECKPOINT") + + # Wait for slots to become inactive. Since nobody has acquired the slot + # yet, it can only be due to the idle timeout mechanism. + wait_for_slot_invalidation(node, "physical_slot", log_offset) + wait_for_slot_invalidation(node, "logical_slot", log_offset) + + # Check that the invalidated slot cannot be acquired + sess = node.connect() + try: + with pytest.raises(QueryError) as excinfo: + sess.query_safe( + "SELECT pg_replication_slot_advance('logical_slot', '0/1');" + ) + assert 'can no longer access replication slot "logical_slot"' in str( + excinfo.value + ), "detected error upon trying to acquire invalidated slot on node" + finally: + sess.close() + + # Testcase end + # ===================================================================== diff --git a/src/test/recovery/pyt/test_045_archive_restartpoint.py b/src/test/recovery/pyt/test_045_archive_restartpoint.py new file mode 100644 index 00000000000..7115b7bb57c --- /dev/null +++ b/src/test/recovery/pyt/test_045_archive_restartpoint.py @@ -0,0 +1,50 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test restartpoints during archive recovery.""" + + +def test_045_archive_restartpoint(create_pg): + archive_max_mb = 320 + wal_segsize = 1 + + # Initialize primary node + node_primary = create_pg( + "primary", + start=False, + initdb_extra=["--wal-segsize", str(wal_segsize)], + has_archiving=True, + allows_streaming=True, + ) + node_primary.start() + backup_name = "my_backup" + node_primary.backup(backup_name) + + node_primary.safe_sql( + "DO $$BEGIN FOR i IN 1.." + + str(archive_max_mb // wal_segsize) + + " LOOP CHECKPOINT; PERFORM pg_switch_wal(); END LOOP; END$$;" + ) + + # Force archiving of WAL file containing recovery target + until_lsn = node_primary.lsn("write") + node_primary.safe_sql("SELECT pg_switch_wal()") + node_primary.stop() + + # Archive recovery + node_restore = create_pg("restore", start=False) + node_restore.init_from_backup(node_primary, backup_name, has_restoring=True) + node_restore.append_conf(f"recovery_target_lsn = '{until_lsn}'") + node_restore.append_conf("recovery_target_action = pause") + node_restore.append_conf(f"max_wal_size = {2 * wal_segsize}") + node_restore.append_conf("log_checkpoints = on") + + node_restore.start() + + # Wait until restore has replayed enough data + caughtup_query = f"SELECT '{until_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + assert node_restore.poll_query_until( + caughtup_query + ), "Timed out while waiting for restore to catch up" + + node_restore.stop() + assert True, "restore caught up" diff --git a/src/test/recovery/pyt/test_046_checkpoint_logical_slot.py b/src/test/recovery/pyt/test_046_checkpoint_logical_slot.py new file mode 100644 index 00000000000..095d5e7db74 --- /dev/null +++ b/src/test/recovery/pyt/test_046_checkpoint_logical_slot.py @@ -0,0 +1,216 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""This test verifies the case when the logical slot is advanced during +checkpoint. The test checks that the logical slot's restart_lsn still refers +to an existed WAL segment after immediate restart. +""" + +import pytest + + +def test_046_checkpoint_logical_slot(create_pg): + node = create_pg("mike", start=False, allows_streaming="logical") + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION injection_points") + + # Create the two slots we'll need. + node.safe_sql( + "select pg_create_logical_replication_slot('slot_logical', 'test_decoding')" + ) + node.safe_sql("select pg_create_physical_replication_slot('slot_physical', true)") + + # Advance both slots to the current position just to have everything + # "valid". + node.safe_sql( + "select count(*) from pg_logical_slot_get_changes('slot_logical', " + "null, null)" + ) + node.safe_sql( + "select pg_replication_slot_advance('slot_physical', pg_current_wal_lsn())" + ) + + # Run checkpoint to flush current state to disk and set a baseline. + node.safe_sql("checkpoint") + + # Generate some transactions to get RUNNING_XACTS. + for _ in range(10): + node.safe_sql("SELECT 1") + + node.advance_wal(20) + + # Run another checkpoint to set a new restore LSN. + node.safe_sql("checkpoint") + + node.advance_wal(20) + + # Run another checkpoint, this time in the background, and make it wait + # on the injection point so that the checkpoint stops right before + # removing old WAL segments. + print("# starting checkpoint") + + node.safe_sql( + "select injection_points_attach('checkpoint-before-old-wal-removal', 'wait')" + ) + checkpoint = node.connect("postgres") + checkpoint.do_async("CHECKPOINT;") + + # Wait until the checkpoint stops right before removing WAL segments. + print("# waiting for injection_point") + node.wait_for_event("checkpointer", "checkpoint-before-old-wal-removal") + print("# injection_point is reached") + + # Try to advance the logical slot, but make it stop when it moves to the + # next WAL segment (this has to happen in the background, too). + # We need to call pg_logical_slot_get_changes repeatedly until the slot + # advances to the next segment and hits the injection point. + logical = node.connect("postgres") + logical.do( + "select injection_points_attach(" + "'logical-replication-slot-advance-segment', 'wait');" + ) + logical.do_async( + "DO $$\n" + "BEGIN\n" + "\tLOOP\n" + "\t\tPERFORM count(*) FROM " + "pg_logical_slot_get_changes('slot_logical', null, null);\n" + "\t\tPERFORM pg_sleep(0.1);\n" + "\tEND LOOP;\n" + "END $$;" + ) + + # Wait until the slot's restart_lsn points to the next WAL segment. + print("# waiting for injection_point") + node.wait_for_event("client backend", "logical-replication-slot-advance-segment") + print("# injection_point is reached") + + # OK, we're in the right situation: time to advance the physical slot, which + # recalculates the required LSN, and then unblock the checkpoint, which + # removes the WAL still needed by the logical slot. + node.safe_sql( + "select pg_replication_slot_advance('slot_physical', pg_current_wal_lsn())" + ) + + # Generate a long WAL record, spawning at least two pages for the follow-up + # post-recovery check. + node.safe_sql( + "select pg_logical_emit_message(false, '', repeat('123456789', 1000))" + ) + + # Continue the checkpoint and wait for its completion. + log_offset = node.log_position() + node.safe_sql("select injection_points_wakeup('checkpoint-before-old-wal-removal')") + node.wait_for_log(r"checkpoint complete", log_offset) + + # Abruptly stop the server. + node.stop("immediate") + + node.start() + + # Logical slot should still be valid after the crash restart: reading from + # it must not raise (its restart_lsn must refer to an existing WAL segment). + node.safe_sql( + "select count(*) from pg_logical_slot_get_changes('slot_logical', " + "null, null);" + ) + + # Sessions were terminated by the server crash; close them so the framework + # does not try to reuse the dead connections. + checkpoint.close() + logical.close() + + # Verify that the synchronized slots won't be invalidated immediately after + # synchronization in the presence of a concurrent checkpoint. + primary = node + + primary.append_conf("autovacuum = off") + primary.reload() + + backup_name = "backup" + + primary.backup(backup_name) + + # Create a standby + standby = create_pg("standby", start=False) + standby.init_from_backup(primary, backup_name, has_streaming=1) + + # PostgresServer.connstr() quotes values and adds dbname, which would break + # embedding inside primary_conninfo = '...'. Build an unquoted conninfo. + connstr_1 = f"port={primary.port} host={primary.host}" + standby.append_conf( + "hot_standby_feedback = on\n" + "primary_slot_name = 'phys_slot'\n" + f"primary_conninfo = '{connstr_1} dbname=postgres'\n" + ) + + primary.safe_sql( + "SELECT pg_create_logical_replication_slot('failover_slot', " + "'test_decoding', false, false, true);" + ) + primary.safe_sql("SELECT pg_create_physical_replication_slot('phys_slot');") + + standby.start() + + # Generate some activity and switch WAL file on the primary + primary.advance_wal(1) + primary.safe_sql("CHECKPOINT") + primary.wait_for_replay_catchup(standby) + + # checkpoint on the standby and make it wait on the injection point so that + # the checkpoint stops right before invalidating replication slots. + print("# starting checkpoint") + + standby.safe_sql( + "select injection_points_attach(" + "'restartpoint-before-slot-invalidation', 'wait')" + ) + standby_checkpoint = standby.connect("postgres") + standby_checkpoint.do_async("CHECKPOINT;") + + # Wait until the checkpoint stops right before invalidating slots + print("# waiting for injection_point") + standby.wait_for_event("checkpointer", "restartpoint-before-slot-invalidation") + print("# injection_point is reached") + + # Enable slot sync worker to synchronize the failover slot to the standby + standby.append_conf("sync_replication_slots = on") + standby.reload() + + # Wait for the slot to be synced + assert standby.poll_query_until( + "SELECT COUNT(*) > 0 FROM pg_replication_slots " + "WHERE slot_name = 'failover_slot'" + ) + + # Release the checkpointer + standby.safe_sql( + "select injection_points_wakeup('restartpoint-before-slot-invalidation')" + ) + standby.safe_sql( + "select injection_points_detach('restartpoint-before-slot-invalidation')" + ) + + standby_checkpoint.wait_for_completion() + standby_checkpoint.close() + + # Confirm that the slot is not invalidated + assert ( + standby.safe_sql( + "SELECT invalidation_reason IS NULL AND synced " + "FROM pg_replication_slots WHERE slot_name = 'failover_slot';" + ) + == "t" + ), "logical slot is not invalidated" diff --git a/src/test/recovery/pyt/test_047_checkpoint_physical_slot.py b/src/test/recovery/pyt/test_047_checkpoint_physical_slot.py new file mode 100644 index 00000000000..5f8708bb1d4 --- /dev/null +++ b/src/test/recovery/pyt/test_047_checkpoint_physical_slot.py @@ -0,0 +1,122 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""This test verifies the case when the physical slot is advanced during +checkpoint. The test checks that the physical slot's restart_lsn still refers +to an existed WAL segment after immediate restart. +""" + +import os + +import pytest + + +def test_047_checkpoint_physical_slot(create_pg): + node = create_pg("mike", start=False) + node.append_conf("wal_level = 'replica'") + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION injection_points") + + # Create a physical replication slot. + node.safe_sql("select pg_create_physical_replication_slot('slot_physical', true)") + + # Advance slot to the current position, just to have everything "valid". + node.safe_sql( + "select pg_replication_slot_advance('slot_physical', pg_current_wal_lsn())" + ) + + # Run checkpoint to flush current state to disk and set a baseline. + node.safe_sql("checkpoint") + + node.advance_wal(20) + + # Advance slot to the current position, just to have everything "valid". + node.safe_sql( + "select pg_replication_slot_advance('slot_physical', pg_current_wal_lsn())" + ) + + # Run another checkpoint to set a new restore LSN. + node.safe_sql("checkpoint") + + node.advance_wal(20) + + restart_lsn_init = node.safe_sql( + "select restart_lsn from pg_replication_slots " + "where slot_name = 'slot_physical'" + ).strip() + print(f"# restart lsn before checkpoint: {restart_lsn_init}") + + # Run another checkpoint, this time in the background, and make it wait + # on the injection point so that the checkpoint stops right before + # removing old WAL segments. + print("# starting checkpoint") + + checkpoint = node.connect("postgres") + checkpoint.do( + "select injection_points_attach('checkpoint-before-old-wal-removal', 'wait')" + ) + checkpoint.do_async("checkpoint") + + # Wait until the checkpoint stops right before removing WAL segments. + print("# waiting for injection_point") + node.wait_for_event("checkpointer", "checkpoint-before-old-wal-removal") + print("# injection_point is reached") + + # OK, we're in the right situation: time to advance the physical slot, which + # recalculates the required LSN and then unblock the checkpoint, which + # removes the WAL still needed by the physical slot. + node.safe_sql( + "select pg_replication_slot_advance('slot_physical', pg_current_wal_lsn())" + ) + + # Continue the checkpoint and wait for its completion. + log_offset = node.log_position() + node.safe_sql("select injection_points_wakeup('checkpoint-before-old-wal-removal')") + node.wait_for_log(r"checkpoint complete", log_offset) + + restart_lsn_old = node.safe_sql( + "select restart_lsn from pg_replication_slots " + "where slot_name = 'slot_physical'" + ).strip() + print(f"# restart lsn before stop: {restart_lsn_old}") + + checkpoint.wait_for_completion() + checkpoint.close() + + # Abruptly stop the server (1 second should be enough for the checkpoint + # to finish; it would be better). + node.stop("immediate") + + node.start() + + # Get the restart_lsn of the slot right after restarting. + restart_lsn = node.safe_sql( + "select restart_lsn from pg_replication_slots " + "where slot_name = 'slot_physical'" + ).strip() + print(f"# restart lsn: {restart_lsn}") + + # Get the WAL segment name for the slot's restart_lsn. + restart_lsn_segment = node.safe_sql( + f"SELECT pg_walfile_name('{restart_lsn}'::pg_lsn)" + ).strip() + + # Check if the required wal segment exists. + print(f"# required by slot segment name: {restart_lsn_segment}") + datadir = node.data_dir + assert os.path.isfile(os.path.join(datadir, "pg_wal", restart_lsn_segment)), ( + f"WAL segment {restart_lsn_segment} for physical slot's restart_lsn " + f"{restart_lsn} exists" + ) diff --git a/src/test/recovery/pyt/test_048_vacuum_horizon_floor.py b/src/test/recovery/pyt/test_048_vacuum_horizon_floor.py new file mode 100644 index 00000000000..90537b39a23 --- /dev/null +++ b/src/test/recovery/pyt/test_048_vacuum_horizon_floor.py @@ -0,0 +1,317 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test that vacuum prunes away all dead tuples killed before OldestXmin. + +This test creates a table on a primary, updates the table to generate dead +tuples for vacuum, and then, during the vacuum, uses the replica to force +GlobalVisState->maybe_needed on the primary to move backwards and precede +the value of OldestXmin set at the beginning of vacuuming the table. +""" + +import re + + +def test_048_vacuum_horizon_floor(create_pg): + # Set up nodes + node_primary = create_pg("primary", start=False, allows_streaming="physical") + + # io_combine_limit is set to 1 to avoid pinning more than one buffer at a + # time to ensure test determinism. + node_primary.append_conf( + """ +hot_standby_feedback = on +autovacuum = off +log_min_messages = INFO +maintenance_work_mem = 64 +io_combine_limit = 1 +""" + ) + node_primary.start() + + node_replica = create_pg("standby", start=False) + + node_primary.backup("my_backup") + node_replica.init_from_backup(node_primary, "my_backup", has_streaming=True) + + node_replica.start() + + test_db = "test_db" + node_primary.safe_sql(f"CREATE DATABASE {test_db}") + + # Save the original connection info for later use + orig_conninfo = node_primary.connstr() + + table1 = "vac_horizon_floor_table" + + # Long-running Primary Session A + session_primaryA = node_primary.connect(test_db) + + # Long-running Primary Session B + session_primaryB = node_primary.connect(test_db) + + try: + # Our test relies on two rounds of index vacuuming for reasons + # elaborated later. To trigger two rounds of index vacuuming, we must + # fill up the TidStore with dead items partway through a vacuum of the + # table. The number of rows is just enough to ensure we exceed + # maintenance_work_mem on all supported platforms, while keeping test + # runtime as short as we can. + nrows = 2000 + + # Because vacuum's first pass, pruning, is where we use the + # GlobalVisState to check tuple visibility, GlobalVisState->maybe_needed + # must move backwards during pruning before checking the visibility for + # a tuple which would have been considered HEAPTUPLE_DEAD prior to + # maybe_needed moving backwards but HEAPTUPLE_RECENTLY_DEAD compared to + # the new, older value of maybe_needed. + # + # We must not only force the horizon on the primary to move backwards + # but also force the vacuuming backend's GlobalVisState to be updated. + # GlobalVisState is forced to update during index vacuuming. + # + # _bt_pendingfsm_finalize() calls GetOldestNonRemovableTransactionId() + # at the end of a round of index vacuuming, updating the backend's + # GlobalVisState and, in our case, moving maybe_needed backwards. + # + # Then vacuum's first (pruning) pass will continue and pruning will find + # our later inserted and updated tuple HEAPTUPLE_RECENTLY_DEAD when + # compared to maybe_needed but HEAPTUPLE_DEAD when compared to + # OldestXmin. + # + # Thus, we must force at least two rounds of index vacuuming to ensure + # that some tuple visibility checks will happen after a round of index + # vacuuming. To accomplish this, we set maintenance_work_mem to its + # minimum value and insert and delete enough rows that we force at least + # one round of index vacuuming before getting to a dead tuple which was + # killed after the standby is disconnected. + node_primary.safe_sql( + f""" + CREATE TABLE {table1}(col1 int) + WITH (autovacuum_enabled=false, fillfactor=10); + INSERT INTO {table1} VALUES(7); + INSERT INTO {table1} SELECT generate_series(1, {nrows}) % 3; + CREATE INDEX on {table1}(col1); + DELETE FROM {table1} WHERE col1 = 0; + INSERT INTO {table1} VALUES(7); + """, + dbname=test_db, + ) + + # We will later move the primary forward while the standby is + # disconnected. For now, however, there is no reason not to wait for the + # standby to catch up. + primary_lsn = node_primary.lsn("flush") + node_primary.wait_for_catchup(node_replica, "replay", primary_lsn) + + # Test that the WAL receiver is up and running. + assert node_replica.poll_query_until( + "SELECT EXISTS (SELECT * FROM pg_stat_wal_receiver);", + expected="t", + dbname=test_db, + ) + + # Set primary_conninfo to something invalid on the replica and reload + # the config. Once the config is reloaded, the startup process will + # force the WAL receiver to restart and it will be unable to reconnect + # because of the invalid connection information. + # + # psql runs each statement in its own implicit transaction, but the + # in-process Session wraps a multi-statement string in one transaction + # block. ALTER SYSTEM cannot run inside a transaction block, so issue + # the statements separately to match psql semantics. + node_replica.safe_sql("ALTER SYSTEM SET primary_conninfo = '';", dbname=test_db) + node_replica.safe_sql("SELECT pg_reload_conf();", dbname=test_db) + + # Wait until the WAL receiver has shut down and been unable to start up + # again. + assert node_replica.poll_query_until( + "SELECT EXISTS (SELECT * FROM pg_stat_wal_receiver);", + expected="f", + dbname=test_db, + ) + + # Now insert and update a tuple which will be visible to the vacuum on + # the primary but which will have xmax newer than the oldest xmin on the + # standby that was recently disconnected. + res = session_primaryA.query( + f""" + INSERT INTO {table1} VALUES (99); + UPDATE {table1} SET col1 = 100 WHERE col1 = 99; + SELECT 'after_update'; + """ + ) + + # Make sure the UPDATE finished + assert re.search( + r"^after_update$", res.psqlout, re.M + ), "UPDATE occurred on primary session A" + + # Open a cursor on the primary whose pin will keep VACUUM from getting a + # cleanup lock on the first page of the relation. We want VACUUM to be + # able to start, calculate initial values for OldestXmin and + # GlobalVisState and then be unable to proceed with pruning our dead + # tuples. This will allow us to reconnect the standby and push the + # horizon back before we start actual pruning and vacuuming. + primary_cursor1 = "vac_horizon_floor_cursor1" + + # The first value inserted into the table was a 7, so FETCH FORWARD + # should return a 7. That's how we know the cursor has a pin. + # Disable index scans so the cursor pins heap pages and not index pages. + res = session_primaryB.query( + f""" + BEGIN; + SET enable_bitmapscan = off; + SET enable_indexscan = off; + SET enable_indexonlyscan = off; + DECLARE {primary_cursor1} CURSOR FOR SELECT * FROM {table1} WHERE col1 = 7; + FETCH {primary_cursor1}; + """ + ) + + assert ( + res.psqlout == "7" + ), f"Cursor query returned {res.psqlout}. Expected value 7." + + # Get the PID of the session which will run the VACUUM FREEZE so that we + # can use it to filter pg_stat_activity later. + vacuum_pid = session_primaryA.query_oneval("SELECT pg_backend_pid();") + + # Now start a VACUUM FREEZE on the primary. It will call + # vacuum_get_cutoffs() and establish values of OldestXmin and + # GlobalVisState which are newer than all of our dead tuples. Then it + # will be unable to get a cleanup lock to start pruning, so it will hang. + # + # We use VACUUM FREEZE because it will wait for a cleanup lock instead of + # skipping the page pinned by the cursor. Note that works because the + # target tuple's xmax precedes OldestXmin which ensures that + # lazy_scan_noprune() will return false and we will wait for the cleanup + # lock. + # + # Disable any prefetching, parallelism, or other concurrent I/O by + # vacuum. The pages of the heap must be processed in order by a single + # worker to ensure test stability (PARALLEL 0 shouldn't be necessary but + # guards against the possibility of parallel heap vacuuming). + session_primaryA.do("SET maintenance_io_concurrency = 0;") + assert session_primaryA.do_async( + f"VACUUM (VERBOSE, FREEZE, PARALLEL 0) {table1};" + ) + + # Make sure that the VACUUM has already called vacuum_get_cutoffs() and + # is just waiting on the lock to start vacuuming. We don't want the + # standby to re-establish a connection to the primary and push the + # horizon back until we've saved initial values in GlobalVisState and + # calculated OldestXmin. + assert node_primary.poll_query_until( + f""" + SELECT count(*) >= 1 FROM pg_stat_activity + WHERE pid = {vacuum_pid} + AND wait_event = 'BufferCleanup'; + """, + expected="t", + dbname=test_db, + ) + + # Ensure the WAL receiver is still not active on the replica. + assert node_replica.poll_query_until( + "SELECT EXISTS (SELECT * FROM pg_stat_wal_receiver);", + expected="f", + dbname=test_db, + ) + + # Allow the WAL receiver connection to re-establish. Issue the + # statements separately (see note above) so ALTER SYSTEM does not run + # inside a transaction block. + # connstr() embeds single quotes (host='...' dbname='...'); double + # them so the whole thing survives as one SQL string literal. + escaped_conninfo = orig_conninfo.replace("'", "''") + node_replica.safe_sql( + f"ALTER SYSTEM SET primary_conninfo = '{escaped_conninfo}';", + dbname=test_db, + ) + node_replica.safe_sql("SELECT pg_reload_conf();", dbname=test_db) + + # Ensure the new WAL receiver has connected. + assert node_replica.poll_query_until( + "SELECT EXISTS (SELECT * FROM pg_stat_wal_receiver);", + expected="t", + dbname=test_db, + ) + + # Once the WAL sender is shown on the primary, the replica should have + # connected with the primary and pushed the horizon backward. Primary + # Session A won't see that until the VACUUM FREEZE proceeds and does its + # first round of index vacuuming. + assert node_primary.poll_query_until( + "SELECT EXISTS (SELECT * FROM pg_stat_replication);", + expected="t", + dbname=test_db, + ) + + # Move the cursor forward to the next 7. We inserted the 7 much later, + # so advancing the cursor should allow vacuum to proceed vacuuming most + # pages of the relation. Because we set maintenance_work_mem + # sufficiently low, we expect that a round of index vacuuming has + # happened and that the vacuum is now waiting for the cursor to release + # its pin on the last page of the relation. + res = session_primaryB.query_oneval(f"FETCH {primary_cursor1}") + assert ( + res == "7" + ), f"Cursor query returned {res} from second fetch. Expected value 7." + + # Prevent the test from incorrectly passing by confirming that we did + # indeed do a pass of index vacuuming. + assert node_primary.poll_query_until( + f""" + SELECT index_vacuum_count > 0 + FROM pg_stat_progress_vacuum + WHERE datname='{test_db}' AND relid::regclass = '{table1}'::regclass; + """, + expected="t", + dbname=test_db, + ) + + # Commit the transaction with the open cursor so that the VACUUM can + # finish. + session_primaryB.do("COMMIT") + + # VACUUM proceeds with pruning and does a visibility check on each tuple. + # In older versions of Postgres, pruning found our final dead tuple + # non-removable (HEAPTUPLE_RECENTLY_DEAD) since its xmax is after the new + # value of maybe_needed. Then heap_prepare_freeze_tuple() would decide + # the tuple xmax should be frozen because it precedes OldestXmin. Vacuum + # would then error out in heap_pre_freeze_checks() with "cannot freeze + # committed xmax". This was fixed by changing pruning to find all + # HEAPTUPLE_RECENTLY_DEAD tuples with xmaxes preceding OldestXmin + # HEAPTUPLE_DEAD and removing them. + + # Collect the VACUUM's async result so it does not error out. + vacuum_res = session_primaryA.get_async_result() + assert vacuum_res is not None + assert vacuum_res.error_message is None, vacuum_res.error_message + + # With the fix, VACUUM should finish successfully, incrementing the + # table vacuum_count. + assert node_primary.poll_query_until( + f""" + SELECT vacuum_count > 0 + FROM pg_stat_all_tables WHERE relname = '{table1}'; + """, + expected="t", + dbname=test_db, + ) + + primary_lsn = node_primary.lsn("flush") + + # Make sure something causes us to flush + node_primary.safe_sql(f"INSERT INTO {table1} VALUES (1);", dbname=test_db) + + # Nothing on the replica should cause a recovery conflict, so this should + # finish successfully. + node_primary.wait_for_catchup(node_replica, "replay", primary_lsn) + finally: + ## Shut down sessions + session_primaryA.close() + session_primaryB.close() + + node_replica.stop() + node_primary.stop() diff --git a/src/test/recovery/pyt/test_049_wait_for_lsn.py b/src/test/recovery/pyt/test_049_wait_for_lsn.py new file mode 100644 index 00000000000..8a559b0ef7e --- /dev/null +++ b/src/test/recovery/pyt/test_049_wait_for_lsn.py @@ -0,0 +1,1022 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Checks waiting for the LSN using the WAIT FOR command. Tests standby modes +(standby_replay/standby_write/standby_flush) on the standby and primary_flush +mode on the primary. + +All SQL runs in-process through libpq Sessions (no psql subprocess). Backends +that block in a WAIT/replay-wait call use a dedicated node.connect() session and +the async API (do_async/get_async_result/wait_for_completion). +""" + +import re + + +# Saved primary_conninfo across stop_walreceiver()/resume_walreceiver(). +_saved_primary_conninfo = None + + +def _stop_walreceiver(node): + """Stop the walreceiver on *node* by clearing primary_conninfo. + + Waits until pg_stat_wal_receiver becomes empty. Used to freeze the + walreceiver-tracked positions (writtenUpto, flushedUpto) so a fencepost + test can rely on them not advancing. The previous value is saved for + _resume_walreceiver(). + """ + global _saved_primary_conninfo # pylint: disable=global-statement + _saved_primary_conninfo = node.safe_sql( + "SELECT pg_catalog.quote_literal(setting) " + "FROM pg_settings WHERE name = 'primary_conninfo';" + ) + node.safe_sql("ALTER SYSTEM SET primary_conninfo = '';") + node.safe_sql("SELECT pg_reload_conf();") + assert node.poll_query_until( + "SELECT NOT EXISTS (SELECT * FROM pg_stat_wal_receiver);" + ) + + +def _resume_walreceiver(node): + """Restart the walreceiver on *node* by restoring primary_conninfo. + + Restores the value captured by _stop_walreceiver() and waits until the + walreceiver reconnects. Must be paired with a prior _stop_walreceiver(). + """ + node.safe_sql(f"ALTER SYSTEM SET primary_conninfo = {_saved_primary_conninfo};") + node.safe_sql("SELECT pg_reload_conf();") + assert node.poll_query_until("SELECT EXISTS (SELECT * FROM pg_stat_wal_receiver);") + + +def _check_wait_for_lsn_fencepost(node, mode, current_lsn, label): + """Verify the wait predicate "target <= currentLSN" at the boundary. + + Given *current_lsn* (the frozen position for *mode*), check that: + target == current -> success (predicate is <=) + target == current - 1 -> success + target == current + 1 -> timeout + Returns (lsn_minus, lsn_plus) so the caller can reuse them. + """ + lsn_minus = node.safe_sql(f"SELECT ('{current_lsn}'::pg_lsn - 1)::text") + lsn_plus = node.safe_sql(f"SELECT ('{current_lsn}'::pg_lsn + 1)::text") + + for target_lsn, expected, desc, timeout in ( + (current_lsn, "success", "target == current succeeds", "5s"), + (lsn_minus, "success", "target == current - 1 succeeds", "5s"), + (lsn_plus, "timeout", "target == current + 1 times out", "500ms"), + ): + output = node.safe_sql( + f"WAIT FOR LSN '{target_lsn}' " + f"WITH (MODE '{mode}', timeout '{timeout}', no_throw);" + ) + assert output == expected, f"{label}: {desc}" + + return lsn_minus, lsn_plus + + +def test_049_wait_for_lsn(create_pg): + # Initialize primary node + node_primary = create_pg("primary", allows_streaming=True) + + # And some content and take a backup + node_primary.safe_sql("CREATE TABLE wait_test AS SELECT generate_series(1,10) AS a") + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Create a streaming standby with a 1 second delay from the backup + node_standby = create_pg("standby", start=False) + delay = 1 + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.append_conf( + f""" +recovery_min_apply_delay = '{delay}s' +""" + ) + node_standby.start() + + # 1. Make sure that WAIT FOR works: add new content to primary and memorize + # primary's insert LSN, then wait for that LSN to be replayed on standby. + node_primary.safe_sql("INSERT INTO wait_test VALUES (generate_series(11, 20))") + lsn1 = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn1}' WITH (timeout '1d');\n" + f"SELECT pg_lsn_cmp(pg_last_wal_replay_lsn(), '{lsn1}'::pg_lsn);" + ) + + # Make sure the current LSN on standby is at least as big as the LSN we + # observed on primary's before. + assert ( + int(output.split("\n")[-1]) >= 0 + ), "standby reached the same LSN as primary after WAIT FOR" + + # 2. Check that new data is visible after calling WAIT FOR + node_primary.safe_sql("INSERT INTO wait_test VALUES (generate_series(21, 30))") + lsn2 = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn2}';\n" "SELECT count(*) FROM wait_test;" + ) + + # Make sure the count(*) on standby reflects the recent changes on primary + assert output.split("\n")[-1] == "30", "standby reached the same LSN as primary" + + # 3. Check that WAIT FOR works with standby_write, standby_flush, and + # primary_flush modes. + node_primary.safe_sql("INSERT INTO wait_test VALUES (generate_series(31, 40))") + lsn_write = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn_write}' WITH (MODE 'standby_write', timeout '1d');\n" + "SELECT pg_lsn_cmp((SELECT written_lsn FROM pg_stat_wal_receiver), " + f"'{lsn_write}'::pg_lsn);" + ) + assert ( + int(output.split("\n")[-1]) >= 0 + ), "standby wrote WAL up to target LSN after WAIT FOR with MODE 'standby_write'" + + node_primary.safe_sql("INSERT INTO wait_test VALUES (generate_series(41, 50))") + lsn_flush = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn_flush}' WITH (MODE 'standby_flush', timeout '1d');\n" + f"SELECT pg_lsn_cmp(pg_last_wal_receive_lsn(), '{lsn_flush}'::pg_lsn);" + ) + assert ( + int(output.split("\n")[-1]) >= 0 + ), "standby flushed WAL up to target LSN after WAIT FOR with MODE 'standby_flush'" + + # Check primary_flush mode on primary + node_primary.safe_sql("INSERT INTO wait_test VALUES (generate_series(51, 60))") + lsn_primary_flush = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + output = node_primary.safe_sql( + f"WAIT FOR LSN '{lsn_primary_flush}' " + "WITH (MODE 'primary_flush', timeout '1d');\n" + "SELECT pg_lsn_cmp(pg_current_wal_flush_lsn(), " + f"'{lsn_primary_flush}'::pg_lsn);" + ) + assert ( + int(output.split("\n")[-1]) >= 0 + ), "primary flushed WAL up to target LSN after WAIT FOR with MODE 'primary_flush'" + + # 4. Check that waiting for unreachable LSN triggers the timeout. The + # unreachable LSN must be well in advance. So WAL records issued by the + # concurrent autovacuum could not affect that. + lsn3 = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn() + 10000000000") + node_standby.safe_sql(f"WAIT FOR LSN '{lsn2}' WITH (timeout '10ms');") + res = node_standby.sql(f"WAIT FOR LSN '{lsn3}' WITH (timeout '1000ms');") + assert res.error_message is not None + assert re.search( + r"timed out while waiting for target LSN", res.error_message + ), "get timeout on waiting for unreachable LSN" + + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn2}' WITH (timeout '0.1s', no_throw);" + ) + assert ( + output == "success" + ), "WAIT FOR returns correct status after successful waiting" + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn3}' WITH (timeout '10ms', no_throw);" + ) + assert output == "timeout", "WAIT FOR returns correct status after timeout" + + # 4a. Check that aborting a subtransaction during WAIT FOR LSN cleans up the + # shared wait-state. Poll pg_stat_activity before canceling the first WAIT + # FOR to ensure that the backend has registered itself in the waiters heap. + # After rolling back to the savepoint, a second WAIT FOR in the same backend + # must be able to register itself again. + subxact_lsn = node_primary.safe_sql( + "SELECT pg_current_wal_insert_lsn() + 10000000000" + ) + subxact_appname = "wait_for_lsn_subxact_cleanup" + subxact_session = node_primary.connect("postgres") + try: + # Send the setup statements individually so the first WAIT FOR LSN can + # be issued asynchronously: it blocks (the target LSN is unreachable) + # and will be canceled below. + subxact_session.do(f"SET application_name = '{subxact_appname}'") + subxact_session.do("BEGIN") + subxact_session.do("SAVEPOINT wait_cleanup") + subxact_session.do_async( + f"WAIT FOR LSN '{subxact_lsn}' WITH (MODE 'primary_flush')" + ) + assert node_primary.poll_query_until( + "SELECT count(*) = 1 FROM pg_stat_activity " + f"WHERE application_name = '{subxact_appname}' " + "AND wait_event = 'WaitForWalFlush'" + ), "WAIT FOR LSN did not enter the primary_flush wait path" + subxact_cancelled = node_primary.safe_sql( + "SELECT pg_cancel_backend(pid) FROM pg_stat_activity " + f"WHERE application_name = '{subxact_appname}' " + "AND wait_event = 'WaitForWalFlush'" + ) + assert subxact_cancelled == "t", "canceled WAIT FOR LSN in subtransaction" + + # The cancel interrupts the blocking WAIT FOR LSN, leaving the + # transaction in an aborted state. + subxact_cancel_res = subxact_session.get_async_result() + assert subxact_cancel_res.error_message is not None + assert re.search( + r"canceling statement due to user request", subxact_cancel_res.error_message + ), "query cancel interrupted WAIT FOR LSN in subtransaction" + + # Roll back to the savepoint so a second WAIT FOR LSN can register again + # in the same backend; with no_throw it returns 'timeout' rather than + # erroring. + subxact_session.do("ROLLBACK TO wait_cleanup") + subxact_timeout = subxact_session.query_oneval( + f"WAIT FOR LSN '{subxact_lsn}' " + "WITH (MODE 'primary_flush', timeout '10ms', no_throw)" + ) + assert ( + subxact_timeout == "timeout" + ), "second WAIT FOR LSN timed out after savepoint rollback" + + # The backend survived the cancel without disconnecting: the connection + # is still usable. + assert ( + subxact_session.query_oneval("SELECT 1") == "1" + ), "WAIT FOR LSN after savepoint rollback did not disconnect" + subxact_session.do("COMMIT") + finally: + subxact_session.close() + + # 5. Check mode validation: standby modes error on primary, primary mode + # errors on standby, and primary_flush works on primary. Also check that + # WAIT FOR triggers an error if called within a function, procedure, + # anonymous DO block, or inside a transaction with an isolation level higher + # than READ COMMITTED. + + # Test standby_flush on primary - should error + res = node_primary.sql(f"WAIT FOR LSN '{lsn3}' WITH (MODE 'standby_flush');") + assert res.error_message and re.search( + r"recovery is not in progress", res.error_message + ), "get an error when running standby_flush on the primary" + + # Test primary_flush on standby - should error + res = node_standby.sql(f"WAIT FOR LSN '{lsn3}' WITH (MODE 'primary_flush');") + assert res.error_message and re.search( + r"recovery is in progress", res.error_message + ), "get an error when running primary_flush on the standby" + + res = node_standby.sql( + "BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1; " f"WAIT FOR LSN '{lsn3}';" + ) + assert res.error_message and re.search( + r"WAIT FOR must be called without an active or registered snapshot", + res.error_message, + ), "get an error when running in a transaction with an isolation level higher than REPEATABLE READ" + + # Test wrapping WAIT FOR into function, procedure, and anonymous DO block -- + # should error + node_primary.safe_sql( + """ +CREATE FUNCTION pg_wal_replay_wait_wrap(target_lsn pg_lsn) RETURNS void AS $$ + BEGIN + EXECUTE format('WAIT FOR LSN %L;', target_lsn); + END +$$ +LANGUAGE plpgsql; + +CREATE PROCEDURE pg_wal_replay_wait_proc(target_lsn pg_lsn) AS $$ + BEGIN + EXECUTE format('WAIT FOR LSN %L;', target_lsn); + END +$$ +LANGUAGE plpgsql; +""" + ) + + node_primary.wait_for_catchup(node_standby) + res = node_standby.sql(f"SELECT pg_wal_replay_wait_wrap('{lsn3}');") + assert res.error_message and re.search( + r"WAIT FOR can only be executed as a top-level statement", res.error_message + ), "get an error when running within a function" + + res = node_standby.sql(f"CALL pg_wal_replay_wait_proc('{lsn3}');") + assert res.error_message and re.search( + r"WAIT FOR can only be executed as a top-level statement", res.error_message + ), "get an error when running within a procedure" + + res = node_standby.sql( + f"DO $$ BEGIN EXECUTE format('WAIT FOR LSN %L;', '{lsn3}'); END $$;" + ) + assert res.error_message and re.search( + r"WAIT FOR can only be executed as a top-level statement", res.error_message + ), "get an error when running within a DO block" + + # 6. Check parameter validation error cases on standby before promotion + test_lsn = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + + # Test negative timeout + res = node_standby.sql(f"WAIT FOR LSN '{test_lsn}' WITH (timeout '-1000ms');") + assert res.error_message and re.search( + r"timeout cannot be negative", res.error_message + ), "get error for negative timeout" + + # Test unknown parameter with WITH clause + res = node_standby.sql(f"WAIT FOR LSN '{test_lsn}' WITH (unknown_param 'value');") + assert res.error_message and re.search( + r'option "unknown_param" not recognized', res.error_message + ), "get error for unknown parameter" + + # Test duplicate TIMEOUT parameter with WITH clause + res = node_standby.sql( + f"WAIT FOR LSN '{test_lsn}' WITH (timeout '1000', timeout '2000');" + ) + assert res.error_message and re.search( + r"conflicting or redundant options", res.error_message + ), "get error for duplicate TIMEOUT parameter" + + # Test duplicate NO_THROW parameter with WITH clause + res = node_standby.sql(f"WAIT FOR LSN '{test_lsn}' WITH (no_throw, no_throw);") + assert res.error_message and re.search( + r"conflicting or redundant options", res.error_message + ), "get error for duplicate NO_THROW parameter" + + # Test syntax error - options without WITH keyword + res = node_standby.sql(f"WAIT FOR LSN '{test_lsn}' (timeout '100ms');") + assert res.error_message and re.search( + r"syntax error", res.error_message + ), "get syntax error when options specified without WITH keyword" + + # Test syntax error - missing LSN + res = node_standby.sql("WAIT FOR TIMEOUT 1000;") + assert res.error_message and re.search( + r"syntax error", res.error_message + ), "get syntax error for missing LSN" + + # Test invalid LSN format + res = node_standby.sql("WAIT FOR LSN 'invalid_lsn';") + assert res.error_message and re.search( + r"invalid input syntax for type pg_lsn", res.error_message + ), "get error for invalid LSN format" + + # Test invalid timeout format + res = node_standby.sql(f"WAIT FOR LSN '{test_lsn}' WITH (timeout 'invalid');") + assert res.error_message and re.search( + r"invalid timeout value", res.error_message + ), "get error for invalid timeout format" + + # Test new WITH clause syntax + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn2}' WITH (timeout '0.1s', no_throw);" + ) + assert output == "success", "WAIT FOR WITH clause syntax works correctly" + + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn3}' WITH (timeout 100, no_throw);" + ) + assert output == "timeout", "WAIT FOR WITH clause returns correct timeout status" + + # Test WITH clause error case - invalid option + res = node_standby.sql(f"WAIT FOR LSN '{test_lsn}' WITH (invalid_option 'value');") + assert res.error_message and re.search( + r'option "invalid_option" not recognized', res.error_message + ), "get error for invalid WITH clause option" + + # Test invalid MODE value + res = node_standby.sql(f"WAIT FOR LSN '{test_lsn}' WITH (MODE 'invalid');") + assert res.error_message and re.search( + r'unrecognized value for WAIT option "mode": "invalid"', res.error_message + ), "get error for invalid MODE value" + + # Test duplicate MODE parameter + res = node_standby.sql( + f"WAIT FOR LSN '{test_lsn}' " + "WITH (MODE 'standby_replay', MODE 'standby_write');" + ) + assert res.error_message and re.search( + r"conflicting or redundant options", res.error_message + ), "get error for duplicate MODE parameter" + + # 7a. Check the scenario of multiple standby_replay waiters. We make 5 + # background sessions each waiting for a corresponding insertion. When + # waiting is finished, stored procedures log if there are as many visible + # rows as should be. + node_primary.safe_sql( + """ +CREATE FUNCTION log_count(i int) RETURNS void AS $$ + DECLARE + count int; + BEGIN + SELECT count(*) FROM wait_test INTO count; + IF count >= 31 + i THEN + RAISE LOG 'count %', i; + END IF; + END +$$ +LANGUAGE plpgsql; + +CREATE FUNCTION log_wait_done(prefix text, i int) RETURNS void AS $$ + BEGIN + RAISE LOG '% %', prefix, i; + END +$$ +LANGUAGE plpgsql; +""" + ) + + node_standby.safe_sql("SELECT pg_wal_replay_pause();") + + psql_sessions = [] + for i in range(5): + node_primary.safe_sql(f"INSERT INTO wait_test VALUES ({i});") + lsn = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + sess = node_standby.connect("postgres") + psql_sessions.append(sess) + sess.do_async(f"WAIT FOR LSN '{lsn}';\n" f"SELECT log_count({i});") + + log_offset = node_standby.log_position() + node_standby.safe_sql("SELECT pg_wal_replay_resume();") + for i in range(5): + node_standby.wait_for_log(f"count {i}", log_offset) + psql_sessions[i].wait_for_completion() + psql_sessions[i].close() + + # multiple standby_replay waiters reported consistent data + + # 7b. Check the scenario of multiple standby_write waiters. + # Stop walreceiver to ensure waiters actually block. + _stop_walreceiver(node_standby) + + # Generate WAL on primary (standby won't receive it yet) + write_lsns = [] + for i in range(5): + node_primary.safe_sql(f"INSERT INTO wait_test VALUES (100 + {i});") + write_lsns.append(node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()")) + + # Start standby_write waiters (they will block since walreceiver is stopped) + write_sessions = [] + for i in range(5): + sess = node_standby.connect("postgres") + write_sessions.append(sess) + sess.do_async( + f"WAIT FOR LSN '{write_lsns[i]}' " + "WITH (MODE 'standby_write', timeout '1d');\n" + f"SELECT log_wait_done('write_done', {i});" + ) + + # Verify waiters are blocked + assert node_standby.poll_query_until( + "SELECT count(*) = 5 FROM pg_stat_activity " + "WHERE wait_event = 'WaitForWalWrite'" + ) + + # Restore walreceiver to unblock waiters + write_log_offset = node_standby.log_position() + _resume_walreceiver(node_standby) + + # Wait for all waiters to complete and close sessions + for i in range(5): + node_standby.wait_for_log(f"write_done {i}", write_log_offset) + write_sessions[i].wait_for_completion() + write_sessions[i].close() + + # Verify on standby that WAL was written up to the target LSN + output = node_standby.safe_sql( + "SELECT pg_lsn_cmp((SELECT written_lsn FROM pg_stat_wal_receiver), " + f"'{write_lsns[4]}'::pg_lsn);" + ) + assert ( + int(output) >= 0 + ), "multiple standby_write waiters: standby wrote WAL up to target LSN" + + # 7c. Check the scenario of multiple standby_flush waiters. + # Stop walreceiver to ensure waiters actually block. + _stop_walreceiver(node_standby) + + # Generate WAL on primary (standby won't receive it yet) + flush_lsns = [] + for i in range(5): + node_primary.safe_sql(f"INSERT INTO wait_test VALUES (200 + {i});") + flush_lsns.append(node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()")) + + # Start standby_flush waiters (they will block since walreceiver is stopped) + flush_sessions = [] + for i in range(5): + sess = node_standby.connect("postgres") + flush_sessions.append(sess) + sess.do_async( + f"WAIT FOR LSN '{flush_lsns[i]}' " + "WITH (MODE 'standby_flush', timeout '1d');\n" + f"SELECT log_wait_done('flush_done', {i});" + ) + + # Verify waiters are blocked + assert node_standby.poll_query_until( + "SELECT count(*) = 5 FROM pg_stat_activity " + "WHERE wait_event = 'WaitForWalFlush'" + ) + + # Restore walreceiver to unblock waiters + flush_log_offset = node_standby.log_position() + _resume_walreceiver(node_standby) + + # Wait for all waiters to complete and close sessions + for i in range(5): + node_standby.wait_for_log(f"flush_done {i}", flush_log_offset) + flush_sessions[i].wait_for_completion() + flush_sessions[i].close() + + # Verify on standby that WAL was flushed up to the target LSN + output = node_standby.safe_sql( + "SELECT pg_lsn_cmp(pg_last_wal_receive_lsn(), " f"'{flush_lsns[4]}'::pg_lsn);" + ) + assert ( + int(output) >= 0 + ), "multiple standby_flush waiters: standby flushed WAL up to target LSN" + + # 7d. Check the scenario of mixed standby mode waiters (standby_replay, + # standby_write, standby_flush) running concurrently. We start 6 sessions: + # 2 for each mode, all waiting for the same target LSN. We stop the + # walreceiver and pause replay to ensure all waiters block. Then we resume + # replay and restart the walreceiver to verify they unblock and complete + # correctly. + + # Stop walreceiver first to ensure we can control the flow without hanging + # (stopping it after pausing replay can hang if the startup process is + # paused). + _stop_walreceiver(node_standby) + + # Pause replay + node_standby.safe_sql("SELECT pg_wal_replay_pause();") + + # Generate WAL on primary + node_primary.safe_sql("INSERT INTO wait_test VALUES (generate_series(301, 310));") + mixed_target_lsn = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + + # Start 6 waiters: 2 for each mode + mixed_sessions = [] + mixed_modes = ("standby_replay", "standby_write", "standby_flush") + for i in range(6): + sess = node_standby.connect("postgres") + mixed_sessions.append(sess) + sess.do_async( + f"WAIT FOR LSN '{mixed_target_lsn}' " + f"WITH (MODE '{mixed_modes[i % 3]}', timeout '1d');\n" + f"SELECT log_wait_done('mixed_done', {i});" + ) + + # Verify all waiters are blocked + assert node_standby.poll_query_until( + "SELECT count(*) = 6 FROM pg_stat_activity " + "WHERE wait_event LIKE 'WaitForWal%'" + ) + + # Resume replay (waiters should still be blocked as no WAL has arrived) + mixed_log_offset = node_standby.log_position() + node_standby.safe_sql("SELECT pg_wal_replay_resume();") + assert node_standby.poll_query_until("SELECT NOT pg_is_wal_replay_paused();") + + # Restore walreceiver to allow WAL to arrive + _resume_walreceiver(node_standby) + + # Wait for all sessions to complete and close them + for i in range(6): + node_standby.wait_for_log(f"mixed_done {i}", mixed_log_offset) + mixed_sessions[i].wait_for_completion() + mixed_sessions[i].close() + + # Verify all modes reached the target LSN + output = node_standby.safe_sql( + "SELECT pg_lsn_cmp((SELECT written_lsn FROM pg_stat_wal_receiver), " + f"'{mixed_target_lsn}'::pg_lsn) >= 0 AND " + f"pg_lsn_cmp(pg_last_wal_receive_lsn(), '{mixed_target_lsn}'::pg_lsn) >= 0 AND " + f"pg_lsn_cmp(pg_last_wal_replay_lsn(), '{mixed_target_lsn}'::pg_lsn) >= 0;" + ) + assert ( + output == "t" + ), "mixed mode waiters: all modes completed and reached target LSN" + + # 7e. Check the scenario of multiple primary_flush waiters on primary. + # We start 5 background sessions waiting for different LSNs with + # primary_flush mode. Each waiter logs when done. + primary_flush_lsns = [] + for i in range(5): + node_primary.safe_sql(f"INSERT INTO wait_test VALUES (400 + {i});") + primary_flush_lsns.append( + node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + ) + + primary_flush_log_offset = node_primary.log_position() + + # Start primary_flush waiters + primary_flush_sessions = [] + for i in range(5): + sess = node_primary.connect("postgres") + primary_flush_sessions.append(sess) + sess.do_async( + f"WAIT FOR LSN '{primary_flush_lsns[i]}' " + "WITH (MODE 'primary_flush', timeout '1d');\n" + f"SELECT log_wait_done('primary_flush_done', {i});" + ) + + # The WAL should already be flushed, so waiters should complete quickly + for i in range(5): + node_primary.wait_for_log(f"primary_flush_done {i}", primary_flush_log_offset) + primary_flush_sessions[i].wait_for_completion() + primary_flush_sessions[i].close() + + # Verify on primary that WAL was flushed up to the target LSN + output = node_primary.safe_sql( + "SELECT pg_lsn_cmp(pg_current_wal_flush_lsn(), " + f"'{primary_flush_lsns[4]}'::pg_lsn);" + ) + assert ( + int(output) >= 0 + ), "multiple primary_flush waiters: primary flushed WAL up to target LSN" + + # 8. Check that the standby promotion terminates all standby wait modes. + # Start waiting for unreachable LSNs with standby_replay, standby_write, and + # standby_flush modes, then promote. Check the log for the relevant error + # messages. Also, check that waiting for already replayed LSN doesn't cause + # an error even after promotion. + lsn4 = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn() + 10000000000") + lsn5 = node_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + + # Start background sessions waiting for unreachable LSN with all modes + wait_modes = ("standby_replay", "standby_write", "standby_flush") + wait_sessions = [] + for i in range(3): + sess = node_standby.connect("postgres") + wait_sessions.append(sess) + sess.do_async(f"WAIT FOR LSN '{lsn4}' WITH (MODE '{wait_modes[i]}');") + + # Make sure standby will be promoted at least at the primary insert LSN we + # have just observed. Use pg_switch_wal() to force the insert LSN to be + # written then wait for standby to catchup. + node_primary.safe_sql("SELECT pg_switch_wal();") + node_primary.wait_for_catchup(node_standby) + + log_offset = node_standby.log_position() + node_standby.promote() + + # Wait for all three sessions to get the error (each mode has distinct + # message) + node_standby.wait_for_log( + r"Recovery ended before target LSN.*was written", log_offset + ) + node_standby.wait_for_log( + r"Recovery ended before target LSN.*was flushed", log_offset + ) + node_standby.wait_for_log( + r"Recovery ended before target LSN.*was replayed", log_offset + ) + + # promotion interrupted all wait modes + for sess in wait_sessions: + sess.close() + + node_standby.safe_sql(f"WAIT FOR LSN '{lsn5}';") + # wait for already replayed LSN exits immediately even after promotion + + output = node_standby.safe_sql( + f"WAIT FOR LSN '{lsn4}' WITH (timeout '10ms', no_throw);" + ) + assert ( + output == "not in recovery" + ), "WAIT FOR returns correct status after standby promotion" + + node_standby.stop() + node_primary.stop() + + # Sessions will be cleaned up automatically when they go out of scope. + + # 9. Archive-only standby tests: verify standby_write/standby_flush work + # without a walreceiver. These exercise the replay-position floor in + # GetCurrentLSNForWaitType(). + # + # We set up a separate primary with archiving and an archive-only standby + # (has_restoring, no has_streaming), so no walreceiver ever starts and the + # shared walreceiver positions (writtenUpto, flushedUpto) stay at their + # zero-initialized values. + + arc_primary = create_pg("arc_primary", has_archiving=True, allows_streaming=True) + + arc_primary.safe_sql("CREATE TABLE arc_test AS SELECT generate_series(1,10) AS a") + + arc_backup_name = "arc_backup" + arc_primary.backup(arc_backup_name) + + # Generate WAL that will be archived and replayed on the standby. + arc_primary.safe_sql("INSERT INTO arc_test VALUES (generate_series(11, 20))") + arc_target_lsn = arc_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + + # Force WAL to be archived by switching segments, then wait for archiving. + arc_segment = arc_primary.safe_sql("SELECT pg_walfile_name(pg_current_wal_lsn())") + arc_primary.safe_sql("SELECT pg_switch_wal()") + assert arc_primary.poll_query_until( + f"SELECT last_archived_wal >= '{arc_segment}' FROM pg_stat_archiver" + ), "Timed out waiting for WAL archiving on arc_primary" + + # Create an archive-only standby: has_restoring but NOT has_streaming. + # No primary_conninfo means no walreceiver will start. + arc_standby = create_pg("arc_standby", start=False) + arc_standby.init_from_backup(arc_primary, arc_backup_name, has_restoring=True) + arc_standby.start() + + # Wait for the standby to replay past our target LSN via archive recovery. + assert arc_standby.poll_query_until( + f"SELECT pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '{arc_target_lsn}') >= 0" + ), "Timed out waiting for archive replay on arc_standby" + + # Sanity: verify no walreceiver is running. + output = arc_standby.safe_sql("SELECT count(*) FROM pg_stat_wal_receiver") + assert output == "0", "arc_standby has no walreceiver" + + # 9a. Getter fallback: standby_write/standby_flush succeed immediately when + # the target LSN has already been replayed, even though writtenUpto and + # flushedUpto are zero. GetCurrentLSNForWaitType() returns + # Max(walrcv_pos, replay), so replay >= target satisfies the check on the + # first loop iteration without ever sleeping. + output = arc_standby.safe_sql( + f"WAIT FOR LSN '{arc_target_lsn}' " + "WITH (MODE 'standby_write', timeout '3s', no_throw);" + ) + assert ( + output == "success" + ), "standby_write succeeds on archive-only standby (getter fallback)" + + output = arc_standby.safe_sql( + f"WAIT FOR LSN '{arc_target_lsn}' " + "WITH (MODE 'standby_flush', timeout '3s', no_throw);" + ) + assert ( + output == "success" + ), "standby_flush succeeds on archive-only standby (getter fallback)" + + # 9b. Replay waker: standby_write/standby_flush waiters that go to sleep + # (target > replay at entry) are woken when replay catches up. This tests + # that PerformWalRecovery() calls WaitLSNWakeup for STANDBY_WRITE and + # STANDBY_FLUSH, not just STANDBY_REPLAY. + # + # Pause replay, archive more WAL, start background waiters, then resume + # replay and verify the waiters complete. + arc_standby.safe_sql("SELECT pg_wal_replay_pause()") + + # Generate more WAL and archive it. + arc_primary.safe_sql("INSERT INTO arc_test VALUES (generate_series(21, 30))") + arc_target_lsn2 = arc_primary.safe_sql("SELECT pg_current_wal_insert_lsn()") + + arc_segment2 = arc_primary.safe_sql("SELECT pg_walfile_name(pg_current_wal_lsn())") + arc_primary.safe_sql("SELECT pg_switch_wal()") + assert arc_primary.poll_query_until( + f"SELECT last_archived_wal >= '{arc_segment2}' FROM pg_stat_archiver" + ), "Timed out waiting for WAL archiving on arc_primary (round 2)" + + # Start background waiters. With replay paused, target > replay, so they + # will sleep on WaitLatch. They can only be woken by the replay-loop + # WaitLSNWakeup calls. + arc_write_session = arc_standby.connect("postgres") + arc_write_session.do_async( + f"WAIT FOR LSN '{arc_target_lsn2}' " + "WITH (MODE 'standby_write', timeout '1d', no_throw);" + ) + + arc_flush_session = arc_standby.connect("postgres") + arc_flush_session.do_async( + f"WAIT FOR LSN '{arc_target_lsn2}' " + "WITH (MODE 'standby_flush', timeout '1d', no_throw);" + ) + + # Verify both waiters are blocked. + assert arc_standby.poll_query_until( + "SELECT count(*) = 2 FROM pg_stat_activity " + "WHERE wait_event LIKE 'WaitForWal%'" + ), "Timed out waiting for arc_standby waiters to block" + + # Resume replay. The startup process should wake the STANDBY_WRITE and + # STANDBY_FLUSH waiters as it replays past arc_target_lsn2. + arc_standby.safe_sql("SELECT pg_wal_replay_resume()") + + arc_write_out = arc_write_session.get_async_result() + arc_flush_out = arc_flush_session.get_async_result() + arc_write_session.close() + arc_flush_session.close() + + assert ( + arc_write_out.psqlout == "success" + ), "standby_write waiter woken by replay on archive-only standby" + assert ( + arc_flush_out.psqlout == "success" + ), "standby_flush waiter woken by replay on archive-only standby" + + arc_standby.stop() + arc_primary.stop() + + # 10. Fresh-shmem walreceiver startup (29e7dbf5e4d). + # RequestXLogStreaming() initializes writtenUpto/flushedUpto to the + # segment-aligned receiveStart only when receiveStart was invalid. + # Restart the standby with the primary stopped, so the walreceiver cannot + # connect and advance these values past the initial one before we observe + # it. + rcv_primary = create_pg("rcv_primary", allows_streaming=True) + # No background WAL during our probes. + rcv_primary.append_conf("autovacuum = off") + rcv_primary.restart() + rcv_primary.safe_sql("CREATE TABLE rcv_test AS SELECT generate_series(1,10) AS a") + + rcv_backup = "rcv_backup" + rcv_primary.backup(rcv_backup) + + rcv_standby = create_pg("rcv_standby", start=False) + rcv_standby.init_from_backup(rcv_primary, rcv_backup, has_streaming=True) + rcv_standby.start() + + # Switch WAL segments mid-stream so the replay ends mid-segment after the + # upcoming standby restart. That guarantees the initial value < + # final replay LSN. + rcv_primary.safe_sql("INSERT INTO rcv_test VALUES (generate_series(11, 100))") + rcv_primary.safe_sql("SELECT pg_switch_wal()") + rcv_primary.safe_sql("INSERT INTO rcv_test VALUES (generate_series(101, 110))") + rcv_primary.wait_for_catchup(rcv_standby) + + # Restart the standby with the primary down: WalRcvData is initialized, but + # the walreceiver cannot connect and update writtenUpto/flushedUpto. So, + # the initial flushedUpto stays observable via pg_last_wal_receive_lsn(). + rcv_standby.stop() + rcv_primary.stop() + rcv_standby.start() + + assert rcv_standby.poll_query_until( + "SELECT pg_last_wal_receive_lsn() IS NOT NULL;" + ), "walreceiver initial value did not become visible" + + # Freeze the replay so the (received, replay] window stays observable. + rcv_standby.safe_sql("SELECT pg_wal_replay_pause()") + assert rcv_standby.poll_query_until( + "SELECT pg_get_wal_replay_pause_state() = 'paused'" + ), "Timed out waiting for rcv_standby replay to pause" + + rcv_receive = rcv_standby.safe_sql("SELECT pg_last_wal_receive_lsn()") + rcv_replay = rcv_standby.safe_sql("SELECT pg_last_wal_replay_lsn()") + rcv_gap = rcv_standby.safe_sql( + f"SELECT pg_wal_lsn_diff('{rcv_replay}'::pg_lsn, " + f"'{rcv_receive}'::pg_lsn) > 0" + ) + assert rcv_gap == "t", "replay sits ahead of initial walreceiver flush position" + + rcv_receive_offset = rcv_standby.safe_sql( + f"SELECT mod(pg_wal_lsn_diff('{rcv_receive}'::pg_lsn, '0/0'::pg_lsn), " + "setting::numeric)::int " + "FROM pg_settings WHERE name = 'wal_segment_size'" + ) + assert ( + rcv_receive_offset == "0" + ), "initial walreceiver flush position is segment-aligned" + + # WAIT FOR an rcv_replay LSN succeeds in standby_write / standby_flush modes + # thanks to GetCurrentLSNForWaitType() taking replay LSN as the floor. + # We observe flushedUpto directly via pg_last_wal_receive_lsn(). + # writtenUpto is covered indirectly: without the replay-position floor, + # standby_write would wait at the seeded segment-start position and time + # out. + for rcv_mode in ("standby_write", "standby_flush"): + output = rcv_standby.safe_sql( + f"WAIT FOR LSN '{rcv_replay}' " + f"WITH (MODE '{rcv_mode}', timeout '5s', no_throw);" + ) + assert ( + output == "success" + ), f"{rcv_mode} succeeds for already-replayed LSN after standby restart" + + # Restore primary and resume replay so section 11 can reuse the clusters. + # Generate fresh WAL after reconnecting so the walreceiver advances its + # flush position past the replay position before we freeze both frontiers. + rcv_standby.safe_sql("SELECT pg_wal_replay_resume()") + rcv_primary.start() + rcv_primary.safe_sql("INSERT INTO rcv_test VALUES (generate_series(111, 120))") + rcv_primary.wait_for_catchup(rcv_standby) + + # 11. Off-by-one boundary checks for the wait predicate target <= + # currentLSN. Stop the walreceiver before pausing replay (stopping after + # pause can hang -- see section 7d) so both replay and walreceiver positions + # are frozen. + _stop_walreceiver(rcv_standby) + rcv_standby.safe_sql("SELECT pg_wal_replay_pause()") + assert rcv_standby.poll_query_until( + "SELECT pg_get_wal_replay_pause_state() = 'paused'" + ), "Timed out waiting for rcv_standby replay to pause" + + # 11a. standby_replay exact fencepost. The replay position is frozen, so + # this probes the standby_replay predicate directly. + replay_lsn = rcv_standby.safe_sql("SELECT pg_last_wal_replay_lsn()") + _, replay_lsn_plus = _check_wait_for_lsn_fencepost( + rcv_standby, "standby_replay", replay_lsn, "standby_replay" + ) + + # 11b. standby_flush exact fencepost. pg_last_wal_receive_lsn() exposes the + # flushed walreceiver position even after walreceiver exits, so this probes + # the standby_flush predicate directly. standby_write has no stable + # SQL-visible boundary once walreceiver is stopped; it is covered by the + # replay-floor and waiter wakeup tests above. + flush_lsn = rcv_standby.safe_sql("SELECT pg_last_wal_receive_lsn()") + flush_covers_replay = rcv_standby.safe_sql( + f"SELECT pg_wal_lsn_diff('{flush_lsn}'::pg_lsn, " + f"'{replay_lsn}'::pg_lsn) >= 0" + ) + assert ( + flush_covers_replay == "t" + ), "standby_flush boundary is not masked by replay floor" + + _check_wait_for_lsn_fencepost( + rcv_standby, "standby_flush", flush_lsn, "standby_flush" + ) + + # 11c. A sleeping waiter at current + 1 wakes once replay advances past it. + # Start the waiter while replay is still paused so it is guaranteed to sleep + # at replay_lsn_plus regardless of whether flush_lsn > replay_lsn. Then + # resume replay and restart the walreceiver to deliver new WAL. + rcv_primary.safe_sql("INSERT INTO rcv_test VALUES (generate_series(200, 210))") + + boundary_session = rcv_standby.connect("postgres") + boundary_session.do_async( + f"WAIT FOR LSN '{replay_lsn_plus}' " + "WITH (MODE 'standby_replay', timeout '1d', no_throw);" + ) + + assert rcv_standby.poll_query_until( + "SELECT count(*) > 0 FROM pg_stat_activity " + "WHERE wait_event = 'WaitForWalReplay'" + ), "Boundary waiter did not sleep" + + rcv_standby.safe_sql("SELECT pg_wal_replay_resume()") + _resume_walreceiver(rcv_standby) + boundary_out = boundary_session.get_async_result() + boundary_session.close() + assert ( + boundary_out.psqlout == "success" + ), "standby_replay: waiter at current + 1 wakes when replay advances" + + rcv_standby.stop() + rcv_primary.stop() + + # 12. Timeline switch on a cascade standby. A WAIT FOR LSN waiter on a + # cascade standby must survive its upstream's promotion: the cascade + # walreceiver reconnects on the new timeline and replay continues across the + # boundary. + tl_primary = create_pg("tl_primary", allows_streaming=True) + tl_primary.append_conf("autovacuum = off") + tl_primary.restart() + tl_primary.safe_sql("CREATE TABLE tl_test AS SELECT generate_series(1, 10) AS a") + + tl_backup = "tl_backup" + tl_primary.backup(tl_backup) + + tl_standby1 = create_pg("tl_standby1", start=False) + tl_standby1.init_from_backup(tl_primary, tl_backup, has_streaming=True) + tl_standby1.start() + + # standby2 cascades from standby1. + tl_backup2 = "tl_backup2" + tl_standby1.backup(tl_backup2) + + tl_standby2 = create_pg("tl_standby2", start=False) + tl_standby2.init_from_backup(tl_standby1, tl_backup2, has_streaming=True) + tl_standby2.start() + + tl_primary.safe_sql("INSERT INTO tl_test VALUES (generate_series(11, 20))") + tl_primary.wait_for_catchup(tl_standby1) + tl_standby1.wait_for_catchup(tl_standby2) + + # Target LSN well past current insert LSN, so reaching it requires WAL + # produced on the new timeline. Pause replay on standby2 to guarantee the + # waiter is asleep when the switch happens. + tl_target = tl_primary.safe_sql( + "SELECT (pg_current_wal_insert_lsn() + 65536)::text" + ) + + tl_standby2.safe_sql("SELECT pg_wal_replay_pause()") + assert tl_standby2.poll_query_until( + "SELECT pg_get_wal_replay_pause_state() = 'paused'" + ), "Timed out waiting for tl_standby2 replay to pause" + + tl_session = tl_standby2.connect("postgres") + tl_session.do_async( + f"WAIT FOR LSN '{tl_target}' " + "WITH (MODE 'standby_replay', timeout '1d', no_throw);" + ) + + assert tl_standby2.poll_query_until( + "SELECT count(*) > 0 FROM pg_stat_activity " + "WHERE wait_event = 'WaitForWalReplay'" + ), "Cascade waiter did not sleep before promotion" + + # Promote standby1 to TLI 2; produce enough WAL on the new timeline to push + # past tl_target and force a segment switch. + tl_standby1.promote() + tl_standby1.safe_sql("INSERT INTO tl_test VALUES (generate_series(21, 1020))") + tl_standby1.safe_sql("SELECT pg_switch_wal()") + + tl_standby2.safe_sql("SELECT pg_wal_replay_resume()") + + assert tl_standby2.poll_query_until( + "SELECT received_tli > 1 FROM pg_stat_wal_receiver" + ), "tl_standby2 did not follow upstream timeline switch" + + tl_out = tl_session.get_async_result() + tl_session.close() + assert ( + tl_out.psqlout == "success" + ), "WAIT FOR LSN survives upstream promotion and timeline switch on cascade standby" + + tl_standby2.stop() + tl_standby1.stop() + tl_primary.stop() diff --git a/src/test/recovery/pyt/test_050_redo_segment_missing.py b/src/test/recovery/pyt/test_050_redo_segment_missing.py new file mode 100644 index 00000000000..46a204236fb --- /dev/null +++ b/src/test/recovery/pyt/test_050_redo_segment_missing.py @@ -0,0 +1,114 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Evaluates PostgreSQL's recovery behavior when a WAL segment containing the +redo record is missing, with a checkpoint record located in a different +segment. +""" + +import os +import re + +import pytest + +from pypg import slurp_file + + +def test_050_redo_segment_missing(create_pg): + node = create_pg("testnode", start=False) + node.append_conf("log_checkpoints = on") + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION injection_points") + + # Note that this uses two injection points based on waits, not one. This + # may look strange, but this works as a workaround to enforce all memory + # allocations to happen outside the critical section of the checkpoint + # required for this test. + # First, "create-checkpoint-initial" is run outside the critical section + # section, and is used as a way to initialize the shared memory required + # for the wait machinery with its DSM registry. + # Then, "create-checkpoint-run" is loaded outside the critical section of + # a checkpoint to allocate any memory required by the library load, and + # its callback is run inside the critical section. + node.safe_sql("select injection_points_attach('create-checkpoint-initial', 'wait')") + node.safe_sql("select injection_points_attach('create-checkpoint-run', 'wait')") + + # Start a session to run the checkpoint in the background and make + # the test wait on the injection point so the checkpoint stops just after + # it starts. + checkpoint = node.connect("postgres") + checkpoint.do_async("CHECKPOINT;") + + # Wait for the initial point to finish, the checkpointer is still + # outside its critical section. Then release to reach the second + # point. + node.wait_for_event("checkpointer", "create-checkpoint-initial") + node.safe_sql("select injection_points_wakeup('create-checkpoint-initial')") + + # Wait until the checkpoint has reached the second injection point. + # We are now in the middle of a checkpoint running, after the redo + # record has been logged. + node.wait_for_event("checkpointer", "create-checkpoint-run") + + # Switch the WAL segment, ensuring that the redo record will be included + # in a different segment than the checkpoint record. + node.safe_sql("SELECT pg_switch_wal()") + + # Continue the checkpoint and wait for its completion. + log_offset = node.log_position() + node.safe_sql("select injection_points_wakeup('create-checkpoint-run')") + node.wait_for_log(r"checkpoint complete", log_offset) + + checkpoint.wait_for_completion() + checkpoint.close() + + # Retrieve the WAL file names for the redo record and checkpoint record. + redo_lsn = node.safe_sql("SELECT redo_lsn FROM pg_control_checkpoint()") + redo_walfile_name = node.safe_sql(f"SELECT pg_walfile_name('{redo_lsn}')") + checkpoint_lsn = node.safe_sql("SELECT checkpoint_lsn FROM pg_control_checkpoint()") + checkpoint_walfile_name = node.safe_sql( + f"SELECT pg_walfile_name('{checkpoint_lsn}')" + ) + + # Redo record and checkpoint record should be on different segments. + assert ( + redo_walfile_name != checkpoint_walfile_name + ), "redo and checkpoint records on different segments" + + # Remove the WAL segment containing the redo record. + os.unlink(os.path.join(node.data_dir, "pg_wal", redo_walfile_name)) + + node.stop("immediate") + + # Use pg_bin.result instead of node.start because this test expects that + # the server ends with an error during recovery. + node.pg_bin.result( + [ + "pg_ctl", + "--pgdata", + node.data_dir, + "--log", + node.logfile, + "start", + ] + ) + + # Confirm that recovery has failed, as expected. + logfile = slurp_file(node.logfile) + assert re.search( + r"FATAL: .* could not find redo location .* " + r"referenced by checkpoint record at .*", + logfile, + ), "ends with FATAL because it could not find redo location" diff --git a/src/test/recovery/pyt/test_051_effective_wal_level.py b/src/test/recovery/pyt/test_051_effective_wal_level.py new file mode 100644 index 00000000000..ca07f69aaeb --- /dev/null +++ b/src/test/recovery/pyt/test_051_effective_wal_level.py @@ -0,0 +1,494 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test that effective_wal_level changes upon logical replication slot creation +and deletion. +""" + +import os + + +def check_wal_level(node, expected, msg): + """Check both wal_level and effective_wal_level on *node* are *expected*.""" + actual = node.safe_sql( + "select current_setting('wal_level'), " + "current_setting('effective_wal_level');" + ) + assert actual == expected, msg + + +def wait_for_logical_decoding_disabled(node): + """Wait for the checkpointer to decrease effective_wal_level to 'replica'.""" + assert node.poll_query_until( + "select current_setting('effective_wal_level') = 'replica';" + ), "timed out waiting for logical decoding to be disabled" + + +def test_051_effective_wal_level(create_pg): + # Initialize the primary server with wal_level = 'replica'. + primary = create_pg("primary", allows_streaming=True, start=False) + primary.append_conf("log_min_messages = debug1") + primary.start() + + # Check both initial wal_level and effective_wal_level values. + check_wal_level( + primary, + "replica|replica", + "wal_level and effective_wal_level start with the same value 'replica'", + ) + + # Create a physical slot and verify it doesn't affect effective_wal_level. + primary.safe_sql( + "select pg_create_physical_replication_slot('test_phy_slot', false, false)" + ) + check_wal_level( + primary, + "replica|replica", + "effective_wal_level doesn't change with a new physical slot", + ) + primary.safe_sql("select pg_drop_replication_slot('test_phy_slot')") + + # Create a temporary logical slot but exit without releasing it explicitly. + # This enables logical decoding but skips disabling it and delegates to the + # checkpointer. The temporary slot is tied to the session's lifetime, so we + # use a dedicated connection and close it to release the slot (safe_sql + # reuses a persistent in-process session that would otherwise hold the + # temp slot indefinitely). + tmp_sess = primary.connect("postgres") + tmp_sess.query_safe( + "select pg_create_logical_replication_slot('test_tmp_slot', " + "'test_decoding', true)" + ) + tmp_sess.close() + assert primary.log_contains( + "logical decoding is enabled upon creating a new logical replication slot" + ), "logical decoding has been enabled upon creating a temp slot" + + # Wait for the checkpointer to disable logical decoding. + wait_for_logical_decoding_disabled(primary) + + # Test that logical decoding is disabled after repack + primary.safe_sql("create table foo(a int primary key)") + primary.safe_sql("repack (concurrently) foo;") + assert primary.log_contains( + "logical decoding is enabled upon creating a new logical replication slot" + ), "logical decoding enabled by repack" + + # Wait for the checkpointer to disable logical decoding. + wait_for_logical_decoding_disabled(primary) + check_wal_level( + primary, "replica|replica", "logical decoding disabled after repack" + ) + + # Create a new logical slot and check that effective_wal_level must be + # increased to 'logical'. + primary.safe_sql( + "select pg_create_logical_replication_slot('test_slot', 'pgoutput')" + ) + check_wal_level( + primary, + "replica|logical", + "effective_wal_level increased to 'logical' upon a logical slot creation", + ) + + # Restart the server and check again. + primary.restart() + check_wal_level( + primary, + "replica|logical", + "effective_wal_level remains 'logical' even after a server restart", + ) + + # Create and drop another logical slot, then verify that + # effective_wal_level remains 'logical'. + primary.safe_sql( + "select pg_create_logical_replication_slot('test_slot2', 'pgoutput')" + ) + primary.safe_sql("select pg_drop_replication_slot('test_slot2')") + check_wal_level( + primary, + "replica|logical", + "effective_wal_level stays 'logical' as one slot remains", + ) + + # Verify that the server cannot start with wal_level='minimal' when there + # is at least one replication slot. + primary.append_conf("wal_level = minimal") + primary.append_conf("max_wal_senders = 0") + primary.stop() + + log_offset = primary.log_position() + started = primary.start(fail_ok=True) + assert not started, ( + "cannot start server with wal_level='minimal' as there is in-use " + "logical slot" + ) + + assert primary.log_contains( + 'logical replication slot "test_slot" exists, but "wal_level" < "replica"', + log_offset, + ), "logical slots requires logical decoding enabled at server startup" + + # Revert the modified settings. + primary.append_conf("wal_level = replica") + primary.append_conf("max_wal_senders = 10") + + # Add other settings to test if we disable logical decoding when + # invalidating the last logical slot. + primary.append_conf( + "\n".join( + [ + "min_wal_size = 32MB", + "max_wal_size = 32MB", + "max_slot_wal_keep_size = 16MB", + "", + ] + ) + ) + primary.start() + + # Advance WAL and verify that the slot gets invalidated. + primary.advance_wal(2) + primary.safe_sql("CHECKPOINT") + assert ( + primary.safe_sql( + "select invalidation_reason = 'wal_removed' from " + "pg_replication_slots where slot_name = 'test_slot';" + ) + == "t" + ), "test_slot gets invalidated due to wal_removed" + + # Verify that logical decoding is disabled after invalidating the last + # logical slot. + wait_for_logical_decoding_disabled(primary) + check_wal_level( + primary, + "replica|replica", + "effective_wal_level got decreased to 'replica' after invalidating " + "the last logical slot", + ) + + # Revert the modified settings, and restart the server. (There is no + # adjust_conf in this framework; appending wins, so re-append defaults.) + primary.append_conf( + "\n".join( + [ + "max_slot_wal_keep_size = -1", + "min_wal_size = 80MB", + "max_wal_size = 128MB", + "", + ] + ) + ) + primary.restart() + + # Recreate the logical slot to enable logical decoding again. + primary.safe_sql("select pg_drop_replication_slot('test_slot')") + primary.safe_sql( + "select pg_create_logical_replication_slot('test_slot', 'pgoutput')" + ) + + # Take backup during the effective_wal_level being 'logical'. But note + # that replication slots are not included in the backup. + primary.backup("my_backup") + + # Initialize standby1 node. + standby1 = create_pg("standby1", start=False) + standby1.init_from_backup(primary, "my_backup", has_streaming=True) + standby1.start() + + # Creating a logical slot on standby should succeed as the primary + # enables it. + primary.wait_for_replay_catchup(standby1) + standby1.create_logical_slot_on_standby(primary, "standby1_slot", "postgres") + + # Promote the standby1 node that has one logical slot. So + # effective_wal_level remains 'logical' even after the promotion. + standby1.promote() + check_wal_level( + standby1, + "replica|logical", + "effective_wal_level remains 'logical' even after the promotion", + ) + + # Confirm if we can create a logical slot after the promotion. + standby1.safe_sql( + "select pg_create_logical_replication_slot('standby1_slot2', 'pgoutput')" + ) + standby1.stop() + + # Initialize standby2 node and start it with wal_level = 'logical'. + standby2 = create_pg("standby2", start=False) + standby2.init_from_backup(primary, "my_backup", has_streaming=True) + standby2.append_conf("wal_level = 'logical'") + standby2.start() + standby2.backup("my_backup3") + + # Initialize cascade standby and start with wal_level = 'replica'. + cascade = create_pg("cascade", start=False) + cascade.init_from_backup(standby2, "my_backup3", has_streaming=True) + cascade.append_conf("wal_level = replica") + cascade.start() + + # Regardless of their wal_level values, effective_wal_level values on the + # standby and the cascaded standby depend on the primary's value, + # 'logical'. + check_wal_level( + standby2, + "logical|logical", + "check wal_level and effective_wal_level on standby", + ) + check_wal_level( + cascade, + "replica|logical", + "check wal_level and effective_wal_level on cascaded standby", + ) + + # Drop the primary's last logical slot, decreasing effective_wal_level to + # 'replica' on all nodes. + primary.safe_sql("select pg_drop_replication_slot('test_slot')") + wait_for_logical_decoding_disabled(primary) + + primary.wait_for_replay_catchup(standby2) + standby2.wait_for_replay_catchup(cascade, primary) + + check_wal_level( + primary, + "replica|replica", + "effective_wal_level got decreased to 'replica' on primary", + ) + check_wal_level( + standby2, + "logical|replica", + "effective_wal_level got decreased to 'replica' on standby", + ) + check_wal_level( + cascade, + "replica|replica", + "effective_wal_level got decreased to 'replica' on cascaded standby", + ) + + # Promote standby2, increasing effective_wal_level to 'logical' as its + # wal_level is set to 'logical'. + standby2.promote() + + # Verify that effective_wal_level is increased to 'logical' on the cascaded + # standby. + standby2.wait_for_replay_catchup(cascade) + check_wal_level( + cascade, + "replica|logical", + "effective_wal_level got increased to 'logical' on standby as the new " + "primary has wal_level='logical'", + ) + + standby2.stop() + cascade.stop() + + # Initialize standby3 node and start it. + standby3 = create_pg("standby3", start=False) + standby3.init_from_backup(primary, "my_backup", has_streaming=True) + standby3.start() + + # Create logical slots on both nodes. + primary.safe_sql( + "select pg_create_logical_replication_slot('test_slot', 'pgoutput')" + ) + primary.wait_for_replay_catchup(standby3) + standby3.create_logical_slot_on_standby(primary, "standby3_slot", "postgres") + + # Drop the logical slot from the primary, decreasing effective_wal_level to + # 'replica' on the primary, which leads to invalidating the logical slot on + # the standby due to 'wal_level_insufficient'. + primary.safe_sql("select pg_drop_replication_slot('test_slot')") + wait_for_logical_decoding_disabled(primary) + check_wal_level( + primary, + "replica|replica", + "effective_wal_level got decreased to 'replica' on the primary to " + "invalidate standby's slots", + ) + assert standby3.poll_query_until( + "select invalidation_reason = 'wal_level_insufficient' from " + "pg_replication_slots where slot_name = 'standby3_slot'" + ), "timed out waiting for standby3_slot to be invalidated" + + # Restart the server to verify that the slot is successfully restored + # during startup. + standby3.restart() + + # Check that the logical decoding is not enabled on the standby3. Note that + # it still has the invalidated logical slot. + check_wal_level( + standby3, + "replica|replica", + "effective_wal_level got decreased to 'replica' on standby", + ) + + res = standby3.sql( + "select pg_logical_slot_get_changes('standby3_slot', null, null)" + ) + assert res.error_message is not None + assert ( + 'logical decoding on standby requires "effective_wal_level" >= ' + '"logical" on the primary' in res.error_message + ), "cannot use logical decoding on standby as it is disabled on primary" + + # Restart the primary with setting wal_level = 'logical' and create a new + # logical slot. + primary.append_conf("wal_level = 'logical'") + primary.restart() + primary.safe_sql( + "select pg_create_logical_replication_slot('test_slot', 'pgoutput')" + ) + + # effective_wal_level should be 'logical' on both nodes. + primary.wait_for_replay_catchup(standby3) + check_wal_level(primary, "logical|logical", "check WAL levels on the primary node") + check_wal_level( + standby3, + "replica|logical", + "effective_wal_level got increased to 'logical' again on standby", + ) + + # Set wal_level to 'replica' and restart the primary. Since one logical + # slot is still present on the primary, effective_wal_level remains + # 'logical' even if wal_level got decreased to 'replica'. + primary.append_conf("wal_level = replica") + primary.restart() + primary.wait_for_replay_catchup(standby3) + + # Verify that the effective_wal_level remains 'logical' on both nodes + check_wal_level( + primary, + "replica|logical", + "effective_wal_level remains 'logical' on primary even after setting " + "wal_level to 'replica'", + ) + check_wal_level( + standby3, + "replica|logical", + "effective_wal_level remains 'logical' on standby even after setting " + "wal_level to 'replica' on primary", + ) + + # Promote the standby3 and verify that effective_wal_level got decreased to + # 'replica' after the promotion since there is no valid logical slot. + standby3.promote() + check_wal_level( + standby3, + "replica|replica", + "effective_wal_level got decreased to 'replica' as there is no valid " + "logical slot", + ) + + # Cleanup the invalidated slot. + standby3.safe_sql("select pg_drop_replication_slot('standby3_slot')") + + standby3.stop() + + # Test the race condition at end of the recovery between the startup and + # logical decoding status change. This test requires injection points + # enabled. + injection_available = ( + os.environ.get("enable_injection_points", "no") == "yes" + and primary.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + != "0" + ) + if injection_available: + # Initialize standby4 and start it. + standby4 = create_pg("standby4", start=False) + standby4.init_from_backup(primary, "my_backup", has_streaming=True) + standby4.start() + + # Both servers have one logical slot. + primary.wait_for_replay_catchup(standby4) + standby4.create_logical_slot_on_standby(primary, "standby4_slot", "postgres") + + # Enable and attach the injection point on the standby4. + primary.safe_sql("create extension injection_points") + primary.wait_for_replay_catchup(standby4) + standby4.safe_sql( + "select injection_points_attach(" + "'startup-logical-decoding-status-change-end-of-recovery', " + "'wait');" + ) + + # Trigger promotion with no wait, and wait for the startup process to + # reach the injection point. + standby4.safe_sql("select pg_promote(false)") + print("# promote the standby and waiting for injection_point") + standby4.wait_for_event( + "startup", "startup-logical-decoding-status-change-end-of-recovery" + ) + print( + "# injection_point " + "'startup-logical-decoding-status-change-end-of-recovery' " + "is reached" + ) + + # Drop the logical slot, requesting to disable logical decoding to the + # checkpointer. + standby4.safe_sql("select pg_drop_replication_slot('standby4_slot');") + + # Resume the startup process to complete the recovery. + standby4.safe_sql( + "select injection_points_wakeup(" + "'startup-logical-decoding-status-change-end-of-recovery')" + ) + + # Verify that logical decoding got disabled after the recovery. + wait_for_logical_decoding_disabled(standby4) + check_wal_level( + standby4, + "replica|replica", + "effective_wal_level properly got decreased to 'replica'", + ) + standby4.stop() + + # Test the abort process of logical decoding activation. We drop the + # primary's slot to decrease its effective_wal_level to 'replica'. + primary.safe_sql("select pg_drop_replication_slot('test_slot')") + wait_for_logical_decoding_disabled(primary) + check_wal_level( + primary, + "replica|replica", + "effective_wal_level got decreased to 'replica' on primary", + ) + + # Start a session to test the case where the activation process is + # interrupted. + psql_create_slot = primary.connect("postgres") + + # Set up the injection point in this session (using set_local so it + # only affects this session), then start the slot creation which will + # block. + psql_create_slot.do("select injection_points_set_local()") + psql_create_slot.do( + "select injection_points_attach('logical-decoding-activation', 'wait')" + ) + psql_create_slot.do_async( + "select pg_create_logical_replication_slot('slot_canceled', 'pgoutput')" + ) + + primary.wait_for_event("client backend", "logical-decoding-activation") + print("# injection_point 'logical-decoding-activation' is reached") + + # Cancel the backend initiated by psql_create_slot, aborting its + # activation process. + primary.safe_sql( + "select pg_cancel_backend(pid) from pg_stat_activity where " + "query ~ 'slot_canceled' and pid <> pg_backend_pid()" + ) + + # Verify that the backend aborted the activation process. + primary.wait_for_log("aborting logical decoding activation process") + check_wal_level(primary, "replica|replica", "the activation process aborted") + + # Clean up the session (the async query was cancelled, so we just + # close) + psql_create_slot.close() + + primary.stop() diff --git a/src/test/recovery/pyt/test_052_checkpoint_segment_missing.py b/src/test/recovery/pyt/test_052_checkpoint_segment_missing.py new file mode 100644 index 00000000000..afbb41bbf7a --- /dev/null +++ b/src/test/recovery/pyt/test_052_checkpoint_segment_missing.py @@ -0,0 +1,64 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Verify crash recovery behavior when the WAL segment containing the +checkpoint record referenced by pg_controldata is missing. This +checks the code path where there is no backup_label file, where the +startup process should fail with FATAL and log a message about the +missing checkpoint record. +""" + +import os +import re + +from pypg import slurp_file + + +def test_052_checkpoint_segment_missing(create_pg): + node = create_pg("testnode", start=False) + node.append_conf("log_checkpoints = on") + node.start() + + # Force a checkpoint so as pg_controldata points to a checkpoint record we + # can target. + node.safe_sql("CHECKPOINT;") + + # Retrieve the checkpoint LSN and derive the WAL segment name. + checkpoint_walfile = node.safe_sql( + "SELECT pg_walfile_name(checkpoint_lsn) FROM pg_control_checkpoint()" + ) + + assert ( + checkpoint_walfile != "" + ), f"derived checkpoint WAL file name: {checkpoint_walfile}" + + # Stop the node. + node.stop("immediate") + + # Remove the WAL segment containing the checkpoint record. + walpath = os.path.join(node.data_dir, "pg_wal", checkpoint_walfile) + assert os.path.isfile( + walpath + ), f"checkpoint WAL file exists before deletion: {walpath}" + + os.unlink(walpath) + + assert not os.path.exists(walpath), f"checkpoint WAL file removed: {walpath}" + + # Use pg_ctl directly instead of node.start because this test expects + # that the server ends with an error during recovery. + node.pg_bin.result( + [ + "pg_ctl", + "--pgdata", + node.data_dir, + "--log", + node.logfile, + "start", + ] + ) + + # Confirm that recovery has failed as expected. + logfile = slurp_file(node.logfile) + assert re.search( + r"FATAL: .* could not locate a valid checkpoint record at .*", logfile + ), "FATAL logged for missing checkpoint record (no backup_label path)" diff --git a/src/test/recovery/pyt/test_053_standby_login_event_trigger.py b/src/test/recovery/pyt/test_053_standby_login_event_trigger.py new file mode 100644 index 00000000000..8a62af1a6be --- /dev/null +++ b/src/test/recovery/pyt/test_053_standby_login_event_trigger.py @@ -0,0 +1,150 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +# +# Verify that connecting to a standby still works after a login event +# trigger has been created and dropped on the primary. +# +# CREATE EVENT TRIGGER ... ON login sets pg_database.dathasloginevt to +# true on the primary, but DROP EVENT TRIGGER does not clear it -- the +# next login event trigger pass clears the flag lazily on the primary. +# That dangling flag replicates to the standby. Before the +# RecoveryInProgress() guard in EventTriggerOnLogin(), the standby +# tried to clear the flag itself, which requires AccessExclusiveLock +# on the database object; that lock mode is forbidden during recovery, +# so the new connection died with FATAL. +# +# To keep the test robust the event trigger is set up in a dedicated +# database (regress_login_evt). All synchronisation helpers below -- +# wait_for_replay_catchup() and friends -- connect to "postgres" on +# the primary; if the trigger were created in "postgres" itself, that +# probe connection would enter the cleanup branch on the primary and +# silently clear the flag before the test even runs, making the +# scenario unreproducible. + +from libpq import LibpqError + + +def test_053_standby_login_event_trigger(create_pg): + # Set up primary and a streaming standby. + primary = create_pg("primary", allows_streaming=True) + + backup_name = "login_evt_backup" + primary.backup(backup_name) + + standby = create_pg("standby", start=False) + standby.init_from_backup(primary, backup_name, has_streaming=True) + standby.start() + + # A dedicated database isolates the dangling dathasloginevt flag from + # any helper that connects to the default "postgres" database. + primary.safe_sql("CREATE DATABASE regress_login_evt") + primary.wait_for_replay_catchup(standby) + + # Sanity check: the standby can connect to the new database before + # the trigger machinery has touched it. + standby.safe_sql("SELECT 1", dbname="regress_login_evt") + + # Create and drop a login event trigger inside the dedicated database + # in a single session. CREATE EVENT TRIGGER sets + # pg_database.dathasloginevt = true for regress_login_evt; mark it + # ENABLE ALWAYS so the scenario matches the original bug report. + # After DROP the flag remains set on disk until a subsequent login on + # the primary clears it; since later helpers only touch the + # "postgres" database, regress_login_evt's flag stays set and + # replicates that way to the standby. + primary.safe_sql( + """ +CREATE FUNCTION init_session() RETURNS event_trigger +LANGUAGE plpgsql AS $$ BEGIN RAISE NOTICE 'init_session'; END $$; +CREATE EVENT TRIGGER init_session ON login + EXECUTE FUNCTION init_session(); +ALTER EVENT TRIGGER init_session ENABLE ALWAYS; +DROP EVENT TRIGGER init_session; +DROP FUNCTION init_session(); +""", + dbname="regress_login_evt", + ) + + # Wait for the standby to replay the CREATE/DROP catalog state. This + # probes "postgres", not regress_login_evt, so it does not disturb + # the dangling flag. + primary.wait_for_replay_catchup(standby) + + # The flag remains set in regress_login_evt on both sides. + assert ( + primary.safe_sql( + "SELECT dathasloginevt FROM pg_database " + "WHERE datname = 'regress_login_evt'" + ) + == "t" + ), "dathasloginevt remains set on primary after DROP EVENT TRIGGER" + assert ( + standby.safe_sql( + "SELECT dathasloginevt FROM pg_database " + "WHERE datname = 'regress_login_evt'" + ) + == "t" + ), "dathasloginevt replicated to standby" + + # A new connection to regress_login_evt on the standby exercises + # EventTriggerOnLogin()'s cleanup branch. With the + # RecoveryInProgress() guard it succeeds; without it the session + # aborts with a FATAL about AccessExclusiveLock. A failure surfaces + # either as a connection error at login time or as a query error. + try: + session = standby.connect("regress_login_evt") + except LibpqError as exc: + assert "cannot acquire lock mode AccessExclusiveLock" not in str( + exc + ), "no AccessExclusiveLock FATAL on standby login" + raise AssertionError( + "standby accepts connection to database with dangling " + f"dathasloginevt: {exc}" + ) from exc + try: + res = session.query("SELECT 1") + assert res.error_message is None, ( + "standby accepts connection to database with dangling " + f"dathasloginevt: {res.error_message}" + ) + assert "cannot acquire lock mode AccessExclusiveLock" not in ( + res.error_message or "" + ), "no AccessExclusiveLock FATAL on standby login" + assert res.psqlout == "1" + finally: + session.close() + + # Finally exercise the primary-side cleanup that the standby is meant + # to defer to. Opening a fresh session against regress_login_evt on + # the primary enters EventTriggerOnLogin()'s cleanup branch with the + # trigger list empty; AccessExclusiveLock is allowed outside recovery, + # so the flag is cleared in place. The in-place update emits a + # XLOG_HEAP_INPLACE record but does not assign an xid or write a + # commit record, so the WAL is not auto-flushed -- force a flush via + # pg_switch_wal() so the record reaches the standby. + # A fresh login requires a brand-new backend. safe_sql reuses a + # cached per-database session, and the CREATE/DROP block above already + # opened (and cached) a regress_login_evt session on the primary -- so a + # plain safe_sql here would not trigger a new login. Open a fresh + # connection to force the login-event cleanup branch to run. + cleanup = primary.connect("regress_login_evt") + try: + cleanup.query_safe("SELECT 1") + finally: + cleanup.close() + assert ( + primary.safe_sql( + "SELECT dathasloginevt FROM pg_database " + "WHERE datname = 'regress_login_evt'" + ) + == "f" + ), "primary clears dathasloginevt on next login after DROP" + + primary.safe_sql("SELECT pg_switch_wal()") + primary.wait_for_replay_catchup(standby) + assert ( + standby.safe_sql( + "SELECT dathasloginevt FROM pg_database " + "WHERE datname = 'regress_login_evt'" + ) + == "f" + ), "cleared dathasloginevt replicates to standby" From 912c47d458f957b3203e5edbb252d6e961f4e94c Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:07 -0400 Subject: [PATCH 10/18] python tests: pytest suite for src/test/subscription --- src/test/subscription/meson.build | 48 + .../subscription/pyt/test_001_rep_changes.py | 572 ++++++++ src/test/subscription/pyt/test_002_types.py | 540 ++++++++ .../subscription/pyt/test_003_constraints.py | 138 ++ src/test/subscription/pyt/test_004_sync.py | 175 +++ .../subscription/pyt/test_005_encoding.py | 46 + src/test/subscription/pyt/test_006_rewrite.py | 52 + src/test/subscription/pyt/test_007_ddl.py | 181 +++ .../subscription/pyt/test_008_diff_schema.py | 126 ++ .../subscription/pyt/test_009_matviews.py | 41 + .../subscription/pyt/test_010_truncate.py | 203 +++ .../subscription/pyt/test_011_generated.py | 358 +++++ .../subscription/pyt/test_012_collation.py | 105 ++ .../subscription/pyt/test_013_partition.py | 838 ++++++++++++ src/test/subscription/pyt/test_014_binary.py | 322 +++++ src/test/subscription/pyt/test_015_stream.py | 314 +++++ .../pyt/test_016_stream_subxact.py | 148 ++ .../subscription/pyt/test_017_stream_ddl.py | 136 ++ .../pyt/test_018_stream_subxact_abort.py | 261 ++++ .../pyt/test_019_stream_subxact_ddl_abort.py | 80 ++ .../subscription/pyt/test_020_messages.py | 138 ++ .../subscription/pyt/test_021_twophase.py | 474 +++++++ .../pyt/test_022_twophase_cascade.py | 403 ++++++ .../pyt/test_023_twophase_stream.py | 485 +++++++ .../subscription/pyt/test_024_add_drop_pub.py | 129 ++ .../pyt/test_025_rep_changes_for_schema.py | 192 +++ src/test/subscription/pyt/test_026_stats.py | 415 ++++++ .../subscription/pyt/test_027_nosuperuser.py | 495 +++++++ .../subscription/pyt/test_028_row_filter.py | 841 ++++++++++++ .../subscription/pyt/test_029_on_error.py | 216 +++ src/test/subscription/pyt/test_030_origin.py | 394 ++++++ .../subscription/pyt/test_031_column_list.py | 1207 +++++++++++++++++ .../pyt/test_032_subscribe_use_index.py | 594 ++++++++ .../pyt/test_033_run_as_table_owner.py | 259 ++++ .../subscription/pyt/test_034_temporal.py | 714 ++++++++++ .../subscription/pyt/test_035_conflicts.py | 666 +++++++++ .../subscription/pyt/test_036_sequences.py | 227 ++++ src/test/subscription/pyt/test_037_except.py | 265 ++++ .../pyt/test_038_walsnd_shutdown_timeout.py | 203 +++ src/test/subscription/pyt/test_100_bugs.py | 596 ++++++++ 40 files changed, 13597 insertions(+) create mode 100644 src/test/subscription/pyt/test_001_rep_changes.py create mode 100644 src/test/subscription/pyt/test_002_types.py create mode 100644 src/test/subscription/pyt/test_003_constraints.py create mode 100644 src/test/subscription/pyt/test_004_sync.py create mode 100644 src/test/subscription/pyt/test_005_encoding.py create mode 100644 src/test/subscription/pyt/test_006_rewrite.py create mode 100644 src/test/subscription/pyt/test_007_ddl.py create mode 100644 src/test/subscription/pyt/test_008_diff_schema.py create mode 100644 src/test/subscription/pyt/test_009_matviews.py create mode 100644 src/test/subscription/pyt/test_010_truncate.py create mode 100644 src/test/subscription/pyt/test_011_generated.py create mode 100644 src/test/subscription/pyt/test_012_collation.py create mode 100644 src/test/subscription/pyt/test_013_partition.py create mode 100644 src/test/subscription/pyt/test_014_binary.py create mode 100644 src/test/subscription/pyt/test_015_stream.py create mode 100644 src/test/subscription/pyt/test_016_stream_subxact.py create mode 100644 src/test/subscription/pyt/test_017_stream_ddl.py create mode 100644 src/test/subscription/pyt/test_018_stream_subxact_abort.py create mode 100644 src/test/subscription/pyt/test_019_stream_subxact_ddl_abort.py create mode 100644 src/test/subscription/pyt/test_020_messages.py create mode 100644 src/test/subscription/pyt/test_021_twophase.py create mode 100644 src/test/subscription/pyt/test_022_twophase_cascade.py create mode 100644 src/test/subscription/pyt/test_023_twophase_stream.py create mode 100644 src/test/subscription/pyt/test_024_add_drop_pub.py create mode 100644 src/test/subscription/pyt/test_025_rep_changes_for_schema.py create mode 100644 src/test/subscription/pyt/test_026_stats.py create mode 100644 src/test/subscription/pyt/test_027_nosuperuser.py create mode 100644 src/test/subscription/pyt/test_028_row_filter.py create mode 100644 src/test/subscription/pyt/test_029_on_error.py create mode 100644 src/test/subscription/pyt/test_030_origin.py create mode 100644 src/test/subscription/pyt/test_031_column_list.py create mode 100644 src/test/subscription/pyt/test_032_subscribe_use_index.py create mode 100644 src/test/subscription/pyt/test_033_run_as_table_owner.py create mode 100644 src/test/subscription/pyt/test_034_temporal.py create mode 100644 src/test/subscription/pyt/test_035_conflicts.py create mode 100644 src/test/subscription/pyt/test_036_sequences.py create mode 100644 src/test/subscription/pyt/test_037_except.py create mode 100644 src/test/subscription/pyt/test_038_walsnd_shutdown_timeout.py create mode 100644 src/test/subscription/pyt/test_100_bugs.py diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build index e71e95c6297..09b59bac184 100644 --- a/src/test/subscription/meson.build +++ b/src/test/subscription/meson.build @@ -51,4 +51,52 @@ tests += { 't/100_bugs.pl', ], }, + 'pytest': { + 'env': { + 'with_icu': icu.found() ? 'yes' : 'no', + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_rep_changes.py', + 'pyt/test_002_types.py', + 'pyt/test_003_constraints.py', + 'pyt/test_004_sync.py', + 'pyt/test_005_encoding.py', + 'pyt/test_006_rewrite.py', + 'pyt/test_007_ddl.py', + 'pyt/test_008_diff_schema.py', + 'pyt/test_009_matviews.py', + 'pyt/test_010_truncate.py', + 'pyt/test_011_generated.py', + 'pyt/test_012_collation.py', + 'pyt/test_013_partition.py', + 'pyt/test_014_binary.py', + 'pyt/test_015_stream.py', + 'pyt/test_016_stream_subxact.py', + 'pyt/test_017_stream_ddl.py', + 'pyt/test_018_stream_subxact_abort.py', + 'pyt/test_019_stream_subxact_ddl_abort.py', + 'pyt/test_020_messages.py', + 'pyt/test_021_twophase.py', + 'pyt/test_022_twophase_cascade.py', + 'pyt/test_023_twophase_stream.py', + 'pyt/test_024_add_drop_pub.py', + 'pyt/test_025_rep_changes_for_schema.py', + 'pyt/test_026_stats.py', + 'pyt/test_027_nosuperuser.py', + 'pyt/test_028_row_filter.py', + 'pyt/test_029_on_error.py', + 'pyt/test_030_origin.py', + 'pyt/test_031_column_list.py', + 'pyt/test_032_subscribe_use_index.py', + 'pyt/test_033_run_as_table_owner.py', + 'pyt/test_034_temporal.py', + 'pyt/test_035_conflicts.py', + 'pyt/test_036_sequences.py', + 'pyt/test_037_except.py', + 'pyt/test_038_walsnd_shutdown_timeout.py', + 'pyt/test_100_bugs.py', + + ], + }, } diff --git a/src/test/subscription/pyt/test_001_rep_changes.py b/src/test/subscription/pyt/test_001_rep_changes.py new file mode 100644 index 00000000000..59a23628ac7 --- /dev/null +++ b/src/test/subscription/pyt/test_001_rep_changes.py @@ -0,0 +1,572 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic logical replication test.""" + +import re + + +def test_001_rep_changes(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql( + "CREATE FUNCTION public.pg_get_replica_identity_index(int)\n" + " RETURNS regclass LANGUAGE sql AS 'SELECT 1/0'" + ) # shall not call + node_publisher.safe_sql( + "CREATE TABLE tab_notrep AS SELECT generate_series(1,10) AS a" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_ins AS SELECT generate_series(1,1002) AS a" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_full AS SELECT generate_series(1,10) AS a" + ) + node_publisher.safe_sql("CREATE TABLE tab_full2 (x text)") + node_publisher.safe_sql("INSERT INTO tab_full2 VALUES ('a'), ('b'), ('b')") + node_publisher.safe_sql("CREATE TABLE tab_rep (a int primary key)") + node_publisher.safe_sql( + "CREATE TABLE tab_mixed (a int primary key, b text, c numeric)" + ) + node_publisher.safe_sql("INSERT INTO tab_mixed (a, b, c) VALUES (1, 'foo', 1.1)") + node_publisher.safe_sql( + "CREATE TABLE tab_include (a int, b text, " + "CONSTRAINT covering PRIMARY KEY(a) INCLUDE(b))" + ) + node_publisher.safe_sql("CREATE TABLE tab_full_pk (a int primary key, b text)") + node_publisher.safe_sql("ALTER TABLE tab_full_pk REPLICA IDENTITY FULL") + # Let this table with REPLICA IDENTITY NOTHING, allowing only INSERT changes. + node_publisher.safe_sql("CREATE TABLE tab_nothing (a int)") + node_publisher.safe_sql("ALTER TABLE tab_nothing REPLICA IDENTITY NOTHING") + + # Replicate the changes without replica identity index + node_publisher.safe_sql("CREATE TABLE tab_no_replidentity_index(c1 int)") + node_publisher.safe_sql( + "CREATE INDEX idx_no_replidentity_index ON tab_no_replidentity_index(c1)" + ) + + # Replicate the changes without columns + node_publisher.safe_sql("CREATE TABLE tab_no_col()") + node_publisher.safe_sql("INSERT INTO tab_no_col default VALUES") + + # Setup structure on subscriber + node_subscriber.safe_sql("CREATE TABLE tab_notrep (a int)") + node_subscriber.safe_sql("CREATE TABLE tab_ins (a int)") + node_subscriber.safe_sql("CREATE TABLE tab_full (a int)") + node_subscriber.safe_sql("CREATE TABLE tab_full2 (x text)") + node_subscriber.safe_sql("CREATE TABLE tab_rep (a int primary key)") + node_subscriber.safe_sql("CREATE TABLE tab_full_pk (a int primary key, b text)") + node_subscriber.safe_sql("ALTER TABLE tab_full_pk REPLICA IDENTITY FULL") + node_subscriber.safe_sql("CREATE TABLE tab_nothing (a int)") + + # different column count and order than on publisher + node_subscriber.safe_sql( + "CREATE TABLE tab_mixed (d text default 'local', c numeric, b text, " + "a int primary key)" + ) + + # replication of the table with included index + node_subscriber.safe_sql( + "CREATE TABLE tab_include (a int, b text, " + "CONSTRAINT covering PRIMARY KEY(a) INCLUDE(b))" + ) + + # replication of the table without replica identity index + node_subscriber.safe_sql("CREATE TABLE tab_no_replidentity_index(c1 int)") + node_subscriber.safe_sql( + "CREATE INDEX idx_no_replidentity_index ON tab_no_replidentity_index(c1)" + ) + + # replication of the table without columns + node_subscriber.safe_sql("CREATE TABLE tab_no_col()") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub") + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_ins_only WITH (publish = insert)" + ) + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub ADD TABLE tab_rep, tab_full, tab_full2, " + "tab_mixed, tab_include, tab_nothing, tab_full_pk, " + "tab_no_replidentity_index, tab_no_col" + ) + node_publisher.safe_sql("ALTER PUBLICATION tap_pub_ins_only ADD TABLE tab_ins") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub, tap_pub_ins_only" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Reset IO statistics, for the WAL sender check with pg_stat_io. + node_publisher.safe_sql("SELECT pg_stat_reset_shared('io')") + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_notrep") + assert result == "0", "check non-replicated table is empty on subscriber" + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_ins") + assert result == "1002", "check initial data was copied to subscriber" + + node_publisher.safe_sql("INSERT INTO tab_ins SELECT generate_series(1,50)") + node_publisher.safe_sql("DELETE FROM tab_ins WHERE a > 20") + node_publisher.safe_sql("UPDATE tab_ins SET a = -a") + + node_publisher.safe_sql("INSERT INTO tab_rep SELECT generate_series(1,50)") + node_publisher.safe_sql("DELETE FROM tab_rep WHERE a > 20") + node_publisher.safe_sql("UPDATE tab_rep SET a = -a") + + node_publisher.safe_sql("INSERT INTO tab_mixed VALUES (2, 'bar', 2.2)") + + node_publisher.safe_sql("INSERT INTO tab_full_pk VALUES (1, 'foo'), (2, 'baz')") + + node_publisher.safe_sql("INSERT INTO tab_nothing VALUES (generate_series(1,20))") + + node_publisher.safe_sql("INSERT INTO tab_include SELECT generate_series(1,50)") + node_publisher.safe_sql("DELETE FROM tab_include WHERE a > 20") + node_publisher.safe_sql("UPDATE tab_include SET a = -a") + + node_publisher.safe_sql("INSERT INTO tab_no_replidentity_index VALUES(1)") + + node_publisher.safe_sql("INSERT INTO tab_no_col default VALUES") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_ins") + assert result == "1052|1|1002", "check replicated inserts on subscriber" + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_rep") + assert result == "20|-20|-1", "check replicated changes on subscriber" + + result = node_subscriber.safe_sql("SELECT * FROM tab_mixed") + assert ( + result == "local|1.1|foo|1\nlocal|2.2|bar|2" + ), "check replicated changes with different column order" + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_nothing") + assert result == "20", "check replicated changes with REPLICA IDENTITY NOTHING" + + result = node_subscriber.safe_sql( + "SELECT count(*), min(a), max(a) FROM tab_include" + ) + assert ( + result == "20|-20|-1" + ), "check replicated changes with primary key index with included columns" + + assert ( + node_subscriber.safe_sql("SELECT c1 FROM tab_no_replidentity_index") == "1" + ), "value replicated to subscriber without replica identity index" + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_no_col") + assert result == "2", "check replicated changes for table having no columns" + + # Wait for the logical WAL sender to update its IO statistics. This is + # done before the next restart, which would force a flush of its stats, and + # far enough from the reset done above to not impact the run time. + assert node_publisher.poll_query_until( + "SELECT sum(reads) > 0\n" + " FROM pg_catalog.pg_stat_io\n" + " WHERE backend_type = 'walsender'\n" + " AND object = 'wal'" + ), "Timed out while waiting for the walsender to update its IO statistics" + + # insert some duplicate rows + node_publisher.safe_sql("INSERT INTO tab_full SELECT generate_series(1,10)") + + # Test behaviour of ALTER PUBLICATION ... DROP TABLE + # + # When a publisher drops a table from publication, it should also stop + # sending its changes to subscribers. We look at the subscriber whether it + # receives the row that is inserted to the table on the publisher after it + # is dropped from the publication. + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_ins") + assert ( + result == "1052|1|1002" + ), "check rows on subscriber before table drop from publication" + + # Drop the table from publication + node_publisher.safe_sql("ALTER PUBLICATION tap_pub_ins_only DROP TABLE tab_ins") + + # Insert a row in publisher, but publisher will not send this row to + # subscriber + node_publisher.safe_sql("INSERT INTO tab_ins VALUES(8888)") + + node_publisher.wait_for_catchup("tap_sub") + + # Subscriber will not receive the inserted row, after table is dropped from + # publication, so row count should remain the same. + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_ins") + assert ( + result == "1052|1|1002" + ), "check rows on subscriber after table drop from publication" + + # Delete the inserted row in publisher + node_publisher.safe_sql("DELETE FROM tab_ins WHERE a = 8888") + + # Add the table to publication again + node_publisher.safe_sql("ALTER PUBLICATION tap_pub_ins_only ADD TABLE tab_ins") + + # Refresh publication after table is added to publication + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION") + + # Test replication with multiple publications for a subscription such that + # the operations are performed on the table from the first publication in + # the list. + + # Create tables on publisher + node_publisher.safe_sql("CREATE TABLE temp1 (a int)") + node_publisher.safe_sql("CREATE TABLE temp2 (a int)") + + # Create tables on subscriber + node_subscriber.safe_sql("CREATE TABLE temp1 (a int)") + node_subscriber.safe_sql("CREATE TABLE temp2 (a int)") + + # Setup logical replication that will only be used for this test + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_temp1 FOR TABLE temp1 WITH (publish = insert)" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_temp2 FOR TABLE temp2") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_temp1 CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub_temp1, tap_pub_temp2" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub_temp1") + + # Subscriber table will have no rows initially + result = node_subscriber.safe_sql("SELECT count(*) FROM temp1") + assert result == "0", "check initial rows on subscriber with multiple publications" + + # Insert a row into the table that's part of first publication in + # subscriber list of publications. + node_publisher.safe_sql("INSERT INTO temp1 VALUES (1)") + + node_publisher.wait_for_catchup("tap_sub_temp1") + + # Subscriber should receive the inserted row + result = node_subscriber.safe_sql("SELECT count(*) FROM temp1") + assert result == "1", "check rows on subscriber with multiple publications" + + # Drop subscription as we don't need it anymore + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_temp1") + + # Drop publications as we don't need them anymore + node_publisher.safe_sql("DROP PUBLICATION tap_pub_temp1") + node_publisher.safe_sql("DROP PUBLICATION tap_pub_temp2") + + # Clean up the tables on both publisher and subscriber as we don't need them + node_publisher.safe_sql("DROP TABLE temp1") + node_publisher.safe_sql("DROP TABLE temp2") + node_subscriber.safe_sql("DROP TABLE temp1") + node_subscriber.safe_sql("DROP TABLE temp2") + + # add REPLICA IDENTITY FULL so we can update + node_publisher.safe_sql("ALTER TABLE tab_full REPLICA IDENTITY FULL") + node_subscriber.safe_sql("ALTER TABLE tab_full REPLICA IDENTITY FULL") + node_publisher.safe_sql("ALTER TABLE tab_full2 REPLICA IDENTITY FULL") + node_subscriber.safe_sql("ALTER TABLE tab_full2 REPLICA IDENTITY FULL") + node_publisher.safe_sql("ALTER TABLE tab_ins REPLICA IDENTITY FULL") + node_subscriber.safe_sql("ALTER TABLE tab_ins REPLICA IDENTITY FULL") + # tab_mixed can use DEFAULT, since it has a primary key + + # and do the updates + node_publisher.safe_sql("UPDATE tab_full SET a = a * a") + node_publisher.safe_sql("UPDATE tab_full2 SET x = 'bb' WHERE x = 'b'") + node_publisher.safe_sql("UPDATE tab_mixed SET b = 'baz' WHERE a = 1") + node_publisher.safe_sql("UPDATE tab_full_pk SET b = 'bar' WHERE a = 1") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_full") + assert ( + result == "20|1|100" + ), "update works with REPLICA IDENTITY FULL and duplicate tuples" + + result = node_subscriber.safe_sql("SELECT x FROM tab_full2 ORDER BY 1") + assert ( + result == "a\nbb\nbb" + ), "update works with REPLICA IDENTITY FULL and text datums" + + result = node_subscriber.safe_sql("SELECT * FROM tab_mixed ORDER BY a") + assert ( + result == "local|1.1|baz|1\nlocal|2.2|bar|2" + ), "update works with different column order and subscriber local values" + + result = node_subscriber.safe_sql("SELECT * FROM tab_full_pk ORDER BY a") + assert ( + result == "1|bar\n2|baz" + ), "update works with REPLICA IDENTITY FULL and a primary key" + + node_subscriber.safe_sql("DELETE FROM tab_full_pk") + node_subscriber.safe_sql("DELETE FROM tab_full WHERE a = 25") + + # Note that the current location of the log file is not grabbed immediately + # after reloading the configuration, but after sending one SQL command to + # the node so as we are sure that the reloading has taken effect. + log_location_pub = node_publisher.log_position() + log_location_sub = node_subscriber.log_position() + + node_publisher.safe_sql("UPDATE tab_full_pk SET b = 'quux' WHERE a = 1") + node_publisher.safe_sql("UPDATE tab_full SET a = a + 1 WHERE a = 25") + node_publisher.safe_sql("DELETE FROM tab_full_pk WHERE a = 2") + + node_publisher.wait_for_catchup("tap_sub") + + logfile = node_subscriber.log_content()[log_location_sub:] + assert re.search( + r'conflict detected on relation "public.tab_full_pk": ' + r"conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be " + r"updated: remote row \(1, quux\), replica identity \(a\)=\(1\)", + logfile, + ), "update target row is missing" + assert re.search( + r'conflict detected on relation "public.tab_full": ' + r"conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be " + r"updated: remote row \(26\), replica identity full \(25\)", + logfile, + ), "update target row is missing" + assert re.search( + r'conflict detected on relation "public.tab_full_pk": ' + r"conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be " + r"deleted: replica identity \(a\)=\(2\)", + logfile, + ), "delete target row is missing" + + node_subscriber.append_conf("log_min_messages = warning") + node_subscriber.reload() + + # check behavior with toasted values + + node_publisher.safe_sql( + "UPDATE tab_mixed SET b = repeat('xyzzy', 100000) WHERE a = 2" + ) + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql( + "SELECT a, length(b), c, d FROM tab_mixed ORDER BY a" + ) + assert ( + result == "1|3|1.1|local\n2|500000|2.2|local" + ), "update transmits large column value" + + node_publisher.safe_sql("UPDATE tab_mixed SET c = 3.3 WHERE a = 2") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql( + "SELECT a, length(b), c, d FROM tab_mixed ORDER BY a" + ) + assert ( + result == "1|3|1.1|local\n2|500000|3.3|local" + ), "update with non-transmitted large column value" + + # check behavior with dropped columns + + # this update should get transmitted before the column goes away + node_publisher.safe_sql("UPDATE tab_mixed SET b = 'bar', c = 2.2 WHERE a = 2") + + node_publisher.safe_sql("ALTER TABLE tab_mixed DROP COLUMN b") + + node_publisher.safe_sql("UPDATE tab_mixed SET c = 11.11 WHERE a = 1") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT * FROM tab_mixed ORDER BY a") + assert ( + result == "local|11.11|baz|1\nlocal|2.2|bar|2" + ), "update works with dropped publisher column" + + node_subscriber.safe_sql("ALTER TABLE tab_mixed DROP COLUMN d") + + node_publisher.safe_sql("UPDATE tab_mixed SET c = 22.22 WHERE a = 2") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT * FROM tab_mixed ORDER BY a") + assert ( + result == "11.11|baz|1\n22.22|bar|2" + ), "update works with dropped subscriber column" + + # Verify that GUC settings supplied in the CONNECTION string take effect on + # the publisher's walsender. We do this by enabling log_statement_stats in + # the CONNECTION string later and checking that the publisher's log contains + # a QUERY STATISTICS message. + # + # First, confirm that no such QUERY STATISTICS message appears before + # enabling log_statement_stats. + logfile = node_publisher.log_content()[log_location_pub:] + assert not re.search( + r"QUERY STATISTICS", logfile + ), "log_statement_stats has not been enabled yet" + log_location_pub = node_publisher.log_position() + + # check that change of connection string and/or publication list causes + # restart of subscription workers. We check the state along with + # application_name to ensure that the walsender is (re)started. + # + # Not all of these are registered as tests as we need to poll for a change + # but the test suite will fail nonetheless when something goes wrong. + # + # Enable log_statement_stats as the change of connection string, + # which is also for the above mentioned test of GUC settings passed through + # CONNECTION. + oldpid = node_publisher.safe_sql( + "SELECT pid FROM pg_stat_replication WHERE application_name = 'tap_sub' " + "AND state = 'streaming';" + ) + node_subscriber.safe_sql( + f"ALTER SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr} " + "options=''-c log_statement_stats=on'''" + ) + assert node_publisher.poll_query_until( + f"SELECT pid != {oldpid} FROM pg_stat_replication " + "WHERE application_name = 'tap_sub' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart after changing CONNECTION" + + # Check that the expected QUERY STATISTICS message appears, + # which shows that log_statement_stats=on from the CONNECTION string + # was correctly passed through to and honored by the walsender. + logfile = node_publisher.log_content()[log_location_pub:] + assert re.search(r"QUERY STATISTICS", logfile), ( + "log_statement_stats in CONNECTION string had effect on publisher's " + "walsender" + ) + + oldpid = node_publisher.safe_sql( + "SELECT pid FROM pg_stat_replication WHERE application_name = 'tap_sub' " + "AND state = 'streaming';" + ) + node_subscriber.safe_sql( + "ALTER SUBSCRIPTION tap_sub SET PUBLICATION tap_pub_ins_only " + "WITH (copy_data = false)" + ) + assert node_publisher.poll_query_until( + f"SELECT pid != {oldpid} FROM pg_stat_replication " + "WHERE application_name = 'tap_sub' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart after changing PUBLICATION" + + node_publisher.safe_sql("INSERT INTO tab_ins SELECT generate_series(1001,1100)") + node_publisher.safe_sql("DELETE FROM tab_rep") + + # Restart the publisher and check the state of the subscriber which + # should be in a streaming state after catching up. + node_publisher.stop("fast") + node_publisher.start() + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_ins") + assert ( + result == "1152|1|1100" + ), "check replicated inserts after subscription publication change" + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_rep") + assert ( + result == "20|-20|-1" + ), "check changes skipped after subscription publication change" + + # check alter publication (relcache invalidation etc) + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub_ins_only SET (publish = 'insert, delete')" + ) + node_publisher.safe_sql("ALTER PUBLICATION tap_pub_ins_only ADD TABLE tab_full") + node_publisher.safe_sql("DELETE FROM tab_ins WHERE a > 0") + node_subscriber.safe_sql( + "ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION WITH (copy_data = false)" + ) + node_publisher.safe_sql("INSERT INTO tab_full VALUES(0)") + + node_publisher.wait_for_catchup("tap_sub") + + # Check that we don't send BEGIN and COMMIT because of empty transaction + # optimization. We have to look for the DEBUG1 log messages about that, so + # temporarily bump up the log verbosity. + node_publisher.append_conf("log_min_messages = debug1") + node_publisher.reload() + + # Note that the current location of the log file is not grabbed immediately + # after reloading the configuration, but after sending one SQL command to + # the node so that we are sure that the reloading has taken effect. + log_location_pub = node_publisher.log_position() + + node_publisher.safe_sql("INSERT INTO tab_notrep VALUES (11)") + + node_publisher.wait_for_catchup("tap_sub") + + # Poll for the DEBUG1 message: the walsender reprocesses the reloaded + # log_min_messages between decoding loops, so the message can lag the + # catchup slightly. + node_publisher.wait_for_log( + r"skipped replication of an empty transaction with XID", log_location_pub + ) + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_notrep") + assert result == "0", "check non-replicated table is empty on subscriber" + + node_publisher.append_conf("log_min_messages = warning") + node_publisher.reload() + + # note that data are different on provider and subscriber + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_ins") + assert result == "1052|1|1002", "check replicated deletes after alter publication" + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_full") + assert result == "19|0|100", "check replicated insert after alter publication" + + # check restart on rename + oldpid = node_publisher.safe_sql( + "SELECT pid FROM pg_stat_replication WHERE application_name = 'tap_sub' " + "AND state = 'streaming';" + ) + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub RENAME TO tap_sub_renamed") + assert node_publisher.poll_query_until( + f"SELECT pid != {oldpid} FROM pg_stat_replication " + "WHERE application_name = 'tap_sub_renamed' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart after renaming SUBSCRIPTION" + + # check all the cleanup + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_renamed") + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_subscription") + assert result == "0", "check subscription was dropped on subscriber" + + result = node_publisher.safe_sql("SELECT count(*) FROM pg_replication_slots") + assert result == "0", "check replication slot was dropped on publisher" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_subscription_rel") + assert result == "0", "check subscription relation status was dropped on subscriber" + + result = node_publisher.safe_sql("SELECT count(*) FROM pg_replication_slots") + assert result == "0", "check replication slot was dropped on publisher" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_replication_origin") + assert result == "0", "check replication origin was dropped on subscriber" + + node_subscriber.stop("fast") + node_publisher.stop("fast") + + # CREATE PUBLICATION while wal_level=minimal should succeed, with a WARNING + node_publisher.append_conf("\nwal_level=minimal\nmax_wal_senders=0\n") + node_publisher.start() + sess = node_publisher.connect() + try: + sess.query("BEGIN") + sess.query("CREATE TABLE skip_wal()") + sess.query("CREATE PUBLICATION tap_pub2 FOR TABLE skip_wal") + sess.query("ROLLBACK") + reterr = sess.get_notices_str() + finally: + sess.close() + assert re.search( + r"WARNING: logical decoding must be enabled to publish logical changes", reterr + ), 'CREATE PUBLICATION while "wal_level=minimal"' diff --git a/src/test/subscription/pyt/test_002_types.py b/src/test/subscription/pyt/test_002_types.py new file mode 100644 index 00000000000..35158f28b59 --- /dev/null +++ b/src/test/subscription/pyt/test_002_types.py @@ -0,0 +1,540 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that more complex datatypes are replicated correctly by logical +replication. +""" + + +# Setup structure on both nodes +DDL = r""" + CREATE EXTENSION hstore WITH SCHEMA public; + CREATE TABLE public.tst_one_array ( + a INTEGER PRIMARY KEY, + b INTEGER[] + ); + CREATE TABLE public.tst_arrays ( + a INTEGER[] PRIMARY KEY, + b TEXT[], + c FLOAT[], + d INTERVAL[] + ); + + CREATE TYPE public.tst_enum_t AS ENUM ('a', 'b', 'c', 'd', 'e'); + CREATE TABLE public.tst_one_enum ( + a INTEGER PRIMARY KEY, + b public.tst_enum_t + ); + CREATE TABLE public.tst_enums ( + a public.tst_enum_t PRIMARY KEY, + b public.tst_enum_t[] + ); + + CREATE TYPE public.tst_comp_basic_t AS (a FLOAT, b TEXT, c INTEGER); + CREATE TYPE public.tst_comp_enum_t AS (a FLOAT, b public.tst_enum_t, c INTEGER); + CREATE TYPE public.tst_comp_enum_array_t AS (a FLOAT, b public.tst_enum_t[], c INTEGER); + CREATE TABLE public.tst_one_comp ( + a INTEGER PRIMARY KEY, + b public.tst_comp_basic_t + ); + CREATE TABLE public.tst_comps ( + a public.tst_comp_basic_t PRIMARY KEY, + b public.tst_comp_basic_t[] + ); + CREATE TABLE public.tst_comp_enum ( + a INTEGER PRIMARY KEY, + b public.tst_comp_enum_t + ); + CREATE TABLE public.tst_comp_enum_array ( + a public.tst_comp_enum_t PRIMARY KEY, + b public.tst_comp_enum_t[] + ); + CREATE TABLE public.tst_comp_one_enum_array ( + a INTEGER PRIMARY KEY, + b public.tst_comp_enum_array_t + ); + CREATE TABLE public.tst_comp_enum_what ( + a public.tst_comp_enum_array_t PRIMARY KEY, + b public.tst_comp_enum_array_t[] + ); + + CREATE TYPE public.tst_comp_mix_t AS ( + a public.tst_comp_basic_t, + b public.tst_comp_basic_t[], + c public.tst_enum_t, + d public.tst_enum_t[] + ); + CREATE TABLE public.tst_comp_mix_array ( + a public.tst_comp_mix_t PRIMARY KEY, + b public.tst_comp_mix_t[] + ); + CREATE TABLE public.tst_range ( + a INTEGER PRIMARY KEY, + b int4range + ); + CREATE TABLE public.tst_range_array ( + a INTEGER PRIMARY KEY, + b TSTZRANGE, + c int8range[] + ); + CREATE TABLE public.tst_hstore ( + a INTEGER PRIMARY KEY, + b public.hstore + ); + + SET check_function_bodies=off; + CREATE FUNCTION public.monot_incr(int) RETURNS bool LANGUAGE sql + AS ' select $1 > max(a) from public.tst_dom_constr; '; + CREATE DOMAIN monot_int AS int CHECK (monot_incr(VALUE)); + CREATE TABLE public.tst_dom_constr (a monot_int); +""" + +# The query used to dump the replicated data on the subscriber. It is +# prefixed with SET timezone = '+2' so that timestamptz values are +# rendered deterministically. +CHECK_QUERY = """ + SET timezone = '+2'; + SELECT a, b FROM tst_one_array ORDER BY a; + SELECT a, b, c, d FROM tst_arrays ORDER BY a; + SELECT a, b FROM tst_one_enum ORDER BY a; + SELECT a, b FROM tst_enums ORDER BY a; + SELECT a, b FROM tst_one_comp ORDER BY a; + SELECT a, b FROM tst_comps ORDER BY a; + SELECT a, b FROM tst_comp_enum ORDER BY a; + SELECT a, b FROM tst_comp_enum_array ORDER BY a; + SELECT a, b FROM tst_comp_one_enum_array ORDER BY a; + SELECT a, b FROM tst_comp_enum_what ORDER BY a; + SELECT a, b FROM tst_comp_mix_array ORDER BY a; + SELECT a, b FROM tst_range ORDER BY a; + SELECT a, b, c FROM tst_range_array ORDER BY a; + SELECT a, b FROM tst_hstore ORDER BY a; +""" + + +def test_002_types(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher and setup structure on + # both nodes + node_publisher.safe_sql(DDL) + node_subscriber.safe_sql(DDL) + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR ALL TABLES") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub WITH (slot_name = tap_sub_slot)" + ) + + # Wait for initial sync to finish as well + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Insert initial test data + node_publisher.safe_sql( + r""" + -- test_tbl_one_array_col + INSERT INTO tst_one_array (a, b) VALUES + (1, '{1, 2, 3}'), + (2, '{2, 3, 1}'), + (3, '{3, 2, 1}'), + (4, '{4, 3, 2}'), + (5, '{5, NULL, 3}'); + + -- test_tbl_arrays + INSERT INTO tst_arrays (a, b, c, d) VALUES + ('{1, 2, 3}', '{"a", "b", "c"}', '{1.1, 2.2, 3.3}', '{"1 day", "2 days", "3 days"}'), + ('{2, 3, 1}', '{"b", "c", "a"}', '{2.2, 3.3, 1.1}', '{"2 minutes", "3 minutes", "1 minute"}'), + ('{3, 1, 2}', '{"c", "a", "b"}', '{3.3, 1.1, 2.2}', '{"3 years", "1 year", "2 years"}'), + ('{4, 1, 2}', '{"d", "a", "b"}', '{4.4, 1.1, 2.2}', '{"4 years", "1 year", "2 years"}'), + ('{5, NULL, NULL}', '{"e", NULL, "b"}', '{5.5, 1.1, NULL}', '{"5 years", NULL, NULL}'); + + -- test_tbl_single_enum + INSERT INTO tst_one_enum (a, b) VALUES + (1, 'a'), + (2, 'b'), + (3, 'c'), + (4, 'd'), + (5, NULL); + + -- test_tbl_enums + INSERT INTO tst_enums (a, b) VALUES + ('a', '{b, c}'), + ('b', '{c, a}'), + ('c', '{b, a}'), + ('d', '{c, b}'), + ('e', '{d, NULL}'); + + -- test_tbl_single_composites + INSERT INTO tst_one_comp (a, b) VALUES + (1, ROW(1.0, 'a', 1)), + (2, ROW(2.0, 'b', 2)), + (3, ROW(3.0, 'c', 3)), + (4, ROW(4.0, 'd', 4)), + (5, ROW(NULL, NULL, 5)); + + -- test_tbl_composites + INSERT INTO tst_comps (a, b) VALUES + (ROW(1.0, 'a', 1), ARRAY[ROW(1, 'a', 1)::tst_comp_basic_t]), + (ROW(2.0, 'b', 2), ARRAY[ROW(2, 'b', 2)::tst_comp_basic_t]), + (ROW(3.0, 'c', 3), ARRAY[ROW(3, 'c', 3)::tst_comp_basic_t]), + (ROW(4.0, 'd', 4), ARRAY[ROW(4, 'd', 3)::tst_comp_basic_t]), + (ROW(5.0, 'e', NULL), ARRAY[NULL, ROW(5, NULL, 5)::tst_comp_basic_t]); + + -- test_tbl_composite_with_enums + INSERT INTO tst_comp_enum (a, b) VALUES + (1, ROW(1.0, 'a', 1)), + (2, ROW(2.0, 'b', 2)), + (3, ROW(3.0, 'c', 3)), + (4, ROW(4.0, 'd', 4)), + (5, ROW(NULL, 'e', NULL)); + + -- test_tbl_composite_with_enums_array + INSERT INTO tst_comp_enum_array (a, b) VALUES + (ROW(1.0, 'a', 1), ARRAY[ROW(1, 'a', 1)::tst_comp_enum_t]), + (ROW(2.0, 'b', 2), ARRAY[ROW(2, 'b', 2)::tst_comp_enum_t]), + (ROW(3.0, 'c', 3), ARRAY[ROW(3, 'c', 3)::tst_comp_enum_t]), + (ROW(4.0, 'd', 3), ARRAY[ROW(3, 'd', 3)::tst_comp_enum_t]), + (ROW(5.0, 'e', 3), ARRAY[ROW(3, 'e', 3)::tst_comp_enum_t, NULL]); + + -- test_tbl_composite_with_single_enums_array_in_composite + INSERT INTO tst_comp_one_enum_array (a, b) VALUES + (1, ROW(1.0, '{a, b, c}', 1)), + (2, ROW(2.0, '{a, b, c}', 2)), + (3, ROW(3.0, '{a, b, c}', 3)), + (4, ROW(4.0, '{c, b, d}', 4)), + (5, ROW(5.0, '{NULL, e, NULL}', 5)); + + -- test_tbl_composite_with_enums_array_in_composite + INSERT INTO tst_comp_enum_what (a, b) VALUES + (ROW(1.0, '{a, b, c}', 1), ARRAY[ROW(1, '{a, b, c}', 1)::tst_comp_enum_array_t]), + (ROW(2.0, '{b, c, a}', 2), ARRAY[ROW(2, '{b, c, a}', 1)::tst_comp_enum_array_t]), + (ROW(3.0, '{c, a, b}', 1), ARRAY[ROW(3, '{c, a, b}', 1)::tst_comp_enum_array_t]), + (ROW(4.0, '{c, b, d}', 4), ARRAY[ROW(4, '{c, b, d}', 4)::tst_comp_enum_array_t]), + (ROW(5.0, '{c, NULL, b}', NULL), ARRAY[ROW(5, '{c, e, b}', 1)::tst_comp_enum_array_t]); + + -- test_tbl_mixed_composites + INSERT INTO tst_comp_mix_array (a, b) VALUES + (ROW( + ROW(1,'a',1), + ARRAY[ROW(1,'a',1)::tst_comp_basic_t, ROW(2,'b',2)::tst_comp_basic_t], + 'a', + '{a,b,NULL,c}'), + ARRAY[ + ROW( + ROW(1,'a',1), + ARRAY[ + ROW(1,'a',1)::tst_comp_basic_t, + ROW(2,'b',2)::tst_comp_basic_t, + NULL + ], + 'a', + '{a,b,c}' + )::tst_comp_mix_t + ] + ); + + -- test_tbl_range + INSERT INTO tst_range (a, b) VALUES + (1, '[1, 10]'), + (2, '[2, 20]'), + (3, '[3, 30]'), + (4, '[4, 40]'), + (5, '[5, 50]'); + + -- test_tbl_range_array + INSERT INTO tst_range_array (a, b, c) VALUES + (1, tstzrange('Mon Aug 04 00:00:00 2014 CEST'::timestamptz, 'infinity'), '{"[1,2]", "[10,20]"}'), + (2, tstzrange('Sat Aug 02 00:00:00 2014 CEST'::timestamptz, 'Mon Aug 04 00:00:00 2014 CEST'::timestamptz), '{"[2,3]", "[20,30]"}'), + (3, tstzrange('Fri Aug 01 00:00:00 2014 CEST'::timestamptz, 'Mon Aug 04 00:00:00 2014 CEST'::timestamptz), '{"[3,4]"}'), + (4, tstzrange('Thu Jul 31 00:00:00 2014 CEST'::timestamptz, 'Mon Aug 04 00:00:00 2014 CEST'::timestamptz), '{"[4,5]", NULL, "[40,50]"}'), + (5, NULL, NULL); + + -- tst_hstore + INSERT INTO tst_hstore (a, b) VALUES + (1, '"a"=>"1"'), + (2, '"zzz"=>"foo"'), + (3, '"123"=>"321"'), + (4, '"yellow horse"=>"moaned"'); + + -- tst_dom_constr + INSERT INTO tst_dom_constr VALUES (10); + """ + ) + + node_publisher.wait_for_catchup("tap_sub") + + # Check the data on subscriber + result = node_subscriber.safe_sql(CHECK_QUERY) + + assert ( + result + == r"""1|{1,2,3} +2|{2,3,1} +3|{3,2,1} +4|{4,3,2} +5|{5,NULL,3} +{1,2,3}|{a,b,c}|{1.1,2.2,3.3}|{"1 day","2 days","3 days"} +{2,3,1}|{b,c,a}|{2.2,3.3,1.1}|{00:02:00,00:03:00,00:01:00} +{3,1,2}|{c,a,b}|{3.3,1.1,2.2}|{"3 years","1 year","2 years"} +{4,1,2}|{d,a,b}|{4.4,1.1,2.2}|{"4 years","1 year","2 years"} +{5,NULL,NULL}|{e,NULL,b}|{5.5,1.1,NULL}|{"5 years",NULL,NULL} +1|a +2|b +3|c +4|d +5| +a|{b,c} +b|{c,a} +c|{b,a} +d|{c,b} +e|{d,NULL} +1|(1,a,1) +2|(2,b,2) +3|(3,c,3) +4|(4,d,4) +5|(,,5) +(1,a,1)|{"(1,a,1)"} +(2,b,2)|{"(2,b,2)"} +(3,c,3)|{"(3,c,3)"} +(4,d,4)|{"(4,d,3)"} +(5,e,)|{NULL,"(5,,5)"} +1|(1,a,1) +2|(2,b,2) +3|(3,c,3) +4|(4,d,4) +5|(,e,) +(1,a,1)|{"(1,a,1)"} +(2,b,2)|{"(2,b,2)"} +(3,c,3)|{"(3,c,3)"} +(4,d,3)|{"(3,d,3)"} +(5,e,3)|{"(3,e,3)",NULL} +1|(1,"{a,b,c}",1) +2|(2,"{a,b,c}",2) +3|(3,"{a,b,c}",3) +4|(4,"{c,b,d}",4) +5|(5,"{NULL,e,NULL}",5) +(1,"{a,b,c}",1)|{"(1,\"{a,b,c}\",1)"} +(2,"{b,c,a}",2)|{"(2,\"{b,c,a}\",1)"} +(3,"{c,a,b}",1)|{"(3,\"{c,a,b}\",1)"} +(4,"{c,b,d}",4)|{"(4,\"{c,b,d}\",4)"} +(5,"{c,NULL,b}",)|{"(5,\"{c,e,b}\",1)"} +("(1,a,1)","{""(1,a,1)"",""(2,b,2)""}",a,"{a,b,NULL,c}")|{"(\"(1,a,1)\",\"{\"\"(1,a,1)\"\",\"\"(2,b,2)\"\",NULL}\",a,\"{a,b,c}\")"} +1|[1,11) +2|[2,21) +3|[3,31) +4|[4,41) +5|[5,51) +1|["2014-08-04 00:00:00+02",infinity)|{"[1,3)","[10,21)"} +2|["2014-08-02 00:00:00+02","2014-08-04 00:00:00+02")|{"[2,4)","[20,31)"} +3|["2014-08-01 00:00:00+02","2014-08-04 00:00:00+02")|{"[3,5)"} +4|["2014-07-31 00:00:00+02","2014-08-04 00:00:00+02")|{"[4,6)",NULL,"[40,51)"} +5|| +1|"a"=>"1" +2|"zzz"=>"foo" +3|"123"=>"321" +4|"yellow horse"=>"moaned""" + + '"' + ), "check replicated inserts on subscriber" + + # Run batch of updates + node_publisher.safe_sql( + r""" + UPDATE tst_one_array SET b = '{4, 5, 6}' WHERE a = 1; + UPDATE tst_one_array SET b = '{4, 5, 6, 1}' WHERE a > 3; + UPDATE tst_arrays SET b = '{"1a", "2b", "3c"}', c = '{1.0, 2.0, 3.0}', d = '{"1 day 1 second", "2 days 2 seconds", "3 days 3 second"}' WHERE a = '{1, 2, 3}'; + UPDATE tst_arrays SET b = '{"c", "d", "e"}', c = '{3.0, 4.0, 5.0}', d = '{"3 day 1 second", "4 days 2 seconds", "5 days 3 second"}' WHERE a[1] > 3; + UPDATE tst_one_enum SET b = 'c' WHERE a = 1; + UPDATE tst_one_enum SET b = NULL WHERE a > 3; + UPDATE tst_enums SET b = '{e, NULL}' WHERE a = 'a'; + UPDATE tst_enums SET b = '{e, d}' WHERE a > 'c'; + UPDATE tst_one_comp SET b = ROW(1.0, 'A', 1) WHERE a = 1; + UPDATE tst_one_comp SET b = ROW(NULL, 'x', -1) WHERE a > 3; + UPDATE tst_comps SET b = ARRAY[ROW(9, 'x', -1)::tst_comp_basic_t] WHERE (a).a = 1.0; + UPDATE tst_comps SET b = ARRAY[NULL, ROW(9, 'x', NULL)::tst_comp_basic_t] WHERE (a).a > 3.9; + UPDATE tst_comp_enum SET b = ROW(1.0, NULL, NULL) WHERE a = 1; + UPDATE tst_comp_enum SET b = ROW(4.0, 'd', 44) WHERE a > 3; + UPDATE tst_comp_enum_array SET b = ARRAY[NULL, ROW(3, 'd', 3)::tst_comp_enum_t] WHERE a = ROW(1.0, 'a', 1)::tst_comp_enum_t; + UPDATE tst_comp_enum_array SET b = ARRAY[ROW(1, 'a', 1)::tst_comp_enum_t, ROW(2, 'b', 2)::tst_comp_enum_t] WHERE (a).a > 3; + UPDATE tst_comp_one_enum_array SET b = ROW(1.0, '{a, e, c}', NULL) WHERE a = 1; + UPDATE tst_comp_one_enum_array SET b = ROW(4.0, '{c, b, d}', 4) WHERE a > 3; + UPDATE tst_comp_enum_what SET b = ARRAY[NULL, ROW(1, '{a, b, c}', 1)::tst_comp_enum_array_t, ROW(NULL, '{a, e, c}', 2)::tst_comp_enum_array_t] WHERE (a).a = 1; + UPDATE tst_comp_enum_what SET b = ARRAY[ROW(5, '{a, b, c}', 5)::tst_comp_enum_array_t] WHERE (a).a > 3; + UPDATE tst_comp_mix_array SET b[2] = NULL WHERE ((a).a).a = 1; + UPDATE tst_range SET b = '[100, 1000]' WHERE a = 1; + UPDATE tst_range SET b = '(1, 90)' WHERE a > 3; + UPDATE tst_range_array SET c = '{"[100, 1000]"}' WHERE a = 1; + UPDATE tst_range_array SET b = tstzrange('Mon Aug 04 00:00:00 2014 CEST'::timestamptz, 'infinity'), c = '{NULL, "[11,9999999]"}' WHERE a > 3; + UPDATE tst_hstore SET b = '"updated"=>"value"' WHERE a < 3; + UPDATE tst_hstore SET b = '"also"=>"updated"' WHERE a = 3; + """ + ) + + node_publisher.wait_for_catchup("tap_sub") + + # Check the data on subscriber + result = node_subscriber.safe_sql(CHECK_QUERY) + + assert ( + result + == r"""1|{4,5,6} +2|{2,3,1} +3|{3,2,1} +4|{4,5,6,1} +5|{4,5,6,1} +{1,2,3}|{1a,2b,3c}|{1,2,3}|{"1 day 00:00:01","2 days 00:00:02","3 days 00:00:03"} +{2,3,1}|{b,c,a}|{2.2,3.3,1.1}|{00:02:00,00:03:00,00:01:00} +{3,1,2}|{c,a,b}|{3.3,1.1,2.2}|{"3 years","1 year","2 years"} +{4,1,2}|{c,d,e}|{3,4,5}|{"3 days 00:00:01","4 days 00:00:02","5 days 00:00:03"} +{5,NULL,NULL}|{c,d,e}|{3,4,5}|{"3 days 00:00:01","4 days 00:00:02","5 days 00:00:03"} +1|c +2|b +3|c +4| +5| +a|{e,NULL} +b|{c,a} +c|{b,a} +d|{e,d} +e|{e,d} +1|(1,A,1) +2|(2,b,2) +3|(3,c,3) +4|(,x,-1) +5|(,x,-1) +(1,a,1)|{"(9,x,-1)"} +(2,b,2)|{"(2,b,2)"} +(3,c,3)|{"(3,c,3)"} +(4,d,4)|{NULL,"(9,x,)"} +(5,e,)|{NULL,"(9,x,)"} +1|(1,,) +2|(2,b,2) +3|(3,c,3) +4|(4,d,44) +5|(4,d,44) +(1,a,1)|{NULL,"(3,d,3)"} +(2,b,2)|{"(2,b,2)"} +(3,c,3)|{"(3,c,3)"} +(4,d,3)|{"(1,a,1)","(2,b,2)"} +(5,e,3)|{"(1,a,1)","(2,b,2)"} +1|(1,"{a,e,c}",) +2|(2,"{a,b,c}",2) +3|(3,"{a,b,c}",3) +4|(4,"{c,b,d}",4) +5|(4,"{c,b,d}",4) +(1,"{a,b,c}",1)|{NULL,"(1,\"{a,b,c}\",1)","(,\"{a,e,c}\",2)"} +(2,"{b,c,a}",2)|{"(2,\"{b,c,a}\",1)"} +(3,"{c,a,b}",1)|{"(3,\"{c,a,b}\",1)"} +(4,"{c,b,d}",4)|{"(5,\"{a,b,c}\",5)"} +(5,"{c,NULL,b}",)|{"(5,\"{a,b,c}\",5)"} +("(1,a,1)","{""(1,a,1)"",""(2,b,2)""}",a,"{a,b,NULL,c}")|{"(\"(1,a,1)\",\"{\"\"(1,a,1)\"\",\"\"(2,b,2)\"\",NULL}\",a,\"{a,b,c}\")",NULL} +1|[100,1001) +2|[2,21) +3|[3,31) +4|[2,90) +5|[2,90) +1|["2014-08-04 00:00:00+02",infinity)|{"[100,1001)"} +2|["2014-08-02 00:00:00+02","2014-08-04 00:00:00+02")|{"[2,4)","[20,31)"} +3|["2014-08-01 00:00:00+02","2014-08-04 00:00:00+02")|{"[3,5)"} +4|["2014-08-04 00:00:00+02",infinity)|{NULL,"[11,10000000)"} +5|["2014-08-04 00:00:00+02",infinity)|{NULL,"[11,10000000)"} +1|"updated"=>"value" +2|"updated"=>"value" +3|"also"=>"updated" +4|"yellow horse"=>"moaned""" + + '"' + ), "check replicated updates on subscriber" + + # Run batch of deletes + node_publisher.safe_sql( + r""" + DELETE FROM tst_one_array WHERE a = 1; + DELETE FROM tst_one_array WHERE b = '{2, 3, 1}'; + DELETE FROM tst_arrays WHERE a = '{1, 2, 3}'; + DELETE FROM tst_arrays WHERE a[1] = 2; + DELETE FROM tst_one_enum WHERE a = 1; + DELETE FROM tst_one_enum WHERE b = 'b'; + DELETE FROM tst_enums WHERE a = 'a'; + DELETE FROM tst_enums WHERE b[1] = 'b'; + DELETE FROM tst_one_comp WHERE a = 1; + DELETE FROM tst_one_comp WHERE (b).a = 2.0; + DELETE FROM tst_comps WHERE (a).b = 'a'; + DELETE FROM tst_comps WHERE ROW(3, 'c', 3)::tst_comp_basic_t = ANY(b); + DELETE FROM tst_comp_enum WHERE a = 1; + DELETE FROM tst_comp_enum WHERE (b).a = 2.0; + DELETE FROM tst_comp_enum_array WHERE a = ROW(1.0, 'a', 1)::tst_comp_enum_t; + DELETE FROM tst_comp_enum_array WHERE ROW(3, 'c', 3)::tst_comp_enum_t = ANY(b); + DELETE FROM tst_comp_one_enum_array WHERE a = 1; + DELETE FROM tst_comp_one_enum_array WHERE 'a' = ANY((b).b); + DELETE FROM tst_comp_enum_what WHERE (a).a = 1; + DELETE FROM tst_comp_enum_what WHERE (b[1]).b = '{c, a, b}'; + DELETE FROM tst_comp_mix_array WHERE ((a).a).a = 1; + DELETE FROM tst_range WHERE a = 1; + DELETE FROM tst_range WHERE '[10,20]' && b; + DELETE FROM tst_range_array WHERE a = 1; + DELETE FROM tst_range_array WHERE tstzrange('Mon Aug 04 00:00:00 2014 CEST'::timestamptz, 'Mon Aug 05 00:00:00 2014 CEST'::timestamptz) && b; + DELETE FROM tst_hstore WHERE a = 1; + """ + ) + + node_publisher.wait_for_catchup("tap_sub") + + # Check the data on subscriber + result = node_subscriber.safe_sql(CHECK_QUERY) + + assert ( + result + == r"""3|{3,2,1} +4|{4,5,6,1} +5|{4,5,6,1} +{3,1,2}|{c,a,b}|{3.3,1.1,2.2}|{"3 years","1 year","2 years"} +{4,1,2}|{c,d,e}|{3,4,5}|{"3 days 00:00:01","4 days 00:00:02","5 days 00:00:03"} +{5,NULL,NULL}|{c,d,e}|{3,4,5}|{"3 days 00:00:01","4 days 00:00:02","5 days 00:00:03"} +3|c +4| +5| +b|{c,a} +d|{e,d} +e|{e,d} +3|(3,c,3) +4|(,x,-1) +5|(,x,-1) +(2,b,2)|{"(2,b,2)"} +(4,d,4)|{NULL,"(9,x,)"} +(5,e,)|{NULL,"(9,x,)"} +3|(3,c,3) +4|(4,d,44) +5|(4,d,44) +(2,b,2)|{"(2,b,2)"} +(4,d,3)|{"(1,a,1)","(2,b,2)"} +(5,e,3)|{"(1,a,1)","(2,b,2)"} +4|(4,"{c,b,d}",4) +5|(4,"{c,b,d}",4) +(2,"{b,c,a}",2)|{"(2,\"{b,c,a}\",1)"} +(4,"{c,b,d}",4)|{"(5,\"{a,b,c}\",5)"} +(5,"{c,NULL,b}",)|{"(5,\"{a,b,c}\",5)"} +2|["2014-08-02 00:00:00+02","2014-08-04 00:00:00+02")|{"[2,4)","[20,31)"} +3|["2014-08-01 00:00:00+02","2014-08-04 00:00:00+02")|{"[3,5)"} +2|"updated"=>"value" +3|"also"=>"updated" +4|"yellow horse"=>"moaned""" + + '"' + ), "check replicated deletes on subscriber" + + # Test a domain with a constraint backed by a SQL-language function, + # which needs an active snapshot in order to operate. + node_publisher.safe_sql("INSERT INTO tst_dom_constr VALUES (11)") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT sum(a) FROM tst_dom_constr") + assert result == "21", "sql-function constraint on domain" + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_003_constraints.py b/src/test/subscription/pyt/test_003_constraints.py new file mode 100644 index 00000000000..a65a41a2d9c --- /dev/null +++ b/src/test/subscription/pyt/test_003_constraints.py @@ -0,0 +1,138 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that constraints work on the subscriber.""" + + +def test_003_constraints(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Setup structure on publisher + node_publisher.safe_sql("CREATE TABLE tab_fk (bid int PRIMARY KEY);") + node_publisher.safe_sql( + "CREATE TABLE tab_fk_ref (id int PRIMARY KEY, junk text, " + "bid int REFERENCES tab_fk (bid));" + ) + + # Setup structure on subscriber; column order intentionally different + node_subscriber.safe_sql("CREATE TABLE tab_fk (bid int PRIMARY KEY);") + node_subscriber.safe_sql( + "CREATE TABLE tab_fk_ref (id int PRIMARY KEY, " + "bid int REFERENCES tab_fk (bid), junk text);" + ) + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR ALL TABLES;") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub WITH (copy_data = false)" + ) + + node_publisher.wait_for_catchup("tap_sub") + + node_publisher.safe_sql("INSERT INTO tab_fk (bid) VALUES (1);") + # "junk" value is meant to be large enough to force out-of-line storage + node_publisher.safe_sql( + "INSERT INTO tab_fk_ref (id, bid, junk) " + "VALUES (1, 1, repeat(pi()::text,20000));" + ) + + node_publisher.wait_for_catchup("tap_sub") + + # Check data on subscriber + result = node_subscriber.safe_sql( + "SELECT count(*), min(bid), max(bid) FROM tab_fk;" + ) + assert result == "1|1|1", "check replicated tab_fk inserts on subscriber" + + result = node_subscriber.safe_sql( + "SELECT count(*), min(bid), max(bid) FROM tab_fk_ref;" + ) + assert result == "1|1|1", "check replicated tab_fk_ref inserts on subscriber" + + # Drop the fk on publisher + node_publisher.safe_sql("DROP TABLE tab_fk CASCADE;") + + # Insert data + node_publisher.safe_sql("INSERT INTO tab_fk_ref (id, bid) VALUES (2, 2);") + + node_publisher.wait_for_catchup("tap_sub") + + # FK is not enforced on subscriber + result = node_subscriber.safe_sql( + "SELECT count(*), min(bid), max(bid) FROM tab_fk_ref;" + ) + assert result == "2|1|2", "check FK ignored on subscriber" + + # Add replica trigger + node_subscriber.safe_sql( + """ +CREATE FUNCTION filter_basic_dml_fn() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + IF (NEW.id < 10) THEN + RETURN NEW; + ELSE + RETURN NULL; + END IF; + ELSIF (TG_OP = 'UPDATE') THEN + RETURN NULL; + ELSE + RAISE WARNING 'Unknown action'; + RETURN NULL; + END IF; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER filter_basic_dml_trg + BEFORE INSERT OR UPDATE OF bid ON tab_fk_ref + FOR EACH ROW EXECUTE PROCEDURE filter_basic_dml_fn(); +ALTER TABLE tab_fk_ref ENABLE REPLICA TRIGGER filter_basic_dml_trg; +""" + ) + + # Insert data + node_publisher.safe_sql("INSERT INTO tab_fk_ref (id, bid) VALUES (10, 10);") + + node_publisher.wait_for_catchup("tap_sub") + + # The trigger should cause the insert to be skipped on subscriber + result = node_subscriber.safe_sql( + "SELECT count(*), min(bid), max(bid) FROM tab_fk_ref;" + ) + assert result == "2|1|2", "check replica insert trigger applied on subscriber" + + # Update data + node_publisher.safe_sql("UPDATE tab_fk_ref SET bid = 2 WHERE bid = 1;") + + node_publisher.wait_for_catchup("tap_sub") + + # The trigger should cause the update to be skipped on subscriber + result = node_subscriber.safe_sql( + "SELECT count(*), min(bid), max(bid) FROM tab_fk_ref;" + ) + assert ( + result == "2|1|2" + ), "check replica update column trigger applied on subscriber" + + # Update on a column not specified in the trigger, but it will trigger + # anyway because logical replication ships all columns in an update. + node_publisher.safe_sql("UPDATE tab_fk_ref SET id = 6 WHERE id = 1;") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql( + "SELECT count(*), min(id), max(id) FROM tab_fk_ref;" + ) + assert ( + result == "2|1|2" + ), "check column trigger applied even on update for other column" + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_004_sync.py b/src/test/subscription/pyt/test_004_sync.py new file mode 100644 index 00000000000..c031989db0b --- /dev/null +++ b/src/test/subscription/pyt/test_004_sync.py @@ -0,0 +1,175 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for logical replication table syncing.""" + + +def test_004_sync(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node + node_subscriber = create_pg("subscriber", start=False) + node_subscriber.append_conf("postgresql.conf", "wal_retrieve_retry_interval = 1ms") + node_subscriber.start() + + # Create some preexisting content on publisher + node_publisher.safe_sql("CREATE TABLE tab_rep (a int primary key)") + node_publisher.safe_sql("INSERT INTO tab_rep SELECT generate_series(1,10)") + + # Setup structure on subscriber + node_subscriber.safe_sql("CREATE TABLE tab_rep (a int primary key)") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR ALL TABLES") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_rep") + assert result == "10", "initial data synced for first sub" + + # drop subscription so that there is unreplicated data + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + + node_publisher.safe_sql("INSERT INTO tab_rep SELECT generate_series(11,20)") + + # recreate the subscription, it will try to do initial copy + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub" + ) + + # but it will be stuck on data copy as it will fail on constraint + started_query = "SELECT srsubstate = 'd' FROM pg_subscription_rel;" + assert node_subscriber.poll_query_until( + started_query + ), "Timed out while waiting for subscriber to start sync" + + # remove the conflicting data + node_subscriber.safe_sql("DELETE FROM tab_rep;") + + # wait for sync to finish this time + node_subscriber.wait_for_subscription_sync() + + # check that all data is synced + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_rep") + assert result == "20", "initial data synced for second sub" + + # now check another subscription for the same node pair + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub2 CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub WITH (copy_data = false)" + ) + + # wait for it to start + assert node_subscriber.poll_query_until( + "SELECT pid IS NOT NULL FROM pg_stat_subscription " + "WHERE subname = 'tap_sub2' AND worker_type = 'apply'" + ), "Timed out while waiting for subscriber to start" + + # and drop both subscriptions + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub2") + + # check subscriptions are removed + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_subscription") + assert result == "0", "second and third sub are dropped" + + # remove the conflicting data + node_subscriber.safe_sql("DELETE FROM tab_rep;") + + # recreate the subscription again + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub" + ) + + # and wait for data sync to finish again + node_subscriber.wait_for_subscription_sync() + + # check that all data is synced + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_rep") + assert result == "20", "initial data synced for fourth sub" + + # add new table on subscriber + node_subscriber.safe_sql("CREATE TABLE tab_rep_next (a int)") + + # setup structure with existing data on publisher + node_publisher.safe_sql( + "CREATE TABLE tab_rep_next (a) AS SELECT generate_series(1,10)" + ) + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_rep_next") + assert result == "0", "no data for table added after subscription initialized" + + # ask for data sync + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION") + + # wait for sync to finish + node_subscriber.wait_for_subscription_sync() + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_rep_next") + assert ( + result == "10" + ), "data for table added after subscription initialized are now synced" + + # Add some data + node_publisher.safe_sql("INSERT INTO tab_rep_next SELECT generate_series(1,10)") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_rep_next") + assert ( + result == "20" + ), "changes for table added after subscription initialized replicated" + + # clean up + node_publisher.safe_sql("DROP TABLE tab_rep_next") + node_subscriber.safe_sql("DROP TABLE tab_rep_next") + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + + # Table tab_rep already has the same records on both publisher and + # subscriber at this time. Recreate the subscription which will do the + # initial copy of the table again and fails due to unique constraint + # violation. + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub" + ) + + assert node_subscriber.poll_query_until( + started_query + ), "Timed out while waiting for subscriber to start sync" + + # DROP SUBSCRIPTION must clean up slots on the publisher side when the + # subscriber is stuck on data copy for constraint violation. + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + + # When DROP SUBSCRIPTION tries to drop the tablesync slot, the slot may not + # have been created, which causes the slot to be created after the DROP + # SUBSCRIPTION finishes. Such slots eventually get dropped at walsender exit + # time. So, to prevent being affected by such ephemeral tablesync slots, we + # wait until all the slots have been cleaned. + assert node_publisher.poll_query_until( + "SELECT count(*) = 0 FROM pg_replication_slots" + ), "DROP SUBSCRIPTION during error can clean up the slots on the publisher" + + # After dropping the subscription, all replication origins, whether created + # by an apply worker or table sync worker, should have been cleaned up. + result = node_subscriber.safe_sql( + "SELECT count(*) FROM pg_replication_origin_status" + ) + assert result == "0", "all replication origins have been cleaned up" + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_005_encoding.py b/src/test/subscription/pyt/test_005_encoding.py new file mode 100644 index 00000000000..9da76227e86 --- /dev/null +++ b/src/test/subscription/pyt/test_005_encoding.py @@ -0,0 +1,46 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test replication between databases with different encodings.""" + + +def test_005_encoding(create_pg): + node_publisher = create_pg( + "publisher", + allows_streaming="logical", + initdb_extra=["--locale=C", "--encoding=UTF8"], + ) + + node_subscriber = create_pg( + "subscriber", + initdb_extra=["--locale=C", "--encoding=LATIN1"], + ) + + ddl = "CREATE TABLE test1 (a int, b text);" + node_publisher.safe_sql(ddl) + node_subscriber.safe_sql(ddl) + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + node_publisher.safe_sql("CREATE PUBLICATION mypub FOR ALL TABLES;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION mysub CONNECTION '{publisher_connstr}' " + "PUBLICATION mypub;" + ) + + # Wait for initial sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "mysub") + + node_publisher.safe_sql( + r"INSERT INTO test1 VALUES (1, E'Mot\xc3\xb6rhead')" + ) # hand-rolled UTF-8 + + node_publisher.wait_for_catchup("mysub") + + assert node_subscriber.poll_query_until( + r"SELECT a FROM test1 WHERE b = E'Mot\xf6rhead'", expected="1" # LATIN1 + ), "data replicated to subscriber" + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_006_rewrite.py b/src/test/subscription/pyt/test_006_rewrite.py new file mode 100644 index 00000000000..3493809d212 --- /dev/null +++ b/src/test/subscription/pyt/test_006_rewrite.py @@ -0,0 +1,52 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test logical replication behavior with heap rewrites.""" + + +def test_006_rewrite(create_pg): + node_publisher = create_pg("publisher", allows_streaming="logical") + + node_subscriber = create_pg("subscriber") + + ddl = "CREATE TABLE test1 (a int, b text);" + node_publisher.safe_sql(ddl) + node_subscriber.safe_sql(ddl) + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + node_publisher.safe_sql("CREATE PUBLICATION mypub FOR ALL TABLES;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION mysub CONNECTION '{publisher_connstr}' " + "PUBLICATION mypub;" + ) + + # Wait for initial sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "mysub") + + node_publisher.safe_sql("INSERT INTO test1 (a, b) VALUES (1, 'one'), (2, 'two');") + + node_publisher.wait_for_catchup("mysub") + + assert node_subscriber.safe_sql("SELECT a, b FROM test1") == ( + "1|one\n" "2|two" + ), "initial data replicated to subscriber" + + # DDL that causes a heap rewrite + ddl2 = "ALTER TABLE test1 ADD c int NOT NULL DEFAULT 0;" + node_subscriber.safe_sql(ddl2) + node_publisher.safe_sql(ddl2) + + node_publisher.wait_for_catchup("mysub") + + node_publisher.safe_sql("INSERT INTO test1 (a, b, c) VALUES (3, 'three', 33);") + + node_publisher.wait_for_catchup("mysub") + + assert node_subscriber.safe_sql("SELECT a, b, c FROM test1") == ( + "1|one|0\n" "2|two|0\n" "3|three|33" + ), "data replicated to subscriber" + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_007_ddl.py b/src/test/subscription/pyt/test_007_ddl.py new file mode 100644 index 00000000000..603d20a02db --- /dev/null +++ b/src/test/subscription/pyt/test_007_ddl.py @@ -0,0 +1,181 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test some logical replication DDL behavior.""" + +import re + + +def test_007_ddl(create_pg): + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + ddl = "CREATE TABLE test1 (a int, b text);" + node_publisher.safe_sql(ddl) + node_subscriber.safe_sql(ddl) + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + node_publisher.safe_sql("CREATE PUBLICATION mypub FOR ALL TABLES;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION mysub CONNECTION '{publisher_connstr}' " + "PUBLICATION mypub;" + ) + + node_publisher.wait_for_catchup("mysub") + + # Disable and drop a subscription in the same transaction. safe_sql + # runs the explicit BEGIN/COMMIT block below as one transaction. + node_subscriber.safe_sql( + "BEGIN;\n" + "ALTER SUBSCRIPTION mysub DISABLE;\n" + "ALTER SUBSCRIPTION mysub SET (slot_name = NONE);\n" + "DROP SUBSCRIPTION mysub;\n" + "COMMIT;\n" + ) + + # pass: subscription disable and drop in same transaction did not hang + + # One of the specified publications exists. + sess = node_subscriber.connect() + try: + sess.clear_notices() + res = sess.query( + f"CREATE SUBSCRIPTION mysub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION mypub, non_existent_pub" + ) + assert res.error_message is None, res.error_message + stderr = sess.get_notices_str() + finally: + sess.close() + assert re.search( + 'WARNING: publication "non_existent_pub" does not exist on the publisher', + stderr, + ), "Create subscription throws warning for non-existent publication" + + # Wait for initial table sync to finish. + node_subscriber.wait_for_subscription_sync(node_publisher, "mysub1") + + # Specifying non-existent publication along with add publication. + sess = node_subscriber.connect() + try: + sess.clear_notices() + res = sess.query( + "ALTER SUBSCRIPTION mysub1 ADD PUBLICATION non_existent_pub1, " + "non_existent_pub2" + ) + assert res.error_message is None, res.error_message + stderr = sess.get_notices_str() + finally: + sess.close() + assert re.search( + r'WARNING: publications "non_existent_pub1", "non_existent_pub2" do ' + r"not exist on the publisher", + stderr, + ), ( + "Alter subscription add publication throws warning for non-existent " + "publications" + ) + + # Specifying non-existent publication along with set publication. + sess = node_subscriber.connect() + try: + sess.clear_notices() + res = sess.query("ALTER SUBSCRIPTION mysub1 SET PUBLICATION non_existent_pub") + assert res.error_message is None, res.error_message + stderr = sess.get_notices_str() + finally: + sess.close() + assert re.search( + 'WARNING: publication "non_existent_pub" does not exist on the publisher', + stderr, + ), ( + "Alter subscription set publication throws warning for non-existent " + "publication" + ) + + # Cleanup + node_publisher.safe_sql( + "DROP PUBLICATION mypub;\nSELECT pg_drop_replication_slot('mysub');\n" + ) + node_subscriber.safe_sql("DROP SUBSCRIPTION mysub1") + + # + # Test ALTER PUBLICATION RENAME command during the replication + # + + def test_swap(table_name, pubname, appname): + # Confirms tuples can be replicated + node_publisher.safe_sql(f"INSERT INTO {table_name} VALUES (1);") + node_publisher.wait_for_catchup(appname) + result = node_subscriber.safe_sql(f"SELECT a FROM {table_name}") + assert ( + result == "1" + ), "check replication worked well before renaming a publication" + + # Swap the name of publications; pubname <-> pub_empty + node_publisher.safe_sql( + f"ALTER PUBLICATION {pubname} RENAME TO tap_pub_tmp;\n" + f"ALTER PUBLICATION pub_empty RENAME TO {pubname};\n" + "ALTER PUBLICATION tap_pub_tmp RENAME TO pub_empty;\n" + ) + + # Insert the data again + node_publisher.safe_sql(f"INSERT INTO {table_name} VALUES (2);") + node_publisher.wait_for_catchup(appname) + + # Confirms the second tuple won't be replicated because pubname does + # not contain relations anymore. + result = node_subscriber.safe_sql(f"SELECT a FROM {table_name} ORDER BY a") + assert ( + result == "1" + ), "check the tuple inserted after the RENAME was not replicated" + + # Restore the name of publications because it can be called several + # times + node_publisher.safe_sql( + f"ALTER PUBLICATION {pubname} RENAME TO tap_pub_tmp;\n" + f"ALTER PUBLICATION pub_empty RENAME TO {pubname};\n" + "ALTER PUBLICATION tap_pub_tmp RENAME TO pub_empty;\n" + ) + + # Create another table + ddl = "CREATE TABLE test2 (a int, b text);" + node_publisher.safe_sql(ddl) + node_subscriber.safe_sql(ddl) + + # Create publications and a subscription + node_publisher.safe_sql( + "CREATE PUBLICATION pub_empty;\n" + "CREATE PUBLICATION pub_for_tab FOR TABLE test1;\n" + "CREATE PUBLICATION pub_for_all_tables FOR ALL TABLES;\n" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_for_tab" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Confirms RENAME command works well for a publication + test_swap("test1", "pub_for_tab", "tap_sub") + + # Switches a publication which includes all tables + node_subscriber.safe_sql( + "ALTER SUBSCRIPTION tap_sub SET PUBLICATION pub_for_all_tables;" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Confirms RENAME command works well for ALL TABLES publication + test_swap("test2", "pub_for_all_tables", "tap_sub") + + # Cleanup + node_publisher.safe_sql( + "DROP PUBLICATION pub_empty, pub_for_tab, pub_for_all_tables;\n" + "DROP TABLE test1, test2;\n" + ) + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub;") + node_subscriber.safe_sql("DROP TABLE test1, test2;") + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_008_diff_schema.py b/src/test/subscription/pyt/test_008_diff_schema.py new file mode 100644 index 00000000000..48532df5a70 --- /dev/null +++ b/src/test/subscription/pyt/test_008_diff_schema.py @@ -0,0 +1,126 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test behavior with a different schema on the subscriber.""" + + +def test_008_diff_schema(create_pg): + # Create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql("CREATE TABLE test_tab (a int primary key, b varchar)") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (1, 'foo'), (2, 'bar')") + + # Setup structure on subscriber + node_subscriber.safe_sql( + "CREATE TABLE test_tab (a int primary key, b text, " + "c timestamptz DEFAULT now(), d bigint DEFAULT 999, " + "e int GENERATED BY DEFAULT AS IDENTITY)" + ) + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} " "dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR ALL TABLES") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "2|2|2", "check initial data was copied to subscriber" + + # Update the rows on the publisher and check the additional columns on + # subscriber didn't change + node_publisher.safe_sql("UPDATE test_tab SET b = encode(sha256(b::bytea), 'hex')") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999), count(e) FROM test_tab" + ) + assert result == "2|2|2|2", "check extra columns contain local defaults after copy" + + # Change the local values of the extra columns on the subscriber, + # update publisher, and check that subscriber retains the expected + # values + node_subscriber.safe_sql( + "UPDATE test_tab SET c = 'epoch'::timestamptz + 987654321 * interval '1s'" + ) + node_publisher.safe_sql( + "UPDATE test_tab SET b = encode(sha256(a::text::bytea), 'hex')" + ) + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql( + "SELECT count(*), count(extract(epoch from c) = 987654321), " + "count(d = 999) FROM test_tab" + ) + assert result == "2|2|2", "check extra columns contain locally changed data" + + # Another insert + node_publisher.safe_sql("INSERT INTO test_tab VALUES (3, 'baz')") + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999), count(e) FROM test_tab" + ) + assert result == "3|3|3|3", "check extra columns contain local defaults after apply" + + # Check a bug about adding a replica identity column on the subscriber + # that was not yet mapped to a column on the publisher. This would + # result in errors on the subscriber and replication thus not + # progressing. + # (https://www.postgresql.org/message-id/flat/a9139c29-7ddd-973b-aa7f-71fed9c38d75%40minerva.info) + + node_publisher.safe_sql("CREATE TABLE test_tab2 (a int)") + + node_subscriber.safe_sql("CREATE TABLE test_tab2 (a int)") + + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION") + + node_subscriber.wait_for_subscription_sync() + + # Add replica identity column. (The serial is not necessary, but it's + # a convenient way to get a default on the new column so that rows + # from the publisher that don't have the column yet can be inserted.) + node_subscriber.safe_sql("ALTER TABLE test_tab2 ADD COLUMN b serial PRIMARY KEY") + + node_publisher.safe_sql("INSERT INTO test_tab2 VALUES (1)") + + node_publisher.wait_for_catchup("tap_sub") + + assert ( + node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM test_tab2") + == "1|1|1" + ), "check replicated inserts on subscriber" + + # Test if the expected error is reported when the subscriber table is + # missing columns which were specified on the publisher table. + node_publisher.safe_sql("CREATE TABLE test_tab3 (a int, b int, c int)") + node_subscriber.safe_sql("CREATE TABLE test_tab3 (a int)") + + offset = node_subscriber.log_position() + + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION") + + node_subscriber.wait_for_log( + r"ERROR: ( [A-Z0-9]+:)? logical replication target relation " + r'"public.test_tab3" is missing replicated columns: "b", "c"', + offset, + ) + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_009_matviews.py b/src/test/subscription/pyt/test_009_matviews.py new file mode 100644 index 00000000000..de80920d147 --- /dev/null +++ b/src/test/subscription/pyt/test_009_matviews.py @@ -0,0 +1,41 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test materialized views behavior.""" + + +def test_009_matviews(create_pg): + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + node_publisher.safe_sql("CREATE TABLE test1 (a int PRIMARY KEY, b text)") + node_subscriber.safe_sql("CREATE TABLE test1 (a int PRIMARY KEY, b text);") + + node_publisher.safe_sql("CREATE PUBLICATION mypub FOR ALL TABLES;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION mysub CONNECTION '{publisher_connstr}' " + "PUBLICATION mypub;" + ) + + node_publisher.safe_sql("INSERT INTO test1 (a, b) VALUES (1, 'one'), (2, 'two');") + + node_publisher.wait_for_catchup("mysub") + + # Materialized views are not supported by logical replication, but + # logical decoding does produce change information for them, so we + # need to make sure they are properly ignored. (bug #15044) + + # create a MV with some data + node_publisher.safe_sql("CREATE MATERIALIZED VIEW testmv1 AS SELECT * FROM test1;") + node_publisher.wait_for_catchup("mysub") + + # There is no equivalent relation on the subscriber, but MV data is + # not replicated, so this does not hang. + + # pass: materialized view data not replicated + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_010_truncate.py b/src/test/subscription/pyt/test_010_truncate.py new file mode 100644 index 00000000000..15f04b5c61c --- /dev/null +++ b/src/test/subscription/pyt/test_010_truncate.py @@ -0,0 +1,203 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test TRUNCATE.""" + + +def test_010_truncate(create_pg): + # setup + + node_publisher = create_pg("publisher", allows_streaming="logical") + + node_subscriber = create_pg("subscriber", start=False) + node_subscriber.append_conf("max_logical_replication_workers = 6") + node_subscriber.start() + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} " "dbname=postgres" + ) + + node_publisher.safe_sql("CREATE TABLE tab1 (a int PRIMARY KEY)") + node_subscriber.safe_sql("CREATE TABLE tab1 (a int PRIMARY KEY)") + + node_publisher.safe_sql("CREATE TABLE tab2 (a int PRIMARY KEY)") + node_subscriber.safe_sql("CREATE TABLE tab2 (a int PRIMARY KEY)") + + node_publisher.safe_sql("CREATE TABLE tab3 (a int PRIMARY KEY)") + node_subscriber.safe_sql("CREATE TABLE tab3 (a int PRIMARY KEY)") + + node_publisher.safe_sql( + "CREATE TABLE tab4 (x int PRIMARY KEY, y int REFERENCES tab3)" + ) + node_subscriber.safe_sql( + "CREATE TABLE tab4 (x int PRIMARY KEY, y int REFERENCES tab3)" + ) + + node_subscriber.safe_sql("CREATE SEQUENCE seq1 OWNED BY tab1.a") + node_subscriber.safe_sql("ALTER SEQUENCE seq1 START 101") + + node_publisher.safe_sql("CREATE PUBLICATION pub1 FOR TABLE tab1") + node_publisher.safe_sql( + "CREATE PUBLICATION pub2 FOR TABLE tab2 WITH (publish = insert)" + ) + node_publisher.safe_sql("CREATE PUBLICATION pub3 FOR TABLE tab3, tab4") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub2 CONNECTION '{publisher_connstr}' " "PUBLICATION pub2" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub3 CONNECTION '{publisher_connstr}' " "PUBLICATION pub3" + ) + + # Wait for initial sync of all subscriptions + node_subscriber.wait_for_subscription_sync() + + # insert data to truncate + + node_subscriber.safe_sql("INSERT INTO tab1 VALUES (1), (2), (3)") + + node_publisher.wait_for_catchup("sub1") + + # truncate and check + + node_publisher.safe_sql("TRUNCATE tab1") + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab1") + assert result == "0||", "truncate replicated" + + result = node_subscriber.safe_sql("SELECT nextval('seq1')") + assert result == "1", "sequence not restarted" + + # truncate with restart identity + + node_publisher.safe_sql("TRUNCATE tab1 RESTART IDENTITY") + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT nextval('seq1')") + assert result == "101", "truncate restarted identities" + + # test publication that does not replicate truncate + + node_subscriber.safe_sql("INSERT INTO tab2 VALUES (1), (2), (3)") + + node_publisher.safe_sql("TRUNCATE tab2") + + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab2") + assert result == "3|1|3", "truncate not replicated" + + node_publisher.safe_sql("ALTER PUBLICATION pub2 SET (publish = 'insert, truncate')") + + node_publisher.safe_sql("TRUNCATE tab2") + + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab2") + assert result == "0||", "truncate replicated after publication change" + + # test multiple tables connected by foreign keys + + node_subscriber.safe_sql("INSERT INTO tab3 VALUES (1), (2), (3)") + node_subscriber.safe_sql("INSERT INTO tab4 VALUES (11, 1), (111, 1), (22, 2)") + + node_publisher.safe_sql("TRUNCATE tab3, tab4") + + node_publisher.wait_for_catchup("sub3") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab3") + assert result == "0||", "truncate of multiple tables replicated" + result = node_subscriber.safe_sql("SELECT count(*), min(x), max(x) FROM tab4") + assert result == "0||", "truncate of multiple tables replicated" + + # test truncate of multiple tables, some of which are not published + + node_subscriber.safe_sql("DROP SUBSCRIPTION sub2") + node_publisher.safe_sql("DROP PUBLICATION pub2") + + node_subscriber.safe_sql("INSERT INTO tab1 VALUES (1), (2), (3)") + node_subscriber.safe_sql("INSERT INTO tab2 VALUES (1), (2), (3)") + + node_publisher.safe_sql("TRUNCATE tab1, tab2") + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab1") + assert result == "0||", "truncate of multiple tables some not published" + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab2") + assert result == "3|1|3", "truncate of multiple tables some not published" + + # Test that truncate works for synchronous logical replication + + node_publisher.safe_sql("ALTER SYSTEM SET synchronous_standby_names TO 'sub1'") + node_publisher.safe_sql("SELECT pg_reload_conf()") + + # insert data to truncate + + node_publisher.safe_sql("INSERT INTO tab1 VALUES (1), (2), (3)") + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab1") + assert result == "3|1|3", "check synchronous logical replication" + + node_publisher.safe_sql("TRUNCATE tab1") + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab1") + assert result == "0||", "truncate replicated in synchronous logical replication" + + node_publisher.safe_sql("ALTER SYSTEM RESET synchronous_standby_names") + node_publisher.safe_sql("SELECT pg_reload_conf()") + + # test that truncate works for logical replication when there are multiple + # subscriptions for a single table + + node_publisher.safe_sql("CREATE TABLE tab5 (a int)") + + node_subscriber.safe_sql("CREATE TABLE tab5 (a int)") + + node_publisher.safe_sql("CREATE PUBLICATION pub5 FOR TABLE tab5") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub5_1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub5" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub5_2 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub5" + ) + + # wait for initial data sync + node_subscriber.wait_for_subscription_sync() + + # insert data to truncate + + node_publisher.safe_sql("INSERT INTO tab5 VALUES (1), (2), (3)") + + node_publisher.wait_for_catchup("sub5_1") + node_publisher.wait_for_catchup("sub5_2") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab5") + assert result == "6|1|3", "insert replicated for multiple subscriptions" + + node_publisher.safe_sql("TRUNCATE tab5") + + node_publisher.wait_for_catchup("sub5_1") + node_publisher.wait_for_catchup("sub5_2") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab5") + assert result == "0||", "truncate replicated for multiple subscriptions" + + # check deadlocks + result = node_subscriber.safe_sql( + "SELECT deadlocks FROM pg_stat_database WHERE datname='postgres'" + ) + assert result == "0", "no deadlocks detected" + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_011_generated.py b/src/test/subscription/pyt/test_011_generated.py new file mode 100644 index 00000000000..b0af0f5f6d6 --- /dev/null +++ b/src/test/subscription/pyt/test_011_generated.py @@ -0,0 +1,358 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test generated columns.""" + + +def test_011_generated(create_pg): + # setup + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} " "dbname=postgres" + ) + + node_publisher.safe_sql( + "CREATE TABLE tab1 (a int PRIMARY KEY, " + "b int GENERATED ALWAYS AS (a * 2) STORED, " + "c int GENERATED ALWAYS AS (a * 3) VIRTUAL)" + ) + + node_subscriber.safe_sql( + "CREATE TABLE tab1 (a int PRIMARY KEY, " + "b int GENERATED ALWAYS AS (a * 22) STORED, " + "c int GENERATED ALWAYS AS (a * 33) VIRTUAL, d int)" + ) + + # data for initial sync + node_publisher.safe_sql("INSERT INTO tab1 (a) VALUES (1), (2), (3)") + + node_publisher.safe_sql("CREATE PUBLICATION pub1 FOR ALL TABLES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + + # Wait for initial sync of all subscriptions + node_subscriber.wait_for_subscription_sync() + + result = node_subscriber.safe_sql("SELECT a, b, c FROM tab1") + assert result == "1|22|33\n2|44|66\n3|66|99", "generated columns initial sync" + + # data to replicate + node_publisher.safe_sql("INSERT INTO tab1 VALUES (4), (5)") + + node_publisher.safe_sql("UPDATE tab1 SET a = 6 WHERE a = 5") + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM tab1") + assert result == ( + "1|22|33|\n2|44|66|\n3|66|99|\n4|88|132|\n6|132|198|" + ), "generated columns replicated" + + # try it with a subscriber-side trigger + node_subscriber.safe_sql( + """ +CREATE FUNCTION tab1_trigger_func() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + NEW.d := NEW.a + 10; + RETURN NEW; +END $$; + +CREATE TRIGGER test1 BEFORE INSERT OR UPDATE ON tab1 + FOR EACH ROW + EXECUTE PROCEDURE tab1_trigger_func(); + +ALTER TABLE tab1 ENABLE REPLICA TRIGGER test1; +""" + ) + + node_publisher.safe_sql("INSERT INTO tab1 VALUES (7), (8)") + + node_publisher.safe_sql("UPDATE tab1 SET a = 9 WHERE a = 7") + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM tab1 ORDER BY 1") + assert result == ( + "1|22|33|\n2|44|66|\n3|66|99|\n4|88|132|\n" + "6|132|198|\n8|176|264|18\n9|198|297|19" + ), "generated columns replicated with trigger" + + # cleanup + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1") + node_publisher.safe_sql("DROP PUBLICATION pub1") + + # ===================================================================== + # Exercise logical replication of a generated column to a subscriber + # side regular column. This is done both when the publication parameter + # 'publish_generated_columns' is set to 'none' (to confirm existing + # default behavior), and is set to 'stored' (to confirm replication + # occurs). + # + # The test environment is set up as follows: + # + # - Publication pub1 on the 'postgres' database. + # pub1 has publish_generated_columns as 'none'. + # + # - Publication pub2 on the 'postgres' database. + # pub2 has publish_generated_columns as 'stored'. + # + # - Subscription sub1 on the 'postgres' database for publication pub1. + # + # - Subscription sub2 on the 'test_pgc_true' database for publication + # pub2. + # ===================================================================== + + node_subscriber.safe_sql("CREATE DATABASE test_pgc_true") + + # ---------------------------------------------------------------- + # Test Case: Generated to regular column replication + # Publisher table has generated column 'b'. + # Subscriber table has regular column 'b'. + # ---------------------------------------------------------------- + + # Create table and publications. Insert data to verify initial sync. + node_publisher.safe_sql( + """ + CREATE TABLE tab_gen_to_nogen (a int, b int GENERATED ALWAYS AS (a * 2) STORED); + INSERT INTO tab_gen_to_nogen (a) VALUES (1), (2), (3); + CREATE PUBLICATION regress_pub1_gen_to_nogen FOR TABLE tab_gen_to_nogen WITH (publish_generated_columns = none); + CREATE PUBLICATION regress_pub2_gen_to_nogen FOR TABLE tab_gen_to_nogen WITH (publish_generated_columns = stored); + """ + ) + + # Create the table and subscription in the 'postgres' database. + # NOTE: CREATE SUBSCRIPTION (which implicitly creates a slot) cannot run + # inside a transaction block, so it is issued separately from the CREATE + # TABLE. The in-process libpq session wraps a multi-statement query + # in a single transaction, so we must split them. + node_subscriber.safe_sql("CREATE TABLE tab_gen_to_nogen (a int, b int);") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION regress_sub1_gen_to_nogen CONNECTION '{publisher_connstr}' PUBLICATION regress_pub1_gen_to_nogen WITH (copy_data = true);" + ) + + # Create the table and subscription in the 'test_pgc_true' database. + node_subscriber.safe_sql( + "CREATE TABLE tab_gen_to_nogen (a int, b int);", + dbname="test_pgc_true", + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION regress_sub2_gen_to_nogen CONNECTION '{publisher_connstr}' PUBLICATION regress_pub2_gen_to_nogen WITH (copy_data = true);", + dbname="test_pgc_true", + ) + + # Wait for the initial synchronization of both subscriptions. + node_subscriber.wait_for_subscription_sync( + node_publisher, "regress_sub1_gen_to_nogen", "postgres" + ) + node_subscriber.wait_for_subscription_sync( + node_publisher, "regress_sub2_gen_to_nogen", "test_pgc_true" + ) + + # Verify that generated column data is not copied during the initial + # synchronization when publish_generated_columns is set to 'none'. + result = node_subscriber.safe_sql("SELECT a, b FROM tab_gen_to_nogen ORDER BY a") + assert ( + result == "1|\n2|\n3|" + ), "tab_gen_to_nogen initial sync, when publish_generated_columns=none" + + # Verify that generated column data is copied during the initial + # synchronization when publish_generated_columns is set to 'stored'. + result = node_subscriber.safe_sql( + "SELECT a, b FROM tab_gen_to_nogen ORDER BY a", dbname="test_pgc_true" + ) + assert ( + result == "1|2\n2|4\n3|6" + ), "tab_gen_to_nogen initial sync, when publish_generated_columns=stored" + + # Insert data to verify incremental replication. + node_publisher.safe_sql("INSERT INTO tab_gen_to_nogen VALUES (4), (5)") + + # Verify that the generated column data is not replicated during + # incremental replication when publish_generated_columns is set to 'none'. + node_publisher.wait_for_catchup("regress_sub1_gen_to_nogen") + result = node_subscriber.safe_sql("SELECT a, b FROM tab_gen_to_nogen ORDER BY a") + assert result == "1|\n2|\n3|\n4|\n5|", ( + "tab_gen_to_nogen incremental replication, " + "when publish_generated_columns=none" + ) + + # Verify that generated column data is replicated during incremental + # synchronization when publish_generated_columns is set to 'stored'. + node_publisher.wait_for_catchup("regress_sub2_gen_to_nogen") + result = node_subscriber.safe_sql( + "SELECT a, b FROM tab_gen_to_nogen ORDER BY a", dbname="test_pgc_true" + ) + assert result == "1|2\n2|4\n3|6\n4|8\n5|10", ( + "tab_gen_to_nogen incremental replication, " + "when publish_generated_columns=stored" + ) + + # cleanup + node_subscriber.safe_sql("DROP SUBSCRIPTION regress_sub1_gen_to_nogen") + node_subscriber.safe_sql( + "DROP SUBSCRIPTION regress_sub2_gen_to_nogen", dbname="test_pgc_true" + ) + node_publisher.safe_sql( + """ + DROP PUBLICATION regress_pub1_gen_to_nogen; + DROP PUBLICATION regress_pub2_gen_to_nogen; + """ + ) + node_subscriber.safe_sql("DROP table tab_gen_to_nogen", dbname="test_pgc_true") + node_subscriber.safe_sql("DROP DATABASE test_pgc_true") + + # ===================================================================== + # The following test cases demonstrate how publication column lists + # interact with the publication parameter 'publish_generated_columns'. + # + # Test: Column lists take precedence, so generated columns in a column + # list will be replicated even when publish_generated_columns is 'none'. + # + # Test: When there is a column list, only those generated columns named + # in the column list will be replicated even when + # publish_generated_columns is 'stored'. + # ===================================================================== + + # ---------------------------------------------------------------- + # Test Case: Publisher replicates the column list, including generated + # columns, even when the publish_generated_columns option is set to + # 'none'. + # ---------------------------------------------------------------- + + # Create table and publication. Insert data to verify initial sync. + node_publisher.safe_sql( + """ + CREATE TABLE tab2 (a int, gen1 int GENERATED ALWAYS AS (a * 2) STORED); + INSERT INTO tab2 (a) VALUES (1), (2); + CREATE PUBLICATION pub1 FOR table tab2(gen1) WITH (publish_generated_columns=none); + """ + ) + + # Create table and subscription. + node_subscriber.safe_sql("CREATE TABLE tab2 (a int, gen1 int);") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' PUBLICATION pub1 WITH (copy_data = true);" + ) + + # Wait for initial sync. + node_subscriber.wait_for_subscription_sync(node_publisher, "sub1") + + # Initial sync test when publish_generated_columns is 'none'. + # Verify 'gen1' is replicated regardless of the 'none' parameter value. + result = node_subscriber.safe_sql("SELECT * FROM tab2 ORDER BY gen1") + assert result == "|2\n|4", "tab2 initial sync, when publish_generated_columns=none" + + # Insert data to verify incremental replication. + node_publisher.safe_sql("INSERT INTO tab2 VALUES (3), (4)") + + # Incremental replication test when publish_generated_columns is 'none'. + # Verify 'gen1' is replicated regardless of the 'none' parameter value. + node_publisher.wait_for_catchup("sub1") + result = node_subscriber.safe_sql("SELECT * FROM tab2 ORDER BY gen1") + assert ( + result == "|2\n|4\n|6\n|8" + ), "tab2 incremental replication, when publish_generated_columns=none" + + # cleanup + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1") + node_publisher.safe_sql("DROP PUBLICATION pub1") + + # ---------------------------------------------------------------- + # Test Case: Even when publish_generated_columns is set to 'stored', the + # publisher only publishes the data of columns specified in the column + # list, skipping other generated and non-generated columns. + # ---------------------------------------------------------------- + + # Create table and publication. Insert data to verify initial sync. + node_publisher.safe_sql( + """ + CREATE TABLE tab3 (a int, gen1 int GENERATED ALWAYS AS (a * 2) STORED, gen2 int GENERATED ALWAYS AS (a * 2) STORED); + INSERT INTO tab3 (a) VALUES (1), (2); + CREATE PUBLICATION pub1 FOR table tab3(gen1) WITH (publish_generated_columns=stored); + """ + ) + + # Create table and subscription. + node_subscriber.safe_sql("CREATE TABLE tab3 (a int, gen1 int, gen2 int);") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' PUBLICATION pub1 WITH (copy_data = true);" + ) + + # Wait for initial sync. + node_subscriber.wait_for_subscription_sync(node_publisher, "sub1") + + # Initial sync test when publish_generated_columns is 'stored'. + # Verify only 'gen1' is replicated regardless of the 'stored' parameter + # value. + result = node_subscriber.safe_sql("SELECT * FROM tab3 ORDER BY gen1") + assert ( + result == "|2|\n|4|" + ), "tab3 initial sync, when publish_generated_columns=stored" + + # Insert data to verify incremental replication. + node_publisher.safe_sql("INSERT INTO tab3 VALUES (3), (4)") + + # Incremental replication test when publish_generated_columns is 'stored'. + # Verify only 'gen1' is replicated regardless of the 'stored' parameter + # value. + node_publisher.wait_for_catchup("sub1") + result = node_subscriber.safe_sql("SELECT * FROM tab3 ORDER BY gen1") + assert ( + result == "|2|\n|4|\n|6|\n|8|" + ), "tab3 incremental replication, when publish_generated_columns=stored" + + # cleanup + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1") + node_publisher.safe_sql("DROP PUBLICATION pub1") + + # ===================================================================== + # The following test verifies the expected error when replicating to a + # generated subscriber column. Test the following combinations: + # - regular -> generated + # - generated -> generated + # ===================================================================== + + # ---------------------------------------------------------------- + # A "regular -> generated" or "generated -> generated" replication + # fails, reporting an error that the generated column on the subscriber + # side cannot be replicated. + # + # Test Case: regular -> generated and generated -> generated + # Publisher table has regular column 'c2' and generated column 'c3'. + # Subscriber table has generated columns 'c2' and 'c3'. + # ---------------------------------------------------------------- + + # Create table and publication. Insert data into the table. + node_publisher.safe_sql( + """ + CREATE TABLE t1(c1 int, c2 int, c3 int GENERATED ALWAYS AS (c1 * 2) STORED); + CREATE PUBLICATION pub1 for table t1(c1, c2, c3); + INSERT INTO t1 VALUES (1); + """ + ) + + # Create table and subscription. + node_subscriber.safe_sql( + "CREATE TABLE t1(c1 int, c2 int GENERATED ALWAYS AS (c1 + 2) STORED, c3 int GENERATED ALWAYS AS (c1 + 2) STORED);" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' PUBLICATION pub1;" + ) + + # Verify that an error occurs. + offset = node_subscriber.log_position() + node_subscriber.wait_for_log( + r"ERROR: ( [A-Z0-9]+:)? logical replication target relation " + r'"public.t1" has incompatible generated columns: "c2", "c3"', + offset, + ) + + # cleanup + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1") + node_publisher.safe_sql("DROP PUBLICATION pub1") + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_012_collation.py b/src/test/subscription/pyt/test_012_collation.py new file mode 100644 index 00000000000..a46d5952797 --- /dev/null +++ b/src/test/subscription/pyt/test_012_collation.py @@ -0,0 +1,105 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test collations, in particular nondeterministic ones (only works with +ICU). +""" + +import os + +import pytest + +# Nondeterministic collations require ICU. Gate on the build's with_icu flag +# (as the Perl test does) rather than a catalog query: pg_collation can contain +# ICU rows even when the server was built without a usable ICU provider, so the +# catalog check would let the test run and then fail at CREATE COLLATION. +pytestmark = pytest.mark.skipif( + os.environ.get("with_icu") != "yes", + reason="ICU not supported by this build", +) + + +def test_012_collation(create_pg): + node_publisher = create_pg( + "publisher", + allows_streaming="logical", + initdb_extra=["--locale=C", "--encoding=UTF8"], + ) + + node_subscriber = create_pg( + "subscriber", + initdb_extra=["--locale=C", "--encoding=UTF8"], + ) + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + # Test plan: Create a table with a nondeterministic collation in the + # primary key column. Pre-insert rows on the publisher and subscriber + # that are collation-wise equal but byte-wise different. (We use a + # string in different normal forms for that.) Set up publisher and + # subscriber. Update the row on the publisher, but don't change the + # primary key column. The subscriber needs to find the row to be + # updated using the nondeterministic collation semantics. We need to + # test for both a replica identity index and for replica identity + # full, since those have different code paths internally. + + node_subscriber.safe_sql( + "CREATE COLLATION ctest_nondet (provider = icu, locale = 'und', " + "deterministic = false)" + ) + + # table with replica identity index + + node_publisher.safe_sql("CREATE TABLE tab1 (a text PRIMARY KEY, b text)") + + node_publisher.safe_sql(r"INSERT INTO tab1 VALUES (U&'\00E4bc', 'foo')") + + node_subscriber.safe_sql( + "CREATE TABLE tab1 (a text COLLATE ctest_nondet PRIMARY KEY, b text)" + ) + + node_subscriber.safe_sql(r"INSERT INTO tab1 VALUES (U&'\0061\0308bc', 'foo')") + + # table with replica identity full + + node_publisher.safe_sql("CREATE TABLE tab2 (a text, b text)") + node_publisher.safe_sql("ALTER TABLE tab2 REPLICA IDENTITY FULL") + + node_publisher.safe_sql(r"INSERT INTO tab2 VALUES (U&'\00E4bc', 'foo')") + + node_subscriber.safe_sql("CREATE TABLE tab2 (a text COLLATE ctest_nondet, b text)") + node_subscriber.safe_sql("ALTER TABLE tab2 REPLICA IDENTITY FULL") + + node_subscriber.safe_sql(r"INSERT INTO tab2 VALUES (U&'\0061\0308bc', 'foo')") + + # set up publication, subscription + + node_publisher.safe_sql("CREATE PUBLICATION pub1 FOR ALL TABLES") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub1 WITH (copy_data = false)" + ) + + node_publisher.wait_for_catchup("sub1") + + # test with replica identity index + + node_publisher.safe_sql("UPDATE tab1 SET b = 'bar' WHERE b = 'foo'") + + node_publisher.wait_for_catchup("sub1") + + assert ( + node_subscriber.safe_sql("SELECT b FROM tab1") == "bar" + ), "update with primary key with nondeterministic collation" + + # test with replica identity full + + node_publisher.safe_sql("UPDATE tab2 SET b = 'bar' WHERE b = 'foo'") + + node_publisher.wait_for_catchup("sub1") + + assert ( + node_subscriber.safe_sql("SELECT b FROM tab2") == "bar" + ), "update with replica identity full with nondeterministic collation" diff --git a/src/test/subscription/pyt/test_013_partition.py b/src/test/subscription/pyt/test_013_partition.py new file mode 100644 index 00000000000..3be8c1433d9 --- /dev/null +++ b/src/test/subscription/pyt/test_013_partition.py @@ -0,0 +1,838 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test logical replication with partitioned tables.""" + + +def test_013_partition(create_pg): + # setup + + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber1 = create_pg("subscriber1") + node_subscriber2 = create_pg("subscriber2") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + # publisher + node_publisher.safe_sql("CREATE PUBLICATION pub1") + node_publisher.safe_sql("CREATE PUBLICATION pub_all FOR ALL TABLES") + node_publisher.safe_sql( + "CREATE TABLE tab1 (a int PRIMARY KEY, b text) PARTITION BY LIST (a)" + ) + node_publisher.safe_sql("CREATE TABLE tab1_1 (b text, a int NOT NULL)") + node_publisher.safe_sql( + "ALTER TABLE tab1 ATTACH PARTITION tab1_1 FOR VALUES IN (1, 2, 3)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab1_2 PARTITION OF tab1 FOR VALUES IN (4, 5, 6)" + ) + node_publisher.safe_sql("CREATE TABLE tab1_def PARTITION OF tab1 DEFAULT") + node_publisher.safe_sql("ALTER PUBLICATION pub1 ADD TABLE tab1, tab1_1") + + # subscriber1 + # + # This is partitioned differently from the publisher. tab1_2 is + # subpartitioned. This tests the tuple routing code on the + # subscriber. + node_subscriber1.safe_sql( + "CREATE TABLE tab1 (c text, a int PRIMARY KEY, b text) PARTITION BY LIST (a)" + ) + # make a BRIN index to test aminsertcleanup logic in subscriber + node_subscriber1.safe_sql("CREATE INDEX tab1_c_brin_idx ON tab1 USING brin (c)") + node_subscriber1.safe_sql( + "CREATE TABLE tab1_1 (b text, c text DEFAULT 'sub1_tab1', a int NOT NULL)" + ) + node_subscriber1.safe_sql( + "ALTER TABLE tab1 ATTACH PARTITION tab1_1 FOR VALUES IN (1, 2, 3)" + ) + node_subscriber1.safe_sql( + "CREATE TABLE tab1_2 PARTITION OF tab1 (c DEFAULT 'sub1_tab1') " + "FOR VALUES IN (4, 5, 6) PARTITION BY LIST (a)" + ) + node_subscriber1.safe_sql("CREATE TABLE tab1_2_1 (c text, b text, a int NOT NULL)") + node_subscriber1.safe_sql( + "ALTER TABLE tab1_2 ATTACH PARTITION tab1_2_1 FOR VALUES IN (5)" + ) + node_subscriber1.safe_sql( + "CREATE TABLE tab1_2_2 PARTITION OF tab1_2 FOR VALUES IN (4, 6)" + ) + node_subscriber1.safe_sql( + "CREATE TABLE tab1_def PARTITION OF tab1 (c DEFAULT 'sub1_tab1') DEFAULT" + ) + node_subscriber1.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + + # Add set of AFTER replica triggers for testing that they are fired + # correctly. This uses a table that records details of all trigger + # activities. Triggers are marked as enabled for a subset of the + # partition tree. + node_subscriber1.safe_sql( + r""" +CREATE TABLE sub1_trigger_activity (tgtab text, tgop text, + tgwhen text, tglevel text, olda int, newa int); +CREATE FUNCTION sub1_trigger_activity_func() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + INSERT INTO public.sub1_trigger_activity + SELECT TG_RELNAME, TG_OP, TG_WHEN, TG_LEVEL, NULL, NEW.a; + ELSIF (TG_OP = 'UPDATE') THEN + INSERT INTO public.sub1_trigger_activity + SELECT TG_RELNAME, TG_OP, TG_WHEN, TG_LEVEL, OLD.a, NEW.a; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER sub1_tab1_log_op_trigger + AFTER INSERT OR UPDATE ON tab1 + FOR EACH ROW EXECUTE PROCEDURE sub1_trigger_activity_func(); +ALTER TABLE ONLY tab1 ENABLE REPLICA TRIGGER sub1_tab1_log_op_trigger; +CREATE TRIGGER sub1_tab1_2_log_op_trigger + AFTER INSERT OR UPDATE ON tab1_2 + FOR EACH ROW EXECUTE PROCEDURE sub1_trigger_activity_func(); +ALTER TABLE ONLY tab1_2 ENABLE REPLICA TRIGGER sub1_tab1_2_log_op_trigger; +CREATE TRIGGER sub1_tab1_2_2_log_op_trigger + AFTER INSERT OR UPDATE ON tab1_2_2 + FOR EACH ROW EXECUTE PROCEDURE sub1_trigger_activity_func(); +ALTER TABLE ONLY tab1_2_2 ENABLE REPLICA TRIGGER sub1_tab1_2_2_log_op_trigger; +""" + ) + + # subscriber 2 + # + # This does not use partitioning. The tables match the leaf tables on + # the publisher. + node_subscriber2.safe_sql( + "CREATE TABLE tab1 (a int PRIMARY KEY, c text DEFAULT 'sub2_tab1', b text)" + ) + node_subscriber2.safe_sql( + "CREATE TABLE tab1_1 (a int PRIMARY KEY, c text DEFAULT 'sub2_tab1_1', " + "b text)" + ) + node_subscriber2.safe_sql( + "CREATE TABLE tab1_2 (a int PRIMARY KEY, c text DEFAULT 'sub2_tab1_2', " + "b text)" + ) + node_subscriber2.safe_sql( + "CREATE TABLE tab1_def (a int PRIMARY KEY, b text, " + "c text DEFAULT 'sub2_tab1_def')" + ) + node_subscriber2.safe_sql( + f"CREATE SUBSCRIPTION sub2 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_all" + ) + + # Add set of AFTER replica triggers for testing that they are fired + # correctly, using the same method as the first subscriber. + node_subscriber2.safe_sql( + r""" +CREATE TABLE sub2_trigger_activity (tgtab text, + tgop text, tgwhen text, tglevel text, olda int, newa int); +CREATE FUNCTION sub2_trigger_activity_func() RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + INSERT INTO public.sub2_trigger_activity + SELECT TG_RELNAME, TG_OP, TG_WHEN, TG_LEVEL, NULL, NEW.a; + ELSIF (TG_OP = 'UPDATE') THEN + INSERT INTO public.sub2_trigger_activity + SELECT TG_RELNAME, TG_OP, TG_WHEN, TG_LEVEL, OLD.a, NEW.a; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER sub2_tab1_log_op_trigger + AFTER INSERT OR UPDATE ON tab1 + FOR EACH ROW EXECUTE PROCEDURE sub2_trigger_activity_func(); +ALTER TABLE ONLY tab1 ENABLE REPLICA TRIGGER sub2_tab1_log_op_trigger; +CREATE TRIGGER sub2_tab1_2_log_op_trigger + AFTER INSERT OR UPDATE ON tab1_2 + FOR EACH ROW EXECUTE PROCEDURE sub2_trigger_activity_func(); +ALTER TABLE ONLY tab1_2 ENABLE REPLICA TRIGGER sub2_tab1_2_log_op_trigger; +""" + ) + + # Wait for initial sync of all subscriptions + node_subscriber1.wait_for_subscription_sync() + node_subscriber2.wait_for_subscription_sync() + + # Tests for replication using leaf partition identity and schema + + # insert + node_publisher.safe_sql("INSERT INTO tab1 VALUES (1)") + node_publisher.safe_sql("INSERT INTO tab1_1 (a) VALUES (3)") + node_publisher.safe_sql("INSERT INTO tab1_2 VALUES (5)") + node_publisher.safe_sql("INSERT INTO tab1 VALUES (0)") + + node_publisher.wait_for_catchup("sub1") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab1 ORDER BY 1, 2") + assert result == ( + "sub1_tab1|0\nsub1_tab1|1\nsub1_tab1|3\nsub1_tab1|5" + ), "inserts into tab1 and its partitions replicated" + + result = node_subscriber1.safe_sql("SELECT a FROM tab1_2_1 ORDER BY 1") + assert result == "5", "inserts into tab1_2 replicated into tab1_2_1 correctly" + + result = node_subscriber1.safe_sql("SELECT a FROM tab1_2_2 ORDER BY 1") + assert result == "", "inserts into tab1_2 replicated into tab1_2_2 correctly" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1_1 ORDER BY 1, 2") + assert result == ("sub2_tab1_1|1\nsub2_tab1_1|3"), "inserts into tab1_1 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1_2 ORDER BY 1, 2") + assert result == "sub2_tab1_2|5", "inserts into tab1_2 replicated" + + # The AFTER trigger of tab1_2 should have recorded one INSERT. + result = node_subscriber2.safe_sql( + "SELECT * FROM sub2_trigger_activity " + "ORDER BY tgtab, tgop, tgwhen, olda, newa;" + ) + assert ( + result == "tab1_2|INSERT|AFTER|ROW||5" + ), "check replica insert after trigger applied on subscriber" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1_def ORDER BY 1, 2") + assert result == "sub2_tab1_def|0", "inserts into tab1_def replicated" + + # update (replicated as update) + node_publisher.safe_sql("UPDATE tab1 SET a = 2 WHERE a = 1") + # All of the following cause an update to be applied to a partitioned + # table on subscriber1: tab1_2 is leaf partition on publisher, whereas + # it's sub-partitioned on subscriber1. + node_publisher.safe_sql("UPDATE tab1 SET a = 6 WHERE a = 5") + node_publisher.safe_sql("UPDATE tab1 SET a = 4 WHERE a = 6") + node_publisher.safe_sql("UPDATE tab1 SET a = 6 WHERE a = 4") + + node_publisher.wait_for_catchup("sub1") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab1 ORDER BY 1, 2") + assert result == ( + "sub1_tab1|0\nsub1_tab1|2\nsub1_tab1|3\nsub1_tab1|6" + ), "update of tab1_1, tab1_2 replicated" + + result = node_subscriber1.safe_sql("SELECT a FROM tab1_2_1 ORDER BY 1") + assert result == "", "updates of tab1_2 replicated into tab1_2_1 correctly" + + result = node_subscriber1.safe_sql("SELECT a FROM tab1_2_2 ORDER BY 1") + assert result == "6", "updates of tab1_2 replicated into tab1_2_2 correctly" + + # The AFTER trigger should have recorded the UPDATEs of tab1_2_2. + result = node_subscriber1.safe_sql( + "SELECT * FROM sub1_trigger_activity " + "ORDER BY tgtab, tgop, tgwhen, olda, newa;" + ) + assert result == ( + "tab1_2_2|INSERT|AFTER|ROW||6\n" + "tab1_2_2|UPDATE|AFTER|ROW|4|6\n" + "tab1_2_2|UPDATE|AFTER|ROW|6|4" + ), "check replica update after trigger applied on subscriber" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1_1 ORDER BY 1, 2") + assert result == ("sub2_tab1_1|2\nsub2_tab1_1|3"), "update of tab1_1 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1_2 ORDER BY 1, 2") + assert result == "sub2_tab1_2|6", "tab1_2 updated" + + # The AFTER trigger should have recorded the updates of tab1_2. + result = node_subscriber2.safe_sql( + "SELECT * FROM sub2_trigger_activity " + "ORDER BY tgtab, tgop, tgwhen, olda, newa;" + ) + assert result == ( + "tab1_2|INSERT|AFTER|ROW||5\n" + "tab1_2|UPDATE|AFTER|ROW|4|6\n" + "tab1_2|UPDATE|AFTER|ROW|5|6\n" + "tab1_2|UPDATE|AFTER|ROW|6|4" + ), "check replica update after trigger applied on subscriber" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1_def ORDER BY 1") + assert result == "sub2_tab1_def|0", "tab1_def unchanged" + + # update (replicated as delete+insert) + node_publisher.safe_sql("UPDATE tab1 SET a = 1 WHERE a = 0") + node_publisher.safe_sql("UPDATE tab1 SET a = 4 WHERE a = 1") + + node_publisher.wait_for_catchup("sub1") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab1 ORDER BY 1, 2") + assert result == ( + "sub1_tab1|2\nsub1_tab1|3\nsub1_tab1|4\nsub1_tab1|6" + ), "update of tab1 (delete from tab1_def + insert into tab1_1) replicated" + + result = node_subscriber1.safe_sql("SELECT a FROM tab1_2_2 ORDER BY 1") + assert result == ( + "4\n6" + ), "updates of tab1 (delete + insert) replicated into tab1_2_2 correctly" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1_1 ORDER BY 1, 2") + assert result == ("sub2_tab1_1|2\nsub2_tab1_1|3"), "tab1_1 unchanged" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1_2 ORDER BY 1, 2") + assert result == ("sub2_tab1_2|4\nsub2_tab1_2|6"), "insert into tab1_2 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab1_def ORDER BY 1") + assert result == "", "delete from tab1_def replicated" + + # delete + node_publisher.safe_sql("DELETE FROM tab1 WHERE a IN (2, 3, 5)") + node_publisher.safe_sql("DELETE FROM tab1_2") + + node_publisher.wait_for_catchup("sub1") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT a FROM tab1") + assert result == "", "delete from tab1_1, tab1_2 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab1_1") + assert result == "", "delete from tab1_1 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab1_2") + assert result == "", "delete from tab1_2 replicated" + + # truncate + node_subscriber1.safe_sql("INSERT INTO tab1 (a) VALUES (1), (2), (5)") + node_subscriber2.safe_sql("INSERT INTO tab1_2 (a) VALUES (2)") + node_publisher.safe_sql("TRUNCATE tab1_2") + + node_publisher.wait_for_catchup("sub1") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT a FROM tab1 ORDER BY 1") + assert result == ("1\n2"), "truncate of tab1_2 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab1_2 ORDER BY 1") + assert result == "", "truncate of tab1_2 replicated" + + node_publisher.safe_sql("TRUNCATE tab1") + + node_publisher.wait_for_catchup("sub1") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT a FROM tab1 ORDER BY 1") + assert result == "", "truncate of tab1_1 replicated" + result = node_subscriber2.safe_sql("SELECT a FROM tab1 ORDER BY 1") + assert result == "", "truncate of tab1 replicated" + + node_publisher.safe_sql( + "INSERT INTO tab1 VALUES (1, 'foo'), (4, 'bar'), (10, 'baz')" + ) + + node_publisher.wait_for_catchup("sub1") + node_publisher.wait_for_catchup("sub2") + + node_subscriber1.safe_sql("DELETE FROM tab1") + + # Note that the current location of the log file is not grabbed immediately + # after reloading the configuration, but after sending one SQL command to + # the node so as we are sure that the reloading has taken effect. + log_location = node_subscriber1.log_position() + + node_publisher.safe_sql("UPDATE tab1 SET b = 'quux' WHERE a = 4") + node_publisher.safe_sql("DELETE FROM tab1") + + node_publisher.wait_for_catchup("sub1") + node_publisher.wait_for_catchup("sub2") + + assert node_subscriber1.log_contains( + r'conflict detected on relation "public.tab1_2_2": ' + r"conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be " + r"updated: remote row \(null, 4, quux\), replica identity \(a\)=\(4\)", + log_location, + ), "update target row is missing in tab1_2_2" + assert node_subscriber1.log_contains( + r'conflict detected on relation "public.tab1_1": ' + r"conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be " + r"deleted: replica identity \(a\)=\(1\)", + log_location, + ), "delete target row is missing in tab1_1" + assert node_subscriber1.log_contains( + r'conflict detected on relation "public.tab1_2_2": ' + r"conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be " + r"deleted: replica identity \(a\)=\(4\)", + log_location, + ), "delete target row is missing in tab1_2_2" + assert node_subscriber1.log_contains( + r'conflict detected on relation "public.tab1_def": ' + r"conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be " + r"deleted: replica identity \(a\)=\(10\)", + log_location, + ), "delete target row is missing in tab1_def" + + # Tests for replication using root table identity and schema + + # publisher + node_publisher.safe_sql("DROP PUBLICATION pub1") + node_publisher.safe_sql( + "CREATE TABLE tab2 (a int PRIMARY KEY, b text) PARTITION BY LIST (a)" + ) + node_publisher.safe_sql("CREATE TABLE tab2_1 (b text, a int NOT NULL)") + node_publisher.safe_sql( + "ALTER TABLE tab2 ATTACH PARTITION tab2_1 FOR VALUES IN (0, 1, 2, 3)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab2_2 PARTITION OF tab2 FOR VALUES IN (5, 6)" + ) + + node_publisher.safe_sql( + "CREATE TABLE tab3 (a int PRIMARY KEY, b text) PARTITION BY LIST (a)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab3_1 PARTITION OF tab3 FOR VALUES IN (0, 1, 2, 3, 5, 6)" + ) + + node_publisher.safe_sql( + "CREATE TABLE tab4 (a int PRIMARY KEY) PARTITION BY LIST (a)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab4_1 PARTITION OF tab4 FOR VALUES IN (-1, 0, 1) " + "PARTITION BY LIST (a)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab4_1_1 PARTITION OF tab4_1 FOR VALUES IN (-1, 0, 1)" + ) + + node_publisher.safe_sql( + "ALTER PUBLICATION pub_all SET (publish_via_partition_root = true)" + ) + # Note: tab3_1's parent is not in the publication, in which case its + # changes are published using own identity. For tab2, even though both + # parent and child tables are present but changes will be replicated via + # the parent's identity and only once. + node_publisher.safe_sql( + "CREATE PUBLICATION pub_viaroot FOR TABLE tab2, tab2_1, tab3_1 " + "WITH (publish_via_partition_root = true)" + ) + + node_publisher.safe_sql( + "CREATE PUBLICATION pub_lower_level FOR TABLE tab4_1 " + "WITH (publish_via_partition_root = true)" + ) + + # prepare data for the initial sync + node_publisher.safe_sql("INSERT INTO tab2 VALUES (1)") + node_publisher.safe_sql("INSERT INTO tab4 VALUES (-1)") + + # subscriber 1 + node_subscriber1.safe_sql("DROP SUBSCRIPTION sub1") + node_subscriber1.safe_sql( + "CREATE TABLE tab2 (a int PRIMARY KEY, c text DEFAULT 'sub1_tab2', " + "b text) PARTITION BY RANGE (a)" + ) + node_subscriber1.safe_sql( + "CREATE TABLE tab2_1 (c text DEFAULT 'sub1_tab2', b text, a int NOT NULL)" + ) + node_subscriber1.safe_sql( + "ALTER TABLE tab2 ATTACH PARTITION tab2_1 FOR VALUES FROM (0) TO (10)" + ) + node_subscriber1.safe_sql( + "CREATE TABLE tab3_1 (c text DEFAULT 'sub1_tab3_1', b text, " + "a int NOT NULL PRIMARY KEY)" + ) + node_subscriber1.safe_sql( + f"CREATE SUBSCRIPTION sub_viaroot CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_viaroot" + ) + + # subscriber 2 + node_subscriber2.safe_sql("DROP TABLE tab1") + node_subscriber2.safe_sql( + "CREATE TABLE tab1 (a int PRIMARY KEY, c text DEFAULT 'sub2_tab1', " + "b text) PARTITION BY HASH (a)" + ) + # Note: tab1's partitions are named tab1_1 and tab1_2 on the publisher. + node_subscriber2.safe_sql( + "CREATE TABLE tab1_part1 (b text, c text, a int NOT NULL)" + ) + node_subscriber2.safe_sql( + "ALTER TABLE tab1 ATTACH PARTITION tab1_part1 " + "FOR VALUES WITH (MODULUS 2, REMAINDER 0)" + ) + node_subscriber2.safe_sql( + "CREATE TABLE tab1_part2 PARTITION OF tab1 " + "FOR VALUES WITH (MODULUS 2, REMAINDER 1)" + ) + node_subscriber2.safe_sql( + "CREATE TABLE tab2 (a int PRIMARY KEY, c text DEFAULT 'sub2_tab2', b text)" + ) + node_subscriber2.safe_sql( + "CREATE TABLE tab3 (a int PRIMARY KEY, c text DEFAULT 'sub2_tab3', b text)" + ) + node_subscriber2.safe_sql( + "CREATE TABLE tab3_1 (a int PRIMARY KEY, c text DEFAULT 'sub2_tab3_1', " + "b text)" + ) + + # Note: We create two separate tables, not a partitioned one, so that we + # can easily identity through which relation were the changes replicated. + node_subscriber2.safe_sql("CREATE TABLE tab4 (a int PRIMARY KEY)") + node_subscriber2.safe_sql("CREATE TABLE tab4_1 (a int PRIMARY KEY)") + # Since we specified publish_via_partition_root in pub_all and + # pub_lower_level, all partition tables use their root tables' identity and + # schema. We set the list of publications so that the FOR ALL TABLES + # publication is second (the list order matters). + node_subscriber2.safe_sql( + "ALTER SUBSCRIPTION sub2 SET PUBLICATION pub_lower_level, pub_all" + ) + + # Wait for initial sync of all subscriptions + node_subscriber1.wait_for_subscription_sync() + node_subscriber2.wait_for_subscription_sync() + + # check that data is synced correctly + result = node_subscriber1.safe_sql("SELECT c, a FROM tab2") + assert result == "sub1_tab2|1", "initial data synced for pub_viaroot" + result = node_subscriber2.safe_sql("SELECT a FROM tab4 ORDER BY 1") + assert result == "-1", "initial data synced for pub_lower_level and pub_all" + result = node_subscriber2.safe_sql("SELECT a FROM tab4_1 ORDER BY 1") + assert result == "", "initial data synced for pub_lower_level and pub_all" + + # insert + node_publisher.safe_sql("INSERT INTO tab1 VALUES (1), (0)") + node_publisher.safe_sql("INSERT INTO tab1_1 (a) VALUES (3)") + node_publisher.safe_sql("INSERT INTO tab1_2 VALUES (5)") + node_publisher.safe_sql("INSERT INTO tab2 VALUES (0), (3), (5)") + node_publisher.safe_sql("INSERT INTO tab3 VALUES (1), (0), (3), (5)") + + # Insert a row into the leaf partition, should be replicated through the + # partition root (thanks to the FOR ALL TABLES partition). + node_publisher.safe_sql("INSERT INTO tab4 VALUES (0)") + + node_publisher.wait_for_catchup("sub_viaroot") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab2 ORDER BY 1, 2") + assert result == ( + "sub1_tab2|0\nsub1_tab2|1\nsub1_tab2|3\nsub1_tab2|5" + ), "inserts into tab2 replicated" + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab3_1 ORDER BY 1, 2") + assert result == ( + "sub1_tab3_1|0\nsub1_tab3_1|1\nsub1_tab3_1|3\nsub1_tab3_1|5" + ), "inserts into tab3_1 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1 ORDER BY 1, 2") + assert result == ( + "sub2_tab1|0\nsub2_tab1|1\nsub2_tab1|3\nsub2_tab1|5" + ), "inserts into tab1 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab2 ORDER BY 1, 2") + assert result == ( + "sub2_tab2|0\nsub2_tab2|1\nsub2_tab2|3\nsub2_tab2|5" + ), "inserts into tab2 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab3 ORDER BY 1, 2") + assert result == ( + "sub2_tab3|0\nsub2_tab3|1\nsub2_tab3|3\nsub2_tab3|5" + ), "inserts into tab3 replicated" + + # tab4 change should be replicated through the root partition, which + # maps to the tab4 relation on subscriber. + result = node_subscriber2.safe_sql("SELECT a FROM tab4 ORDER BY 1") + assert result == ("-1\n0"), "inserts into tab4 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab4_1 ORDER BY 1") + assert result == "", "inserts into tab4_1 replicated" + + # now switch the order of publications in the list, try again, the result + # should be the same (no dependence on order of publications) + node_subscriber2.safe_sql( + "ALTER SUBSCRIPTION sub2 SET PUBLICATION pub_all, pub_lower_level" + ) + + # make sure the subscription on the second subscriber is synced, before + # continuing + node_subscriber2.wait_for_subscription_sync() + + # Insert a change into the leaf partition, should be replicated through + # the partition root (thanks to the FOR ALL TABLES partition). + node_publisher.safe_sql("INSERT INTO tab4 VALUES (1)") + + node_publisher.wait_for_catchup("sub2") + + # tab4 change should be replicated through the root partition, which + # maps to the tab4 relation on subscriber. + result = node_subscriber2.safe_sql("SELECT a FROM tab4 ORDER BY 1") + assert result == ("-1\n0\n1"), "inserts into tab4 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab4_1 ORDER BY 1") + assert result == "", "inserts into tab4_1 replicated" + + # update (replicated as update) + node_publisher.safe_sql("UPDATE tab1 SET a = 6 WHERE a = 5") + node_publisher.safe_sql("UPDATE tab2 SET a = 6 WHERE a = 5") + node_publisher.safe_sql("UPDATE tab3 SET a = 6 WHERE a = 5") + + node_publisher.wait_for_catchup("sub_viaroot") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab2 ORDER BY 1, 2") + assert result == ( + "sub1_tab2|0\nsub1_tab2|1\nsub1_tab2|3\nsub1_tab2|6" + ), "update of tab2 replicated" + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab3_1 ORDER BY 1, 2") + assert result == ( + "sub1_tab3_1|0\nsub1_tab3_1|1\nsub1_tab3_1|3\nsub1_tab3_1|6" + ), "update of tab3_1 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1 ORDER BY 1, 2") + assert result == ( + "sub2_tab1|0\nsub2_tab1|1\nsub2_tab1|3\nsub2_tab1|6" + ), "inserts into tab1 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab2 ORDER BY 1, 2") + assert result == ( + "sub2_tab2|0\nsub2_tab2|1\nsub2_tab2|3\nsub2_tab2|6" + ), "inserts into tab2 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab3 ORDER BY 1, 2") + assert result == ( + "sub2_tab3|0\nsub2_tab3|1\nsub2_tab3|3\nsub2_tab3|6" + ), "inserts into tab3 replicated" + + # update (replicated as delete+insert) + node_publisher.safe_sql("UPDATE tab1 SET a = 2 WHERE a = 6") + node_publisher.safe_sql("UPDATE tab2 SET a = 2 WHERE a = 6") + node_publisher.safe_sql("UPDATE tab3 SET a = 2 WHERE a = 6") + + node_publisher.wait_for_catchup("sub_viaroot") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab2 ORDER BY 1, 2") + assert result == ( + "sub1_tab2|0\nsub1_tab2|1\nsub1_tab2|2\nsub1_tab2|3" + ), "update of tab2 replicated" + + result = node_subscriber1.safe_sql("SELECT c, a FROM tab3_1 ORDER BY 1, 2") + assert result == ( + "sub1_tab3_1|0\nsub1_tab3_1|1\nsub1_tab3_1|2\nsub1_tab3_1|3" + ), "update of tab3_1 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab1 ORDER BY 1, 2") + assert result == ( + "sub2_tab1|0\nsub2_tab1|1\nsub2_tab1|2\nsub2_tab1|3" + ), "update of tab1 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab2 ORDER BY 1, 2") + assert result == ( + "sub2_tab2|0\nsub2_tab2|1\nsub2_tab2|2\nsub2_tab2|3" + ), "update of tab2 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a FROM tab3 ORDER BY 1, 2") + assert result == ( + "sub2_tab3|0\nsub2_tab3|1\nsub2_tab3|2\nsub2_tab3|3" + ), "update of tab3 replicated" + + # delete + node_publisher.safe_sql("DELETE FROM tab1") + node_publisher.safe_sql("DELETE FROM tab2") + node_publisher.safe_sql("DELETE FROM tab3") + + node_publisher.wait_for_catchup("sub_viaroot") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT a FROM tab2") + assert result == "", "delete tab2 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab1") + assert result == "", "delete from tab1 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab2") + assert result == "", "delete from tab2 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab3") + assert result == "", "delete from tab3 replicated" + + # truncate + node_publisher.safe_sql("INSERT INTO tab1 VALUES (1), (2), (5)") + node_publisher.safe_sql("INSERT INTO tab2 VALUES (1), (2), (5)") + # these will NOT be replicated + node_publisher.safe_sql("TRUNCATE tab1_2, tab2_1, tab3_1") + + node_publisher.wait_for_catchup("sub_viaroot") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT a FROM tab2 ORDER BY 1") + assert result == ("1\n2\n5"), "truncate of tab2_1 NOT replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab1 ORDER BY 1") + assert result == ("1\n2\n5"), "truncate of tab1_2 NOT replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab2 ORDER BY 1") + assert result == ("1\n2\n5"), "truncate of tab2_1 NOT replicated" + + node_publisher.safe_sql("TRUNCATE tab1, tab2, tab3") + + node_publisher.wait_for_catchup("sub_viaroot") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT a FROM tab2") + assert result == "", "truncate of tab2 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab1") + assert result == "", "truncate of tab1 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab2") + assert result == "", "truncate of tab2 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab3") + assert result == "", "truncate of tab3 replicated" + + result = node_subscriber2.safe_sql("SELECT a FROM tab3_1") + assert result == "", "truncate of tab3_1 replicated" + + # check that the map to convert tuples from leaf partition to the root + # table is correctly rebuilt when a new column is added + node_publisher.safe_sql( + "ALTER TABLE tab2 DROP b, ADD COLUMN c text DEFAULT 'pub_tab2', ADD b text" + ) + node_publisher.safe_sql( + "INSERT INTO tab2 (a, b) VALUES (1, 'xxx'), (3, 'yyy'), (5, 'zzz')" + ) + node_publisher.safe_sql("INSERT INTO tab2 (a, b, c) VALUES (6, 'aaa', 'xxx_c')") + + node_publisher.wait_for_catchup("sub_viaroot") + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber1.safe_sql("SELECT c, a, b FROM tab2 ORDER BY 1, 2") + assert result == ( + "pub_tab2|1|xxx\npub_tab2|3|yyy\npub_tab2|5|zzz\nxxx_c|6|aaa" + ), "inserts into tab2 replicated" + + result = node_subscriber2.safe_sql("SELECT c, a, b FROM tab2 ORDER BY 1, 2") + assert result == ( + "pub_tab2|1|xxx\npub_tab2|3|yyy\npub_tab2|5|zzz\nxxx_c|6|aaa" + ), "inserts into tab2 replicated" + + node_subscriber1.safe_sql("DELETE FROM tab2") + + # Note that the current location of the log file is not grabbed immediately + # after reloading the configuration, but after sending one SQL command to + # the node so as we are sure that the reloading has taken effect. + log_location = node_subscriber1.log_position() + + node_publisher.safe_sql("UPDATE tab2 SET b = 'quux' WHERE a = 5") + node_publisher.safe_sql("DELETE FROM tab2 WHERE a = 1") + + node_publisher.wait_for_catchup("sub_viaroot") + node_publisher.wait_for_catchup("sub2") + + assert node_subscriber1.log_contains( + r'conflict detected on relation "public.tab2_1": ' + r"conflict=update_missing.*\n.*DETAIL:.* Could not find the row to be " + r"updated: remote row \(pub_tab2, quux, 5\), " + r"replica identity \(a\)=\(5\)", + log_location, + ), "update target row is missing in tab2_1" + assert node_subscriber1.log_contains( + r'conflict detected on relation "public.tab2_1": ' + r"conflict=delete_missing.*\n.*DETAIL:.* Could not find the row to be " + r"deleted: replica identity \(a\)=\(1\)", + log_location, + ), "delete target row is missing in tab2_1" + + # Enable the track_commit_timestamp to detect the conflict when attempting + # to update a row that was previously modified by a different origin. + node_subscriber1.append_conf("track_commit_timestamp = on") + node_subscriber1.restart() + + node_subscriber1.safe_sql("INSERT INTO tab2 VALUES (3, 'yyy')") + node_publisher.safe_sql("UPDATE tab2 SET b = 'quux' WHERE a = 3") + + node_publisher.wait_for_catchup("sub_viaroot") + + assert node_subscriber1.log_contains( + r'conflict detected on relation "public.tab2_1": ' + r"conflict=update_origin_differs.*\n.*DETAIL:.* Updating the row that " + r"was modified locally in transaction [0-9]+ at .*: " + r"local row \(yyy, null, 3\), remote row \(pub_tab2, quux, 3\), " + r"replica identity \(a\)=\(3\).", + log_location, + ), "updating a row that was modified by a different origin" + + # The remaining tests no longer test conflict detection. + node_subscriber1.append_conf("track_commit_timestamp = off") + node_subscriber1.restart() + + # Test that replication continues to work correctly after altering the + # partition of a partitioned target table. + + node_publisher.safe_sql( + """ + CREATE TABLE tab5 (a int NOT NULL, b int); + CREATE UNIQUE INDEX tab5_a_idx ON tab5 (a); + ALTER TABLE tab5 REPLICA IDENTITY USING INDEX tab5_a_idx;""" + ) + + node_subscriber2.safe_sql( + """ + CREATE TABLE tab5 (a int NOT NULL, b int, c int) PARTITION BY LIST (a); + CREATE TABLE tab5_1 PARTITION OF tab5 DEFAULT; + CREATE UNIQUE INDEX tab5_a_idx ON tab5 (a); + ALTER TABLE tab5 REPLICA IDENTITY USING INDEX tab5_a_idx; + ALTER TABLE tab5_1 REPLICA IDENTITY USING INDEX tab5_1_a_idx;""" + ) + + node_subscriber2.safe_sql("ALTER SUBSCRIPTION sub2 REFRESH PUBLICATION") + + node_subscriber2.wait_for_subscription_sync() + + # Make partition map cache + node_publisher.safe_sql("INSERT INTO tab5 VALUES (1, 1)") + node_publisher.safe_sql("UPDATE tab5 SET a = 2 WHERE a = 1") + + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber2.safe_sql("SELECT a, b FROM tab5 ORDER BY 1") + assert result == "2|1", "updates of tab5 replicated correctly" + + # Change the column order of partition on subscriber + node_subscriber2.safe_sql( + """ + ALTER TABLE tab5 DETACH PARTITION tab5_1; + ALTER TABLE tab5_1 DROP COLUMN b; + ALTER TABLE tab5_1 ADD COLUMN b int; + ALTER TABLE tab5 ATTACH PARTITION tab5_1 DEFAULT""" + ) + + node_publisher.safe_sql("UPDATE tab5 SET a = 3 WHERE a = 2") + + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber2.safe_sql("SELECT a, b, c FROM tab5 ORDER BY 1") + assert ( + result == "3|1|" + ), "updates of tab5 replicated correctly after altering table on subscriber" + + # Test that replication into the partitioned target table continues to + # work correctly when the published table is altered. + node_publisher.safe_sql( + """ + ALTER TABLE tab5 DROP COLUMN b, ADD COLUMN c INT; + ALTER TABLE tab5 ADD COLUMN b INT;""" + ) + + node_publisher.safe_sql("UPDATE tab5 SET c = 1 WHERE a = 3") + + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber2.safe_sql("SELECT a, b, c FROM tab5 ORDER BY 1") + assert ( + result == "3||1" + ), "updates of tab5 replicated correctly after altering table on publisher" + + # Test that replication works correctly as long as the leaf partition + # has the necessary REPLICA IDENTITY, even though the actual target + # partitioned table does not. + node_subscriber2.safe_sql("ALTER TABLE tab5 REPLICA IDENTITY NOTHING") + + node_publisher.safe_sql("UPDATE tab5 SET a = 4 WHERE a = 3") + + node_publisher.wait_for_catchup("sub2") + + result = node_subscriber2.safe_sql("SELECT a, b, c FROM tab5_1 ORDER BY 1") + assert result == "4||1", "updates of tab5 replicated correctly" diff --git a/src/test/subscription/pyt/test_014_binary.py b/src/test/subscription/pyt/test_014_binary.py new file mode 100644 index 00000000000..7dd44e16b34 --- /dev/null +++ b/src/test/subscription/pyt/test_014_binary.py @@ -0,0 +1,322 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Binary mode logical replication test.""" + + +def test_014_binary(create_pg): + # Create and initialize a publisher node. Set log_statement='all' + # explicitly here since this test relies on the publisher logging + # COPY ... TO STDOUT statements. + node_publisher = create_pg("publisher", allows_streaming="logical", start=False) + node_publisher.append_conf("log_statement = 'all'") + node_publisher.start() + + # Create and initialize subscriber node + node_subscriber = create_pg("subscriber") + + # Create tables on both sides of the replication + ddl = """ + CREATE TABLE public.test_numerical ( + a INTEGER PRIMARY KEY, + b NUMERIC, + c FLOAT, + d BIGINT + ); + CREATE TABLE public.test_arrays ( + a INTEGER[] PRIMARY KEY, + b NUMERIC[], + c TEXT[] + );""" + + node_publisher.safe_sql(ddl) + node_subscriber.safe_sql(ddl) + + # Configure logical replication + node_publisher.safe_sql("CREATE PUBLICATION tpub FOR ALL TABLES") + + # ------------------------------------------------------ + # Ensure binary mode also executes COPY in binary format + # ------------------------------------------------------ + + # Insert some content before creating a subscription + node_publisher.safe_sql( + """ + INSERT INTO public.test_numerical (a, b, c, d) VALUES + (1, 1.2, 1.3, 10), + (2, 2.2, 2.3, 20); + INSERT INTO public.test_arrays (a, b, c) VALUES + ('{1,2,3}', '{1.1, 1.2, 1.3}', '{"one", "two", "three"}'), + ('{3,1,2}', '{1.3, 1.1, 1.2}', '{"three", "one", "two"}'); + """ + ) + + publisher_connstring = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tsub CONNECTION '{publisher_connstring}' " + "PUBLICATION tpub WITH (slot_name = tpub_slot, binary = true)" + ) + + # Ensure the COPY command is executed in binary format on the publisher + node_publisher.wait_for_log( + r"LOG: ( [A-Z0-9]+:)? statement: COPY (.+)? TO STDOUT WITH \(FORMAT binary\)" + ) + + # Ensure nodes are in sync with each other + node_subscriber.wait_for_subscription_sync(node_publisher, "tsub") + + sync_check = """ + SELECT a, b, c, d FROM test_numerical ORDER BY a; + SELECT a, b, c FROM test_arrays ORDER BY a; + """ + + # Check the synced data on the subscriber + result = node_subscriber.safe_sql(sync_check) + + assert ( + result + == """1|1.2|1.3|10 +2|2.2|2.3|20 +{1,2,3}|{1.1,1.2,1.3}|{one,two,three} +{3,1,2}|{1.3,1.1,1.2}|{three,one,two}""" + ), "check synced data on subscriber" + + # ---------------------------------- + # Ensure apply works in binary mode + # ---------------------------------- + + # Insert some content and make sure it's replicated across + node_publisher.safe_sql( + """ + INSERT INTO public.test_arrays (a, b, c) VALUES + ('{2,1,3}', '{1.2, 1.1, 1.3}', '{"two", "one", "three"}'), + ('{1,3,2}', '{1.1, 1.3, 1.2}', '{"one", "three", "two"}'); + + INSERT INTO public.test_numerical (a, b, c, d) VALUES + (3, 3.2, 3.3, 30), + (4, 4.2, 4.3, 40); + """ + ) + + node_publisher.wait_for_catchup("tsub") + + result = node_subscriber.safe_sql( + "SELECT a, b, c, d FROM test_numerical ORDER BY a" + ) + + assert ( + result + == """1|1.2|1.3|10 +2|2.2|2.3|20 +3|3.2|3.3|30 +4|4.2|4.3|40""" + ), "check replicated data on subscriber" + + # Test updates as well + node_publisher.safe_sql( + """ + UPDATE public.test_arrays SET b[1] = 42, c = NULL; + UPDATE public.test_numerical SET b = 42, c = NULL; + """ + ) + + node_publisher.wait_for_catchup("tsub") + + result = node_subscriber.safe_sql("SELECT a, b, c FROM test_arrays ORDER BY a") + + assert ( + result + == """{1,2,3}|{42,1.2,1.3}| +{1,3,2}|{42,1.3,1.2}| +{2,1,3}|{42,1.1,1.3}| +{3,1,2}|{42,1.1,1.2}|""" + ), "check updated replicated data on subscriber" + + result = node_subscriber.safe_sql( + "SELECT a, b, c, d FROM test_numerical ORDER BY a" + ) + + assert ( + result + == """1|42||10 +2|42||20 +3|42||30 +4|42||40""" + ), "check updated replicated data on subscriber" + + # ------------------------------------------------------------------------------ + # Use ALTER SUBSCRIPTION to change to text format and then back to binary format + # ------------------------------------------------------------------------------ + + # Test to reset back to text formatting, and then to binary again + node_subscriber.safe_sql("ALTER SUBSCRIPTION tsub SET (binary = false);") + + node_publisher.safe_sql( + """ + INSERT INTO public.test_numerical (a, b, c, d) VALUES + (5, 5.2, 5.3, 50); + """ + ) + + node_publisher.wait_for_catchup("tsub") + + result = node_subscriber.safe_sql( + "SELECT a, b, c, d FROM test_numerical ORDER BY a" + ) + + assert ( + result + == """1|42||10 +2|42||20 +3|42||30 +4|42||40 +5|5.2|5.3|50""" + ), "check replicated data on subscriber" + + node_subscriber.safe_sql("ALTER SUBSCRIPTION tsub SET (binary = true);") + + node_publisher.safe_sql( + """ + INSERT INTO public.test_arrays (a, b, c) VALUES + ('{2,3,1}', '{1.2, 1.3, 1.1}', '{"two", "three", "one"}'); + """ + ) + + node_publisher.wait_for_catchup("tsub") + + result = node_subscriber.safe_sql("SELECT a, b, c FROM test_arrays ORDER BY a") + + assert ( + result + == """{1,2,3}|{42,1.2,1.3}| +{1,3,2}|{42,1.3,1.2}| +{2,1,3}|{42,1.1,1.3}| +{2,3,1}|{1.2,1.3,1.1}|{two,three,one} +{3,1,2}|{42,1.1,1.2}|""" + ), "check replicated data on subscriber" + + # --------------------------------------------------------------- + # Test binary replication without and with send/receive functions + # --------------------------------------------------------------- + + # Create a custom type without send/rcv functions + ddl = """ + CREATE TYPE myvarchar; + CREATE FUNCTION myvarcharin(cstring, oid, integer) RETURNS myvarchar + LANGUAGE internal IMMUTABLE PARALLEL SAFE STRICT AS 'varcharin'; + CREATE FUNCTION myvarcharout(myvarchar) RETURNS cstring + LANGUAGE internal IMMUTABLE PARALLEL SAFE STRICT AS 'varcharout'; + CREATE TYPE myvarchar ( + input = myvarcharin, + output = myvarcharout); + CREATE TABLE public.test_myvarchar ( + a myvarchar + );""" + + node_publisher.safe_sql(ddl) + node_subscriber.safe_sql(ddl) + + # Insert some initial data + node_publisher.safe_sql( + """ + INSERT INTO public.test_myvarchar (a) VALUES + ('a'); + """ + ) + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # Refresh the publication to trigger the tablesync + node_subscriber.safe_sql("ALTER SUBSCRIPTION tsub REFRESH PUBLICATION") + + # It should fail + node_subscriber.wait_for_log( + r"ERROR: ( [A-Z0-9]+:)? no binary input function available for type", + offset, + ) + + # Create and set send/rcv functions for the custom type + ddl = """ + CREATE FUNCTION myvarcharsend(myvarchar) RETURNS bytea + LANGUAGE internal STABLE PARALLEL SAFE STRICT AS 'varcharsend'; + CREATE FUNCTION myvarcharrecv(internal, oid, integer) RETURNS myvarchar + LANGUAGE internal STABLE PARALLEL SAFE STRICT AS 'varcharrecv'; + ALTER TYPE myvarchar SET ( + send = myvarcharsend, + receive = myvarcharrecv + );""" + + node_publisher.safe_sql(ddl) + node_subscriber.safe_sql(ddl) + + # Now tablesync should succeed + node_subscriber.wait_for_subscription_sync(node_publisher, "tsub") + + # Check the synced data on the subscriber + result = node_subscriber.safe_sql("SELECT a FROM test_myvarchar;") + + assert result == "a", "check synced data on subscriber with custom type" + + # ----------------------------------------------------- + # Test mismatched column types with/without binary mode + # ----------------------------------------------------- + + # Test syncing tables with mismatching column types + node_publisher.safe_sql( + """ + CREATE TABLE public.test_mismatching_types ( + a bigint PRIMARY KEY + ); + INSERT INTO public.test_mismatching_types (a) + VALUES (1), (2); + """ + ) + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # NB: the in-process safe_sql wraps a multi-statement string in a single + # implicit transaction, but ALTER SUBSCRIPTION ... REFRESH PUBLICATION + # cannot run inside a transaction block, so issue it separately. + node_subscriber.safe_sql( + """ + CREATE TABLE public.test_mismatching_types ( + a int PRIMARY KEY + ); + """ + ) + node_subscriber.safe_sql("ALTER SUBSCRIPTION tsub REFRESH PUBLICATION;") + + # Cannot sync due to type mismatch + node_subscriber.wait_for_log( + r"ERROR: ( [A-Z0-9]+:)? incorrect binary data format", offset + ) + + # Check the publisher log from now on. + offset = node_publisher.log_position() + + # Setting binary to false should allow syncing + node_subscriber.safe_sql("ALTER SUBSCRIPTION tsub SET (binary = false);") + + # Ensure the COPY command is executed in text format on the publisher + node_publisher.wait_for_log( + r"LOG: ( [A-Z0-9]+:)? statement: COPY (.+)? TO STDOUT\n", offset + ) + + node_subscriber.wait_for_subscription_sync(node_publisher, "tsub") + + # Check the synced data on the subscriber + result = node_subscriber.safe_sql( + "SELECT a FROM test_mismatching_types ORDER BY a;" + ) + + assert ( + result + == """1 +2""" + ), "check synced data on subscriber with binary = false" + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_015_stream.py b/src/test/subscription/pyt/test_015_stream.py new file mode 100644 index 00000000000..e6cf6d24328 --- /dev/null +++ b/src/test/subscription/pyt/test_015_stream.py @@ -0,0 +1,314 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test streaming of a simple large transaction.""" + + +def check_parallel_log(node_subscriber, offset, is_parallel, type_): + """Check that the parallel apply worker has finished applying the streaming + transaction. + """ + if is_parallel: + node_subscriber.wait_for_log( + r"DEBUG: ( [A-Z0-9]+:)? finished processing the STREAM " + + type_ + + " command", + offset, + ) + + +def do_streaming(node_publisher, node_subscriber, appname, is_parallel): + """Common test steps for both the streaming=on and streaming=parallel + cases. + """ + # Interleave a pair of transactions, each exceeding the 64kB limit. + + h = node_publisher.connect() + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + h.do( + "BEGIN", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5000) s(i)", + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0", + "DELETE FROM test_tab WHERE mod(a,3) = 0", + ) + + node_publisher.safe_sql( + """ + BEGIN; + INSERT INTO test_tab SELECT i, sha256(i::text::bytea) FROM generate_series(5001, 9999) s(i); + DELETE FROM test_tab WHERE a > 5000; + COMMIT; + """ + ) + + h.do("COMMIT") + # errors make the next test fail, so ignore them here + h.close() + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "COMMIT") + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "3334|3334|3334", "check extra columns contain local defaults" + + # Test the streaming in binary mode + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub SET (binary = on)") + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # Insert, update and delete enough rows to exceed the 64kB limit. + node_publisher.safe_sql( + """ + BEGIN; + INSERT INTO test_tab SELECT i, sha256(i::text::bytea) FROM generate_series(5001, 10000) s(i); + UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0; + DELETE FROM test_tab WHERE mod(a,3) = 0; + COMMIT; + """ + ) + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "COMMIT") + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "6667|6667|6667", "check extra columns contain local defaults" + + # Change the local values of the extra columns on the subscriber, + # update publisher, and check that subscriber retains the expected + # values. This is to ensure that non-streaming transactions behave + # properly after a streaming transaction. + node_subscriber.safe_sql( + "UPDATE test_tab SET c = 'epoch'::timestamptz + 987654321 * interval '1s'" + ) + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + node_publisher.safe_sql("UPDATE test_tab SET b = sha256(a::text::bytea)") + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "COMMIT") + + result = node_subscriber.safe_sql( + "SELECT count(*), count(extract(epoch from c) = 987654321), " + "count(d = 999) FROM test_tab" + ) + assert ( + result == "6667|6667|6667" + ), "check extra columns contain locally changed data" + + # Cleanup the test data + node_publisher.safe_sql("DELETE FROM test_tab WHERE (a > 2)") + node_publisher.wait_for_catchup(appname) + + +def test_015_stream(create_pg): + # Create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical", start=False) + node_publisher.append_conf("logical_decoding_work_mem = 64kB") + node_publisher.start() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql("CREATE TABLE test_tab (a int primary key, b bytea)") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (1, 'foo'), (2, 'bar')") + + node_publisher.safe_sql("CREATE TABLE test_tab_2 (a int)") + + # Setup structure on subscriber + node_subscriber.safe_sql( + "CREATE TABLE test_tab (a int primary key, b bytea, " + "c timestamptz DEFAULT now(), d bigint DEFAULT 999)" + ) + + node_subscriber.safe_sql("CREATE TABLE test_tab_2 (a int)") + node_subscriber.safe_sql("CREATE UNIQUE INDEX idx_tab on test_tab_2(a)") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE test_tab, test_tab_2") + + appname = "tap_sub" + + ################################ + # Test using streaming mode 'on' + ################################ + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub WITH (streaming = on)" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "2|2|2", "check initial data was copied to subscriber" + + do_streaming(node_publisher, node_subscriber, appname, 0) + + ###################################### + # Test using streaming mode 'parallel' + ###################################### + oldpid = node_publisher.safe_sql( + "SELECT pid FROM pg_stat_replication " + f"WHERE application_name = '{appname}' AND state = 'streaming';" + ) + + node_subscriber.safe_sql( + "ALTER SUBSCRIPTION tap_sub SET(streaming = parallel, binary = off)" + ) + + assert node_publisher.poll_query_until( + f"SELECT pid != {oldpid} FROM pg_stat_replication " + f"WHERE application_name = '{appname}' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart after changing SUBSCRIPTION" + + # We need to check DEBUG logs to ensure that the parallel apply worker has + # applied the transaction. So, bump up the log verbosity. + node_subscriber.append_conf("log_min_messages = debug1") + node_subscriber.reload() + + # Run a query to make sure that the reload has taken effect. + node_subscriber.safe_sql("SELECT 1") + + do_streaming(node_publisher, node_subscriber, appname, 1) + + # Test that the deadlock is detected among the leader and parallel apply + # workers. + + node_subscriber.append_conf("deadlock_timeout = 10ms") + node_subscriber.reload() + + # Run a query to make sure that the reload has taken effect. + node_subscriber.safe_sql("SELECT 1") + + # Interleave a pair of transactions, each exceeding the 64kB limit. + h = node_publisher.connect() + + # Confirm if a deadlock between the leader apply worker and the parallel + # apply worker can be detected. + + offset = node_subscriber.log_position() + + h.do( + """ + BEGIN; + INSERT INTO test_tab_2 SELECT i FROM generate_series(1, 5000) s(i); + """ + ) + + # Ensure that the parallel apply worker executes the insert command before + # the leader worker. + node_subscriber.wait_for_log( + r"DEBUG: ( [A-Z0-9]+:)? applied [0-9]+ changes in the streaming chunk", offset + ) + + node_publisher.safe_sql("INSERT INTO test_tab_2 values(1)") + + h.do("COMMIT") + h.close() + + node_subscriber.wait_for_log(r"ERROR: ( [A-Z0-9]+:)? deadlock detected", offset) + + # In order for the two transactions to be completed normally without + # causing conflicts due to the unique index, we temporarily drop it. + node_subscriber.safe_sql("DROP INDEX idx_tab") + + # Wait for this streaming transaction to be applied in the apply worker. + node_publisher.wait_for_catchup(appname) + + result = node_subscriber.safe_sql("SELECT count(*) FROM test_tab_2") + assert result == "5001", "data replicated to subscriber after dropping index" + + # Clean up test data from the environment. + node_publisher.safe_sql("TRUNCATE TABLE test_tab_2") + node_publisher.wait_for_catchup(appname) + node_subscriber.safe_sql("CREATE UNIQUE INDEX idx_tab on test_tab_2(a)") + + # Confirm if a deadlock between two parallel apply workers can be detected. + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + h.reconnect() + h.do( + """ + BEGIN; + INSERT INTO test_tab_2 SELECT i FROM generate_series(1, 5000) s(i); + """ + ) + + # Ensure that the first parallel apply worker executes the insert command + # before the second one. + node_subscriber.wait_for_log( + r"DEBUG: ( [A-Z0-9]+:)? applied [0-9]+ changes in the streaming chunk", offset + ) + + node_publisher.safe_sql( + "INSERT INTO test_tab_2 SELECT i FROM generate_series(1, 5000) s(i)" + ) + + h.do("COMMIT") + h.close() + + node_subscriber.wait_for_log(r"ERROR: ( [A-Z0-9]+:)? deadlock detected", offset) + + # In order for the two transactions to be completed normally without + # causing conflicts due to the unique index, we temporarily drop it. + node_subscriber.safe_sql("DROP INDEX idx_tab") + + # Wait for this streaming transaction to be applied in the apply worker. + node_publisher.wait_for_catchup(appname) + + result = node_subscriber.safe_sql("SELECT count(*) FROM test_tab_2") + assert result == "10000", "data replicated to subscriber after dropping index" + + # Test serializing changes to files and notify the parallel apply worker to + # apply them at the end of the transaction. + node_subscriber.append_conf("debug_logical_replication_streaming = immediate") + # Reset the log_min_messages to default. + node_subscriber.append_conf("log_min_messages = warning") + node_subscriber.reload() + + # Run a query to make sure that the reload has taken effect. + node_subscriber.safe_sql("SELECT 1") + + offset = node_subscriber.log_position() + + node_publisher.safe_sql( + "INSERT INTO test_tab_2 SELECT i FROM generate_series(1, 5000) s(i)" + ) + + # Ensure that the changes are serialized. + node_subscriber.wait_for_log( + r"LOG: ( [A-Z0-9]+:)? logical replication apply worker will serialize " + r"the remaining changes of remote transaction \d+ to a file", + offset, + ) + + node_publisher.wait_for_catchup(appname) + + # Check that transaction is committed on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM test_tab_2") + assert result == "15000", "parallel apply worker replayed all changes from file" + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_016_stream_subxact.py b/src/test/subscription/pyt/test_016_stream_subxact.py new file mode 100644 index 00000000000..f56db628f6e --- /dev/null +++ b/src/test/subscription/pyt/test_016_stream_subxact.py @@ -0,0 +1,148 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test streaming of a transaction containing subtransactions.""" + + +def check_parallel_log(node_subscriber, offset, is_parallel, type_): + """Check that the parallel apply worker has finished applying the streaming + transaction. + """ + if is_parallel: + node_subscriber.wait_for_log( + r"DEBUG: ( [A-Z0-9]+:)? finished processing the STREAM " + + type_ + + " command", + offset, + ) + + +def do_streaming(node_publisher, node_subscriber, appname, is_parallel): + """Common test steps for both the streaming=on and streaming=parallel + cases. + """ + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # Insert, update and delete some rows. This is one deliberately-grouped + # transaction containing subtransactions (savepoints). + h = node_publisher.connect() + h.do( + "BEGIN", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5) s(i)", + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0", + "DELETE FROM test_tab WHERE mod(a,3) = 0", + "SAVEPOINT s1", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(6, 8) s(i)", + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0", + "DELETE FROM test_tab WHERE mod(a,3) = 0", + "SAVEPOINT s2", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(9, 11) s(i)", + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0", + "DELETE FROM test_tab WHERE mod(a,3) = 0", + "SAVEPOINT s3", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(12, 14) s(i)", + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0", + "DELETE FROM test_tab WHERE mod(a,3) = 0", + "SAVEPOINT s4", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(15, 17) s(i)", + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0", + "DELETE FROM test_tab WHERE mod(a,3) = 0", + "COMMIT", + ) + h.close() + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "COMMIT") + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "12|12|12", ( + "check data was copied to subscriber in streaming mode and extra " + "columns contain local defaults" + ) + + # Cleanup the test data + node_publisher.safe_sql("DELETE FROM test_tab WHERE (a > 2)") + node_publisher.wait_for_catchup(appname) + + +def test_016_stream_subxact(create_pg): + # Create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical", start=False) + node_publisher.append_conf("debug_logical_replication_streaming = immediate") + node_publisher.start() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql("CREATE TABLE test_tab (a int primary key, b bytea)") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (1, 'foo'), (2, 'bar')") + + # Setup structure on subscriber + node_subscriber.safe_sql( + "CREATE TABLE test_tab (a int primary key, b bytea, " + "c timestamptz DEFAULT now(), d bigint DEFAULT 999)" + ) + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE test_tab") + + appname = "tap_sub" + + ################################ + # Test using streaming mode 'on' + ################################ + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub WITH (streaming = on)" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "2|2|2", "check initial data was copied to subscriber" + + do_streaming(node_publisher, node_subscriber, appname, 0) + + ###################################### + # Test using streaming mode 'parallel' + ###################################### + oldpid = node_publisher.safe_sql( + "SELECT pid FROM pg_stat_replication " + f"WHERE application_name = '{appname}' AND state = 'streaming';" + ) + + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub SET(streaming = parallel)") + + assert node_publisher.poll_query_until( + f"SELECT pid != {oldpid} FROM pg_stat_replication " + f"WHERE application_name = '{appname}' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart after changing SUBSCRIPTION" + + # We need to check DEBUG logs to ensure that the parallel apply worker has + # applied the transaction. So, bump up the log verbosity. + node_subscriber.append_conf("log_min_messages = debug1") + node_subscriber.reload() + + # Run a query to make sure that the reload has taken effect. + node_subscriber.safe_sql("SELECT 1") + + do_streaming(node_publisher, node_subscriber, appname, 1) + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_017_stream_ddl.py b/src/test/subscription/pyt/test_017_stream_ddl.py new file mode 100644 index 00000000000..536b4b6b900 --- /dev/null +++ b/src/test/subscription/pyt/test_017_stream_ddl.py @@ -0,0 +1,136 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test streaming of a large transaction with DDL and subtransactions. + +This file is mainly to test the DDL/DML interaction of the publisher side, +so we didn't add a parallel apply version for the tests in this file. +""" + + +def test_017_stream_ddl(create_pg): + # Create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical", start=False) + node_publisher.append_conf("logical_decoding_work_mem = 64kB") + node_publisher.start() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql("CREATE TABLE test_tab (a int primary key, b varchar)") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (1, 'foo'), (2, 'bar')") + + # Setup structure on subscriber + node_subscriber.safe_sql( + "CREATE TABLE test_tab (a int primary key, b bytea, c INT, d INT, " + "e INT, f INT)" + ) + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE test_tab") + + appname = "tap_sub" + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub WITH (streaming = on)" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "2|0|0", "check initial data was copied to subscriber" + + # a small (non-streamed) transaction with DDL and DML + h = node_publisher.connect() + h.do( + "BEGIN", + "INSERT INTO test_tab VALUES (3, sha256(3::text::bytea))", + "ALTER TABLE test_tab ADD COLUMN c INT", + "SAVEPOINT s1", + "INSERT INTO test_tab VALUES (4, sha256(4::text::bytea), -4)", + "COMMIT", + ) + h.close() + + # large (streamed) transaction with DDL and DML + h = node_publisher.connect() + h.do( + "BEGIN", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea), -i " + "FROM generate_series(5, 1000) s(i)", + "ALTER TABLE test_tab ADD COLUMN d INT", + "SAVEPOINT s1", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea), -i, 2*i " + "FROM generate_series(1001, 2000) s(i)", + "COMMIT", + ) + h.close() + + # a small (non-streamed) transaction with DDL and DML + h = node_publisher.connect() + h.do( + "BEGIN", + "INSERT INTO test_tab VALUES (2001, sha256(2001::text::bytea), " + "-2001, 2*2001)", + "ALTER TABLE test_tab ADD COLUMN e INT", + "SAVEPOINT s1", + "INSERT INTO test_tab VALUES (2002, sha256(2002::text::bytea), " + "-2002, 2*2002, -3*2002)", + "COMMIT", + ) + h.close() + + node_publisher.wait_for_catchup(appname) + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d), count(e) FROM test_tab" + ) + assert result == "2002|1999|1002|1", ( + "check data was copied to subscriber in streaming mode and extra " + "columns contain local defaults" + ) + + # A large (streamed) transaction with DDL and DML. One of the DDL is + # performed after DML to ensure that we invalidate the schema sent for + # test_tab so that the next transaction has to send the schema again. + h = node_publisher.connect() + h.do( + "BEGIN", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea), -i, 2*i, -3*i " + "FROM generate_series(2003,5000) s(i)", + "ALTER TABLE test_tab ADD COLUMN f INT", + "COMMIT", + ) + h.close() + + # A small transaction that won't get streamed. This is just to ensure that + # we send the schema again to reflect the last column added in the previous + # test. + h = node_publisher.connect() + h.do( + "BEGIN", + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea), -i, 2*i, -3*i, " + "4*i FROM generate_series(5001,5005) s(i)", + "COMMIT", + ) + h.close() + + node_publisher.wait_for_catchup(appname) + + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d), count(e), count(f) FROM test_tab" + ) + assert result == "5005|5002|4005|3004|5", ( + "check data was copied to subscriber for both streaming and " + "non-streaming transactions" + ) + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_018_stream_subxact_abort.py b/src/test/subscription/pyt/test_018_stream_subxact_abort.py new file mode 100644 index 00000000000..6ada7ffc8ab --- /dev/null +++ b/src/test/subscription/pyt/test_018_stream_subxact_abort.py @@ -0,0 +1,261 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test streaming of a transaction containing multiple subtransactions and +rollbacks. +""" + + +def check_parallel_log(node_subscriber, offset, is_parallel, type_): + """Check that the parallel apply worker has finished applying the streaming + transaction. + """ + if is_parallel: + node_subscriber.wait_for_log( + r"DEBUG: ( [A-Z0-9]+:)? finished processing the STREAM " + + type_ + + " command", + offset, + ) + + +def do_streaming(node_publisher, node_subscriber, appname, is_parallel): + """Common test steps for both the streaming=on and streaming=parallel + cases. + """ + offset = 0 + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # streamed transaction with DDL, DML and ROLLBACKs + h = node_publisher.connect() + h.do( + """ + BEGIN; + INSERT INTO test_tab VALUES (3, sha256(3::text::bytea)); + SAVEPOINT s1; + INSERT INTO test_tab VALUES (4, sha256(4::text::bytea)); + SAVEPOINT s2; + INSERT INTO test_tab VALUES (5, sha256(5::text::bytea)); + SAVEPOINT s3; + INSERT INTO test_tab VALUES (6, sha256(6::text::bytea)); + ROLLBACK TO s2; + INSERT INTO test_tab VALUES (7, sha256(7::text::bytea)); + ROLLBACK TO s1; + INSERT INTO test_tab VALUES (8, sha256(8::text::bytea)); + SAVEPOINT s4; + INSERT INTO test_tab VALUES (9, sha256(9::text::bytea)); + SAVEPOINT s5; + INSERT INTO test_tab VALUES (10, sha256(10::text::bytea)); + COMMIT; + """ + ) + h.close() + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "COMMIT") + + result = node_subscriber.safe_sql("SELECT count(*), count(c) FROM test_tab") + assert result == "6|0", ( + "check rollback to savepoint was reflected on subscriber and extra " + "columns contain local defaults" + ) + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # streamed transaction with subscriber receiving out of order + # subtransaction ROLLBACKs + h = node_publisher.connect() + h.do( + """ + BEGIN; + INSERT INTO test_tab VALUES (11, sha256(11::text::bytea)); + SAVEPOINT s1; + INSERT INTO test_tab VALUES (12, sha256(12::text::bytea)); + SAVEPOINT s2; + INSERT INTO test_tab VALUES (13, sha256(13::text::bytea)); + SAVEPOINT s3; + INSERT INTO test_tab VALUES (14, sha256(14::text::bytea)); + RELEASE s2; + INSERT INTO test_tab VALUES (15, sha256(15::text::bytea)); + ROLLBACK TO s1; + COMMIT; + """ + ) + h.close() + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "COMMIT") + + result = node_subscriber.safe_sql("SELECT count(*), count(c) FROM test_tab") + assert result == "7|0", "check rollback to savepoint was reflected on subscriber" + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # streamed transaction with subscriber receiving rollback + h = node_publisher.connect() + h.do( + """ + BEGIN; + INSERT INTO test_tab VALUES (16, sha256(16::text::bytea)); + SAVEPOINT s1; + INSERT INTO test_tab VALUES (17, sha256(17::text::bytea)); + SAVEPOINT s2; + INSERT INTO test_tab VALUES (18, sha256(18::text::bytea)); + ROLLBACK; + """ + ) + h.close() + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "ABORT") + + result = node_subscriber.safe_sql("SELECT count(*), count(c) FROM test_tab") + assert result == "7|0", "check rollback was reflected on subscriber" + + # Cleanup the test data + node_publisher.safe_sql("DELETE FROM test_tab WHERE (a > 2)") + node_publisher.wait_for_catchup(appname) + + +def test_018_stream_subxact_abort(create_pg): + # Create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical", start=False) + node_publisher.append_conf("debug_logical_replication_streaming = immediate") + node_publisher.start() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql("CREATE TABLE test_tab (a int primary key, b bytea)") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (1, 'foo'), (2, 'bar')") + node_publisher.safe_sql("CREATE TABLE test_tab_2 (a int)") + + # Setup structure on subscriber + node_subscriber.safe_sql( + "CREATE TABLE test_tab (a int primary key, b text, c INT, d INT, e INT)" + ) + node_subscriber.safe_sql("CREATE TABLE test_tab_2 (a int)") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE test_tab, test_tab_2") + + appname = "tap_sub" + + ################################ + # Test using streaming mode 'on' + ################################ + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub WITH (streaming = on)" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + result = node_subscriber.safe_sql("SELECT count(*), count(c) FROM test_tab") + assert result == "2|0", "check initial data was copied to subscriber" + + do_streaming(node_publisher, node_subscriber, appname, 0) + + ###################################### + # Test using streaming mode 'parallel' + ###################################### + oldpid = node_publisher.safe_sql( + "SELECT pid FROM pg_stat_replication " + f"WHERE application_name = '{appname}' AND state = 'streaming';" + ) + + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub SET(streaming = parallel)") + + assert node_publisher.poll_query_until( + f"SELECT pid != {oldpid} FROM pg_stat_replication " + f"WHERE application_name = '{appname}' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart after changing SUBSCRIPTION" + + # We need to check DEBUG logs to ensure that the parallel apply worker has + # applied the transaction. So, bump up the log verbosity. + node_subscriber.append_conf("log_min_messages = debug1") + node_subscriber.reload() + + # Run a query to make sure that the reload has taken effect. + node_subscriber.safe_sql("SELECT 1") + + do_streaming(node_publisher, node_subscriber, appname, 1) + + # Test serializing changes to files and notify the parallel apply worker to + # apply them at the end of the transaction. + node_subscriber.append_conf("debug_logical_replication_streaming = immediate") + # Reset the log_min_messages to default. + node_subscriber.append_conf("log_min_messages = warning") + node_subscriber.reload() + + # Run a query to make sure that the reload has taken effect. + node_subscriber.safe_sql("SELECT 1") + + offset = node_subscriber.log_position() + + h = node_publisher.connect() + h.do( + """ + BEGIN; + INSERT INTO test_tab_2 values(1); + ROLLBACK; + """ + ) + h.close() + + # Ensure that the changes are serialized. + node_subscriber.wait_for_log( + r"LOG: ( [A-Z0-9]+:)? logical replication apply worker will serialize " + r"the remaining changes of remote transaction \d+ to a file", + offset, + ) + + node_publisher.wait_for_catchup(appname) + + # Check that transaction is aborted on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM test_tab_2") + assert result == "0", "check rollback was reflected on subscriber" + + # Serialize the ABORT sub-transaction. + offset = node_subscriber.log_position() + + h = node_publisher.connect() + h.do( + """ + BEGIN; + INSERT INTO test_tab_2 values(1); + SAVEPOINT sp; + INSERT INTO test_tab_2 values(1); + ROLLBACK TO sp; + COMMIT; + """ + ) + h.close() + + # Ensure that the changes are serialized. + node_subscriber.wait_for_log( + r"LOG: ( [A-Z0-9]+:)? logical replication apply worker will serialize " + r"the remaining changes of remote transaction \d+ to a file", + offset, + ) + + node_publisher.wait_for_catchup(appname) + + # Check that only sub-transaction is aborted on subscriber. + result = node_subscriber.safe_sql("SELECT count(*) FROM test_tab_2") + assert result == "1", "check rollback to savepoint was reflected on subscriber" + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_019_stream_subxact_ddl_abort.py b/src/test/subscription/pyt/test_019_stream_subxact_ddl_abort.py new file mode 100644 index 00000000000..f254364e9c5 --- /dev/null +++ b/src/test/subscription/pyt/test_019_stream_subxact_ddl_abort.py @@ -0,0 +1,80 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test streaming of a transaction with subtransactions, DDLs, DMLs, and +rollbacks. + +This file is mainly to test the DDL/DML interaction of the publisher side, +so we didn't add a parallel apply version for the tests in this file. +""" + + +def test_019_stream_subxact_ddl_abort(create_pg): + # Create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical", start=False) + node_publisher.append_conf("debug_logical_replication_streaming = immediate") + node_publisher.start() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql("CREATE TABLE test_tab (a int primary key, b bytea)") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (1, 'foo'), (2, 'bar')") + + # Setup structure on subscriber + node_subscriber.safe_sql( + "CREATE TABLE test_tab (a int primary key, b bytea, c INT, d INT, e INT)" + ) + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE test_tab") + + appname = "tap_sub" + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub WITH (streaming = on)" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + result = node_subscriber.safe_sql("SELECT count(*), count(c) FROM test_tab") + assert result == "2|0", "check initial data was copied to subscriber" + + # streamed transaction with DDL, DML and ROLLBACKs + h = node_publisher.connect() + h.do( + """ + BEGIN; + INSERT INTO test_tab VALUES (3, sha256(3::text::bytea)); + ALTER TABLE test_tab ADD COLUMN c INT; + SAVEPOINT s1; + INSERT INTO test_tab VALUES (4, sha256(4::text::bytea), -4); + ALTER TABLE test_tab ADD COLUMN d INT; + SAVEPOINT s2; + INSERT INTO test_tab VALUES (5, sha256(5::text::bytea), -5, 5*2); + ALTER TABLE test_tab ADD COLUMN e INT; + SAVEPOINT s3; + INSERT INTO test_tab VALUES (6, sha256(6::text::bytea), -6, 6*2, -6*3); + ALTER TABLE test_tab DROP COLUMN c; + ROLLBACK TO s1; + INSERT INTO test_tab VALUES (4, sha256(4::text::bytea), 4); + COMMIT; + """ + ) + h.close() + + node_publisher.wait_for_catchup(appname) + + result = node_subscriber.safe_sql("SELECT count(*), count(c) FROM test_tab") + assert result == "4|1", ( + "check rollback to savepoint was reflected on subscriber and extra " + "columns contain local defaults" + ) + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_020_messages.py b/src/test/subscription/pyt/test_020_messages.py new file mode 100644 index 00000000000..919dea06571 --- /dev/null +++ b/src/test/subscription/pyt/test_020_messages.py @@ -0,0 +1,138 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests that logical decoding messages are decoded correctly.""" + + +def test_020_messages(create_pg): + # Create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + node_publisher.append_conf("autovacuum = off") + node_publisher.restart() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql("CREATE TABLE tab_test (a int primary key)") + + # Setup structure on subscriber + node_subscriber.safe_sql("CREATE TABLE tab_test (a int primary key)") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE tab_test") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub" + ) + + node_publisher.wait_for_catchup("tap_sub") + + # Ensure a transactional logical decoding message shows up on the slot + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub DISABLE") + + # wait for the replication slot to become inactive on the publisher + assert node_publisher.poll_query_until( + "SELECT COUNT(*) FROM pg_catalog.pg_replication_slots " + "WHERE slot_name = 'tap_sub' AND active='f'", + "1", + ) + + node_publisher.safe_sql( + "SELECT pg_logical_emit_message(true, 'pgoutput', 'a transactional message')" + ) + + result = node_publisher.safe_sql( + "SELECT get_byte(data, 0)\n" + "FROM pg_logical_slot_peek_binary_changes('tap_sub', NULL, NULL,\n" + " 'proto_version', '1',\n" + " 'publication_names', 'tap_pub',\n" + " 'messages', 'true')" + ) + + # 66 77 67 == B M C == BEGIN MESSAGE COMMIT + assert result == "66\n77\n67", "messages on slot are B M C with message option" + + result = node_publisher.safe_sql( + "SELECT get_byte(data, 1), encode(substr(data, 11, 8), 'escape')\n" + "FROM pg_logical_slot_peek_binary_changes('tap_sub', NULL, NULL,\n" + " 'proto_version', '1',\n" + " 'publication_names', 'tap_pub',\n" + " 'messages', 'true')\n" + "OFFSET 1 LIMIT 1" + ) + + assert ( + result == "1|pgoutput" + ), "flag transactional is set to 1 and prefix is pgoutput" + + result = node_publisher.safe_sql( + "SELECT get_byte(data, 0)\n" + "FROM pg_logical_slot_get_binary_changes('tap_sub', NULL, NULL,\n" + " 'proto_version', '1',\n" + " 'publication_names', 'tap_pub')" + ) + + # no message and no BEGIN and COMMIT because of empty transaction + # optimization + assert ( + result == "" + ), "option messages defaults to false so message (M) is not available on slot" + + node_publisher.safe_sql("INSERT INTO tab_test VALUES (1)") + + message_lsn = node_publisher.safe_sql( + "SELECT pg_logical_emit_message(false, 'pgoutput', " + "'a non-transactional message')" + ) + + node_publisher.safe_sql("INSERT INTO tab_test VALUES (2)") + + result = node_publisher.safe_sql( + "SELECT get_byte(data, 0), get_byte(data, 1)\n" + "FROM pg_logical_slot_get_binary_changes('tap_sub', NULL, NULL,\n" + " 'proto_version', '1',\n" + " 'publication_names', 'tap_pub',\n" + " 'messages', 'true')\n" + f"WHERE lsn = '{message_lsn}' AND xid = 0" + ) + + assert result == "77|0", "non-transactional message on slot is M" + + # Ensure a non-transactional logical decoding message shows up on the slot + # when it is emitted within an aborted transaction. The message won't emit + # until something advances the LSN, hence, we intentionally forces the + # server to switch to a new WAL file. + node_publisher.safe_sql( + "BEGIN;\n" + "SELECT pg_logical_emit_message(false, 'pgoutput',\n" + " 'a non-transactional message is available even if the " + "transaction is aborted 1');\n" + "INSERT INTO tab_test VALUES (3);\n" + "SELECT pg_logical_emit_message(true, 'pgoutput',\n" + " 'a transactional message is not available if the transaction " + "is aborted');\n" + "SELECT pg_logical_emit_message(false, 'pgoutput',\n" + " 'a non-transactional message is available even if the " + "transaction is aborted 2');\n" + "ROLLBACK;\n" + "SELECT pg_switch_wal();" + ) + + result = node_publisher.safe_sql( + "SELECT get_byte(data, 0), get_byte(data, 1)\n" + "FROM pg_logical_slot_peek_binary_changes('tap_sub', NULL, NULL,\n" + " 'proto_version', '1',\n" + " 'publication_names', 'tap_pub',\n" + " 'messages', 'true')" + ) + + assert ( + result == "77|0\n77|0" + ), "non-transactional message on slot from aborted transaction is M" + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_021_twophase.py b/src/test/subscription/pyt/test_021_twophase.py new file mode 100644 index 00000000000..4c87192ad1c --- /dev/null +++ b/src/test/subscription/pyt/test_021_twophase.py @@ -0,0 +1,474 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test logical replication of two-phase commit (2PC).""" + + +def test_021_twophase(create_pg): + ############################### + # Setup + ############################### + + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + node_publisher.append_conf("max_prepared_transactions = 10") + node_publisher.restart() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + node_subscriber.append_conf("max_prepared_transactions = 0") + node_subscriber.restart() + + # Create some pre-existing content on publisher + node_publisher.safe_sql("CREATE TABLE tab_full (a int PRIMARY KEY)") + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full SELECT generate_series(1,10);\n" + "PREPARE TRANSACTION 'some_initial_data';" + ) + node_publisher.safe_sql("COMMIT PREPARED 'some_initial_data';") + + # Setup structure on subscriber + node_subscriber.safe_sql("CREATE TABLE tab_full (a int PRIMARY KEY)") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE tab_full") + + appname = "tap_sub" + node_subscriber.safe_sql( + "CREATE SUBSCRIPTION tap_sub " + f"CONNECTION '{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub " + "WITH (two_phase = on)" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # Also wait for two-phase to be enabled + twophase_query = ( + "SELECT count(1) = 0 FROM pg_subscription " + "WHERE subtwophasestate NOT IN ('e');" + ) + assert node_subscriber.poll_query_until( + twophase_query + ), "Timed out while waiting for subscriber to enable twophase" + + ############################### + # check that 2PC gets replicated to subscriber + # then COMMIT PREPARED + ############################### + + # Save the log location, to see the failure of the application + log_location = node_subscriber.log_position() + + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (11);\n" + "PREPARE TRANSACTION 'test_prepared_tab_full';" + ) + + # Confirm the ERROR is reported because max_prepared_transactions is zero + node_subscriber.wait_for_log( + r"ERROR: ( [A-Z0-9]+:)? prepared transactions are disabled", log_location + ) + + # Set max_prepared_transactions to correct value to resume the replication + node_subscriber.append_conf("max_prepared_transactions = 10") + node_subscriber.restart() + + node_publisher.wait_for_catchup(appname) + + # check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # check that 2PC gets committed on subscriber + node_publisher.safe_sql("COMMIT PREPARED 'test_prepared_tab_full';") + + node_publisher.wait_for_catchup(appname) + + # check that transaction is committed on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_full where a = 11;") + assert result == "1", "Row inserted via 2PC has committed on subscriber" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is committed on subscriber" + + ############################### + # check that 2PC gets replicated to subscriber + # then ROLLBACK PREPARED + ############################### + + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (12);\n" + "PREPARE TRANSACTION 'test_prepared_tab_full';" + ) + + node_publisher.wait_for_catchup(appname) + + # check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # check that 2PC gets aborted on subscriber + node_publisher.safe_sql("ROLLBACK PREPARED 'test_prepared_tab_full';") + + node_publisher.wait_for_catchup(appname) + + # check that transaction is aborted on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_full where a = 12;") + assert result == "0", "Row inserted via 2PC is not present on subscriber" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is aborted on subscriber" + + ############################### + # Check that ROLLBACK PREPARED is decoded properly on crash restart + # (publisher and subscriber crash) + ############################### + + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (12);\n" + "INSERT INTO tab_full VALUES (13);\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_subscriber.stop("immediate") + node_publisher.stop("immediate") + + node_publisher.start() + node_subscriber.start() + + # rollback post the restart + node_publisher.safe_sql("ROLLBACK PREPARED 'test_prepared_tab';") + node_publisher.wait_for_catchup(appname) + + # check inserts are rolled back + result = node_subscriber.safe_sql( + "SELECT count(*) FROM tab_full where a IN (12,13);" + ) + assert result == "0", "Rows rolled back are not on the subscriber" + + ############################### + # Check that COMMIT PREPARED is decoded properly on crash restart + # (publisher and subscriber crash) + ############################### + + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (12);\n" + "INSERT INTO tab_full VALUES (13);\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_subscriber.stop("immediate") + node_publisher.stop("immediate") + + node_publisher.start() + node_subscriber.start() + + # commit post the restart + node_publisher.safe_sql("COMMIT PREPARED 'test_prepared_tab';") + node_publisher.wait_for_catchup(appname) + + # check inserts are visible + result = node_subscriber.safe_sql( + "SELECT count(*) FROM tab_full where a IN (12,13);" + ) + assert result == "2", "Rows inserted via 2PC are visible on the subscriber" + + ############################### + # Check that COMMIT PREPARED is decoded properly on crash restart + # (subscriber only crash) + ############################### + + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (14);\n" + "INSERT INTO tab_full VALUES (15);\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_subscriber.stop("immediate") + node_subscriber.start() + + # commit post the restart + node_publisher.safe_sql("COMMIT PREPARED 'test_prepared_tab';") + node_publisher.wait_for_catchup(appname) + + # check inserts are visible + result = node_subscriber.safe_sql( + "SELECT count(*) FROM tab_full where a IN (14,15);" + ) + assert result == "2", "Rows inserted via 2PC are visible on the subscriber" + + ############################### + # Check that COMMIT PREPARED is decoded properly on crash restart + # (publisher only crash) + ############################### + + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (16);\n" + "INSERT INTO tab_full VALUES (17);\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_publisher.stop("immediate") + node_publisher.start() + + # commit post the restart + node_publisher.safe_sql("COMMIT PREPARED 'test_prepared_tab';") + node_publisher.wait_for_catchup(appname) + + # check inserts are visible + result = node_subscriber.safe_sql( + "SELECT count(*) FROM tab_full where a IN (16,17);" + ) + assert result == "2", "Rows inserted via 2PC are visible on the subscriber" + + ############################### + # Test nested transaction with 2PC + ############################### + + # check that 2PC gets replicated to subscriber + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (21);\n" + "SAVEPOINT sp_inner;\n" + "INSERT INTO tab_full VALUES (22);\n" + "ROLLBACK TO SAVEPOINT sp_inner;\n" + "PREPARE TRANSACTION 'outer';\n" + ) + node_publisher.wait_for_catchup(appname) + + # check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # COMMIT + node_publisher.safe_sql("COMMIT PREPARED 'outer';") + + node_publisher.wait_for_catchup(appname) + + # check the transaction state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is ended on subscriber" + + # check inserts are visible. 22 should be rolled back. 21 should be committed. + result = node_subscriber.safe_sql("SELECT a FROM tab_full where a IN (21,22);") + assert result == "21", "Rows committed are on the subscriber" + + ############################### + # Test using empty GID + ############################### + + # check that 2PC gets replicated to subscriber + node_publisher.safe_sql( + "BEGIN;\nINSERT INTO tab_full VALUES (51);\nPREPARE TRANSACTION '';" + ) + node_publisher.wait_for_catchup(appname) + + # check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # ROLLBACK + node_publisher.safe_sql("ROLLBACK PREPARED '';") + + # check that 2PC gets aborted on subscriber + node_publisher.wait_for_catchup(appname) + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is aborted on subscriber" + + ############################### + # copy_data=false and two_phase + ############################### + + # create some test tables for copy tests + node_publisher.safe_sql("CREATE TABLE tab_copy (a int PRIMARY KEY)") + node_publisher.safe_sql("INSERT INTO tab_copy SELECT generate_series(1,5);") + node_subscriber.safe_sql("CREATE TABLE tab_copy (a int PRIMARY KEY)") + node_subscriber.safe_sql("INSERT INTO tab_copy VALUES (88);") + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_copy;") + assert result == "1", "initial data in subscriber table" + + # Setup logical replication + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_copy FOR TABLE tab_copy;") + + appname_copy = "appname_copy" + node_subscriber.safe_sql( + "CREATE SUBSCRIPTION tap_sub_copy " + f"CONNECTION '{publisher_connstr} application_name={appname_copy}' " + "PUBLICATION tap_pub_copy " + "WITH (two_phase=on, copy_data=false);" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname_copy) + + # Also wait for two-phase to be enabled + assert node_subscriber.poll_query_until( + twophase_query + ), "Timed out while waiting for subscriber to enable twophase" + + # Check that the initial table data was NOT replicated (because we said + # copy_data=false) + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_copy;") + assert result == "1", "initial data in subscriber table" + + # Now do a prepare on publisher and check that it IS replicated + node_publisher.safe_sql( + "BEGIN;\nINSERT INTO tab_copy VALUES (99);\nPREPARE TRANSACTION 'mygid';" + ) + + # Wait for both subscribers to catchup + node_publisher.wait_for_catchup(appname_copy) + node_publisher.wait_for_catchup(appname) + + # Check that the transaction has been prepared on the subscriber, there + # will be 2 prepared transactions for the 2 subscriptions. + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "2", "transaction is prepared on subscriber" + + # Now commit the insert and verify that it IS replicated + node_publisher.safe_sql("COMMIT PREPARED 'mygid';") + + result = node_publisher.safe_sql("SELECT count(*) FROM tab_copy;") + assert result == "6", "publisher inserted data" + + # Wait for both subscribers to catchup + node_publisher.wait_for_catchup(appname_copy) + node_publisher.wait_for_catchup(appname) + + # Make sure there are no prepared transactions on the subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "should be no prepared transactions on subscriber" + + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_copy;") + assert result == "2", "replicated data in subscriber table" + + # Clean up + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + + ############################### + # Alter the subscription to set two_phase to false. + # Verify that the altered subscription reflects the new two_phase option. + ############################### + + # Confirm that the two-phase slot option is enabled before altering + result = node_publisher.safe_sql( + "SELECT two_phase FROM pg_replication_slots " + "WHERE slot_name = 'tap_sub_copy';" + ) + assert result == "t", "two-phase is enabled" + + # Alter subscription two_phase to false + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_copy DISABLE;") + node_subscriber.poll_query_until( + "SELECT count(*) = 0 FROM pg_stat_activity " + "WHERE backend_type = 'logical replication apply worker'" + ) + # ALTER SUBSCRIPTION ... SET (two_phase) cannot run inside a transaction + # block; under libpq's simple query protocol a multi-statement string is + # one implicit transaction, so issue each statement separately. + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_copy SET (two_phase = false);") + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_copy ENABLE;") + + # Wait for subscription startup + node_subscriber.wait_for_subscription_sync(node_publisher, appname_copy) + + # Make sure that the two-phase is disabled on the subscriber + result = node_subscriber.safe_sql( + "SELECT subtwophasestate FROM pg_subscription " + "WHERE subname = 'tap_sub_copy';" + ) + assert result == "d", "two-phase subscription option should be disabled" + + # Make sure that the two-phase slot option is also disabled + result = node_publisher.safe_sql( + "SELECT two_phase FROM pg_replication_slots " + "WHERE slot_name = 'tap_sub_copy';" + ) + assert result == "f", "two-phase slot option should be disabled" + + ############################### + # Now do a prepare on the publisher and verify that it is not replicated. + ############################### + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_copy VALUES (100);\n" + "PREPARE TRANSACTION 'newgid';" + ) + + # Wait for the subscriber to catchup + node_publisher.wait_for_catchup(appname_copy) + + # Make sure there are no prepared transactions on the subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "should be no prepared transactions on subscriber" + + ############################### + # Set two_phase to "true" and failover to "true" before the COMMIT PREPARED. + # + # This tests the scenario where both two_phase and failover are altered + # simultaneously. + ############################### + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_copy DISABLE;") + node_subscriber.poll_query_until( + "SELECT count(*) = 0 FROM pg_stat_activity " + "WHERE backend_type = 'logical replication apply worker'" + ) + # ALTER SUBSCRIPTION ... SET (two_phase) cannot run inside a transaction + # block; issue each statement separately (see note above). + node_subscriber.safe_sql( + "ALTER SUBSCRIPTION tap_sub_copy SET (two_phase = true, failover = true);" + ) + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_copy ENABLE;") + + ############################### + # Now commit the insert and verify that it is replicated. + ############################### + node_publisher.safe_sql("COMMIT PREPARED 'newgid';") + + # Wait for the subscriber to catchup + node_publisher.wait_for_catchup(appname_copy) + + # Make sure that the committed transaction is replicated. + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_copy;") + assert result == "3", "replicated data in subscriber table" + + # Make sure that the two-phase is enabled on the subscriber + result = node_subscriber.safe_sql( + "SELECT subtwophasestate FROM pg_subscription " + "WHERE subname = 'tap_sub_copy';" + ) + assert result == "e", "two-phase should be enabled" + + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_copy;") + node_publisher.safe_sql("DROP PUBLICATION tap_pub_copy;") + + ############################### + # check all the cleanup + ############################### + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_subscription") + assert result == "0", "check subscription was dropped on subscriber" + + result = node_publisher.safe_sql("SELECT count(*) FROM pg_replication_slots") + assert result == "0", "check replication slot was dropped on publisher" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_subscription_rel") + assert result == "0", "check subscription relation status was dropped on subscriber" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_replication_origin") + assert result == "0", "check replication origin was dropped on subscriber" + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_022_twophase_cascade.py b/src/test/subscription/pyt/test_022_twophase_cascade.py new file mode 100644 index 00000000000..ae93d187139 --- /dev/null +++ b/src/test/subscription/pyt/test_022_twophase_cascade.py @@ -0,0 +1,403 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test cascading logical replication of two-phase commit (2PC).""" + +# Includes tests for options 2PC (not-streaming) and also for 2PC (streaming). +# +# Two-phase and parallel apply will be tested in 023_twophase_stream, so we +# didn't add a parallel apply version for the tests in this file. + + +def test_022_twophase_cascade(create_pg): + ############################### + # Setup a cascade of pub/sub nodes. + # node_A -> node_B -> node_C + ############################### + + # Initialize nodes + # node_A + node_A = create_pg("node_A", allows_streaming="logical") + node_A.append_conf( + "max_prepared_transactions = 10\nlogical_decoding_work_mem = 64kB\n" + ) + node_A.restart() + # node_B + node_B = create_pg("node_B", allows_streaming="logical") + node_B.append_conf( + "max_prepared_transactions = 10\nlogical_decoding_work_mem = 64kB\n" + ) + node_B.restart() + # node_C + node_C = create_pg("node_C", allows_streaming="logical") + node_C.append_conf( + "max_prepared_transactions = 10\nlogical_decoding_work_mem = 64kB\n" + ) + node_C.restart() + + # Create some pre-existing content on node_A + node_A.safe_sql("CREATE TABLE tab_full (a int PRIMARY KEY)") + node_A.safe_sql("INSERT INTO tab_full SELECT generate_series(1,10);") + + # Create the same tables on node_B and node_C + node_B.safe_sql("CREATE TABLE tab_full (a int PRIMARY KEY)") + node_C.safe_sql("CREATE TABLE tab_full (a int PRIMARY KEY)") + + # Create some pre-existing content on node_A (for streaming tests) + node_A.safe_sql("CREATE TABLE test_tab (a int primary key, b bytea)") + node_A.safe_sql("INSERT INTO test_tab VALUES (1, 'foo'), (2, 'bar')") + + # Create the same tables on node_B and node_C + # columns a and b are compatible with same table name on node_A + node_B.safe_sql( + "CREATE TABLE test_tab (a int primary key, b bytea, " + "c timestamptz DEFAULT now(), d bigint DEFAULT 999)" + ) + node_C.safe_sql( + "CREATE TABLE test_tab (a int primary key, b bytea, " + "c timestamptz DEFAULT now(), d bigint DEFAULT 999)" + ) + + # Setup logical replication + + # ----------------------- + # 2PC NON-STREAMING TESTS + # ----------------------- + + # node_A (pub) -> node_B (sub) + connstr_A = f"host={node_A.host} port={node_A.port} dbname=postgres" + node_A.safe_sql("CREATE PUBLICATION tap_pub_A FOR TABLE tab_full, test_tab") + appname_B = "tap_sub_B" + node_B.safe_sql( + "CREATE SUBSCRIPTION tap_sub_B " + f"CONNECTION '{connstr_A} application_name={appname_B}' " + "PUBLICATION tap_pub_A " + "WITH (two_phase = on, streaming = off)" + ) + + # node_B (pub) -> node_C (sub) + connstr_B = f"host={node_B.host} port={node_B.port} dbname=postgres" + node_B.safe_sql("CREATE PUBLICATION tap_pub_B FOR TABLE tab_full, test_tab") + appname_C = "tap_sub_C" + node_C.safe_sql( + "CREATE SUBSCRIPTION tap_sub_C " + f"CONNECTION '{connstr_B} application_name={appname_C}' " + "PUBLICATION tap_pub_B " + "WITH (two_phase = on, streaming = off)" + ) + + # Wait for subscribers to finish initialization + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # Also wait for two-phase to be enabled + twophase_query = ( + "SELECT count(1) = 0 FROM pg_subscription " + "WHERE subtwophasestate NOT IN ('e');" + ) + assert node_B.poll_query_until( + twophase_query + ), "Timed out while waiting for subscriber to enable twophase" + assert node_C.poll_query_until( + twophase_query + ), "Timed out while waiting for subscriber to enable twophase" + + # is(1, 1, "Cascade setup is complete") + + ############################### + # check that 2PC gets replicated to subscriber(s) + # then COMMIT PREPARED + ############################### + + # 2PC PREPARE + node_A.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (11);\n" + "PREPARE TRANSACTION 'test_prepared_tab_full';" + ) + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check the transaction state is prepared on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber C" + + # 2PC COMMIT + node_A.safe_sql("COMMIT PREPARED 'test_prepared_tab_full';") + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check that transaction was committed on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM tab_full where a = 11;") + assert result == "1", "Row inserted via 2PC has committed on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM tab_full where a = 11;") + assert result == "1", "Row inserted via 2PC has committed on subscriber C" + + # check the transaction state is ended on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is committed on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is committed on subscriber C" + + ############################### + # check that 2PC gets replicated to subscriber(s) + # then ROLLBACK PREPARED + ############################### + + # 2PC PREPARE + node_A.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (12);\n" + "PREPARE TRANSACTION 'test_prepared_tab_full';" + ) + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check the transaction state is prepared on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber C" + + # 2PC ROLLBACK + node_A.safe_sql("ROLLBACK PREPARED 'test_prepared_tab_full';") + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check that transaction is aborted on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM tab_full where a = 12;") + assert result == "0", "Row inserted via 2PC is not present on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM tab_full where a = 12;") + assert result == "0", "Row inserted via 2PC is not present on subscriber C" + + # check the transaction state is ended on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is ended on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is ended on subscriber C" + + ############################### + # Test nested transactions with 2PC + ############################### + + # 2PC PREPARE with a nested ROLLBACK TO SAVEPOINT + node_A.safe_sql( + "BEGIN;\n" + "INSERT INTO tab_full VALUES (21);\n" + "SAVEPOINT sp_inner;\n" + "INSERT INTO tab_full VALUES (22);\n" + "ROLLBACK TO SAVEPOINT sp_inner;\n" + "PREPARE TRANSACTION 'outer';\n" + ) + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check the transaction state prepared on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber C" + + # 2PC COMMIT + node_A.safe_sql("COMMIT PREPARED 'outer';") + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check the transaction state is ended on subscriber + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is ended on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is ended on subscriber C" + + # check inserts are visible at subscriber(s). + # 22 should be rolled back. + # 21 should be committed. + result = node_B.safe_sql("SELECT a FROM tab_full where a IN (21,22);") + assert result == "21", "Rows committed are present on subscriber B" + result = node_C.safe_sql("SELECT a FROM tab_full where a IN (21,22);") + assert result == "21", "Rows committed are present on subscriber C" + + # --------------------- + # 2PC + STREAMING TESTS + # --------------------- + + oldpid_B = node_A.safe_sql( + "SELECT pid FROM pg_stat_replication " + f"WHERE application_name = '{appname_B}' AND state = 'streaming';" + ) + oldpid_C = node_B.safe_sql( + "SELECT pid FROM pg_stat_replication " + f"WHERE application_name = '{appname_C}' AND state = 'streaming';" + ) + + # Setup logical replication (streaming = on) + + node_B.safe_sql("ALTER SUBSCRIPTION tap_sub_B SET (streaming = on);") + node_C.safe_sql("ALTER SUBSCRIPTION tap_sub_C SET (streaming = on)") + + # Wait for subscribers to finish initialization + + assert node_A.poll_query_until( + f"SELECT pid != {oldpid_B} FROM pg_stat_replication " + f"WHERE application_name = '{appname_B}' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart" + assert node_B.poll_query_until( + f"SELECT pid != {oldpid_C} FROM pg_stat_replication " + f"WHERE application_name = '{appname_C}' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart" + + ############################### + # Test 2PC PREPARE / COMMIT PREPARED. + # 1. Data is streamed as a 2PC transaction. + # 2. Then do commit prepared. + # + # Expect all data is replicated on subscriber(s) after the commit. + ############################### + + # Insert, update and delete enough rows to exceed the 64kB limit. + # Then 2PC PREPARE + node_A.safe_sql( + "BEGIN;\n" + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5000) s(i);\n" + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0;\n" + "DELETE FROM test_tab WHERE mod(a,3) = 0;\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check the transaction state is prepared on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber C" + + # 2PC COMMIT + node_A.safe_sql("COMMIT PREPARED 'test_prepared_tab';") + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check that transaction was committed on subscriber(s) + result = node_B.safe_sql("SELECT count(*), count(c), count(d = 999) FROM test_tab") + assert result == "3334|3334|3334", ( + "Rows inserted by 2PC have committed on subscriber B, and extra " + "columns have local defaults" + ) + result = node_C.safe_sql("SELECT count(*), count(c), count(d = 999) FROM test_tab") + assert result == "3334|3334|3334", ( + "Rows inserted by 2PC have committed on subscriber C, and extra " + "columns have local defaults" + ) + + # check the transaction state is ended on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is committed on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is committed on subscriber C" + + ############################### + # Test 2PC PREPARE with a nested ROLLBACK TO SAVEPOINT. + # 0. Cleanup from previous test leaving only 2 rows. + # 1. Insert one more row. + # 2. Record a SAVEPOINT. + # 3. Data is streamed using 2PC. + # 4. Do rollback to SAVEPOINT prior to the streamed inserts. + # 5. Then COMMIT PREPARED. + # + # Expect data after the SAVEPOINT is aborted leaving only 3 rows + # (= 2 original + 1 from step 1). + ############################### + + # First, delete the data except for 2 rows (delete will be replicated) + node_A.safe_sql("DELETE FROM test_tab WHERE a > 2;") + + # 2PC PREPARE with a nested ROLLBACK TO SAVEPOINT + node_A.safe_sql( + "BEGIN;\n" + "INSERT INTO test_tab VALUES (9999, 'foobar');\n" + "SAVEPOINT sp_inner;\n" + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5000) s(i);\n" + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0;\n" + "DELETE FROM test_tab WHERE mod(a,3) = 0;\n" + "ROLLBACK TO SAVEPOINT sp_inner;\n" + "PREPARE TRANSACTION 'outer';\n" + ) + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check the transaction state prepared on subscriber(s) + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber C" + + # 2PC COMMIT + node_A.safe_sql("COMMIT PREPARED 'outer';") + + node_A.wait_for_catchup(appname_B) + node_B.wait_for_catchup(appname_C) + + # check the transaction state is ended on subscriber + result = node_B.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is ended on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is ended on subscriber C" + + # check inserts are visible at subscriber(s). + # All the streamed data (prior to the SAVEPOINT) should be rolled back. + # (9999, 'foobar') should be committed. + result = node_B.safe_sql("SELECT count(*) FROM test_tab where b = 'foobar';") + assert result == "1", "Rows committed are present on subscriber B" + result = node_B.safe_sql("SELECT count(*) FROM test_tab;") + assert result == "3", "Rows committed are present on subscriber B" + result = node_C.safe_sql("SELECT count(*) FROM test_tab where b = 'foobar';") + assert result == "1", "Rows committed are present on subscriber C" + result = node_C.safe_sql("SELECT count(*) FROM test_tab;") + assert result == "3", "Rows committed are present on subscriber C" + + ############################### + # check all the cleanup + ############################### + + # cleanup the node_B => node_C pub/sub + node_C.safe_sql("DROP SUBSCRIPTION tap_sub_C") + result = node_C.safe_sql("SELECT count(*) FROM pg_subscription") + assert result == "0", "check subscription was dropped on subscriber node C" + result = node_C.safe_sql("SELECT count(*) FROM pg_subscription_rel") + assert ( + result == "0" + ), "check subscription relation status was dropped on subscriber node C" + result = node_C.safe_sql("SELECT count(*) FROM pg_replication_origin") + assert result == "0", "check replication origin was dropped on subscriber node C" + result = node_B.safe_sql("SELECT count(*) FROM pg_replication_slots") + assert result == "0", "check replication slot was dropped on publisher node B" + + # cleanup the node_A => node_B pub/sub + node_B.safe_sql("DROP SUBSCRIPTION tap_sub_B") + result = node_B.safe_sql("SELECT count(*) FROM pg_subscription") + assert result == "0", "check subscription was dropped on subscriber node B" + result = node_B.safe_sql("SELECT count(*) FROM pg_subscription_rel") + assert ( + result == "0" + ), "check subscription relation status was dropped on subscriber node B" + result = node_B.safe_sql("SELECT count(*) FROM pg_replication_origin") + assert result == "0", "check replication origin was dropped on subscriber node B" + result = node_A.safe_sql("SELECT count(*) FROM pg_replication_slots") + assert result == "0", "check replication slot was dropped on publisher node A" + + # shutdown + node_C.stop("fast") + node_B.stop("fast") + node_A.stop("fast") diff --git a/src/test/subscription/pyt/test_023_twophase_stream.py b/src/test/subscription/pyt/test_023_twophase_stream.py new file mode 100644 index 00000000000..2890a551b87 --- /dev/null +++ b/src/test/subscription/pyt/test_023_twophase_stream.py @@ -0,0 +1,485 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test logical replication of two-phase commit (2PC) with streaming.""" + + +def check_parallel_log(node_subscriber, offset, is_parallel, type_): + """Check that the parallel apply worker has finished applying the streaming + transaction. + """ + if is_parallel: + node_subscriber.wait_for_log( + r"DEBUG: ( [A-Z0-9]+:)? finished processing the STREAM " + + type_ + + " command", + offset, + ) + + +def do_streaming(node_publisher, node_subscriber, appname, is_parallel): + """Common test steps for both the streaming=on and streaming=parallel + cases. + """ + ############################### + # Test 2PC PREPARE / COMMIT PREPARED. + # 1. Data is streamed as a 2PC transaction. + # 2. Then do commit prepared. + # + # Expect all data is replicated on subscriber side after the commit. + ############################### + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # check that 2PC gets replicated to subscriber + # Insert, update and delete some rows. + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5) s(i);\n" + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0;\n" + "DELETE FROM test_tab WHERE mod(a,3) = 0;\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "PREPARE") + + # check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # 2PC transaction gets committed + node_publisher.safe_sql("COMMIT PREPARED 'test_prepared_tab';") + + node_publisher.wait_for_catchup(appname) + + # check that transaction is committed on subscriber + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "4|4|4", ( + "Rows inserted by 2PC have committed on subscriber, and extra " + "columns contain local defaults" + ) + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is committed on subscriber" + + ############################### + # Test 2PC PREPARE / ROLLBACK PREPARED. + # 1. Table is deleted back to 2 rows which are replicated on subscriber. + # 2. Data is streamed using 2PC. + # 3. Do rollback prepared. + # + # Expect data rolls back leaving only the original 2 rows. + ############################### + + # First, delete the data except for 2 rows (will be replicated) + node_publisher.safe_sql("DELETE FROM test_tab WHERE a > 2;") + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # Then insert, update and delete some rows. + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5) s(i);\n" + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0;\n" + "DELETE FROM test_tab WHERE mod(a,3) = 0;\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "PREPARE") + + # check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # 2PC transaction gets aborted + node_publisher.safe_sql("ROLLBACK PREPARED 'test_prepared_tab';") + + node_publisher.wait_for_catchup(appname) + + # check that transaction is aborted on subscriber + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert ( + result == "2|2|2" + ), "Rows inserted by 2PC are rolled back, leaving only the original 2 rows" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is aborted on subscriber" + + ############################### + # Check that 2PC COMMIT PREPARED is decoded properly on crash restart. + # 1. insert, update and delete some rows. + # 2. Then server crashes before the 2PC transaction is committed. + # 3. After servers are restarted the pending transaction is committed. + # + # Expect all data is replicated on subscriber side after the commit. + # Note: both publisher and subscriber do crash/restart. + ############################### + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5) s(i);\n" + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0;\n" + "DELETE FROM test_tab WHERE mod(a,3) = 0;\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_subscriber.stop("immediate") + node_publisher.stop("immediate") + + node_publisher.start() + node_subscriber.start() + + # We don't try to check the log for parallel option here as the subscriber + # may have stopped after finishing the prepare and before logging the + # appropriate message. + + # commit post the restart + node_publisher.safe_sql("COMMIT PREPARED 'test_prepared_tab';") + node_publisher.wait_for_catchup(appname) + + # check inserts are visible + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "4|4|4", ( + "Rows inserted by 2PC have committed on subscriber, and extra " + "columns contain local defaults" + ) + + ############################### + # Do INSERT after the PREPARE but before ROLLBACK PREPARED. + # 1. Table is deleted back to 2 rows which are replicated on subscriber. + # 2. Data is streamed using 2PC. + # 3. A single row INSERT is done which is after the PREPARE. + # 4. Then do a ROLLBACK PREPARED. + # + # Expect the 2PC data rolls back leaving only 3 rows on the subscriber + # (the original 2 + inserted 1). + ############################### + + # First, delete the data except for 2 rows (will be replicated) + node_publisher.safe_sql("DELETE FROM test_tab WHERE a > 2;") + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # Then insert, update and delete some rows. + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5) s(i);\n" + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0;\n" + "DELETE FROM test_tab WHERE mod(a,3) = 0;\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "PREPARE") + + # check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # Insert a different record (now we are outside of the 2PC transaction) + # Note: the 2PC transaction still holds row locks so make sure this insert + # is for a separate primary key + node_publisher.safe_sql("INSERT INTO test_tab VALUES (99999, 'foobar')") + + # 2PC transaction gets aborted + node_publisher.safe_sql("ROLLBACK PREPARED 'test_prepared_tab';") + + node_publisher.wait_for_catchup(appname) + + # check that transaction is aborted on subscriber, + # but the extra INSERT outside of the 2PC still was replicated + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "3|3|3", "check the outside insert was copied to subscriber" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is aborted on subscriber" + + ############################### + # Do INSERT after the PREPARE but before COMMIT PREPARED. + # 1. Table is deleted back to 2 rows which are replicated on subscriber. + # 2. Data is streamed using 2PC. + # 3. A single row INSERT is done which is after the PREPARE. + # 4. Then do a COMMIT PREPARED. + # + # Expect 2PC data + the extra row are on the subscriber + # (the 3334 + inserted 1 = 3335). + ############################### + + # First, delete the data except for 2 rows (will be replicated) + node_publisher.safe_sql("DELETE FROM test_tab WHERE a > 2;") + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + # Then insert, update and delete some rows. + node_publisher.safe_sql( + "BEGIN;\n" + "INSERT INTO test_tab SELECT i, sha256(i::text::bytea) " + "FROM generate_series(3, 5) s(i);\n" + "UPDATE test_tab SET b = sha256(b) WHERE mod(a,2) = 0;\n" + "DELETE FROM test_tab WHERE mod(a,3) = 0;\n" + "PREPARE TRANSACTION 'test_prepared_tab';" + ) + + node_publisher.wait_for_catchup(appname) + + check_parallel_log(node_subscriber, offset, is_parallel, "PREPARE") + + # check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # Insert a different record (now we are outside of the 2PC transaction) + # Note: the 2PC transaction still holds row locks so make sure this insert + # is for a separate primary key + node_publisher.safe_sql("INSERT INTO test_tab VALUES (99999, 'foobar')") + + # 2PC transaction gets committed + node_publisher.safe_sql("COMMIT PREPARED 'test_prepared_tab';") + + node_publisher.wait_for_catchup(appname) + + # check that transaction is committed on subscriber + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "5|5|5", ( + "Rows inserted by 2PC (as well as outside insert) have committed on " + "subscriber, and extra columns contain local defaults" + ) + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "0", "transaction is committed on subscriber" + + # Cleanup the test data + node_publisher.safe_sql("DELETE FROM test_tab WHERE a > 2;") + node_publisher.wait_for_catchup(appname) + + +def test_023_twophase_stream(create_pg): + ############################### + # Setup + ############################### + + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + node_publisher.append_conf( + "max_prepared_transactions = 10\n" + "debug_logical_replication_streaming = immediate" + ) + node_publisher.restart() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + node_subscriber.append_conf("max_prepared_transactions = 10") + node_subscriber.restart() + + # Create some pre-existing content on publisher + node_publisher.safe_sql("CREATE TABLE test_tab (a int primary key, b bytea)") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (1, 'foo'), (2, 'bar')") + node_publisher.safe_sql("CREATE TABLE test_tab_2 (a int)") + + # Setup structure on subscriber (columns a and b are compatible with same + # table name on publisher) + node_subscriber.safe_sql( + "CREATE TABLE test_tab (a int primary key, b bytea, " + "c timestamptz DEFAULT now(), d bigint DEFAULT 999)" + ) + node_subscriber.safe_sql("CREATE TABLE test_tab_2 (a int)") + + # Setup logical replication (streaming = on) + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE test_tab, test_tab_2") + + appname = "tap_sub" + + ################################ + # Test using streaming mode 'on' + ################################ + node_subscriber.safe_sql( + "CREATE SUBSCRIPTION tap_sub " + f"CONNECTION '{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub " + "WITH (streaming = on, two_phase = on)" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # Also wait for two-phase to be enabled + twophase_query = ( + "SELECT count(1) = 0 FROM pg_subscription " + "WHERE subtwophasestate NOT IN ('e');" + ) + assert node_subscriber.poll_query_until( + twophase_query + ), "Timed out while waiting for subscriber to enable twophase" + + # Check initial data was copied to subscriber + result = node_subscriber.safe_sql( + "SELECT count(*), count(c), count(d = 999) FROM test_tab" + ) + assert result == "2|2|2", "check initial data was copied to subscriber" + + do_streaming(node_publisher, node_subscriber, appname, 0) + + ###################################### + # Test using streaming mode 'parallel' + ###################################### + oldpid = node_publisher.safe_sql( + "SELECT pid FROM pg_stat_replication " + f"WHERE application_name = '{appname}' AND state = 'streaming';" + ) + + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub SET(streaming = parallel)") + + assert node_publisher.poll_query_until( + f"SELECT pid != {oldpid} FROM pg_stat_replication " + f"WHERE application_name = '{appname}' AND state = 'streaming';" + ), "Timed out while waiting for apply to restart after changing SUBSCRIPTION" + + # We need to check DEBUG logs to ensure that the parallel apply worker has + # applied the transaction. So, bump up the log verbosity. + node_subscriber.append_conf("log_min_messages = debug1") + node_subscriber.reload() + + # Run a query to make sure that the reload has taken effect. + node_subscriber.safe_sql("SELECT 1") + + do_streaming(node_publisher, node_subscriber, appname, 1) + + # Test serializing changes to files and notify the parallel apply worker to + # apply them at the end of the transaction. + reload_offset = node_subscriber.log_position() + node_subscriber.append_conf("debug_logical_replication_streaming = immediate") + # Reset the log_min_messages to default. + node_subscriber.append_conf("log_min_messages = warning") + node_subscriber.reload() + + # Run a query to make sure that the reload has taken effect. + node_subscriber.safe_sql("SELECT 1") + + # Spawning a psql subprocess used to provide enough latency to give the + # apply leader time to re-read debug_logical_replication_streaming before + # the publisher transaction below is streamed to it; the in-process libpq + # session here returns too quickly, so wait until the reload has been + # processed (the parallel apply leader only serializes to a file once it + # observes the GUC set to "immediate"). + node_subscriber.wait_for_log( + r'parameter "debug_logical_replication_streaming" changed to "immediate"', + reload_offset, + ) + + offset = node_subscriber.log_position() + + node_publisher.safe_sql( + "BEGIN;\nINSERT INTO test_tab_2 values(1);\nPREPARE TRANSACTION 'xact';" + ) + + # Ensure that the changes are serialized. + node_subscriber.wait_for_log( + r"LOG: ( [A-Z0-9]+:)? logical replication apply worker will serialize " + r"the remaining changes of remote transaction \d+ to a file", + offset, + ) + + node_publisher.wait_for_catchup(appname) + + # Check that transaction is in prepared state on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_prepared_xacts;") + assert result == "1", "transaction is prepared on subscriber" + + # Check that 2PC gets committed on subscriber + node_publisher.safe_sql("COMMIT PREPARED 'xact';") + + node_publisher.wait_for_catchup(appname) + + # Check that transaction is committed on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM test_tab_2") + assert result == "1", "transaction is committed on subscriber" + + # Test the ability to re-apply a transaction when a parallel apply worker + # fails to prepare the transaction due to insufficient + # max_prepared_transactions setting. + node_subscriber.append_conf( + "max_prepared_transactions = 0\n" + "debug_logical_replication_streaming = buffered" + ) + node_subscriber.restart() + + # COMMIT PREPARED cannot run inside a transaction block; under libpq's + # simple query protocol a multi-statement string is one implicit + # transaction, so issue the COMMIT PREPARED separately. + node_publisher.safe_sql( + "BEGIN;\nINSERT INTO test_tab_2 values(2);\nPREPARE TRANSACTION 'xact';" + ) + node_publisher.safe_sql("COMMIT PREPARED 'xact';") + + offset = node_subscriber.log_position() + + # Confirm the ERROR is reported because max_prepared_transactions is zero + node_subscriber.wait_for_log( + r"ERROR: ( [A-Z0-9]+:)? prepared transactions are disabled", offset + ) + + # Confirm that the parallel apply worker has encountered an error. The check + # focuses on the worker type as a keyword, since the error message content + # may differ based on whether the leader initially detected the parallel + # apply worker's failure or received a signal from it. + node_subscriber.wait_for_log( + r"ERROR: .*logical replication parallel apply worker.*", offset + ) + + # Set max_prepared_transactions to correct value to resume the replication + node_subscriber.append_conf("max_prepared_transactions = 10") + node_subscriber.restart() + + node_publisher.wait_for_catchup(appname) + + # Check that transaction is committed on subscriber + result = node_subscriber.safe_sql("SELECT count(*) FROM test_tab_2") + assert result == "2", "transaction is committed on subscriber after retrying" + + ############################### + # check all the cleanup + ############################### + + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_subscription") + assert result == "0", "check subscription was dropped on subscriber" + + result = node_publisher.safe_sql("SELECT count(*) FROM pg_replication_slots") + assert result == "0", "check replication slot was dropped on publisher" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_subscription_rel") + assert result == "0", "check subscription relation status was dropped on subscriber" + + result = node_subscriber.safe_sql("SELECT count(*) FROM pg_replication_origin") + assert result == "0", "check replication origin was dropped on subscriber" + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_024_add_drop_pub.py b/src/test/subscription/pyt/test_024_add_drop_pub.py new file mode 100644 index 00000000000..5c87f353a1a --- /dev/null +++ b/src/test/subscription/pyt/test_024_add_drop_pub.py @@ -0,0 +1,129 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test ALTER SUBSCRIPTION ... ADD/DROP PUBLICATION behaviour. + +Ensures that creating a publication associated with a subscription at a later +point of time does not break logical replication. +""" + + +def test_024_add_drop_pub(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create table on publisher + node_publisher.safe_sql("CREATE TABLE tab_1 (a int)") + node_publisher.safe_sql("INSERT INTO tab_1 SELECT generate_series(1,10)") + + # Create table on subscriber + node_subscriber.safe_sql("CREATE TABLE tab_1 (a int)") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} " "dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_1 FOR TABLE tab_1") + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_2") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub_1, tap_pub_2" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Check the initial data of tab_1 is copied to subscriber + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_1") + assert result == "10|1|10", "check initial data is copied to subscriber" + + # Create a new table on publisher + node_publisher.safe_sql("CREATE TABLE tab_2 (a int)") + node_publisher.safe_sql("INSERT INTO tab_2 SELECT generate_series(1,10)") + + # Create a new table on subscriber + node_subscriber.safe_sql("CREATE TABLE tab_2 (a int)") + + # Add the table to publication + node_publisher.safe_sql("ALTER PUBLICATION tap_pub_2 ADD TABLE tab_2") + + # Dropping tap_pub_1 will refresh the entire publication list + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub DROP PUBLICATION tap_pub_1") + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Check the initial data of tab_2 was copied to subscriber + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_2") + assert result == "10|1|10", "check initial data is copied to subscriber" + + # Re-adding tap_pub_1 will refresh the entire publication list + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub ADD PUBLICATION tap_pub_1") + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Check the initial data of tab_1 was copied to subscriber again + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM tab_1") + assert result == "20|1|10", "check initial data is copied to subscriber" + + # Ensure that setting a missing publication to the subscription does not + # disrupt existing logical replication. Instead, it should log a warning + # while allowing replication to continue. Additionally, verify that + # replication resumes after the missing publication is created for the + # publication table. + + # Create table on publisher and subscriber + node_publisher.safe_sql("CREATE TABLE tab_3 (a int)") + node_subscriber.safe_sql("CREATE TABLE tab_3 (a int)") + + oldpid = node_publisher.safe_sql( + "SELECT pid FROM pg_stat_replication " + "WHERE application_name = 'tap_sub' AND state = 'streaming';" + ) + + # Set the subscription with a missing publication + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub SET PUBLICATION tap_pub_3") + + # Wait for the walsender to restart after altering the subscription + assert node_publisher.poll_query_until( + f"SELECT pid != {oldpid} FROM pg_stat_replication " + "WHERE application_name = 'tap_sub' AND state = 'streaming';" + ), ( + "Timed out while waiting for apply worker to restart after altering " + "the subscription" + ) + + offset = node_publisher.log_position() + + node_publisher.safe_sql("INSERT INTO tab_3 values(1)") + + # Verify that a warning is logged. + node_publisher.wait_for_log( + r'WARNING: ( [A-Z0-9]+:)? skipped loading publication "tap_pub_3"', offset + ) + + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_3 FOR TABLE tab_3") + + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION") + + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + node_publisher.safe_sql("INSERT INTO tab_3 values(2)") + + node_publisher.wait_for_catchup("tap_sub") + + # Verify that the insert operation gets replicated to subscriber after + # publication is created. + result = node_subscriber.safe_sql("SELECT * FROM tab_3") + assert result == "1\n2", ( + "check that the incremental data is replicated after the publication " + "is created" + ) + + # shutdown + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_025_rep_changes_for_schema.py b/src/test/subscription/pyt/test_025_rep_changes_for_schema.py new file mode 100644 index 00000000000..af33164a6be --- /dev/null +++ b/src/test/subscription/pyt/test_025_rep_changes_for_schema.py @@ -0,0 +1,192 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Logical replication tests for schema publications.""" + + +def test_025_rep_changes_for_schema(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Test replication with publications created using FOR TABLES IN SCHEMA + # option. + # Create schemas and tables on publisher + node_publisher.safe_sql("CREATE SCHEMA sch1") + node_publisher.safe_sql( + "CREATE TABLE sch1.tab1 AS SELECT generate_series(1,10) AS a" + ) + node_publisher.safe_sql( + "CREATE TABLE sch1.tab2 AS SELECT generate_series(1,10) AS a" + ) + node_publisher.safe_sql( + "CREATE TABLE sch1.tab1_parent (a int PRIMARY KEY, b text) " + "PARTITION BY LIST (a)" + ) + node_publisher.safe_sql( + "CREATE TABLE public.tab1_child1 PARTITION OF sch1.tab1_parent " + "FOR VALUES IN (1, 2, 3)" + ) + node_publisher.safe_sql( + "CREATE TABLE public.tab1_child2 PARTITION OF sch1.tab1_parent " + "FOR VALUES IN (4, 5, 6)" + ) + + node_publisher.safe_sql("INSERT INTO sch1.tab1_parent values (1),(4)") + + # Create schemas and tables on subscriber + node_subscriber.safe_sql("CREATE SCHEMA sch1") + node_subscriber.safe_sql("CREATE TABLE sch1.tab1 (a int)") + node_subscriber.safe_sql("CREATE TABLE sch1.tab2 (a int)") + node_subscriber.safe_sql( + "CREATE TABLE sch1.tab1_parent (a int PRIMARY KEY, b text) " + "PARTITION BY LIST (a)" + ) + node_subscriber.safe_sql( + "CREATE TABLE public.tab1_child1 PARTITION OF sch1.tab1_parent " + "FOR VALUES IN (1, 2, 3)" + ) + node_subscriber.safe_sql( + "CREATE TABLE public.tab1_child2 PARTITION OF sch1.tab1_parent " + "FOR VALUES IN (4, 5, 6)" + ) + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_schema FOR TABLES IN SCHEMA sch1" + ) + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_schema CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub_schema" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub_schema") + + # Check the schema table data is synced up + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM sch1.tab1") + assert result == "10|1|10", "check rows on subscriber catchup" + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM sch1.tab2") + assert result == "10|1|10", "check rows on subscriber catchup" + + result = node_subscriber.safe_sql("SELECT * FROM sch1.tab1_parent order by 1") + assert result == "1|\n4|", "check rows on subscriber catchup" + + # Insert some data into few tables and verify that inserted data is replicated + node_publisher.safe_sql("INSERT INTO sch1.tab1 VALUES(generate_series(11,20))") + + node_publisher.safe_sql("INSERT INTO sch1.tab1_parent values (2),(5)") + + node_publisher.wait_for_catchup("tap_sub_schema") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM sch1.tab1") + assert result == "20|1|20", "check replicated inserts on subscriber" + + result = node_subscriber.safe_sql("SELECT * FROM sch1.tab1_parent order by 1") + assert result == "1|\n2|\n4|\n5|", "check replicated inserts on subscriber" + + # Create new table in the publication schema, verify that subscriber does not get + # the new table data before refresh. + node_publisher.safe_sql( + "CREATE TABLE sch1.tab3 AS SELECT generate_series(1,10) AS a" + ) + + node_subscriber.safe_sql("CREATE TABLE sch1.tab3(a int)") + + node_publisher.wait_for_catchup("tap_sub_schema") + + result = node_subscriber.safe_sql("SELECT count(*) FROM sch1.tab3") + assert result == "0", "check replicated inserts on subscriber" + + # Table data should be reflected after refreshing the publication in + # subscriber. + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_schema REFRESH PUBLICATION") + + # Wait for sync to finish + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO sch1.tab3 VALUES(11)") + + node_publisher.wait_for_catchup("tap_sub_schema") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM sch1.tab3") + assert result == "11|1|11", "check rows on subscriber catchup" + + # Set the schema of a publication schema table to a non publication schema and + # verify that inserted data is not reflected by the subscriber. + node_publisher.safe_sql("ALTER TABLE sch1.tab3 SET SCHEMA public") + node_publisher.safe_sql("INSERT INTO public.tab3 VALUES(12)") + + node_publisher.wait_for_catchup("tap_sub_schema") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM sch1.tab3") + assert result == "11|1|11", "check replicated inserts on subscriber" + + # Verify that the subscription relation list is updated after refresh + result = node_subscriber.safe_sql( + "SELECT count(*) FROM pg_subscription_rel WHERE srsubid IN " + "(SELECT oid FROM pg_subscription WHERE subname = 'tap_sub_schema')" + ) + assert ( + result == "5" + ), "check subscription relation status is not yet dropped on subscriber" + + # Ask for data sync + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_schema REFRESH PUBLICATION") + + # Wait for sync to finish + node_subscriber.wait_for_subscription_sync() + + result = node_subscriber.safe_sql( + "SELECT count(*) FROM pg_subscription_rel WHERE srsubid IN " + "(SELECT oid FROM pg_subscription WHERE subname = 'tap_sub_schema')" + ) + assert result == "4", "check subscription relation status was dropped on subscriber" + + # Drop table from the publication schema, verify that subscriber removes the + # table entry after refresh. + node_publisher.safe_sql("DROP TABLE sch1.tab2") + node_publisher.wait_for_catchup("tap_sub_schema") + result = node_subscriber.safe_sql( + "SELECT count(*) FROM pg_subscription_rel WHERE srsubid IN " + "(SELECT oid FROM pg_subscription WHERE subname = 'tap_sub_schema')" + ) + assert ( + result == "4" + ), "check subscription relation status is not yet dropped on subscriber" + + # Table should be removed from pg_subscription_rel after refreshing the + # publication in subscriber. + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_schema REFRESH PUBLICATION") + + # Wait for sync to finish + node_subscriber.wait_for_subscription_sync() + + result = node_subscriber.safe_sql( + "SELECT count(*) FROM pg_subscription_rel WHERE srsubid IN " + "(SELECT oid FROM pg_subscription WHERE subname = 'tap_sub_schema')" + ) + assert result == "3", "check subscription relation status was dropped on subscriber" + + # Drop schema from publication, verify that the inserts are not published after + # dropping the schema from publication. Here 2nd insert should not be + # published. + node_publisher.safe_sql( + "INSERT INTO sch1.tab1 VALUES(21);\n" + "ALTER PUBLICATION tap_pub_schema DROP TABLES IN SCHEMA sch1;\n" + "INSERT INTO sch1.tab1 values(22);" + ) + + node_publisher.wait_for_catchup("tap_sub_schema") + + result = node_subscriber.safe_sql("SELECT count(*), min(a), max(a) FROM sch1.tab1") + assert result == "21|1|21", "check replicated inserts on subscriber" + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_026_stats.py b/src/test/subscription/pyt/test_026_stats.py new file mode 100644 index 00000000000..d4a9d68dff8 --- /dev/null +++ b/src/test/subscription/pyt/test_026_stats.py @@ -0,0 +1,415 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for subscription stats.""" + + +def create_sub_pub_w_errors( + node_publisher, node_subscriber, db, table_name, sequence_name +): + # Initial table and sequence setup on both publisher and subscriber. + # + # Tables: Created on both nodes, but the subscriber version includes + # primary keys and pre-populated data that will intentionally conflict + # with replicated data from the publisher. + # + # Sequences: Created on both nodes with different INCREMENT values to + # intentionally trigger replication conflicts. + node_publisher.safe_sql( + f""" + BEGIN; + CREATE TABLE {table_name}(a int); + ALTER TABLE {table_name} REPLICA IDENTITY FULL; + INSERT INTO {table_name} VALUES (1); + CREATE SEQUENCE {sequence_name}; + COMMIT; + """, + dbname=db, + ) + node_subscriber.safe_sql( + f""" + BEGIN; + CREATE TABLE {table_name}(a int primary key); + INSERT INTO {table_name} VALUES (1); + CREATE SEQUENCE {sequence_name} INCREMENT BY 10; + COMMIT; + """, + dbname=db, + ) + + # Set up publication. + pub_name = table_name + "_pub" + pub_seq_name = sequence_name + "_pub" + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname={db}" + ) + + node_publisher.safe_sql( + f""" + CREATE PUBLICATION {pub_name} FOR TABLE {table_name}; + CREATE PUBLICATION {pub_seq_name} FOR ALL SEQUENCES; + """, + dbname=db, + ) + + # Create subscription. The tablesync for table on subscription will enter + # into infinite error loop due to violating the unique constraint. The + # sequencesync will also fail due to different sequence increment values on + # publisher and subscriber. + sub_name = table_name + "_sub" + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION {sub_name} CONNECTION '{publisher_connstr}' " + f"PUBLICATION {pub_name}, {pub_seq_name}", + dbname=db, + ) + + node_publisher.wait_for_catchup(sub_name) + + # Wait for the tablesync and sequencesync error to be reported. + assert node_subscriber.poll_query_until( + f""" + SELECT count(1) = 1 FROM pg_stat_subscription_stats + WHERE subname = '{sub_name}' AND sync_seq_error_count > 0 AND sync_table_error_count > 0 + """, + dbname=db, + ), ( + f"Timed out while waiting for sequencesync errors and tablesync errors " + f"for subscription '{sub_name}'" + ) + + # Change the sequence INCREMENT value back to the default on the subscriber + # so it doesn't error out. + node_subscriber.safe_sql(f"ALTER SEQUENCE {sequence_name} INCREMENT 1", dbname=db) + + # Wait for sequencesync to finish. + assert node_subscriber.poll_query_until( + f""" + SELECT count(1) = 1 FROM pg_subscription_rel + WHERE srrelid = '{sequence_name}'::regclass AND srsubstate = 'r' + """, + dbname=db, + ), ( + f"Timed out while waiting for subscriber to synchronize data for " + f"sequence '{sequence_name}'." + ) + + # Truncate test_tab1 so that tablesync worker can continue. + node_subscriber.safe_sql(f"TRUNCATE {table_name}", dbname=db) + + # Wait for initial tablesync to finish. + assert node_subscriber.poll_query_until( + f""" + SELECT count(1) = 1 FROM pg_subscription_rel + WHERE srrelid = '{table_name}'::regclass AND srsubstate in ('r', 's') + """, + dbname=db, + ), ( + f"Timed out while waiting for subscriber to synchronize data for " + f"table '{table_name}'." + ) + + # Check test table on the subscriber has one row. + result = node_subscriber.safe_sql(f"SELECT a FROM {table_name}", dbname=db) + assert result == "1", f"Check that table '{table_name}' now has 1 row." + + # Insert data to test table on the publisher, raising an error on the + # subscriber due to violation of the unique constraint on test table. + node_publisher.safe_sql(f"INSERT INTO {table_name} VALUES (1)", dbname=db) + + # Wait for the subscriber to report both an apply error and an + # insert_exists conflict. + assert node_subscriber.poll_query_until( + f""" + SELECT apply_error_count > 0 AND confl_insert_exists > 0 + FROM pg_stat_subscription_stats + WHERE subname = '{sub_name}' + """, + dbname=db, + ), ( + f"Timed out while waiting for apply error and insert_exists conflict " + f"for subscription '{sub_name}'" + ) + + # Truncate test table so that apply worker can continue. + node_subscriber.safe_sql(f"TRUNCATE {table_name}", dbname=db) + + # Delete data from the test table on the publisher. This delete operation + # should be skipped on the subscriber since the table is already empty. + node_publisher.safe_sql(f"DELETE FROM {table_name};", dbname=db) + + # Wait for the subscriber to report a delete_missing conflict. + assert node_subscriber.poll_query_until( + f""" + SELECT confl_delete_missing > 0 + FROM pg_stat_subscription_stats + WHERE subname = '{sub_name}' + """, + dbname=db, + ), ( + f"Timed out while waiting for delete_missing conflict for " + f"subscription '{sub_name}'" + ) + + return (pub_name, sub_name) + + +def test_026_stats(create_pg): + # Create publisher node. + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node. + node_subscriber = create_pg("subscriber") + + db = "postgres" + + # There shouldn't be any subscription errors before starting logical + # replication. + result = node_subscriber.safe_sql( + "SELECT count(1) FROM pg_stat_subscription_stats", dbname=db + ) + assert result == "0", ( + "Check that there are no subscription errors before starting logical " + "replication." + ) + + # Create the publication and subscription with sync and apply errors + table1_name = "test_tab1" + sequence1_name = "test_seq1" + (_, sub1_name) = create_sub_pub_w_errors( + node_publisher, node_subscriber, db, table1_name, sequence1_name + ) + + # Apply errors, sequencesync errors, tablesync errors, and conflicts are + # > 0 and stats_reset timestamp is NULL. + assert ( + node_subscriber.safe_sql( + f"""SELECT apply_error_count > 0, + sync_seq_error_count > 0, + sync_table_error_count > 0, + confl_insert_exists > 0, + confl_delete_missing > 0, + stats_reset IS NULL + FROM pg_stat_subscription_stats + WHERE subname = '{sub1_name}'""", + dbname=db, + ) + == "t|t|t|t|t|t" + ), ( + f"Check that apply errors, sequencesync errors, tablesync errors, and " + f"conflicts are > 0 and stats_reset is NULL for subscription " + f"'{sub1_name}'." + ) + + # Reset a single subscription + node_subscriber.safe_sql( + f"SELECT pg_stat_reset_subscription_stats((SELECT subid FROM " + f"pg_stat_subscription_stats WHERE subname = '{sub1_name}'))", + dbname=db, + ) + + # Apply errors, sequencesync errors, tablesync errors, and conflicts are 0 + # and stats_reset timestamp is not NULL. + assert ( + node_subscriber.safe_sql( + f"""SELECT apply_error_count = 0, + sync_seq_error_count = 0, + sync_table_error_count = 0, + confl_insert_exists = 0, + confl_delete_missing = 0, + stats_reset IS NOT NULL + FROM pg_stat_subscription_stats + WHERE subname = '{sub1_name}'""", + dbname=db, + ) + == "t|t|t|t|t|t" + ), ( + f"Confirm that apply errors, sequencesync errors, tablesync errors, " + f"and conflicts are 0 and stats_reset is not NULL after reset for " + f"subscription '{sub1_name}'." + ) + + # Get reset timestamp + reset_time1 = node_subscriber.safe_sql( + f"SELECT stats_reset FROM pg_stat_subscription_stats " + f"WHERE subname = '{sub1_name}'", + dbname=db, + ) + + # Reset single sub again + node_subscriber.safe_sql( + f"SELECT pg_stat_reset_subscription_stats((SELECT subid FROM " + f"pg_stat_subscription_stats WHERE subname = '{sub1_name}'))", + dbname=db, + ) + + # check reset timestamp is newer after reset + assert ( + node_subscriber.safe_sql( + f"SELECT stats_reset > '{reset_time1}'::timestamptz FROM " + f"pg_stat_subscription_stats WHERE subname = '{sub1_name}'", + dbname=db, + ) + == "t" + ), f"Check reset timestamp for '{sub1_name}' is newer after second reset." + + # Make second subscription and publication + table2_name = "test_tab2" + sequence2_name = "test_seq2" + (_, sub2_name) = create_sub_pub_w_errors( + node_publisher, node_subscriber, db, table2_name, sequence2_name + ) + + # Apply errors, sequencesync errors, tablesync errors, and conflicts are + # > 0 and stats_reset timestamp is NULL + assert ( + node_subscriber.safe_sql( + f"""SELECT apply_error_count > 0, + sync_seq_error_count > 0, + sync_table_error_count > 0, + confl_insert_exists > 0, + confl_delete_missing > 0, + stats_reset IS NULL + FROM pg_stat_subscription_stats + WHERE subname = '{sub2_name}'""", + dbname=db, + ) + == "t|t|t|t|t|t" + ), ( + f"Confirm that apply errors, sequencesync errors, tablesync errors, " + f"and conflicts are > 0 and stats_reset is NULL for sub '{sub2_name}'." + ) + + # Reset all subscriptions + node_subscriber.safe_sql("SELECT pg_stat_reset_subscription_stats(NULL)", dbname=db) + + # Apply errors, sequencesync errors, tablesync errors, and conflicts are 0 + # and stats_reset timestamp is not NULL. + assert ( + node_subscriber.safe_sql( + f"""SELECT apply_error_count = 0, + sync_seq_error_count = 0, + sync_table_error_count = 0, + confl_insert_exists = 0, + confl_delete_missing = 0, + stats_reset IS NOT NULL + FROM pg_stat_subscription_stats + WHERE subname = '{sub1_name}'""", + dbname=db, + ) + == "t|t|t|t|t|t" + ), ( + f"Confirm that apply errors, sequencesync errors, tablesync errors, " + f"and conflicts are 0 and stats_reset is not NULL for sub " + f"'{sub1_name}' after reset." + ) + + assert ( + node_subscriber.safe_sql( + f"""SELECT apply_error_count = 0, + sync_seq_error_count = 0, + sync_table_error_count = 0, + confl_insert_exists = 0, + confl_delete_missing = 0, + stats_reset IS NOT NULL + FROM pg_stat_subscription_stats + WHERE subname = '{sub2_name}'""", + dbname=db, + ) + == "t|t|t|t|t|t" + ), ( + f"Confirm that apply errors, sequencesync errors, tablesync errors, " + f"errors, and conflicts are 0 and stats_reset is not NULL for sub " + f"'{sub2_name}' after reset." + ) + + reset_time1 = node_subscriber.safe_sql( + f"SELECT stats_reset FROM pg_stat_subscription_stats " + f"WHERE subname = '{sub1_name}'", + dbname=db, + ) + reset_time2 = node_subscriber.safe_sql( + f"SELECT stats_reset FROM pg_stat_subscription_stats " + f"WHERE subname = '{sub2_name}'", + dbname=db, + ) + + # Reset all subscriptions + node_subscriber.safe_sql("SELECT pg_stat_reset_subscription_stats(NULL)", dbname=db) + + # check reset timestamp for sub1 is newer after reset + assert ( + node_subscriber.safe_sql( + f"SELECT stats_reset > '{reset_time1}'::timestamptz FROM " + f"pg_stat_subscription_stats WHERE subname = '{sub1_name}'", + dbname=db, + ) + == "t" + ), ( + f"Confirm that reset timestamp for '{sub1_name}' is newer after " + f"second reset." + ) + + # check reset timestamp for sub2 is newer after reset + assert ( + node_subscriber.safe_sql( + f"SELECT stats_reset > '{reset_time2}'::timestamptz FROM " + f"pg_stat_subscription_stats WHERE subname = '{sub2_name}'", + dbname=db, + ) + == "t" + ), ( + f"Confirm that reset timestamp for '{sub2_name}' is newer after " + f"second reset." + ) + + # Get subscription 1 oid + sub1_oid = node_subscriber.safe_sql( + f"SELECT oid FROM pg_subscription WHERE subname = '{sub1_name}'", + dbname=db, + ) + + # Drop subscription 1 + node_subscriber.safe_sql(f"DROP SUBSCRIPTION {sub1_name}", dbname=db) + + # Subscription stats for sub1 should be gone + assert ( + node_subscriber.safe_sql( + f"SELECT pg_stat_have_stats('subscription', 0, {sub1_oid})", dbname=db + ) + == "f" + ), f"Subscription stats for subscription '{sub1_name}' should be removed." + + # Get subscription 2 oid + sub2_oid = node_subscriber.safe_sql( + f"SELECT oid FROM pg_subscription WHERE subname = '{sub2_name}'", + dbname=db, + ) + + # Disassociate the subscription 2 from its replication slot and drop it + node_subscriber.safe_sql(f"ALTER SUBSCRIPTION {sub2_name} DISABLE", dbname=db) + node_subscriber.safe_sql( + f"ALTER SUBSCRIPTION {sub2_name} SET (slot_name = NONE)", dbname=db + ) + node_subscriber.safe_sql(f"DROP SUBSCRIPTION {sub2_name}", dbname=db) + + # Subscription stats for sub2 should be gone + assert ( + node_subscriber.safe_sql( + f"SELECT pg_stat_have_stats('subscription', 0, {sub2_oid})", dbname=db + ) + == "f" + ), f"Subscription stats for subscription '{sub2_name}' should be removed." + + # Since disabling subscription doesn't wait for walsender to release the + # replication slot and exit, wait for the slot to become inactive. + assert node_publisher.poll_query_until( + f"SELECT EXISTS (SELECT 1 FROM pg_replication_slots " + f"WHERE slot_name = '{sub2_name}' AND active_pid IS NULL)", + dbname=db, + ), "slot never became inactive" + + node_publisher.safe_sql( + f"SELECT pg_drop_replication_slot('{sub2_name}')", dbname=db + ) + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_027_nosuperuser.py b/src/test/subscription/pyt/test_027_nosuperuser.py new file mode 100644 index 00000000000..f0b4b0bd578 --- /dev/null +++ b/src/test/subscription/pyt/test_027_nosuperuser.py @@ -0,0 +1,495 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that logical replication respects permissions.""" + +import os +import re + +from pypg.util import USE_UNIX_SOCKETS + +publisher_connstr = None +offset = 0 + + +def run_as(node, role, *statements): + """Run *statements* on a fresh connection as *role*. + + Running each role-scoped statement on its own one-shot connection keeps + the authorization change from outliving the statement. + Here safe_sql reuses one cached libpq session per database, so a stray + SET SESSION AUTHORIZATION would leak into later safe_sql calls; running + the role-scoped statements on a throwaway connection (logging in as the + role directly) keeps each statement properly isolated. + """ + sess = node.connect(user=role) + try: + for stmt in statements: + sess.query_safe(stmt) + finally: + sess.close() + + +def publish_insert(node_publisher, tbl, new_i): + sess = node_publisher.connect(user="regress_alice") + try: + sess.query_safe(f"INSERT INTO {tbl} (i) VALUES ({new_i})") + finally: + sess.close() + + +def publish_update(node_publisher, tbl, old_i, new_i): + sess = node_publisher.connect(user="regress_alice") + try: + sess.query_safe(f"UPDATE {tbl} SET i = {new_i} WHERE i = {old_i}") + finally: + sess.close() + + +def publish_delete(node_publisher, tbl, old_i): + sess = node_publisher.connect(user="regress_alice") + try: + sess.query_safe(f"DELETE FROM {tbl} WHERE i = {old_i}") + finally: + sess.close() + + +def expect_replication(node_publisher, node_subscriber, tbl, cnt, mn, mx, testname): + node_publisher.wait_for_catchup("admin_sub") + result = node_subscriber.safe_sql(f"SELECT COUNT(i), MIN(i), MAX(i) FROM {tbl}") + assert result == f"{cnt}|{mn}|{mx}", testname + + +def expect_failure(node_subscriber, tbl, cnt, mn, mx, regexp, testname): + global offset # pylint: disable=global-statement + offset = node_subscriber.wait_for_log(regexp, offset) + result = node_subscriber.safe_sql(f"SELECT COUNT(i), MIN(i), MAX(i) FROM {tbl}") + assert result == f"{cnt}|{mn}|{mx}", testname + + +def revoke_superuser(node_subscriber, role): + node_subscriber.safe_sql(f"ALTER ROLE {role} NOSUPERUSER") + + +def grant_superuser(node_subscriber, role): + node_subscriber.safe_sql(f"ALTER ROLE {role} SUPERUSER") + + +def test_027_nosuperuser(create_pg): + global publisher_connstr, offset # pylint: disable=global-statement + + # Create publisher and subscriber nodes with schemas owned and published by + # "regress_alice" but subscribed and replicated by different role + # "regress_admin". For partitioned tables, layout the partitions + # differently on the publisher than on the subscriber. + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + remainder_a = {"publisher": 0, "subscriber": 1} + remainder_b = {"publisher": 1, "subscriber": 0} + + for node in (node_publisher, node_subscriber): + ra = remainder_a[node.name] + rb = remainder_b[node.name] + node.safe_sql( + """ + CREATE ROLE regress_admin SUPERUSER LOGIN; + CREATE ROLE regress_alice NOSUPERUSER LOGIN; + GRANT CREATE ON DATABASE postgres TO regress_alice; + GRANT PG_CREATE_SUBSCRIPTION TO regress_alice; + """ + ) + # Remaining statements run as regress_alice. + run_as( + node, + "regress_alice", + "CREATE SCHEMA alice", + "GRANT USAGE ON SCHEMA alice TO regress_admin", + "CREATE TABLE alice.unpartitioned (i INTEGER)", + "ALTER TABLE alice.unpartitioned REPLICA IDENTITY FULL", + "GRANT SELECT ON TABLE alice.unpartitioned TO regress_admin", + "CREATE TABLE alice.hashpart (i INTEGER) PARTITION BY HASH (i)", + "ALTER TABLE alice.hashpart REPLICA IDENTITY FULL", + "GRANT SELECT ON TABLE alice.hashpart TO regress_admin", + "CREATE TABLE alice.hashpart_a PARTITION OF alice.hashpart " + f"FOR VALUES WITH (MODULUS 2, REMAINDER {ra})", + "ALTER TABLE alice.hashpart_a REPLICA IDENTITY FULL", + "CREATE TABLE alice.hashpart_b PARTITION OF alice.hashpart " + f"FOR VALUES WITH (MODULUS 2, REMAINDER {rb})", + "ALTER TABLE alice.hashpart_b REPLICA IDENTITY FULL", + ) + + run_as( + node_publisher, + "regress_alice", + "CREATE PUBLICATION alice " + " FOR TABLE alice.unpartitioned, alice.hashpart " + " WITH (publish_via_partition_root = true)", + ) + + # CREATE SUBSCRIPTION cannot run in a transaction block, so it must be sent + # as a standalone statement. Run it directly as regress_admin rather than + # via SET SESSION AUTHORIZATION (which would force it into an implicit + # transaction with the SET). + sess = node_subscriber.connect(user="regress_admin") + try: + sess.query_safe( + f"CREATE SUBSCRIPTION admin_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION alice WITH (password_required=false)" + ) + finally: + sess.close() + + # Wait for initial sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "admin_sub") + + # Verify that "regress_admin" can replicate into the tables + publish_insert(node_publisher, "alice.unpartitioned", 1) + publish_insert(node_publisher, "alice.unpartitioned", 3) + publish_insert(node_publisher, "alice.unpartitioned", 5) + publish_update(node_publisher, "alice.unpartitioned", 1, 7) + publish_delete(node_publisher, "alice.unpartitioned", 3) + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 2, + 5, + 7, + "superuser admin replicates into unpartitioned", + ) + + # Revoke and restore superuser privilege for "regress_admin", verifying + # that replication fails while superuser privilege is missing, but works + # again and catches up once superuser is restored. + revoke_superuser(node_subscriber, "regress_admin") + publish_update(node_publisher, "alice.unpartitioned", 5, 9) + expect_failure( + node_subscriber, + "alice.unpartitioned", + 2, + 5, + 7, + r'(?msi)ERROR: ( [A-Z0-9]+:)? role "regress_admin" cannot SET ROLE to "regress_alice"', + "non-superuser admin fails to replicate update", + ) + grant_superuser(node_subscriber, "regress_admin") + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 2, + 7, + 9, + "admin with restored superuser privilege replicates update", + ) + + # Privileges on the target role suffice for non-superuser replication. + node_subscriber.safe_sql( + """ +ALTER ROLE regress_admin NOSUPERUSER; +GRANT regress_alice TO regress_admin; +""" + ) + + publish_insert(node_publisher, "alice.unpartitioned", 11) + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 3, + 7, + 11, + "nosuperuser admin with privileges on role can replicate INSERT into unpartitioned", + ) + + publish_update(node_publisher, "alice.unpartitioned", 7, 13) + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 3, + 9, + 13, + "nosuperuser admin with privileges on role can replicate UPDATE into unpartitioned", + ) + + publish_delete(node_publisher, "alice.unpartitioned", 9) + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 2, + 11, + 13, + "nosuperuser admin with privileges on role can replicate DELETE into unpartitioned", + ) + + # Test partitioning + publish_insert(node_publisher, "alice.hashpart", 101) + publish_insert(node_publisher, "alice.hashpart", 102) + publish_insert(node_publisher, "alice.hashpart", 103) + publish_update(node_publisher, "alice.hashpart", 102, 120) + publish_delete(node_publisher, "alice.hashpart", 101) + expect_replication( + node_publisher, + node_subscriber, + "alice.hashpart", + 2, + 103, + 120, + "nosuperuser admin with privileges on role can replicate into hashpart", + ) + + # Force RLS on the target table and check that replication fails. + run_as( + node_subscriber, + "regress_alice", + "ALTER TABLE alice.unpartitioned ENABLE ROW LEVEL SECURITY", + "ALTER TABLE alice.unpartitioned FORCE ROW LEVEL SECURITY", + ) + + publish_insert(node_publisher, "alice.unpartitioned", 15) + expect_failure( + node_subscriber, + "alice.unpartitioned", + 2, + 11, + 13, + r'(?msi)ERROR: ( [A-Z0-9]+:)? user "regress_alice" cannot replicate into relation with row-level security enabled: "unpartitioned\w*"', + "replication of insert into table with forced rls fails", + ) + + # Since replication acts as the table owner, replication will succeed if we + # don't force it. + node_subscriber.safe_sql( + "ALTER TABLE alice.unpartitioned NO FORCE ROW LEVEL SECURITY" + ) + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 3, + 11, + 15, + "non-superuser admin can replicate insert if rls is not forced", + ) + + node_subscriber.safe_sql("ALTER TABLE alice.unpartitioned FORCE ROW LEVEL SECURITY") + publish_update(node_publisher, "alice.unpartitioned", 11, 17) + expect_failure( + node_subscriber, + "alice.unpartitioned", + 3, + 11, + 15, + r'(?msi)ERROR: ( [A-Z0-9]+:)? user "regress_alice" cannot replicate into relation with row-level security enabled: "unpartitioned\w*"', + "replication of update into table with forced rls fails", + ) + node_subscriber.safe_sql( + "ALTER TABLE alice.unpartitioned NO FORCE ROW LEVEL SECURITY" + ) + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 3, + 13, + 17, + "non-superuser admin can replicate update if rls is not forced", + ) + + # Remove some of alice's privileges on her own table. Then replication + # should fail. + node_subscriber.safe_sql( + "REVOKE SELECT, INSERT ON alice.unpartitioned FROM regress_alice" + ) + publish_insert(node_publisher, "alice.unpartitioned", 19) + expect_failure( + node_subscriber, + "alice.unpartitioned", + 3, + 13, + 17, + r"(?msi)ERROR: ( [A-Z0-9]+:)? permission denied for table unpartitioned", + "replication of insert fails if table owner lacks insert permission", + ) + + # alice needs INSERT but not SELECT to replicate an INSERT. + node_subscriber.safe_sql("GRANT INSERT ON alice.unpartitioned TO regress_alice") + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 4, + 13, + 19, + "restoring insert permission permits replication to continue", + ) + + # Now let's try an UPDATE and a DELETE. + node_subscriber.safe_sql( + "REVOKE UPDATE, DELETE ON alice.unpartitioned FROM regress_alice" + ) + publish_update(node_publisher, "alice.unpartitioned", 13, 21) + publish_delete(node_publisher, "alice.unpartitioned", 15) + expect_failure( + node_subscriber, + "alice.unpartitioned", + 4, + 13, + 19, + r"(?msi)ERROR: ( [A-Z0-9]+:)? permission denied for table unpartitioned", + "replication of update/delete fails if table owner lacks corresponding permission", + ) + + # Restoring UPDATE and DELETE is insufficient. + node_subscriber.safe_sql( + "GRANT UPDATE, DELETE ON alice.unpartitioned TO regress_alice" + ) + expect_failure( + node_subscriber, + "alice.unpartitioned", + 4, + 13, + 19, + r"(?msi)ERROR: ( [A-Z0-9]+:)? permission denied for table unpartitioned", + "replication of update/delete fails if table owner lacks SELECT permission", + ) + + # alice needs INSERT but not SELECT to replicate an INSERT. + node_subscriber.safe_sql("GRANT SELECT ON alice.unpartitioned TO regress_alice") + expect_replication( + node_publisher, + node_subscriber, + "alice.unpartitioned", + 3, + 17, + 21, + "restoring SELECT permission permits replication to continue", + ) + + # The apply worker should get restarted after the superuser privileges are + # revoked for subscription owner alice. + grant_superuser(node_subscriber, "regress_alice") + + # CREATE SUBSCRIPTION cannot run in a transaction block; run it standalone + # as regress_alice. + sess = node_subscriber.connect(user="regress_alice") + try: + sess.query_safe( + f"CREATE SUBSCRIPTION regression_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION alice" + ) + finally: + sess.close() + + # Wait for initial sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "regression_sub") + + # Check the subscriber log from now on. + offset = node_subscriber.log_position() + + revoke_superuser(node_subscriber, "regress_alice") + + # After the user becomes non-superuser the apply worker should be + # restarted. + node_subscriber.wait_for_log( + r'LOG: ( [A-Z0-9]+:)? logical replication worker for subscription "regression_sub" will restart because the subscription owner\'s superuser privileges have been revoked', + offset, + ) + + # If the subscription connection requires a password ('password_required' + # is true) then a non-superuser must specify that password in the + # connection string. Below this rewrites pg_hba.conf with a "local" + # (Unix-domain socket) rule, so the connection must come over a socket; + # skip it over TCP. + if not USE_UNIX_SOCKETS: + return + + node_publisher1 = create_pg("publisher1", allows_streaming="logical") + node_subscriber1 = create_pg("subscriber1") + publisher_connstr1 = ( + f"host={node_publisher1.host} port={node_publisher1.port} " + "user=regress_test_user dbname=postgres" + ) + publisher_connstr2 = ( + f"host={node_publisher1.host} port={node_publisher1.port} " + "user=regress_test_user dbname=postgres password=secret" + ) + + for node in (node_publisher1, node_subscriber1): + node.safe_sql( + """ + CREATE ROLE regress_test_user PASSWORD 'secret' LOGIN REPLICATION; + GRANT CREATE ON DATABASE postgres TO regress_test_user; + GRANT PG_CREATE_SUBSCRIPTION TO regress_test_user; + """ + ) + + sess = node_publisher1.connect(user="regress_test_user") + try: + sess.query_safe("CREATE PUBLICATION regress_test_pub") + finally: + sess.close() + node_subscriber1.safe_sql( + f"CREATE SUBSCRIPTION regress_test_sub CONNECTION '{publisher_connstr1}' " + "PUBLICATION regress_test_pub" + ) + + # Wait for initial sync to finish + node_subscriber1.wait_for_subscription_sync(node_publisher1, "regress_test_sub") + + # Setup pg_hba configuration so that logical replication connection without + # password is not allowed. + os.remove(os.path.join(node_publisher1.data_dir, "pg_hba.conf")) + node_publisher1.append_conf( + "local all \t\t\t\tregress_test_user \tmd5", filename="pg_hba.conf" + ) + node_publisher1.reload() + + # Change the subscription owner to a non-superuser + node_subscriber1.safe_sql( + "ALTER SUBSCRIPTION regress_test_sub OWNER TO regress_test_user" + ) + + # Non-superuser must specify password in the connection string. Run as + # regress_test_user (ALTER SUBSCRIPTION ... REFRESH PUBLICATION cannot run + # in a transaction block, so it cannot be combined with SET SESSION + # AUTHORIZATION). + sess = node_subscriber1.connect(user="regress_test_user") + try: + res = sess.query("ALTER SUBSCRIPTION regress_test_sub REFRESH PUBLICATION") + stderr = res.error_message or "" + finally: + sess.close() + assert stderr != "", ( + "non zero exit for subscription whose owner is a non-superuser must " + "specify password parameter of the connection string" + ) + assert re.search( + r"DETAIL: Non-superusers must provide a password in the connection string.", + stderr, + ), ( + "subscription whose owner is a non-superuser must specify password " + "parameter of the connection string" + ) + + # It should succeed after including the password parameter of the + # connection string. + sess = node_subscriber1.connect(user="regress_test_user") + try: + res = sess.query( + f"ALTER SUBSCRIPTION regress_test_sub CONNECTION '{publisher_connstr2}'" + ) + err1 = res.error_message + res = sess.query("ALTER SUBSCRIPTION regress_test_sub REFRESH PUBLICATION") + err2 = res.error_message + finally: + sess.close() + assert err1 is None and err2 is None, ( + "Non-superuser will be able to refresh the publication after specifying " + "the password parameter of the connection string" + ) diff --git a/src/test/subscription/pyt/test_028_row_filter.py b/src/test/subscription/pyt/test_028_row_filter.py new file mode 100644 index 00000000000..54cba4d4743 --- /dev/null +++ b/src/test/subscription/pyt/test_028_row_filter.py @@ -0,0 +1,841 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test logical replication behavior with row filtering.""" + + +def test_028_row_filter(create_pg): + # create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # create subscriber node + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + appname = "tap_sub" + + # ==================================================================== + # Testcase start: FOR ALL TABLES + # + # The FOR ALL TABLES test must come first so that it is not affected by + # all the other test tables that are later created. + + # create tables pub and sub + node_publisher.safe_sql("CREATE TABLE tab_rf_x (x int primary key)") + node_subscriber.safe_sql("CREATE TABLE tab_rf_x (x int primary key)") + + # insert some initial data + node_publisher.safe_sql( + "INSERT INTO tab_rf_x (x) VALUES (0), (5), (10), (15), (20)" + ) + + # create pub/sub + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_x FOR TABLE tab_rf_x WHERE (x > 10)" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_forall FOR ALL TABLES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_x, tap_pub_forall" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # The subscription of the FOR ALL TABLES publication means there should be + # no filtering on the tablesync COPY, so all expect all 5 will be present. + result = node_subscriber.safe_sql("SELECT count(x) FROM tab_rf_x") + assert ( + result == "5" + ), "check initial data copy from table tab_rf_x should not be filtered" + + # Similarly, the table filter for tab_rf_x (after the initial phase) has no + # effect when combined with the ALL TABLES. + # Expected: 5 initial rows + 2 new rows = 7 rows + node_publisher.safe_sql("INSERT INTO tab_rf_x (x) VALUES (-99), (99)") + node_publisher.wait_for_catchup(appname) + result = node_subscriber.safe_sql("SELECT count(x) FROM tab_rf_x") + assert result == "7", "check table tab_rf_x should not be filtered" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_forall") + node_publisher.safe_sql("DROP PUBLICATION tap_pub_x") + node_publisher.safe_sql("DROP TABLE tab_rf_x") + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + node_subscriber.safe_sql("DROP TABLE tab_rf_x") + + # Testcase end: FOR ALL TABLES + # ==================================================================== + + # ==================================================================== + # Testcase start: TABLES IN SCHEMA + # + # The TABLES IN SCHEMA test is independent of all other test cases so it + # cleans up after itself. + + # create tables pub and sub + node_publisher.safe_sql("CREATE SCHEMA schema_rf_x") + node_publisher.safe_sql("CREATE TABLE schema_rf_x.tab_rf_x (x int primary key)") + node_publisher.safe_sql( + "CREATE TABLE schema_rf_x.tab_rf_partitioned (x int primary key) " + "PARTITION BY RANGE(x)" + ) + node_publisher.safe_sql( + "CREATE TABLE public.tab_rf_partition (LIKE schema_rf_x.tab_rf_partitioned)" + ) + node_publisher.safe_sql( + "ALTER TABLE schema_rf_x.tab_rf_partitioned ATTACH PARTITION " + "public.tab_rf_partition DEFAULT" + ) + node_subscriber.safe_sql("CREATE SCHEMA schema_rf_x") + node_subscriber.safe_sql("CREATE TABLE schema_rf_x.tab_rf_x (x int primary key)") + node_subscriber.safe_sql( + "CREATE TABLE schema_rf_x.tab_rf_partitioned (x int primary key) " + "PARTITION BY RANGE(x)" + ) + node_subscriber.safe_sql( + "CREATE TABLE public.tab_rf_partition (LIKE schema_rf_x.tab_rf_partitioned)" + ) + node_subscriber.safe_sql( + "ALTER TABLE schema_rf_x.tab_rf_partitioned ATTACH PARTITION " + "public.tab_rf_partition DEFAULT" + ) + + # insert some initial data + node_publisher.safe_sql( + "INSERT INTO schema_rf_x.tab_rf_x (x) VALUES (0), (5), (10), (15), (20)" + ) + node_publisher.safe_sql( + "INSERT INTO schema_rf_x.tab_rf_partitioned (x) VALUES (1), (20)" + ) + + # create pub/sub + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_x FOR TABLE schema_rf_x.tab_rf_x WHERE (x > 10)" + ) + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_allinschema FOR TABLES IN SCHEMA " + "schema_rf_x, TABLE schema_rf_x.tab_rf_x WHERE (x > 10)" + ) + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub_allinschema ADD TABLE " + "public.tab_rf_partition WHERE (x > 10)" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_x, tap_pub_allinschema" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # The subscription of the TABLES IN SCHEMA publication means there should be + # no filtering on the tablesync COPY, so expect all 5 will be present. + result = node_subscriber.safe_sql("SELECT count(x) FROM schema_rf_x.tab_rf_x") + assert ( + result == "5" + ), "check initial data copy from table tab_rf_x should not be filtered" + + # Similarly, the table filter for tab_rf_x (after the initial phase) has no + # effect when combined with the TABLES IN SCHEMA. Meanwhile, the filter for + # the tab_rf_partition does work because that partition belongs to a + # different schema (and publish_via_partition_root = false). + # Expected: + # tab_rf_x : 5 initial rows + 2 new rows = 7 rows + # tab_rf_partition : 1 initial row + 1 new row = 2 rows + node_publisher.safe_sql("INSERT INTO schema_rf_x.tab_rf_x (x) VALUES (-99), (99)") + node_publisher.safe_sql( + "INSERT INTO schema_rf_x.tab_rf_partitioned (x) VALUES (5), (25)" + ) + node_publisher.wait_for_catchup(appname) + result = node_subscriber.safe_sql("SELECT count(x) FROM schema_rf_x.tab_rf_x") + assert result == "7", "check table tab_rf_x should not be filtered" + result = node_subscriber.safe_sql("SELECT * FROM public.tab_rf_partition") + assert result == "20\n25", "check table tab_rf_partition should be filtered" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_allinschema") + node_publisher.safe_sql("DROP PUBLICATION tap_pub_x") + node_publisher.safe_sql("DROP TABLE public.tab_rf_partition") + node_publisher.safe_sql("DROP TABLE schema_rf_x.tab_rf_partitioned") + node_publisher.safe_sql("DROP TABLE schema_rf_x.tab_rf_x") + node_publisher.safe_sql("DROP SCHEMA schema_rf_x") + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + node_subscriber.safe_sql("DROP TABLE public.tab_rf_partition") + node_subscriber.safe_sql("DROP TABLE schema_rf_x.tab_rf_partitioned") + node_subscriber.safe_sql("DROP TABLE schema_rf_x.tab_rf_x") + node_subscriber.safe_sql("DROP SCHEMA schema_rf_x") + + # Testcase end: TABLES IN SCHEMA + # ==================================================================== + + # ====================================================== + # Testcase start: FOR TABLE with row filter publications + + # setup structure on publisher + node_publisher.safe_sql("CREATE TABLE tab_rowfilter_1 (a int primary key, b text)") + node_publisher.safe_sql("ALTER TABLE tab_rowfilter_1 REPLICA IDENTITY FULL;") + node_publisher.safe_sql("CREATE TABLE tab_rowfilter_2 (c int primary key)") + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_3 (a int primary key, b boolean)" + ) + node_publisher.safe_sql("CREATE TABLE tab_rowfilter_4 (c int primary key)") + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_partitioned (a int primary key, b integer) " + "PARTITION BY RANGE(a)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_less_10k (LIKE tab_rowfilter_partitioned)" + ) + node_publisher.safe_sql( + "ALTER TABLE tab_rowfilter_partitioned ATTACH PARTITION " + "tab_rowfilter_less_10k FOR VALUES FROM (MINVALUE) TO (10000)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_greater_10k (LIKE tab_rowfilter_partitioned)" + ) + node_publisher.safe_sql( + "ALTER TABLE tab_rowfilter_partitioned ATTACH PARTITION " + "tab_rowfilter_greater_10k FOR VALUES FROM (10000) TO (MAXVALUE)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_partitioned_2 " + "(a int primary key, b integer) PARTITION BY RANGE(a)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_partition (LIKE tab_rowfilter_partitioned_2)" + ) + node_publisher.safe_sql( + "ALTER TABLE tab_rowfilter_partitioned_2 ATTACH PARTITION " + "tab_rowfilter_partition DEFAULT" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_toast (a text NOT NULL, b text NOT NULL)" + ) + node_publisher.safe_sql( + "ALTER TABLE tab_rowfilter_toast ALTER COLUMN a SET STORAGE EXTERNAL" + ) + node_publisher.safe_sql( + "CREATE UNIQUE INDEX tab_rowfilter_toast_ri_index on " + "tab_rowfilter_toast (a, b)" + ) + node_publisher.safe_sql( + "ALTER TABLE tab_rowfilter_toast REPLICA IDENTITY USING INDEX " + "tab_rowfilter_toast_ri_index" + ) + node_publisher.safe_sql("CREATE TABLE tab_rowfilter_inherited (a int)") + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_child (b text) " + "INHERITS (tab_rowfilter_inherited)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_viaroot_part (a int) PARTITION BY RANGE (a)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_viaroot_part_1 PARTITION OF " + "tab_rowfilter_viaroot_part FOR VALUES FROM (1) TO (20)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_parent_sync (a int) PARTITION BY RANGE (a)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_child_sync PARTITION OF " + "tab_rowfilter_parent_sync FOR VALUES FROM (1) TO (20)" + ) + node_publisher.safe_sql( + "CREATE TABLE tab_rowfilter_virtual (id int PRIMARY KEY, x int, " + "y int GENERATED ALWAYS AS (x * 2) VIRTUAL)" + ) + + # setup structure on subscriber + node_subscriber.safe_sql("CREATE TABLE tab_rowfilter_1 (a int primary key, b text)") + node_subscriber.safe_sql("CREATE TABLE tab_rowfilter_2 (c int primary key)") + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_3 (a int primary key, b boolean)" + ) + node_subscriber.safe_sql("CREATE TABLE tab_rowfilter_4 (c int primary key)") + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_partitioned (a int primary key, b integer) " + "PARTITION BY RANGE(a)" + ) + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_less_10k (LIKE tab_rowfilter_partitioned)" + ) + node_subscriber.safe_sql( + "ALTER TABLE tab_rowfilter_partitioned ATTACH PARTITION " + "tab_rowfilter_less_10k FOR VALUES FROM (MINVALUE) TO (10000)" + ) + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_greater_10k (LIKE tab_rowfilter_partitioned)" + ) + node_subscriber.safe_sql( + "ALTER TABLE tab_rowfilter_partitioned ATTACH PARTITION " + "tab_rowfilter_greater_10k FOR VALUES FROM (10000) TO (MAXVALUE)" + ) + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_partitioned_2 " + "(a int primary key, b integer) PARTITION BY RANGE(a)" + ) + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_partition (LIKE tab_rowfilter_partitioned_2)" + ) + node_subscriber.safe_sql( + "ALTER TABLE tab_rowfilter_partitioned_2 ATTACH PARTITION " + "tab_rowfilter_partition DEFAULT" + ) + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_toast (a text NOT NULL, b text NOT NULL)" + ) + node_subscriber.safe_sql( + "CREATE UNIQUE INDEX tab_rowfilter_toast_ri_index on " + "tab_rowfilter_toast (a, b)" + ) + node_subscriber.safe_sql( + "ALTER TABLE tab_rowfilter_toast REPLICA IDENTITY USING INDEX " + "tab_rowfilter_toast_ri_index" + ) + node_subscriber.safe_sql("CREATE TABLE tab_rowfilter_inherited (a int)") + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_child (b text) " + "INHERITS (tab_rowfilter_inherited)" + ) + node_subscriber.safe_sql("CREATE TABLE tab_rowfilter_viaroot_part (a int)") + node_subscriber.safe_sql("CREATE TABLE tab_rowfilter_viaroot_part_1 (a int)") + node_subscriber.safe_sql("CREATE TABLE tab_rowfilter_parent_sync (a int)") + node_subscriber.safe_sql("CREATE TABLE tab_rowfilter_child_sync (a int)") + node_subscriber.safe_sql( + "CREATE TABLE tab_rowfilter_virtual (id int PRIMARY KEY, x int, " + "y int GENERATED ALWAYS AS (x * 2) VIRTUAL)" + ) + + # setup logical replication + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_1 FOR TABLE tab_rowfilter_1 " + "WHERE (a > 1000 AND b <> 'filtered')" + ) + + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub_1 ADD TABLE tab_rowfilter_2 WHERE (c % 7 = 0)" + ) + + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub_1 SET TABLE tab_rowfilter_1 " + "WHERE (a > 1000 AND b <> 'filtered'), tab_rowfilter_2 " + "WHERE (c % 2 = 0), tab_rowfilter_3" + ) + + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_2 FOR TABLE tab_rowfilter_2 WHERE (c % 3 = 0)" + ) + + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_3 FOR TABLE tab_rowfilter_partitioned" + ) + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub_3 ADD TABLE tab_rowfilter_less_10k " + "WHERE (a < 6000)" + ) + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_not_used FOR TABLE tab_rowfilter_1 WHERE (a < 0)" + ) + + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_4a FOR TABLE tab_rowfilter_4 WHERE (c % 2 = 0)" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_4b FOR TABLE tab_rowfilter_4") + + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_5a FOR TABLE tab_rowfilter_partitioned_2" + ) + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_5b FOR TABLE tab_rowfilter_partition " + "WHERE (a > 10)" + ) + + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_toast FOR TABLE tab_rowfilter_toast " + "WHERE (a = repeat('1234567890', 200) AND b < '10')" + ) + + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_inherits FOR TABLE tab_rowfilter_inherited " + "WHERE (a > 15)" + ) + + # two publications, each publishing the partition through a different + # ancestor, with different row filters + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_viaroot_1 FOR TABLE " + "tab_rowfilter_viaroot_part WHERE (a > 15) " + "WITH (publish_via_partition_root)" + ) + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_viaroot_2 FOR TABLE " + "tab_rowfilter_viaroot_part_1 WHERE (a < 15) " + "WITH (publish_via_partition_root)" + ) + + # two publications, one publishing through ancestor and another one directly + # publishing the partition, with different row filters + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_parent_sync FOR TABLE " + "tab_rowfilter_parent_sync WHERE (a > 15) " + "WITH (publish_via_partition_root)" + ) + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_child_sync FOR TABLE " + "tab_rowfilter_child_sync WHERE (a < 15)" + ) + + # publication using virtual generated column in row filter expression + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_virtual FOR TABLE tab_rowfilter_virtual " + "WHERE (y > 10)" + ) + + # + # The following INSERTs are executed before the CREATE SUBSCRIPTION, so + # these SQL commands are for testing the initial data copy using logical + # replication. + # + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) VALUES (1, 'not replicated')" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) VALUES (1500, 'filtered')" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) VALUES (1980, 'not filtered')" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) SELECT x, 'test ' || x " + "FROM generate_series(990,1002) x" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_2 (c) SELECT generate_series(1, 20)" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_3 (a, b) SELECT x, (x % 3 = 0) " + "FROM generate_series(1, 10) x" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_4 (c) SELECT generate_series(1, 10)" + ) + + node_publisher.safe_sql("INSERT INTO tab_rowfilter_parent_sync(a) VALUES(14), (16)") + + # insert data into partitioned table and directly on the partition + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_partitioned (a, b) " + "VALUES(1, 100),(7000, 101),(15000, 102),(5500, 300)" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_less_10k (a, b) VALUES(2, 200),(6005, 201)" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_greater_10k (a, b) VALUES(16000, 103)" + ) + + # insert data into partitioned table. + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_partitioned_2 (a, b) VALUES(1, 1),(20, 20)" + ) + + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_toast(a, b) " + "VALUES(repeat('1234567890', 200), '1234567890')" + ) + + # insert data into parent and child table. + node_publisher.safe_sql("INSERT INTO tab_rowfilter_inherited(a) VALUES(10),(20)") + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_child(a, b) VALUES(0,'0'),(30,'30'),(40,'40')" + ) + + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_virtual (id, x) VALUES (1, 2), (2, 4), (3, 6)" + ) + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_1, tap_pub_2, tap_pub_3, tap_pub_4a, tap_pub_4b, " + "tap_pub_5a, tap_pub_5b, tap_pub_toast, tap_pub_inherits, " + "tap_pub_viaroot_2, tap_pub_viaroot_1, tap_pub_parent_sync, " + "tap_pub_child_sync, tap_pub_virtual" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # Check expected replicated rows for tab_rowfilter_1 + # tap_pub_1 filter is: (a > 1000 AND b <> 'filtered') + # - INSERT (1, 'not replicated') NO, because a is not > 1000 + # - INSERT (1500, 'filtered') NO, because b == 'filtered' + # - INSERT (1980, 'not filtered') YES + # - generate_series(990,1002) YES, only for 1001,1002 because a > 1000 + # + result = node_subscriber.safe_sql("SELECT a, b FROM tab_rowfilter_1 ORDER BY 1, 2") + assert result == ( + "1001|test 1001\n1002|test 1002\n1980|not filtered" + ), "check initial data copy from table tab_rowfilter_1" + + # Check expected replicated rows for tab_rowfilter_2 + # tap_pub_1 filter is: (c % 2 = 0) + # tap_pub_2 filter is: (c % 3 = 0) + # When there are multiple publications for the same table, the filters + # expressions are OR'ed together. In this case, rows are replicated if + # c value is divided by 2 OR 3 (2, 3, 4, 6, 8, 9, 10, 12, 14, 15, 16, 18, 20) + # + result = node_subscriber.safe_sql( + "SELECT count(c), min(c), max(c) FROM tab_rowfilter_2" + ) + assert result == "13|2|20", "check initial data copy from table tab_rowfilter_2" + + # Check expected replicated rows for tab_rowfilter_4 + # (same table in two publications but only one has a filter). + # tap_pub_4a filter is: (c % 2 = 0) + # tap_pub_4b filter is: + # Expressions are OR'ed together but when there is no filter it just means + # OR everything - e.g. same as no filter at all. + # Expect all rows: (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + result = node_subscriber.safe_sql( + "SELECT count(c), min(c), max(c) FROM tab_rowfilter_4" + ) + assert result == "10|1|10", "check initial data copy from table tab_rowfilter_4" + + # Check expected replicated rows for tab_rowfilter_3 + # There is no filter. 10 rows are inserted, so 10 rows are replicated. + result = node_subscriber.safe_sql("SELECT count(a) FROM tab_rowfilter_3") + assert result == "10", "check initial data copy from table tab_rowfilter_3" + + # Check expected replicated rows for partitions + # publication option publish_via_partition_root is false so use the row + # filter from a partition + # tab_rowfilter_partitioned filter: (a < 5000) + # tab_rowfilter_less_10k filter: (a < 6000) + # tab_rowfilter_greater_10k filter: no filter + # + # INSERT into tab_rowfilter_partitioned: + # - INSERT (1,100) YES, because 1 < 6000 + # - INSERT (7000, 101) NO, because 7000 is not < 6000 + # - INSERT (15000, 102) YES, because tab_rowfilter_greater_10k has no filter + # - INSERT (5500, 300) YES, because 5500 < 6000 + # + # INSERT directly into tab_rowfilter_less_10k: + # - INSERT (2, 200) YES, because 2 < 6000 + # - INSERT (6005, 201) NO, because 6005 is not < 6000 + # + # INSERT directly into tab_rowfilter_greater_10k: + # - INSERT (16000, 103) YES, because tab_rowfilter_greater_10k has no filter + # + result = node_subscriber.safe_sql( + "SELECT a, b FROM tab_rowfilter_less_10k ORDER BY 1, 2" + ) + assert result == ( + "1|100\n2|200\n5500|300" + ), "check initial data copy from partition tab_rowfilter_less_10k" + + result = node_subscriber.safe_sql( + "SELECT a, b FROM tab_rowfilter_greater_10k ORDER BY 1, 2" + ) + assert result == ( + "15000|102\n16000|103" + ), "check initial data copy from partition tab_rowfilter_greater_10k" + + # Check expected replicated rows for partitions + # publication option publish_via_partition_root is false so use the row + # filter from a partition + # tap_pub_5a filter: + # tap_pub_5b filter: (a > 10) + # The parent table for this partition is published via tap_pub_5a, so there + # is no filter for the partition. And expressions are OR'ed together so it + # means OR everything - e.g. same as no filter at all. + # Expect all rows: (1, 1) and (20, 20) + # + result = node_subscriber.safe_sql( + "SELECT a, b FROM tab_rowfilter_partition ORDER BY 1, 2" + ) + assert result == ( + "1|1\n20|20" + ), "check initial data copy from partition tab_rowfilter_partition" + + # Check expected replicated rows for tab_rowfilter_toast + # tab_rowfilter_toast filter: (a = repeat('1234567890', 200) AND b < '10') + # INSERT (repeat('1234567890', 200) ,'1234567890') NO + result = node_subscriber.safe_sql("SELECT count(*) FROM tab_rowfilter_toast") + assert result == "0", "check initial data copy from table tab_rowfilter_toast" + + # Check expected replicated rows for tab_rowfilter_inherited + # tab_rowfilter_inherited filter is: (a > 15) + # - INSERT (10) NO, 10 < 15 + # - INSERT (20) YES, 20 > 15 + # - INSERT (0, '0') NO, 0 < 15 + # - INSERT (30, '30') YES, 30 > 15 + # - INSERT (40, '40') YES, 40 > 15 + result = node_subscriber.safe_sql( + "SELECT a FROM tab_rowfilter_inherited ORDER BY a" + ) + assert result == ( + "20\n30\n40" + ), "check initial data copy from table tab_rowfilter_inherited" + + # Check expected replicated rows for tap_pub_parent_sync and + # tap_pub_child_sync. + # Since the option publish_via_partition_root of tap_pub_parent_sync is + # true, so the row filter of tap_pub_parent_sync will be used: + # tap_pub_parent_sync filter is: (a > 15) + # tap_pub_child_sync filter is: (a < 15) + # - INSERT (14) NO, 14 < 15 + # - INSERT (16) YES, 16 > 15 + result = node_subscriber.safe_sql( + "SELECT a FROM tab_rowfilter_parent_sync ORDER BY 1" + ) + assert result == "16", "check initial data copy from tab_rowfilter_parent_sync" + result = node_subscriber.safe_sql( + "SELECT a FROM tab_rowfilter_child_sync ORDER BY 1" + ) + assert result == "", "check initial data copy from tab_rowfilter_child_sync" + + # Check expected replicated rows for tab_rowfilter_virtual + # tap_pub_virtual filter is: (y > 10), where y is generated as (x * 2) + # - INSERT (1, 2) NO, 2 * 2 <= 10 + # - INSERT (2, 4) NO, 4 * 2 <= 10 + # - INSERT (3, 6) YES, 6 * 2 > 10 + result = node_subscriber.safe_sql( + "SELECT id, x FROM tab_rowfilter_virtual ORDER BY id" + ) + assert result == "3|6", "check initial data copy from table tab_rowfilter_virtual" + + # The following commands are executed after CREATE SUBSCRIPTION, so these + # SQL commands are for testing normal logical replication behavior. + # + # test row filter (INSERT, UPDATE, DELETE) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) VALUES (800, 'test 800')" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) VALUES (1600, 'test 1600')" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) VALUES (1601, 'test 1601')" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) VALUES (1602, 'filtered')" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_1 (a, b) VALUES (1700, 'test 1700')" + ) + node_publisher.safe_sql("UPDATE tab_rowfilter_1 SET b = NULL WHERE a = 1600") + node_publisher.safe_sql( + "UPDATE tab_rowfilter_1 SET b = 'test 1601 updated' WHERE a = 1601" + ) + node_publisher.safe_sql( + "UPDATE tab_rowfilter_1 SET b = 'test 1602 updated' WHERE a = 1602" + ) + node_publisher.safe_sql("DELETE FROM tab_rowfilter_1 WHERE a = 1700") + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_2 (c) VALUES (21), (22), (23), (24), (25)" + ) + node_publisher.safe_sql("INSERT INTO tab_rowfilter_4 (c) VALUES (0), (11), (12)") + node_publisher.safe_sql("INSERT INTO tab_rowfilter_inherited (a) VALUES (14), (16)") + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_child (a, b) VALUES (13, '13'), (17, '17')" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_viaroot_part (a) VALUES (14), (15), (16)" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_virtual (id, x) VALUES (4, 3), (5, 7)" + ) + + node_publisher.wait_for_catchup(appname) + + # Check expected replicated rows for tab_rowfilter_2 + # tap_pub_1 filter is: (c % 2 = 0) + # tap_pub_2 filter is: (c % 3 = 0) + # When there are multiple publications for the same table, the filters + # expressions are OR'ed together. In this case, rows are replicated if + # c value is divided by 2 OR 3. + # + # Expect original rows (2, 3, 4, 6, 8, 9, 10, 12, 14, 15, 16, 18, 20) + # Plus (21, 22, 24) + # + result = node_subscriber.safe_sql( + "SELECT count(c), min(c), max(c) FROM tab_rowfilter_2" + ) + assert result == "16|2|24", "check replicated rows to tab_rowfilter_2" + + # Check expected replicated rows for tab_rowfilter_4 + # (same table in two publications but only one has a filter). + # tap_pub_4a filter is: (c % 2 = 0) + # tap_pub_4b filter is: + # Expressions are OR'ed together but when there is no filter it just means + # OR everything - e.g. same as no filter at all. + # Expect all rows from initial copy: (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) + # And also (0, 11, 12) + result = node_subscriber.safe_sql( + "SELECT count(c), min(c), max(c) FROM tab_rowfilter_4" + ) + assert result == "13|0|12", "check replicated rows to tab_rowfilter_4" + + # Check expected replicated rows for tab_rowfilter_1 + # tap_pub_1 filter is: (a > 1000 AND b <> 'filtered') + # + # - 1001, 1002, 1980 already exist from initial data copy + # - INSERT (800, 'test 800') NO, because 800 is not > 1000 + # - INSERT (1600, 'test 1600') YES, because 1600 > 1000 and 'test 1600' <> 'filtered', + # but row deleted after the update below. + # - INSERT (1601, 'test 1601') YES, because 1601 > 1000 and 'test 1601' <> 'filtered' + # - INSERT (1602, 'filtered') NO, because b == 'filtered' + # - INSERT (1700, 'test 1700') YES, because 1700 > 1000 and 'test 1700' <> 'filtered' + # - UPDATE (1600, NULL) NO, row filter evaluates to false because NULL is not <> 'filtered' + # - UPDATE (1601, 'test 1601 updated') YES, because 1601 > 1000 and 'test 1601 updated' <> 'filtered' + # - UPDATE (1602, 'test 1602 updated') YES, because 1602 > 1000 and 'test 1602 updated' <> 'filtered' + # - DELETE (1700) YES, because 1700 > 1000 and 'test 1700' <> 'filtered' + # + result = node_subscriber.safe_sql("SELECT a, b FROM tab_rowfilter_1 ORDER BY 1, 2") + assert result == ( + "1001|test 1001\n" + "1002|test 1002\n" + "1601|test 1601 updated\n" + "1602|test 1602 updated\n" + "1980|not filtered" + ), "check replicated rows to table tab_rowfilter_1" + + # Publish using root partitioned table + # Use a different partitioned table layout (exercise + # publish_via_partition_root) + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub_3 SET (publish_via_partition_root = true)" + ) + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub_3 SET TABLE tab_rowfilter_partitioned " + "WHERE (a < 5000), tab_rowfilter_less_10k WHERE (a < 6000)" + ) + node_subscriber.safe_sql("TRUNCATE TABLE tab_rowfilter_partitioned") + node_subscriber.safe_sql( + "ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION WITH (copy_data = true)" + ) + + # wait for table synchronization to finish + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_partitioned (a, b) " + "VALUES(4000, 400),(4001, 401),(4002, 402)" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_less_10k (a, b) VALUES(4500, 450)" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_less_10k (a, b) VALUES(5600, 123)" + ) + node_publisher.safe_sql( + "INSERT INTO tab_rowfilter_greater_10k (a, b) VALUES(14000, 1950)" + ) + node_publisher.safe_sql("UPDATE tab_rowfilter_less_10k SET b = 30 WHERE a = 4001") + node_publisher.safe_sql("DELETE FROM tab_rowfilter_less_10k WHERE a = 4002") + + node_publisher.wait_for_catchup(appname) + + # Check expected replicated rows for partitions + # publication option publish_via_partition_root is true so use the row + # filter from the root partitioned table + # tab_rowfilter_partitioned filter: (a < 5000) + # tab_rowfilter_less_10k filter: (a < 6000) + # tab_rowfilter_greater_10k filter: no filter + # + # After TRUNCATE, REFRESH PUBLICATION, the initial data copy will apply the + # partitioned table row filter. + # - INSERT (1, 100) YES, 1 < 5000 + # - INSERT (7000, 101) NO, 7000 is not < 5000 + # - INSERT (15000, 102) NO, 15000 is not < 5000 + # - INSERT (5500, 300) NO, 5500 is not < 5000 + # - INSERT (2, 200) YES, 2 < 5000 + # - INSERT (6005, 201) NO, 6005 is not < 5000 + # - INSERT (16000, 103) NO, 16000 is not < 5000 + # + # Execute SQL commands after initial data copy for testing the logical + # replication behavior. + # - INSERT (4000, 400) YES, 4000 < 5000 + # - INSERT (4001, 401) YES, 4001 < 5000 + # - INSERT (4002, 402) YES, 4002 < 5000 + # - INSERT (4500, 450) YES, 4500 < 5000 + # - INSERT (5600, 123) NO, 5600 is not < 5000 + # - INSERT (14000, 1950) NO, 16000 is not < 5000 + # - UPDATE (4001) YES, 4001 < 5000 + # - DELETE (4002) YES, 4002 < 5000 + result = node_subscriber.safe_sql( + "SELECT a, b FROM tab_rowfilter_partitioned ORDER BY 1, 2" + ) + assert result == ( + "1|100\n2|200\n4000|400\n4001|30\n4500|450" + ), "check publish_via_partition_root behavior" + + # Check expected replicated rows for tab_rowfilter_inherited and + # tab_rowfilter_child. + # tab_rowfilter_inherited filter is: (a > 15) + # - INSERT (14) NO, 14 < 15 + # - INSERT (16) YES, 16 > 15 + # + # tab_rowfilter_child filter is: (a > 15) + # - INSERT (13, '13') NO, 13 < 15 + # - INSERT (17, '17') YES, 17 > 15 + + result = node_subscriber.safe_sql( + "SELECT a FROM tab_rowfilter_inherited ORDER BY a" + ) + assert result == ( + "16\n17\n20\n30\n40" + ), "check replicated rows to tab_rowfilter_inherited and tab_rowfilter_child" + + # Check expected replicated rows for tab_rowfilter_virtual + # tap_pub_virtual filter is: (y > 10), where y is generated as (x * 2) + # - INSERT (4, 3) NO, 3 * 2 <= 10 + # - INSERT (5, 7) YES, 7 * 2 > 10 + result = node_subscriber.safe_sql( + "SELECT id, x FROM tab_rowfilter_virtual ORDER BY id" + ) + assert result == ("3|6\n5|7"), "check replicated rows to tab_rowfilter_virtual" + + # UPDATE the non-toasted column for table tab_rowfilter_toast + node_publisher.safe_sql("UPDATE tab_rowfilter_toast SET b = '1'") + + node_publisher.wait_for_catchup(appname) + + # Check expected replicated rows for tab_rowfilter_toast + # tab_rowfilter_toast filter: (a = repeat('1234567890', 200) AND b < '10') + # UPDATE old (repeat('1234567890', 200) ,'1234567890') NO + # new: (repeat('1234567890', 200) ,'1') YES + result = node_subscriber.safe_sql( + "SELECT a = repeat('1234567890', 200), b FROM tab_rowfilter_toast" + ) + assert result == "t|1", "check replicated rows to tab_rowfilter_toast" + + # Check expected replicated rows for tab_rowfilter_viaroot_part and + # tab_rowfilter_viaroot_part_1. We should replicate only rows matching + # the row filter for the top-level ancestor: + # + # tab_rowfilter_viaroot_part filter is: (a > 15) + # - INSERT (14) NO, 14 < 15 + # - INSERT (15) NO, 15 = 15 + # - INSERT (16) YES, 16 > 15 + result = node_subscriber.safe_sql("SELECT a FROM tab_rowfilter_viaroot_part") + assert result == "16", "check replicated rows to tab_rowfilter_viaroot_part" + + # Check there is no data in tab_rowfilter_viaroot_part_1 because rows are + # replicated via the top most parent table tab_rowfilter_viaroot_part + result = node_subscriber.safe_sql("SELECT a FROM tab_rowfilter_viaroot_part_1") + assert result == "", "check replicated rows to tab_rowfilter_viaroot_part_1" + + # Testcase end: FOR TABLE with row filter publications + # ====================================================== + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_029_on_error.py b/src/test/subscription/pyt/test_029_on_error.py new file mode 100644 index 00000000000..14afbb60068 --- /dev/null +++ b/src/test/subscription/pyt/test_029_on_error.py @@ -0,0 +1,216 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for disable_on_error and SKIP transaction features.""" + +import re + + +def skip_lsn(node_publisher, node_subscriber, offset, nonconflict_data, expected, msg): + """Test skipping the transaction. + + This function must be called after the caller has inserted data that + conflicts with the subscriber. The finish LSN of the error transaction + that is used to specify to ALTER SUBSCRIPTION ... SKIP is fetched from + the server logs. After executing ALTER SUBSCRIPTION ... SKIP, we check + if logical replication can continue working by inserting nonconflict_data + on the publisher. + + Returns the new log offset. + """ + + # Wait until a conflict occurs on the subscriber. + node_subscriber.poll_query_until( + "SELECT subenabled = FALSE FROM pg_subscription WHERE subname = 'sub'" + ) + + # Get the finish LSN of the error transaction, mapping the expected + # ERROR with its CONTEXT when retrieving this information. + contents = node_subscriber.log_content()[offset:] + match = re.search( + r'conflict detected on relation "public.tbl".*\n.*DETAIL:.* Could not ' + r"apply remote change.*\n.*Key already exists in unique index " + r'"tbl_pkey", modified by .*origin.* in transaction \d+ at .*: ' + r"key .*, local row .*\n.*CONTEXT:.* for replication target relation " + r'"public.tbl" in transaction \d+, finished at ' + r"([0-9a-fA-F]+/[0-9a-fA-F]+)", + contents, + ) + assert match, "could not get error-LSN" + lsn = match.group(1) + + # Set skip lsn. + node_subscriber.safe_sql(f"ALTER SUBSCRIPTION sub SKIP (lsn = '{lsn}')") + + # Re-enable the subscription. + node_subscriber.safe_sql("ALTER SUBSCRIPTION sub ENABLE") + + # Wait for the failed transaction to be skipped + node_subscriber.poll_query_until( + "SELECT subskiplsn = '0/0' FROM pg_subscription WHERE subname = 'sub'" + ) + + # Check the log to ensure that the transaction is skipped, and advance the + # offset of the log file for the next test. + offset = node_subscriber.wait_for_log( + r"LOG: ( [A-Z0-9]+:)? logical replication completed skipping " + rf"transaction at LSN {lsn}", + offset, + ) + + # Insert non-conflict data + node_publisher.safe_sql(f"INSERT INTO tbl VALUES {nonconflict_data}") + + node_publisher.wait_for_catchup("sub") + + # Check replicated data + res = node_subscriber.safe_sql("SELECT count(*) FROM tbl") + assert res == expected, msg + + return offset + + +def test_029_on_error(create_pg): + offset = 0 + + # Create publisher node. Set a low value of logical_decoding_work_mem to + # test streaming cases. + node_publisher = create_pg("publisher", allows_streaming="logical") + node_publisher.append_conf( + "\n".join( + [ + "logical_decoding_work_mem = 64kB", + "max_prepared_transactions = 10", + ] + ) + ) + node_publisher.restart() + + # Create subscriber node + node_subscriber = create_pg("subscriber") + node_subscriber.append_conf( + "\n".join( + [ + "max_prepared_transactions = 10", + "track_commit_timestamp = on", + ] + ) + ) + node_subscriber.restart() + + # Initial table setup on both publisher and subscriber. On the subscriber, + # we create the same tables but with a primary key. Also, insert some data + # that will conflict with the data replicated from publisher later. + node_publisher.safe_sql( + """ + CREATE TABLE tbl (i INT, t BYTEA); + ALTER TABLE tbl REPLICA IDENTITY FULL; + INSERT INTO tbl VALUES (1, NULL); + """ + ) + node_subscriber.safe_sql( + """ + CREATE TABLE tbl (i INT PRIMARY KEY, t BYTEA); + INSERT INTO tbl VALUES (1, NULL); + """ + ) + + # Create a pub/sub to set up logical replication. This tests that the + # uniqueness violation will cause the subscription to fail during initial + # synchronization and make it disabled. + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION pub FOR TABLE tbl") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub CONNECTION '{publisher_connstr}' " + "PUBLICATION pub WITH (disable_on_error = true, streaming = on, " + "two_phase = on)" + ) + + # Initial synchronization failure causes the subscription to be disabled. + assert node_subscriber.poll_query_until( + "SELECT subenabled = false FROM pg_catalog.pg_subscription " + "WHERE subname = 'sub'" + ), "Timed out while waiting for subscriber to be disabled" + + # Truncate the table on the subscriber which caused the subscription to be + # disabled. + node_subscriber.safe_sql("TRUNCATE tbl") + + # Re-enable the subscription "sub". + node_subscriber.safe_sql("ALTER SUBSCRIPTION sub ENABLE") + + # Wait for the data to replicate. + node_subscriber.wait_for_subscription_sync(node_publisher, "sub") + + # Confirm that we have finished the table sync. + result = node_subscriber.safe_sql("SELECT COUNT(*) FROM tbl") + assert result == "1", "subscription sub replicated data" + + # Insert data to tbl, raising an error on the subscriber due to violation + # of the unique constraint on tbl. Then skip the transaction. + node_publisher.safe_sql( + """ + BEGIN; + INSERT INTO tbl VALUES (1, NULL); + COMMIT; + """ + ) + offset = skip_lsn( + node_publisher, + node_subscriber, + offset, + "(2, NULL)", + "2", + "test skipping transaction", + ) + + # Test for PREPARE and COMMIT PREPARED. Update the data and PREPARE the + # transaction, raising an error on the subscriber due to violation of the + # unique constraint on tbl. Then skip the transaction. + # COMMIT PREPARED must be issued outside a transaction block, so it is + # sent as a separate command (safe_sql wraps a multi-statement string in + # one implicit transaction). + node_publisher.safe_sql( + """ + BEGIN; + UPDATE tbl SET i = 2; + PREPARE TRANSACTION 'gtx'; + """ + ) + node_publisher.safe_sql("COMMIT PREPARED 'gtx';") + offset = skip_lsn( + node_publisher, + node_subscriber, + offset, + "(3, NULL)", + "3", + "test skipping prepare and commit prepared ", + ) + + # Test for STREAM COMMIT. Insert enough rows to tbl to exceed the 64kB + # limit, also raising an error on the subscriber during applying spooled + # changes for the same reason. Then skip the transaction. + node_publisher.safe_sql( + """ + BEGIN; + INSERT INTO tbl SELECT i, sha256(i::text::bytea) FROM generate_series(1, 10000) s(i); + COMMIT; + """ + ) + offset = skip_lsn( + node_publisher, + node_subscriber, + offset, + "(4, sha256(4::text::bytea))", + "4", + "test skipping stream-commit", + ) + + result = node_subscriber.safe_sql("SELECT COUNT(*) FROM pg_prepared_xacts") + assert ( + result == "0" + ), "check all prepared transactions are resolved on the subscriber" + + node_subscriber.stop() + node_publisher.stop() diff --git a/src/test/subscription/pyt/test_030_origin.py b/src/test/subscription/pyt/test_030_origin.py new file mode 100644 index 00000000000..05efda9ac7c --- /dev/null +++ b/src/test/subscription/pyt/test_030_origin.py @@ -0,0 +1,394 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test the CREATE SUBSCRIPTION 'origin' parameter and its interaction with +the 'copy_data' parameter. +""" + +import re + +tab_unquoted = "tab'le" +tab = f'"{tab_unquoted}"' + +subname_AB = "tap_sub_A_B" +subname2_AB = "tap_sub_A_B_2" +subname_BA = "tap_sub_B_A" +subname_BC = "tap_sub_B_C" + + +def _create_subscription_capture_stderr(node, sql): + """Run *sql* on *node* and return the client-side stderr (notices). + + WARNING messages emitted by the server are delivered to the + client as notices. + """ + sess = node.connect() + try: + sess.query(sql) + return sess.get_notices_str() + finally: + sess.close() + + +def test_030_origin(create_pg): + ########################################################################### + # Setup a bidirectional logical replication between node_A & node_B + ########################################################################### + + # Initialize nodes + # node_A + node_A = create_pg("node_A", allows_streaming="logical") + + # node_B + node_B = create_pg("node_B", allows_streaming="logical") + + # Enable the track_commit_timestamp to detect the conflict when attempting + # to update a row that was previously modified by a different origin. + node_B.append_conf("track_commit_timestamp = on") + node_B.restart() + + # Create table on node_A + node_A.safe_sql(f"CREATE TABLE {tab} (a int PRIMARY KEY)") + + # Create the same table on node_B + node_B.safe_sql(f"CREATE TABLE {tab} (a int PRIMARY KEY)") + + # Setup logical replication + # node_A (pub) -> node_B (sub) + connstr_A = f"host={node_A.host} port={node_A.port} dbname=postgres" + node_A.safe_sql(f"CREATE PUBLICATION tap_pub_A FOR TABLE {tab}") + node_B.safe_sql( + f"CREATE SUBSCRIPTION {subname_BA} " + f"CONNECTION '{connstr_A} application_name={subname_BA}' " + "PUBLICATION tap_pub_A " + "WITH (origin = none)" + ) + + # node_B (pub) -> node_A (sub) + connstr_B = f"host={node_B.host} port={node_B.port} dbname=postgres" + node_B.safe_sql(f"CREATE PUBLICATION tap_pub_B FOR TABLE {tab}") + node_A.safe_sql( + f"CREATE SUBSCRIPTION {subname_AB} " + f"CONNECTION '{connstr_B} application_name={subname_AB}' " + "PUBLICATION tap_pub_B " + "WITH (origin = none, copy_data = off)" + ) + + # Wait for initial table sync to finish + node_A.wait_for_subscription_sync(node_B, subname_AB) + node_B.wait_for_subscription_sync(node_A, subname_BA) + + # 'Bidirectional replication setup is complete' + + ########################################################################### + # Check that bidirectional logical replication setup does not cause + # infinite recursive insertion. + ########################################################################### + + # insert a record + node_A.safe_sql(f"INSERT INTO {tab} VALUES (11);") + node_B.safe_sql(f"INSERT INTO {tab} VALUES (21);") + + node_A.wait_for_catchup(subname_BA) + node_B.wait_for_catchup(subname_AB) + + # check that transaction was committed on subscriber(s) + result = node_A.safe_sql(f"SELECT * FROM {tab} ORDER BY 1;") + assert result == "11\n21", ( + "Inserted successfully without leading to infinite recursion in " + "bidirectional replication setup" + ) + result = node_B.safe_sql(f"SELECT * FROM {tab} ORDER BY 1;") + assert result == "11\n21", ( + "Inserted successfully without leading to infinite recursion in " + "bidirectional replication setup" + ) + + node_A.safe_sql(f"DELETE FROM {tab};") + + node_A.wait_for_catchup(subname_BA) + node_B.wait_for_catchup(subname_AB) + + ########################################################################### + # Check that remote data of node_B (that originated from node_C) is not + # published to node_A. + ########################################################################### + result = node_A.safe_sql(f"SELECT * FROM {tab} ORDER BY 1;") + assert result == "", "Check existing data" + + result = node_B.safe_sql(f"SELECT * FROM {tab} ORDER BY 1;") + assert result == "", "Check existing data" + + # Initialize node node_C + node_C = create_pg("node_C", allows_streaming="logical") + + node_C.safe_sql(f"CREATE TABLE {tab} (a int PRIMARY KEY)") + + # Setup logical replication + # node_C (pub) -> node_B (sub) + connstr_C = f"host={node_C.host} port={node_C.port} dbname=postgres" + node_C.safe_sql(f"CREATE PUBLICATION tap_pub_C FOR TABLE {tab}") + node_B.safe_sql( + f"CREATE SUBSCRIPTION {subname_BC} " + f"CONNECTION '{connstr_C} application_name={subname_BC}' " + "PUBLICATION tap_pub_C " + "WITH (origin = none)" + ) + node_B.wait_for_subscription_sync(node_C, subname_BC) + + # insert a record + node_C.safe_sql(f"INSERT INTO {tab} VALUES (32);") + + node_C.wait_for_catchup(subname_BC) + node_B.wait_for_catchup(subname_AB) + node_A.wait_for_catchup(subname_BA) + + result = node_B.safe_sql(f"SELECT * FROM {tab} ORDER BY 1;") + assert result == "32", "The node_C data replicated to node_B" + + # check that the data published from node_C to node_B is not sent to node_A + result = node_A.safe_sql(f"SELECT * FROM {tab} ORDER BY 1;") + assert result == "", ( + "Remote data originating from another node (not the publisher) is " + "not replicated when origin parameter is none" + ) + + ########################################################################### + # Check that the conflict can be detected when attempting to update or + # delete a row that was previously modified by a different source. + ########################################################################### + + node_B.safe_sql(f"DELETE FROM {tab};") + + node_A.safe_sql(f"INSERT INTO {tab} VALUES (32);") + + node_A.wait_for_catchup(subname_BA) + node_B.wait_for_catchup(subname_AB) + + result = node_B.safe_sql(f"SELECT * FROM {tab} ORDER BY 1;") + assert result == "32", "The node_A data replicated to node_B" + + # The update should update the row on node B that was inserted by node A. + node_C.safe_sql(f"UPDATE {tab} SET a = 33 WHERE a = 32;") + + node_B.wait_for_log( + r'conflict detected on relation "public.' + tab_unquoted + r'": ' + r"conflict=update_origin_differs.*\n.*DETAIL:.* Updating the row that " + r'was modified by a different origin ".*" in transaction [0-9]+ at .*: ' + r"local row \(32\), remote row \(33\), replica identity \(a\)=\(32\)." + ) + + node_B.safe_sql(f"DELETE FROM {tab};") + node_A.safe_sql(f"INSERT INTO {tab} VALUES (33);") + + node_A.wait_for_catchup(subname_BA) + node_B.wait_for_catchup(subname_AB) + + result = node_B.safe_sql(f"SELECT * FROM {tab} ORDER BY 1;") + assert result == "33", "The node_A data replicated to node_B" + + # The delete should remove the row on node B that was inserted by node A. + node_C.safe_sql(f"DELETE FROM {tab} WHERE a = 33;") + + node_B.wait_for_log( + r'conflict detected on relation "public.' + tab_unquoted + r'": ' + r"conflict=delete_origin_differs.*\n.*DETAIL:.* Deleting the row that " + r'was modified by a different origin ".*" in transaction [0-9]+ at .*: ' + r"local row \(33\), replica identity \(a\)=\(33\).*" + ) + + # The remaining tests no longer test conflict detection. + node_B.append_conf("track_commit_timestamp = off") + node_B.restart() + + ########################################################################### + # Specifying origin = NONE indicates that the publisher should only + # replicate the changes that are generated locally from node_B, but in this + # case since the node_B is also subscribing data from node_A, node_B can + # have remotely originated data from node_A. We log a warning, in this + # case, to draw attention to there being possible remote data. + ########################################################################### + stderr = _create_subscription_capture_stderr( + node_A, + f"CREATE SUBSCRIPTION {subname2_AB} " + f"CONNECTION '{connstr_B} application_name={subname2_AB}' " + "PUBLICATION tap_pub_B " + "WITH (origin = none, copy_data = on)", + ) + assert re.search( + r"WARNING: ( [A-Z0-9]+:)? subscription \"tap_sub_a_b_2\" requested " + r"copy_data with origin = NONE but might copy data that had a " + r"different origin", + stderr, + ), ( + "Create subscription with origin = none and copy_data when the " + "publisher has subscribed same table" + ) + + node_A.wait_for_subscription_sync(node_B, subname2_AB) + + # Alter subscription ... refresh publication should be successful when no + # new table is added + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname2_AB} REFRESH PUBLICATION") + + # Check Alter subscription ... refresh publication when there is a new + # table that is subscribing data from a different publication + node_A.safe_sql("CREATE TABLE tab_new (a int PRIMARY KEY)") + node_B.safe_sql("CREATE TABLE tab_new (a int PRIMARY KEY)") + + # add a new table to the publication + node_A.safe_sql("ALTER PUBLICATION tap_pub_A ADD TABLE tab_new") + node_B.safe_sql(f"ALTER SUBSCRIPTION {subname_BA} REFRESH PUBLICATION") + + node_B.wait_for_subscription_sync(node_A, subname_BA) + + # add a new table to the publication + node_B.safe_sql("ALTER PUBLICATION tap_pub_B ADD TABLE tab_new") + + # Alter subscription ... refresh publication should log a warning when a + # new table on the publisher is subscribing data from a different + # publication + stderr = _create_subscription_capture_stderr( + node_A, f"ALTER SUBSCRIPTION {subname2_AB} REFRESH PUBLICATION" + ) + assert re.search( + r"WARNING: ( [A-Z0-9]+:)? subscription \"tap_sub_a_b_2\" requested " + r"copy_data with origin = NONE but might copy data that had a " + r"different origin", + stderr, + ), ( + "Refresh publication when the publisher has subscribed for the new " + "table, but the subscriber-side wants origin = none" + ) + + # Ensure that relation has reached 'ready' state before we try to drop it + synced_query = ( + "SELECT count(1) = 0 FROM pg_subscription_rel WHERE srsubstate NOT IN ('r');" + ) + assert node_A.poll_query_until( + synced_query + ), "Timed out while waiting for subscriber to synchronize data" + + node_B.wait_for_catchup(subname2_AB) + + # clear the operations done by this test + node_A.safe_sql("DROP TABLE tab_new;") + node_A.safe_sql(f"DROP SUBSCRIPTION {subname2_AB};") + node_A.safe_sql(f"DROP SUBSCRIPTION {subname_AB};") + node_A.safe_sql("DROP PUBLICATION tap_pub_A;") + node_B.safe_sql("DROP TABLE tab_new;") + node_B.safe_sql(f"DROP SUBSCRIPTION {subname_BA};") + node_B.safe_sql("DROP PUBLICATION tap_pub_B;") + + ########################################################################### + # Specifying origin = NONE and copy_data = on must raise WARNING if we + # subscribe to a partitioned table and this table contains any remotely + # originated data. + # + # node_B + # __________________________ + # | tab_main | --------------> node_C (tab_main) + # |__________________________| + # | tab_part1 | tab_part2 | <-------------- node_A (tab_part2) + # |____________|_____________| + # | tab_part2_1 | + # |_____________| + # + # node_B + # __________________________ + # | tab_main | + # |__________________________| + # | tab_part1 | tab_part2 | <-------------- node_A (tab_part2) + # |____________|_____________| + # | tab_part2_1 | --------------> node_C (tab_part2_1) + # |_____________| + ########################################################################### + + # create a table on node A which will act as a source for a partition on + # node B + node_A.safe_sql("CREATE TABLE tab_part2(a int);") + node_A.safe_sql("CREATE PUBLICATION tap_pub_A FOR TABLE tab_part2;") + + # create a partition table on node B + node_B.safe_sql("CREATE TABLE tab_main(a int) PARTITION BY RANGE(a);") + node_B.safe_sql( + "CREATE TABLE tab_part1 PARTITION OF tab_main FOR VALUES FROM (0) TO (5);" + ) + node_B.safe_sql("CREATE TABLE tab_part2(a int) PARTITION BY RANGE(a);") + node_B.safe_sql( + "CREATE TABLE tab_part2_1 PARTITION OF tab_part2 " + "FOR VALUES FROM (5) TO (10);" + ) + node_B.safe_sql( + "ALTER TABLE tab_main ATTACH PARTITION tab_part2 FOR VALUES FROM (5) to (10);" + ) + node_B.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_A_B CONNECTION '{connstr_A}' " + "PUBLICATION tap_pub_A;" + ) + + # create a table on node C + node_C.safe_sql("CREATE TABLE tab_main(a int);") + node_C.safe_sql("CREATE TABLE tab_part2_1(a int);") + + # create a logical replication setup between node B and node C with + # subscription on node C having origin = NONE and copy_data = on + node_B.safe_sql( + "CREATE PUBLICATION tap_pub_B FOR TABLE tab_main " + "WITH (publish_via_partition_root);" + ) + node_B.safe_sql("CREATE PUBLICATION tap_pub_B_2 FOR TABLE tab_part2_1;") + + stderr = _create_subscription_capture_stderr( + node_C, + f"CREATE SUBSCRIPTION tap_sub_B_C CONNECTION '{connstr_B}' " + "PUBLICATION tap_pub_B WITH (origin = none, copy_data = on);", + ) + + # A warning must be logged as a partition 'tab_part2' in node B is + # subscribed to node A so partition 'tab_part2' can have remotely + # originated data + assert re.search( + r"WARNING: ( [A-Z0-9]+:)? subscription \"tap_sub_b_c\" requested " + r"copy_data with origin = NONE but might copy data that had a " + r"different origin", + stderr, + ), ( + "Create subscription with origin = none and copy_data when the " + "publisher's partition is subscribing from different origin" + ) + node_C.safe_sql("DROP SUBSCRIPTION tap_sub_B_C") + + stderr = _create_subscription_capture_stderr( + node_C, + f"CREATE SUBSCRIPTION tap_sub_B_C CONNECTION '{connstr_B}' " + "PUBLICATION tap_pub_B_2 WITH (origin = none, copy_data = on);", + ) + + # A warning must be logged as ancestor of table 'tab_part2_1' in node B is + # subscribed to node A so table 'tab_part2_1' can have remotely originated + # data + assert re.search( + r"WARNING: ( [A-Z0-9]+:)? subscription \"tap_sub_b_c\" requested " + r"copy_data with origin = NONE but might copy data that had a " + r"different origin", + stderr, + ), ( + "Create subscription with origin = none and copy_data when the " + "publisher's ancestor is subscribing from different origin" + ) + + # clear the operations done by this test + node_C.safe_sql("DROP SUBSCRIPTION tap_sub_B_C;") + node_C.safe_sql("DROP TABLE tab_main;") + node_C.safe_sql("DROP TABLE tab_part2_1;") + node_B.safe_sql("DROP SUBSCRIPTION tap_sub_A_B;") + node_B.safe_sql("DROP PUBLICATION tap_pub_B;") + node_B.safe_sql("DROP PUBLICATION tap_pub_B_2;") + node_B.safe_sql("DROP TABLE tab_main;") + node_A.safe_sql("DROP PUBLICATION tap_pub_A;") + node_A.safe_sql("DROP TABLE tab_part2;") + + # shutdown + node_B.stop("fast") + node_A.stop("fast") + node_C.stop("fast") diff --git a/src/test/subscription/pyt/test_031_column_list.py b/src/test/subscription/pyt/test_031_column_list.py new file mode 100644 index 00000000000..0cfd28dcab5 --- /dev/null +++ b/src/test/subscription/pyt/test_031_column_list.py @@ -0,0 +1,1207 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Test partial-column publication of tables.""" + +import re + + +def test_031_column_list(create_pg): + # create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # create subscriber node + node_subscriber = create_pg("subscriber") + node_subscriber.append_conf("max_logical_replication_workers = 6") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + offset = 0 + + # setup tables on both nodes + + # tab1: simple 1:1 replication + node_publisher.safe_sql('CREATE TABLE tab1 (a int PRIMARY KEY, "B" int, c int)') + + node_subscriber.safe_sql('CREATE TABLE tab1 (a int PRIMARY KEY, "B" int, c int)') + + # tab2: replication from regular to table with fewer columns + node_publisher.safe_sql("CREATE TABLE tab2 (a int PRIMARY KEY, b varchar, c int);") + + node_subscriber.safe_sql("CREATE TABLE tab2 (a int PRIMARY KEY, b varchar)") + + # tab3: simple 1:1 replication with weird column names + node_publisher.safe_sql( + 'CREATE TABLE tab3 ("a\'" int PRIMARY KEY, "B" varchar, "c\'" int)' + ) + + node_subscriber.safe_sql('CREATE TABLE tab3 ("a\'" int PRIMARY KEY, "c\'" int)') + + # test_part: partitioned tables, with partitioning (including multi-level + # partitioning, and fewer columns on the subscriber) + node_publisher.safe_sql( + """ + CREATE TABLE test_part (a int PRIMARY KEY, b text, c timestamptz) PARTITION BY LIST (a); + CREATE TABLE test_part_1_1 PARTITION OF test_part FOR VALUES IN (1,2,3,4,5,6); + CREATE TABLE test_part_2_1 PARTITION OF test_part FOR VALUES IN (7,8,9,10,11,12) PARTITION BY LIST (a); + CREATE TABLE test_part_2_2 PARTITION OF test_part_2_1 FOR VALUES IN (7,8,9,10); + """ + ) + + node_subscriber.safe_sql( + """ + CREATE TABLE test_part (a int PRIMARY KEY, b text) PARTITION BY LIST (a); + CREATE TABLE test_part_1_1 PARTITION OF test_part FOR VALUES IN (1,2,3,4,5,6); + CREATE TABLE test_part_2_1 PARTITION OF test_part FOR VALUES IN (7,8,9,10,11,12) PARTITION BY LIST (a); + CREATE TABLE test_part_2_2 PARTITION OF test_part_2_1 FOR VALUES IN (7,8,9,10); + """ + ) + + # tab4: table with user-defined enum types + node_publisher.safe_sql( + """ + CREATE TYPE test_typ AS ENUM ('blue', 'red'); + CREATE TABLE tab4 (a INT PRIMARY KEY, b test_typ, c int, d text); + """ + ) + + node_subscriber.safe_sql( + """ + CREATE TYPE test_typ AS ENUM ('blue', 'red'); + CREATE TABLE tab4 (a INT PRIMARY KEY, b test_typ, d text); + """ + ) + + # TEST: create publication and subscription for some of the tables with + # column lists + node_publisher.safe_sql( + """ + CREATE PUBLICATION pub1 + FOR TABLE tab1 (a, "B"), tab3 ("a'", "c'"), test_part (a, b), tab4 (a, b, d) + WITH (publish_via_partition_root = 'true'); + """ + ) + + # check that we got the right prattrs values for the publication in the + # pg_publication_rel catalog (order by relname, to get stable ordering) + result = node_publisher.safe_sql( + """ + SELECT relname, prattrs + FROM pg_publication_rel pb JOIN pg_class pc ON(pb.prrelid = pc.oid) + ORDER BY relname + """ + ) + + assert result == ( + "tab1|1 2\n" "tab3|1 3\n" "tab4|1 2 4\n" "test_part|1 2" + ), "publication relation updated" + + # TEST: insert data into the tables, create subscription and see if sync + # replicates the right columns + node_publisher.safe_sql( + """ + INSERT INTO tab1 VALUES (1, 2, 3); + INSERT INTO tab1 VALUES (4, 5, 6); + """ + ) + + node_publisher.safe_sql( + """ + INSERT INTO tab3 VALUES (1, 2, 3); + INSERT INTO tab3 VALUES (4, 5, 6); + """ + ) + + node_publisher.safe_sql( + """ + INSERT INTO tab4 VALUES (1, 'red', 3, 'oh my'); + INSERT INTO tab4 VALUES (2, 'blue', 4, 'hello'); + """ + ) + + # replication of partitioned table + node_publisher.safe_sql( + """ + INSERT INTO test_part VALUES (1, 'abc', '2021-07-04 12:00:00'); + INSERT INTO test_part VALUES (2, 'bcd', '2021-07-03 11:12:13'); + INSERT INTO test_part VALUES (7, 'abc', '2021-07-04 12:00:00'); + INSERT INTO test_part VALUES (8, 'bcd', '2021-07-03 11:12:13'); + """ + ) + + # create subscription for the publication, wait for sync to complete, + # then check the sync results + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + + node_subscriber.wait_for_subscription_sync() + + # tab1: only (a,b) is replicated + result = node_subscriber.safe_sql("SELECT * FROM tab1 ORDER BY a") + assert result == ("1|2|\n" "4|5|"), "insert on column tab1.c is not replicated" + + # tab3: only (a,c) is replicated + result = node_subscriber.safe_sql('SELECT * FROM tab3 ORDER BY "a\'"') + assert result == ("1|3\n" "4|6"), "insert on column tab3.b is not replicated" + + # tab4: only (a,b,d) is replicated + result = node_subscriber.safe_sql("SELECT * FROM tab4 ORDER BY a") + assert result == ( + "1|red|oh my\n" "2|blue|hello" + ), "insert on column tab4.c is not replicated" + + # test_part: (a,b) is replicated + result = node_subscriber.safe_sql("SELECT * FROM test_part ORDER BY a") + assert result == ( + "1|abc\n" "2|bcd\n" "7|abc\n" "8|bcd" + ), "insert on column test_part.c columns is not replicated" + + # TEST: now insert more data into the tables, and wait until we replicate + # them (not by tablesync, but regular decoding and replication) + + node_publisher.safe_sql( + """ + INSERT INTO tab1 VALUES (2, 3, 4); + INSERT INTO tab1 VALUES (5, 6, 7); + """ + ) + + node_publisher.safe_sql( + """ + INSERT INTO tab3 VALUES (2, 3, 4); + INSERT INTO tab3 VALUES (5, 6, 7); + """ + ) + + node_publisher.safe_sql( + """ + INSERT INTO tab4 VALUES (3, 'red', 5, 'foo'); + INSERT INTO tab4 VALUES (4, 'blue', 6, 'bar'); + """ + ) + + # replication of partitioned table + node_publisher.safe_sql( + """ + INSERT INTO test_part VALUES (3, 'xxx', '2022-02-01 10:00:00'); + INSERT INTO test_part VALUES (4, 'yyy', '2022-03-02 15:12:13'); + INSERT INTO test_part VALUES (9, 'zzz', '2022-04-03 21:00:00'); + INSERT INTO test_part VALUES (10, 'qqq', '2022-05-04 22:12:13'); + """ + ) + + # wait for catchup before checking the subscriber + node_publisher.wait_for_catchup("sub1") + + # tab1: only (a,b) is replicated + result = node_subscriber.safe_sql("SELECT * FROM tab1 ORDER BY a") + assert result == ( + "1|2|\n" "2|3|\n" "4|5|\n" "5|6|" + ), "insert on column tab1.c is not replicated" + + # tab3: only (a,c) is replicated + result = node_subscriber.safe_sql('SELECT * FROM tab3 ORDER BY "a\'"') + assert result == ( + "1|3\n" "2|4\n" "4|6\n" "5|7" + ), "insert on column tab3.b is not replicated" + + # tab4: only (a,b,d) is replicated + result = node_subscriber.safe_sql("SELECT * FROM tab4 ORDER BY a") + assert result == ( + "1|red|oh my\n" "2|blue|hello\n" "3|red|foo\n" "4|blue|bar" + ), "insert on column tab4.c is not replicated" + + # test_part: (a,b) is replicated + result = node_subscriber.safe_sql("SELECT * FROM test_part ORDER BY a") + assert result == ( + "1|abc\n" "2|bcd\n" "3|xxx\n" "4|yyy\n" "7|abc\n" "8|bcd\n" "9|zzz\n" "10|qqq" + ), "insert on column test_part.c columns is not replicated" + + # TEST: do some updates on some of the tables, both on columns included + # in the column list and other + + # tab1: update of replicated column + node_publisher.safe_sql('UPDATE tab1 SET "B" = 2 * "B" where a = 1') + + # tab1: update of non-replicated column + node_publisher.safe_sql("UPDATE tab1 SET c = 2*c where a = 4") + + # tab3: update of non-replicated + node_publisher.safe_sql('UPDATE tab3 SET "B" = "B" || \' updated\' where "a\'" = 4') + + # tab3: update of replicated column + node_publisher.safe_sql('UPDATE tab3 SET "c\'" = 2 * "c\'" where "a\'" = 1') + + # tab4 + node_publisher.safe_sql( + "UPDATE tab4 SET b = 'blue', c = c * 2, d = d || ' updated' where a = 1" + ) + + # tab4 + node_publisher.safe_sql( + "UPDATE tab4 SET b = 'red', c = c * 2, d = d || ' updated' where a = 2" + ) + + # wait for the replication to catch up, and check the UPDATE results got + # replicated correctly, with the right column list + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM tab1 ORDER BY a") + assert result == ( + "1|4|\n" "2|3|\n" "4|5|\n" "5|6|" + ), "only update on column tab1.b is replicated" + + result = node_subscriber.safe_sql('SELECT * FROM tab3 ORDER BY "a\'"') + assert result == ( + "1|6\n" "2|4\n" "4|6\n" "5|7" + ), "only update on column tab3.c is replicated" + + result = node_subscriber.safe_sql("SELECT * FROM tab4 ORDER BY a") + assert result == ( + "1|blue|oh my updated\n" "2|red|hello updated\n" "3|red|foo\n" "4|blue|bar" + ), "update on column tab4.c is not replicated" + + # TEST: add table with a column list, insert data, replicate + + # insert some data before adding it to the publication + node_publisher.safe_sql("INSERT INTO tab2 VALUES (1, 'abc', 3);") + + node_publisher.safe_sql("ALTER PUBLICATION pub1 ADD TABLE tab2 (a, b)") + + node_subscriber.safe_sql("ALTER SUBSCRIPTION sub1 REFRESH PUBLICATION") + + # wait for the tablesync to complete, add a bit more data and then check + # the results of the replication + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO tab2 VALUES (2, 'def', 6);") + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM tab2 ORDER BY a") + assert result == ("1|abc\n" "2|def"), "insert on column tab2.c is not replicated" + + # do a couple updates, check the correct stuff gets replicated + node_publisher.safe_sql( + """ + UPDATE tab2 SET c = 5 where a = 1; + UPDATE tab2 SET b = 'xyz' where a = 2; + """ + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM tab2 ORDER BY a") + assert result == ("1|abc\n" "2|xyz"), "update on column tab2.c is not replicated" + + # TEST: add a table to two publications with same column lists, and + # create a single subscription replicating both publications + node_publisher.safe_sql( + """ + CREATE TABLE tab5 (a int PRIMARY KEY, b int, c int, d int); + CREATE PUBLICATION pub2 FOR TABLE tab5 (a, b); + CREATE PUBLICATION pub3 FOR TABLE tab5 (a, b); + + -- insert a couple initial records + INSERT INTO tab5 VALUES (1, 11, 111, 1111); + INSERT INTO tab5 VALUES (2, 22, 222, 2222); + """ + ) + + node_subscriber.safe_sql("CREATE TABLE tab5 (a int PRIMARY KEY, b int, d int);") + + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub2, pub3" + ) + + node_subscriber.wait_for_subscription_sync(node_publisher, "sub1") + + # insert data and make sure the columns in column list get fully replicated + node_publisher.safe_sql( + """ + INSERT INTO tab5 VALUES (3, 33, 333, 3333); + INSERT INTO tab5 VALUES (4, 44, 444, 4444); + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM tab5 ORDER BY a") == ( + "1|11|\n" "2|22|\n" "3|33|\n" "4|44|" + ), "overlapping publications with overlapping column lists" + + # TEST: create a table with a column list, then change the replica + # identity by replacing a primary key (but use a different column in + # the column list) + node_publisher.safe_sql( + """ + CREATE TABLE tab6 (a int PRIMARY KEY, b int, c int, d int); + CREATE PUBLICATION pub4 FOR TABLE tab6 (a, b); + + -- initial data + INSERT INTO tab6 VALUES (1, 22, 333, 4444); + """ + ) + + node_subscriber.safe_sql( + "CREATE TABLE tab6 (a int PRIMARY KEY, b int, c int, d int);" + ) + + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub4" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + INSERT INTO tab6 VALUES (2, 33, 444, 5555); + UPDATE tab6 SET b = b * 2, c = c * 3, d = d * 4; + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM tab6 ORDER BY a") == ( + "1|44||\n" "2|66||" + ), "replication with the original primary key" + + # now redefine the constraint - move the primary key to a different column + # (which is still covered by the column list, though) + + node_publisher.safe_sql( + """ + ALTER TABLE tab6 DROP CONSTRAINT tab6_pkey; + ALTER TABLE tab6 ADD PRIMARY KEY (b); + """ + ) + + # we need to do the same thing on the subscriber + # XXX What would happen if this happens before the publisher ALTER? Or + # interleaved, somehow? But that seems unrelated to column lists. + node_subscriber.safe_sql( + """ + ALTER TABLE tab6 DROP CONSTRAINT tab6_pkey; + ALTER TABLE tab6 ADD PRIMARY KEY (b); + """ + ) + + node_subscriber.safe_sql("ALTER SUBSCRIPTION sub1 REFRESH PUBLICATION") + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + INSERT INTO tab6 VALUES (3, 55, 666, 8888); + UPDATE tab6 SET b = b * 2, c = c * 3, d = d * 4; + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM tab6 ORDER BY a") == ( + "1|88||\n" "2|132||\n" "3|110||" + ), "replication with the modified primary key" + + # TEST: create a table with a column list, then change the replica + # identity by replacing a primary key with a key on multiple columns + # (all of them covered by the column list) + node_publisher.safe_sql( + """ + CREATE TABLE tab7 (a int PRIMARY KEY, b int, c int, d int); + CREATE PUBLICATION pub5 FOR TABLE tab7 (a, b); + + -- some initial data + INSERT INTO tab7 VALUES (1, 22, 333, 4444); + """ + ) + + node_subscriber.safe_sql( + "CREATE TABLE tab7 (a int PRIMARY KEY, b int, c int, d int);" + ) + + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub5" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + INSERT INTO tab7 VALUES (2, 33, 444, 5555); + UPDATE tab7 SET b = b * 2, c = c * 3, d = d * 4; + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM tab7 ORDER BY a") == ( + "1|44||\n" "2|66||" + ), "replication with the original primary key" + + # now redefine the constraint - move the primary key to a different column + # (which is not covered by the column list) + node_publisher.safe_sql( + """ + ALTER TABLE tab7 DROP CONSTRAINT tab7_pkey; + ALTER TABLE tab7 ADD PRIMARY KEY (a, b); + """ + ) + + node_publisher.safe_sql( + """ + INSERT INTO tab7 VALUES (3, 55, 666, 7777); + UPDATE tab7 SET b = b * 2, c = c * 3, d = d * 4; + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM tab7 ORDER BY a") == ( + "1|88||\n" "2|132||\n" "3|110||" + ), "replication with the modified primary key" + + # now switch the primary key again to another columns not covered by the + # column list, but also generate writes between the drop and creation + # of the new constraint + + node_publisher.safe_sql( + """ + ALTER TABLE tab7 DROP CONSTRAINT tab7_pkey; + INSERT INTO tab7 VALUES (4, 77, 888, 9999); + -- update/delete is not allowed for tables without RI + ALTER TABLE tab7 ADD PRIMARY KEY (b, a); + UPDATE tab7 SET b = b * 2, c = c * 3, d = d * 4; + DELETE FROM tab7 WHERE a = 1; + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM tab7 ORDER BY a") == ( + "2|264||\n" "3|220||\n" "4|154||" + ), "replication with the modified primary key" + + # TEST: partitioned tables (with publish_via_partition_root = false) + # and replica identity. The (leaf) partitions may have different RI, so + # we need to check the partition RI (with respect to the column list) + # while attaching the partition. + + # First, let's create a partitioned table with two partitions, each with + # a different RI, but a column list not covering all those RI. + + node_publisher.safe_sql( + """ + CREATE TABLE test_part_a (a int, b int, c int) PARTITION BY LIST (a); + + CREATE TABLE test_part_a_1 PARTITION OF test_part_a FOR VALUES IN (1,2,3,4,5); + ALTER TABLE test_part_a_1 ADD PRIMARY KEY (a); + ALTER TABLE test_part_a_1 REPLICA IDENTITY USING INDEX test_part_a_1_pkey; + + CREATE TABLE test_part_a_2 PARTITION OF test_part_a FOR VALUES IN (6,7,8,9,10); + ALTER TABLE test_part_a_2 ADD PRIMARY KEY (b); + ALTER TABLE test_part_a_2 REPLICA IDENTITY USING INDEX test_part_a_2_pkey; + + -- initial data, one row in each partition + INSERT INTO test_part_a VALUES (1, 3); + INSERT INTO test_part_a VALUES (6, 4); + """ + ) + + # do the same thing on the subscriber (with the opposite column order) + node_subscriber.safe_sql( + """ + CREATE TABLE test_part_a (b int, a int) PARTITION BY LIST (a); + + CREATE TABLE test_part_a_1 PARTITION OF test_part_a FOR VALUES IN (1,2,3,4,5); + ALTER TABLE test_part_a_1 ADD PRIMARY KEY (a); + ALTER TABLE test_part_a_1 REPLICA IDENTITY USING INDEX test_part_a_1_pkey; + + CREATE TABLE test_part_a_2 PARTITION OF test_part_a FOR VALUES IN (6,7,8,9,10); + ALTER TABLE test_part_a_2 ADD PRIMARY KEY (b); + ALTER TABLE test_part_a_2 REPLICA IDENTITY USING INDEX test_part_a_2_pkey; + """ + ) + + # create a publication replicating just the column "a", which is not enough + # for the second partition + node_publisher.safe_sql( + """ + CREATE PUBLICATION pub6 FOR TABLE test_part_a (b, a) WITH (publish_via_partition_root = true); + ALTER PUBLICATION pub6 ADD TABLE test_part_a_1 (a); + ALTER PUBLICATION pub6 ADD TABLE test_part_a_2 (b); + """ + ) + + # create the subscription for the above publication, wait for sync to + # complete + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub6" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + INSERT INTO test_part_a VALUES (2, 5); + INSERT INTO test_part_a VALUES (7, 6); + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT a, b FROM test_part_a ORDER BY a, b") == ( + "1|3\n" "2|5\n" "6|4\n" "7|6" + ), "partitions with different replica identities not replicated correctly" + + # This time start with a column list covering RI for all partitions, but + # then update the column list to not cover column "b" (needed by the + # second partition) + + node_publisher.safe_sql( + """ + CREATE TABLE test_part_b (a int, b int) PARTITION BY LIST (a); + + CREATE TABLE test_part_b_1 PARTITION OF test_part_b FOR VALUES IN (1,2,3,4,5); + ALTER TABLE test_part_b_1 ADD PRIMARY KEY (a); + ALTER TABLE test_part_b_1 REPLICA IDENTITY USING INDEX test_part_b_1_pkey; + + CREATE TABLE test_part_b_2 PARTITION OF test_part_b FOR VALUES IN (6,7,8,9,10); + ALTER TABLE test_part_b_2 ADD PRIMARY KEY (b); + ALTER TABLE test_part_b_2 REPLICA IDENTITY USING INDEX test_part_b_2_pkey; + + -- initial data, one row in each partitions + INSERT INTO test_part_b VALUES (1, 1); + INSERT INTO test_part_b VALUES (6, 2); + """ + ) + + # do the same thing on the subscriber + node_subscriber.safe_sql( + """ + CREATE TABLE test_part_b (a int, b int) PARTITION BY LIST (a); + + CREATE TABLE test_part_b_1 PARTITION OF test_part_b FOR VALUES IN (1,2,3,4,5); + ALTER TABLE test_part_b_1 ADD PRIMARY KEY (a); + ALTER TABLE test_part_b_1 REPLICA IDENTITY USING INDEX test_part_b_1_pkey; + + CREATE TABLE test_part_b_2 PARTITION OF test_part_b FOR VALUES IN (6,7,8,9,10); + ALTER TABLE test_part_b_2 ADD PRIMARY KEY (b); + ALTER TABLE test_part_b_2 REPLICA IDENTITY USING INDEX test_part_b_2_pkey; + """ + ) + + # create a publication replicating both columns, which is sufficient for + # both partitions + node_publisher.safe_sql( + "CREATE PUBLICATION pub7 FOR TABLE test_part_b (a, b) " + "WITH (publish_via_partition_root = true);" + ) + + # create the subscription for the above publication, wait for sync to + # complete + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub7" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + INSERT INTO test_part_b VALUES (2, 3); + INSERT INTO test_part_b VALUES (7, 4); + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM test_part_b ORDER BY a, b") == ( + "1|1\n" "2|3\n" "6|2\n" "7|4" + ), "partitions with different replica identities not replicated correctly" + + # TEST: This time start with a column list covering RI for all partitions, + # but then update RI for one of the partitions to not be covered by the + # column list anymore. + + node_publisher.safe_sql( + """ + CREATE TABLE test_part_c (a int, b int, c int) PARTITION BY LIST (a); + + CREATE TABLE test_part_c_1 PARTITION OF test_part_c FOR VALUES IN (1,3); + ALTER TABLE test_part_c_1 ADD PRIMARY KEY (a); + ALTER TABLE test_part_c_1 REPLICA IDENTITY USING INDEX test_part_c_1_pkey; + + CREATE TABLE test_part_c_2 PARTITION OF test_part_c FOR VALUES IN (2,4); + ALTER TABLE test_part_c_2 ADD PRIMARY KEY (b); + ALTER TABLE test_part_c_2 REPLICA IDENTITY USING INDEX test_part_c_2_pkey; + + -- initial data, one row for each partition + INSERT INTO test_part_c VALUES (1, 3, 5); + INSERT INTO test_part_c VALUES (2, 4, 6); + """ + ) + + # do the same thing on the subscriber + node_subscriber.safe_sql( + """ + CREATE TABLE test_part_c (a int, b int, c int) PARTITION BY LIST (a); + + CREATE TABLE test_part_c_1 PARTITION OF test_part_c FOR VALUES IN (1,3); + ALTER TABLE test_part_c_1 ADD PRIMARY KEY (a); + ALTER TABLE test_part_c_1 REPLICA IDENTITY USING INDEX test_part_c_1_pkey; + + CREATE TABLE test_part_c_2 PARTITION OF test_part_c FOR VALUES IN (2,4); + ALTER TABLE test_part_c_2 ADD PRIMARY KEY (b); + ALTER TABLE test_part_c_2 REPLICA IDENTITY USING INDEX test_part_c_2_pkey; + """ + ) + + # create a publication replicating data through partition root, with a column + # list on the root, and then add the partitions one by one with separate + # column lists (but those are not applied) + node_publisher.safe_sql( + """ + CREATE PUBLICATION pub8 FOR TABLE test_part_c WITH (publish_via_partition_root = false); + ALTER PUBLICATION pub8 ADD TABLE test_part_c_1 (a,c); + ALTER PUBLICATION pub8 ADD TABLE test_part_c_2 (a,b); + """ + ) + + # create the subscription for the above publication, wait for sync to + # complete + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub8;" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + INSERT INTO test_part_c VALUES (3, 7, 8); + INSERT INTO test_part_c VALUES (4, 9, 10); + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM test_part_c ORDER BY a, b") == ( + "1||5\n" "2|4|\n" "3||8\n" "4|9|" + ), "partitions with different replica identities not replicated correctly" + + # create a publication not replicating data through partition root, without + # a column list on the root, and then add the partitions one by one with + # separate column lists + node_publisher.safe_sql( + """ + DROP PUBLICATION pub8; + CREATE PUBLICATION pub8 FOR TABLE test_part_c WITH (publish_via_partition_root = false); + ALTER PUBLICATION pub8 ADD TABLE test_part_c_1 (a); + ALTER PUBLICATION pub8 ADD TABLE test_part_c_2 (a,b); + """ + ) + + # add the publication to our subscription, wait for sync to complete + node_subscriber.safe_sql("ALTER SUBSCRIPTION sub1 REFRESH PUBLICATION;") + node_subscriber.safe_sql("TRUNCATE test_part_c;") + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + TRUNCATE test_part_c; + INSERT INTO test_part_c VALUES (1, 3, 5); + INSERT INTO test_part_c VALUES (2, 4, 6); + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM test_part_c ORDER BY a, b") == ( + "1||\n" "2|4|" + ), "partitions with different replica identities not replicated correctly" + + # TEST: Start with a single partition, with RI compatible with the column + # list, and then attach a partition with incompatible RI. + + node_publisher.safe_sql( + """ + CREATE TABLE test_part_d (a int, b int) PARTITION BY LIST (a); + + CREATE TABLE test_part_d_1 PARTITION OF test_part_d FOR VALUES IN (1,3); + ALTER TABLE test_part_d_1 ADD PRIMARY KEY (a); + ALTER TABLE test_part_d_1 REPLICA IDENTITY USING INDEX test_part_d_1_pkey; + + INSERT INTO test_part_d VALUES (1, 2); + """ + ) + + # do the same thing on the subscriber (in fact, create both partitions right + # away, no need to delay that) + node_subscriber.safe_sql( + """ + CREATE TABLE test_part_d (a int, b int) PARTITION BY LIST (a); + + CREATE TABLE test_part_d_1 PARTITION OF test_part_d FOR VALUES IN (1,3); + ALTER TABLE test_part_d_1 ADD PRIMARY KEY (a); + ALTER TABLE test_part_d_1 REPLICA IDENTITY USING INDEX test_part_d_1_pkey; + + CREATE TABLE test_part_d_2 PARTITION OF test_part_d FOR VALUES IN (2,4); + ALTER TABLE test_part_d_2 ADD PRIMARY KEY (a); + ALTER TABLE test_part_d_2 REPLICA IDENTITY USING INDEX test_part_d_2_pkey; + """ + ) + + # create a publication replicating both columns, which is sufficient for + # both partitions + node_publisher.safe_sql( + "CREATE PUBLICATION pub9 FOR TABLE test_part_d (a) " + "WITH (publish_via_partition_root = true);" + ) + + # create the subscription for the above publication, wait for sync to + # complete + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub9" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO test_part_d VALUES (3, 4);") + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM test_part_d ORDER BY a, b") == ( + "1|\n" "3|" + ), "partitions with different replica identities not replicated correctly" + + # TEST: With a table included in the publications which is FOR ALL TABLES, it + # means replicate all columns. + + # drop unnecessary tables, so as not to interfere with the FOR ALL TABLES + node_publisher.safe_sql( + """ + DROP TABLE tab1, tab2, tab3, tab4, tab5, tab6, tab7, + test_part, test_part_a, test_part_b, test_part_c, test_part_d; + """ + ) + + node_publisher.safe_sql( + """ + CREATE TABLE test_mix_2 (a int PRIMARY KEY, b int, c int); + CREATE PUBLICATION pub_mix_3 FOR TABLE test_mix_2 (a, b, c); + CREATE PUBLICATION pub_mix_4 FOR ALL TABLES; + + -- initial data + INSERT INTO test_mix_2 VALUES (1, 2, 3); + """ + ) + + node_subscriber.safe_sql( + "CREATE TABLE test_mix_2 (a int PRIMARY KEY, b int, c int);" + ) + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_mix_3, pub_mix_4;" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO test_mix_2 VALUES (4, 5, 6);") + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM test_mix_2") == ( + "1|2|3\n" "4|5|6" + ), "all columns should be replicated" + + # TEST: With a table included in the publication which is FOR TABLES IN + # SCHEMA, it means replicate all columns. + + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + "CREATE TABLE test_mix_3 (a int PRIMARY KEY, b int, c int);" + ) + + node_publisher.safe_sql( + """ + DROP TABLE test_mix_2; + CREATE TABLE test_mix_3 (a int PRIMARY KEY, b int, c int); + CREATE PUBLICATION pub_mix_5 FOR TABLE test_mix_3 (a, b, c); + CREATE PUBLICATION pub_mix_6 FOR TABLES IN SCHEMA public; + + -- initial data + INSERT INTO test_mix_3 VALUES (1, 2, 3); + """ + ) + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_mix_5, pub_mix_6;" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO test_mix_3 VALUES (4, 5, 6);") + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM test_mix_3") == ( + "1|2|3\n" "4|5|6" + ), "all columns should be replicated" + + # TEST: Check handling of publish_via_partition_root - if a partition is + # published through partition root, we should only apply the column list + # defined for the whole table (not the partitions) - both during the initial + # sync and when replicating changes. This is what we do for row filters. + + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + """ + CREATE TABLE test_root (a int PRIMARY KEY, b int, c int) PARTITION BY RANGE (a); + CREATE TABLE test_root_1 PARTITION OF test_root FOR VALUES FROM (1) TO (10); + CREATE TABLE test_root_2 PARTITION OF test_root FOR VALUES FROM (10) TO (20); + """ + ) + + node_publisher.safe_sql( + """ + CREATE TABLE test_root (a int PRIMARY KEY, b int, c int) PARTITION BY RANGE (a); + CREATE TABLE test_root_1 PARTITION OF test_root FOR VALUES FROM (1) TO (10); + CREATE TABLE test_root_2 PARTITION OF test_root FOR VALUES FROM (10) TO (20); + + CREATE PUBLICATION pub_test_root FOR TABLE test_root (a) WITH (publish_via_partition_root = true); + CREATE PUBLICATION pub_test_root_1 FOR TABLE test_root_1 (a, b); + + -- initial data + INSERT INTO test_root VALUES (1, 2, 3); + INSERT INTO test_root VALUES (10, 20, 30); + """ + ) + + # Subscribe to pub_test_root and pub_test_root_1 at the same time, which means + # that the initial data will be synced once, and only the column list of the + # parent table (test_root) in the publication pub_test_root will be used for + # both table sync and data replication. + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_test_root, pub_test_root_1;" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + INSERT INTO test_root VALUES (2, 3, 4); + INSERT INTO test_root VALUES (11, 21, 31); + """ + ) + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM test_root ORDER BY a, b, c") == ( + "1||\n" "2||\n" "10||\n" "11||" + ), "publication via partition root applies column list" + + # TEST: Multiple publications which publish schema of parent table and + # partition. The partition is published through two publications, once + # through a schema (so no column list) containing the parent, and then + # also directly (with all columns). The expected outcome is there is + # no column list. + + node_publisher.safe_sql( + """ + DROP PUBLICATION pub1, pub2, pub3, pub4, pub5, pub6, pub7, pub8; + + CREATE SCHEMA s1; + CREATE TABLE s1.t (a int, b int, c int) PARTITION BY RANGE (a); + CREATE TABLE t_1 PARTITION OF s1.t FOR VALUES FROM (1) TO (10); + + CREATE PUBLICATION pub1 FOR TABLES IN SCHEMA s1; + CREATE PUBLICATION pub2 FOR TABLE t_1(a, b, c); + + -- initial data + INSERT INTO s1.t VALUES (1, 2, 3); + """ + ) + + node_subscriber.safe_sql( + """ + CREATE SCHEMA s1; + CREATE TABLE s1.t (a int, b int, c int) PARTITION BY RANGE (a); + CREATE TABLE t_1 PARTITION OF s1.t FOR VALUES FROM (1) TO (10); + """ + ) + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub1, pub2;" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO s1.t VALUES (4, 5, 6);") + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM s1.t ORDER BY a") == ( + "1|2|3\n" "4|5|6" + ), "two publications, publishing the same relation" + + # Now resync the subscription, but with publications in the opposite order. + # The result should be the same. + + node_subscriber.safe_sql("TRUNCATE s1.t;") + node_subscriber.safe_sql("ALTER SUBSCRIPTION sub1 SET PUBLICATION pub2, pub1;") + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO s1.t VALUES (7, 8, 9);") + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM s1.t ORDER BY a") == ( + "7|8|9" + ), "two publications, publishing the same relation" + + # TEST: One publication, containing both the parent and child relations. + # The expected outcome is list "a", because that's the column list defined + # for the top-most ancestor added to the publication. + + node_publisher.safe_sql( + """ + DROP SCHEMA s1 CASCADE; + CREATE TABLE t (a int, b int, c int) PARTITION BY RANGE (a); + CREATE TABLE t_1 PARTITION OF t FOR VALUES FROM (1) TO (10) + PARTITION BY RANGE (a); + CREATE TABLE t_2 PARTITION OF t_1 FOR VALUES FROM (1) TO (10); + + CREATE PUBLICATION pub3 FOR TABLE t_1 (a), t_2 + WITH (PUBLISH_VIA_PARTITION_ROOT); + + -- initial data + INSERT INTO t VALUES (1, 2, 3); + """ + ) + + node_subscriber.safe_sql( + """ + DROP SCHEMA s1 CASCADE; + CREATE TABLE t (a int, b int, c int) PARTITION BY RANGE (a); + CREATE TABLE t_1 PARTITION OF t FOR VALUES FROM (1) TO (10) + PARTITION BY RANGE (a); + CREATE TABLE t_2 PARTITION OF t_1 FOR VALUES FROM (1) TO (10); + """ + ) + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub3;" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO t VALUES (4, 5, 6);") + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM t ORDER BY a, b, c") == ( + "1||\n" "4||" + ), "publication containing both parent and child relation" + + # TEST: One publication, containing both the parent and child relations. + # The expected outcome is list "a", because that's the column list defined + # for the top-most ancestor added to the publication. + # Note: The difference from the preceding test is that in this case both + # relations have a column list defined. + + node_publisher.safe_sql( + """ + DROP TABLE t; + CREATE TABLE t (a int, b int, c int) PARTITION BY RANGE (a); + CREATE TABLE t_1 PARTITION OF t FOR VALUES FROM (1) TO (10) + PARTITION BY RANGE (a); + CREATE TABLE t_2 PARTITION OF t_1 FOR VALUES FROM (1) TO (10); + + CREATE PUBLICATION pub4 FOR TABLE t_1 (a), t_2 (b) + WITH (PUBLISH_VIA_PARTITION_ROOT); + + -- initial data + INSERT INTO t VALUES (1, 2, 3); + """ + ) + + node_subscriber.safe_sql( + """ + DROP TABLE t; + CREATE TABLE t (a int, b int, c int) PARTITION BY RANGE (a); + CREATE TABLE t_1 PARTITION OF t FOR VALUES FROM (1) TO (10) + PARTITION BY RANGE (a); + CREATE TABLE t_2 PARTITION OF t_1 FOR VALUES FROM (1) TO (10); + """ + ) + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub4;" + ) + + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql("INSERT INTO t VALUES (4, 5, 6);") + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM t ORDER BY a, b, c") == ( + "1||\n" "4||" + ), "publication containing both parent and child relation" + + # TEST: Only columns in the column list should exist in the old tuple of UPDATE + # and DELETE. + + node_publisher.safe_sql( + """ + CREATE TABLE test_oldtuple_col (a int PRIMARY KEY, b int, c int); + CREATE PUBLICATION pub_check_oldtuple FOR TABLE test_oldtuple_col (a, b); + INSERT INTO test_oldtuple_col VALUES(1, 2, 3); + """ + ) + node_publisher.safe_sql( + "SELECT * FROM pg_create_logical_replication_slot('test_slot', 'pgoutput');" + ) + node_publisher.safe_sql( + """ + UPDATE test_oldtuple_col SET a = 2; + DELETE FROM test_oldtuple_col; + """ + ) + + # Check at 7th byte of binary data for the number of columns in the old tuple. + # + # 7 = 1 (count from 1) + 1 byte (message type) + 4 byte (relid) + 1 byte (flag + # for old key). + # + # The message type of UPDATE is 85('U'). + # The message type of DELETE is 68('D'). + result = node_publisher.safe_sql( + """ + SELECT substr(data, 7, 2) = int2send(2::smallint) + FROM pg_logical_slot_peek_binary_changes('test_slot', NULL, NULL, + 'proto_version', '1', + 'publication_names', 'pub_check_oldtuple') + WHERE get_byte(data, 0) = 85 OR get_byte(data, 0) = 68 + """ + ) + + assert result == ("t\n" "t"), "check the number of columns in the old tuple" + + # TEST: Dropped columns are not considered for the column list, and generated + # columns are not replicated if they are not explicitly included in the column + # list. So, the publication having a column list except for those columns and a + # publication without any column list (aka all columns as part of the columns + # list) are considered to have the same column list. + node_publisher.safe_sql( + """ + CREATE TABLE test_mix_4 (a int PRIMARY KEY, b int, c int, d int GENERATED ALWAYS AS (a + 1) STORED, e int GENERATED ALWAYS AS (a + 2) VIRTUAL); + ALTER TABLE test_mix_4 DROP COLUMN c; + + CREATE PUBLICATION pub_mix_7 FOR TABLE test_mix_4 (a, b); + CREATE PUBLICATION pub_mix_8 FOR TABLE test_mix_4; + + -- initial data + INSERT INTO test_mix_4 VALUES (1, 2); + """ + ) + + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + "CREATE TABLE test_mix_4 (a int PRIMARY KEY, b int, c int, d int);" + ) + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_mix_7, pub_mix_8;" + ) + + node_subscriber.wait_for_subscription_sync() + + assert node_subscriber.safe_sql("SELECT * FROM test_mix_4 ORDER BY a") == ( + "1|2||" + ), ( + "initial synchronization with multiple publications with the same " + "column list" + ) + + node_publisher.safe_sql("INSERT INTO test_mix_4 VALUES (3, 4);") + + node_publisher.wait_for_catchup("sub1") + + assert node_subscriber.safe_sql("SELECT * FROM test_mix_4 ORDER BY a") == ( + "1|2||\n" "3|4||" + ), "replication with multiple publications with the same column list" + + # TEST: With a table included in multiple publications with different column + # lists, we should catch the error when creating the subscription. + + node_publisher.safe_sql( + """ + CREATE TABLE test_mix_1 (a int PRIMARY KEY, b int, c int); + CREATE PUBLICATION pub_mix_1 FOR TABLE test_mix_1 (a, b); + CREATE PUBLICATION pub_mix_2 FOR TABLE test_mix_1 (a, c); + """ + ) + + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1;") + node_subscriber.safe_sql( + "CREATE TABLE test_mix_1 (a int PRIMARY KEY, b int, c int);" + ) + + res = node_subscriber.sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_mix_1, pub_mix_2;" + ) + stderr = res.error_message or "" + + assert re.search( + r'cannot use different column lists for table "public.test_mix_1" in ' + r"different publications", + stderr, + ), "different column lists detected" + + # TEST: If the column list is changed after creating the subscription, we + # should catch the error reported by walsender. + + node_publisher.safe_sql("ALTER PUBLICATION pub_mix_1 SET TABLE test_mix_1 (a, c);") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION pub_mix_1, pub_mix_2;" + ) + + node_publisher.wait_for_catchup("sub1") + + node_publisher.safe_sql( + """ + ALTER PUBLICATION pub_mix_1 SET TABLE test_mix_1 (a, b); + INSERT INTO test_mix_1 VALUES(1, 1, 1); + """ + ) + + offset = node_publisher.wait_for_log( + r'cannot use different column lists for table "public.test_mix_1" in ' + r"different publications", + offset, + ) + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_032_subscribe_use_index.py b/src/test/subscription/pyt/test_032_subscribe_use_index.py new file mode 100644 index 00000000000..ca21b8b95fa --- /dev/null +++ b/src/test/subscription/pyt/test_032_subscribe_use_index.py @@ -0,0 +1,594 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Test logical replication behavior with subscriber using available index.""" + + +def test_032_subscribe_use_index(create_pg): + # create publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # create subscriber node + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + appname = "tap_sub" + + # ========================================================================= + # Testcase start: Subscription can use index with multiple rows and columns + # + + # create tables pub and sub + node_publisher.safe_sql("CREATE TABLE test_replica_id_full (x int, y text)") + node_publisher.safe_sql("ALTER TABLE test_replica_id_full REPLICA IDENTITY FULL") + node_subscriber.safe_sql("CREATE TABLE test_replica_id_full (x int, y text)") + node_subscriber.safe_sql( + "CREATE INDEX test_replica_id_full_idx ON test_replica_id_full(x,y)" + ) + + # insert some initial data within the range 0-9 for x and y + node_publisher.safe_sql( + "INSERT INTO test_replica_id_full SELECT (i%10), (i%10)::text " + "FROM generate_series(0,10) i" + ) + + # create pub/sub + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_rep_full FOR TABLE test_replica_id_full" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_rep_full CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_rep_full" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # delete 2 rows + node_publisher.safe_sql("DELETE FROM test_replica_id_full WHERE x IN (5, 6)") + + # update 2 rows + node_publisher.safe_sql( + "UPDATE test_replica_id_full SET x = 100, y = '200' WHERE x IN (1, 2)" + ) + + # wait until the index is used on the subscriber + node_publisher.wait_for_catchup(appname) + assert node_subscriber.poll_query_until( + "select (idx_scan = 4) from pg_stat_all_indexes " + "where indexrelname = 'test_replica_id_full_idx';" + ), ( + "Timed out while waiting for check subscriber tap_sub_rep_full " + "updates 4 rows via index" + ) + + # make sure that the subscriber has the correct data after the UPDATE + result = node_subscriber.safe_sql( + "select count(*) from test_replica_id_full WHERE (x = 100 and y = '200')" + ) + assert ( + result == "2" + ), "ensure subscriber has the correct data at the end of the test" + + # make sure that the subscriber has the correct data after the first DELETE + result = node_subscriber.safe_sql( + "select count(*) from test_replica_id_full where x in (5, 6)" + ) + assert ( + result == "0" + ), "ensure subscriber has the correct data at the end of the test" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_rep_full") + node_publisher.safe_sql("DROP TABLE test_replica_id_full") + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_rep_full") + node_subscriber.safe_sql("DROP TABLE test_replica_id_full") + + # Testcase end: Subscription can use index with multiple rows and columns + # ========================================================================= + + # ========================================================================= + # Testcase start: Subscription can use index on partitioned tables + + # create tables pub and sub + node_publisher.safe_sql( + "CREATE TABLE users_table_part(user_id bigint, value_1 int, " + "value_2 int) PARTITION BY RANGE (value_1)" + ) + node_publisher.safe_sql( + "CREATE TABLE users_table_part_0 PARTITION OF users_table_part " + "FOR VALUES FROM (0) TO (10)" + ) + node_publisher.safe_sql( + "CREATE TABLE users_table_part_1 PARTITION OF users_table_part " + "FOR VALUES FROM (10) TO (20)" + ) + + node_publisher.safe_sql("ALTER TABLE users_table_part REPLICA IDENTITY FULL") + node_publisher.safe_sql("ALTER TABLE users_table_part_0 REPLICA IDENTITY FULL") + node_publisher.safe_sql("ALTER TABLE users_table_part_1 REPLICA IDENTITY FULL") + + node_subscriber.safe_sql( + "CREATE TABLE users_table_part(user_id bigint, value_1 int, " + "value_2 int) PARTITION BY RANGE (value_1)" + ) + node_subscriber.safe_sql( + "CREATE TABLE users_table_part_0 PARTITION OF users_table_part " + "FOR VALUES FROM (0) TO (10)" + ) + node_subscriber.safe_sql( + "CREATE TABLE users_table_part_1 PARTITION OF users_table_part " + "FOR VALUES FROM (10) TO (20)" + ) + node_subscriber.safe_sql( + "CREATE INDEX users_table_part_idx ON users_table_part(user_id, value_1)" + ) + + # insert some initial data + node_publisher.safe_sql( + "INSERT INTO users_table_part SELECT (i%100), (i%20), i " + "FROM generate_series(0,100) i" + ) + + # create pub/sub + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_rep_full FOR TABLE users_table_part" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_rep_full CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_rep_full" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # update rows, moving them to other partitions + node_publisher.safe_sql("UPDATE users_table_part SET value_1 = 0 WHERE user_id = 4") + + # delete rows from different partitions + node_publisher.safe_sql( + "DELETE FROM users_table_part WHERE user_id = 1 and value_1 = 1" + ) + node_publisher.safe_sql( + "DELETE FROM users_table_part WHERE user_id = 12 and value_1 = 12" + ) + + # wait until the index is used on the subscriber + node_publisher.wait_for_catchup(appname) + assert node_subscriber.poll_query_until( + "select sum(idx_scan)=3 from pg_stat_all_indexes " + "where indexrelname ilike 'users_table_part_%';" + ), ( + "Timed out while waiting for check subscriber tap_sub_rep_full " + "updates partitioned table" + ) + + # make sure that the subscriber has the correct data + result = node_subscriber.safe_sql( + "select sum(user_id+value_1+value_2) from users_table_part" + ) + assert ( + result == "10907" + ), "ensure subscriber has the correct data at the end of the test" + result = node_subscriber.safe_sql( + "select count(DISTINCT(user_id,value_1, value_2)) from users_table_part" + ) + assert ( + result == "99" + ), "ensure subscriber has the correct data at the end of the test" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_rep_full") + node_publisher.safe_sql("DROP TABLE users_table_part") + + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_rep_full") + node_subscriber.safe_sql("DROP TABLE users_table_part") + + # Testcase end: Subscription can use index on partitioned tables + # ========================================================================= + + # ========================================================================= + # Testcase start: Subscription will not use indexes with only expressions + # or partial index + + # create tables pub and sub + node_publisher.safe_sql("CREATE TABLE people (firstname text, lastname text)") + node_publisher.safe_sql("ALTER TABLE people REPLICA IDENTITY FULL") + node_subscriber.safe_sql("CREATE TABLE people (firstname text, lastname text)") + + # index with only an expression + node_subscriber.safe_sql( + "CREATE INDEX people_names_expr_only ON people " + "((firstname || ' ' || lastname))" + ) + + # partial index + node_subscriber.safe_sql( + "CREATE INDEX people_names_partial ON people(firstname) " + "WHERE (firstname = 'first_name_1')" + ) + + # insert some initial data + node_publisher.safe_sql( + "INSERT INTO people SELECT 'first_name_' || i::text, " + "'last_name_' || i::text FROM generate_series(0,200) i" + ) + + # create pub/sub + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_rep_full FOR TABLE people") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_rep_full CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_rep_full" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # update 2 rows + node_publisher.safe_sql( + "UPDATE people SET firstname = 'no-name' WHERE firstname = 'first_name_1'" + ) + node_publisher.safe_sql( + "UPDATE people SET firstname = 'no-name' " + "WHERE firstname = 'first_name_2' AND lastname = 'last_name_2'" + ) + + # make sure none of the indexes is used on the subscriber + node_publisher.wait_for_catchup(appname) + result = node_subscriber.safe_sql( + "select sum(idx_scan) from pg_stat_all_indexes where indexrelname " + "IN ('people_names_expr_only', 'people_names_partial')" + ) + assert result == "0", ( + "ensure subscriber tap_sub_rep_full updates two rows via seq. scan " + "with index on expressions" + ) + + node_publisher.safe_sql("DELETE FROM people WHERE firstname = 'first_name_3'") + node_publisher.safe_sql( + "DELETE FROM people WHERE firstname = 'first_name_4' " + "AND lastname = 'last_name_4'" + ) + + # make sure the index is not used on the subscriber + node_publisher.wait_for_catchup(appname) + result = node_subscriber.safe_sql( + "select sum(idx_scan) from pg_stat_all_indexes where indexrelname " + "IN ('people_names_expr_only', 'people_names_partial')" + ) + assert result == "0", ( + "ensure subscriber tap_sub_rep_full updates two rows via seq. scan " + "with index on expressions" + ) + + # make sure that the subscriber has the correct data + result = node_subscriber.safe_sql("SELECT count(*) FROM people") + assert ( + result == "199" + ), "ensure subscriber has the correct data at the end of the test" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_rep_full") + node_publisher.safe_sql("DROP TABLE people") + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_rep_full") + node_subscriber.safe_sql("DROP TABLE people") + + # Testcase end: Subscription will not use indexes with only expressions or + # partial index + # ========================================================================= + + # ========================================================================= + # Testcase start: Subscription can use index having expressions and columns + + # create tables pub and sub + node_publisher.safe_sql("CREATE TABLE people (firstname text, lastname text)") + node_publisher.safe_sql("ALTER TABLE people REPLICA IDENTITY FULL") + node_subscriber.safe_sql("CREATE TABLE people (firstname text, lastname text)") + node_subscriber.safe_sql( + "CREATE INDEX people_names ON people " + "(firstname, lastname, (firstname || ' ' || lastname))" + ) + + # insert some initial data + node_publisher.safe_sql( + "INSERT INTO people SELECT 'first_name_' || i::text, " + "'last_name_' || i::text FROM generate_series(0, 20) i" + ) + + # create pub/sub + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_rep_full FOR TABLE people") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_rep_full CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_rep_full" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # update 1 row + node_publisher.safe_sql( + "UPDATE people SET firstname = 'no-name' WHERE firstname = 'first_name_1'" + ) + + # delete the updated row + node_publisher.safe_sql("DELETE FROM people WHERE firstname = 'no-name'") + + # wait until the index is used on the subscriber + node_publisher.wait_for_catchup(appname) + assert node_subscriber.poll_query_until( + "select idx_scan=2 from pg_stat_all_indexes " + "where indexrelname = 'people_names';" + ), ( + "Timed out while waiting for check subscriber tap_sub_rep_full " + "deletes two rows via index scan with index on expressions and " + "columns" + ) + + # make sure that the subscriber has the correct data + result = node_subscriber.safe_sql("SELECT count(*) FROM people") + assert ( + result == "20" + ), "ensure subscriber has the correct data at the end of the test" + + result = node_subscriber.safe_sql( + "SELECT count(*) FROM people WHERE firstname = 'no-name'" + ) + assert ( + result == "0" + ), "ensure subscriber has the correct data at the end of the test" + + # now, drop the index with the expression, we'll use sequential scan + node_subscriber.safe_sql("DROP INDEX people_names") + + # delete 1 row + node_publisher.safe_sql("DELETE FROM people WHERE lastname = 'last_name_18'") + + # make sure that the subscriber has the correct data + node_publisher.wait_for_catchup(appname) + result = node_subscriber.safe_sql( + "SELECT count(*) FROM people WHERE lastname = 'last_name_18'" + ) + assert ( + result == "0" + ), "ensure subscriber has the correct data at the end of the test" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_rep_full") + node_publisher.safe_sql("DROP TABLE people") + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_rep_full") + node_subscriber.safe_sql("DROP TABLE people") + + # Testcase end: Subscription can use index having expressions and columns + # ========================================================================= + + # ========================================================================= + # Testcase start: Null values and missing column + + node_publisher.safe_sql("CREATE TABLE test_replica_id_full (x int)") + + node_publisher.safe_sql("ALTER TABLE test_replica_id_full REPLICA IDENTITY FULL") + + node_subscriber.safe_sql("CREATE TABLE test_replica_id_full (x int, y int)") + + node_subscriber.safe_sql( + "CREATE INDEX test_replica_id_full_idx ON test_replica_id_full(x,y)" + ) + + # create pub/sub + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_rep_full FOR TABLE test_replica_id_full" + ) + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_rep_full CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_rep_full" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # load some data, and update 2 tuples + node_publisher.safe_sql("INSERT INTO test_replica_id_full VALUES (1), (2), (3)") + node_publisher.safe_sql("UPDATE test_replica_id_full SET x = x + 1 WHERE x = 1") + + # check if the index is used even when the index has NULL values + node_publisher.wait_for_catchup(appname) + assert node_subscriber.poll_query_until( + "select idx_scan=1 from pg_stat_all_indexes " + "where indexrelname = 'test_replica_id_full_idx';" + ), ( + "Timed out while waiting for check subscriber tap_sub_rep_full " + "updates test_replica_id_full table" + ) + + # make sure that the subscriber has the correct data + result = node_subscriber.safe_sql( + "select sum(x) from test_replica_id_full WHERE y IS NULL" + ) + assert ( + result == "7" + ), "ensure subscriber has the correct data at the end of the test" + + # make sure that the subscriber has the correct data + result = node_subscriber.safe_sql( + "select count(*) from test_replica_id_full WHERE y IS NULL" + ) + assert ( + result == "3" + ), "ensure subscriber has the correct data at the end of the test" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_rep_full") + node_publisher.safe_sql("DROP TABLE test_replica_id_full") + + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_rep_full") + node_subscriber.safe_sql("DROP TABLE test_replica_id_full") + + # Testcase end: Null values And missing column + # ========================================================================= + + # ========================================================================= + # Testcase start: Subscription using a unique index when Pub/Sub has + # different data + # + # The subscriber has duplicate tuples that publisher does not have. When + # publisher updates/deletes 1 row, subscriber uses indexes and + # updates/deletes exactly 1 row. + # + + # create tables pub and sub + node_publisher.safe_sql("CREATE TABLE test_replica_id_full (x int, y int)") + node_publisher.safe_sql("ALTER TABLE test_replica_id_full REPLICA IDENTITY FULL") + node_subscriber.safe_sql("CREATE TABLE test_replica_id_full (x int, y int)") + node_subscriber.safe_sql( + "CREATE UNIQUE INDEX test_replica_id_full_idxy ON test_replica_id_full(x,y)" + ) + + # insert some initial data + node_publisher.safe_sql( + "INSERT INTO test_replica_id_full SELECT i, i FROM generate_series(0,21) i" + ) + + # create pub/sub + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_rep_full FOR TABLE test_replica_id_full" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_rep_full CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_rep_full" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # duplicate the data in subscriber for y column + node_subscriber.safe_sql( + "INSERT INTO test_replica_id_full SELECT i+100, i " + "FROM generate_series(0,21) i" + ) + + # now, we update only 1 row on the publisher and expect the subscriber to + # only update 1 row although there are two tuples with y = 15 on the + # subscriber + node_publisher.safe_sql("UPDATE test_replica_id_full SET x = 2000 WHERE y = 15") + + # wait until the index is used on the subscriber + node_publisher.wait_for_catchup(appname) + assert node_subscriber.poll_query_until( + "select (idx_scan = 1) from pg_stat_all_indexes " + "where indexrelname = 'test_replica_id_full_idxy';" + ), ( + "Timed out while waiting for check subscriber tap_sub_rep_full " + "updates one row via index" + ) + + # make sure that the subscriber has the correct data + # we only updated 1 row + result = node_subscriber.safe_sql( + "SELECT count(*) FROM test_replica_id_full WHERE x = 2000" + ) + assert ( + result == "1" + ), "ensure subscriber has the correct data at the end of the test" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_rep_full") + node_publisher.safe_sql("DROP TABLE test_replica_id_full") + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_rep_full") + node_subscriber.safe_sql("DROP TABLE test_replica_id_full") + + # Testcase start: Subscription using a unique index when Pub/Sub has + # different data + # ========================================================================= + + # ========================================================================= + # Testcase start: Subscription can use hash index + # + + # create tables on pub and sub + node_publisher.safe_sql("CREATE TABLE test_replica_id_full (x int, y text)") + node_publisher.safe_sql("ALTER TABLE test_replica_id_full REPLICA IDENTITY FULL") + node_subscriber.safe_sql("CREATE TABLE test_replica_id_full (x int, y text)") + node_subscriber.safe_sql( + "CREATE INDEX test_replica_id_full_idx " + "ON test_replica_id_full USING HASH (x)" + ) + + # insert some initial data + node_publisher.safe_sql( + "INSERT INTO test_replica_id_full SELECT i, (i%10)::text " + "FROM generate_series(0,10) i" + ) + + # create pub/sub + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub_rep_full FOR TABLE test_replica_id_full" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_rep_full CONNECTION " + f"'{publisher_connstr} application_name={appname}' " + "PUBLICATION tap_pub_rep_full" + ) + + # wait for initial table synchronization to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + # delete 2 rows + node_publisher.safe_sql("DELETE FROM test_replica_id_full WHERE x IN (5, 6)") + + # update 2 rows + node_publisher.safe_sql( + "UPDATE test_replica_id_full SET x = 100, y = '200' WHERE x IN (1, 2)" + ) + + # wait until the index is used on the subscriber + node_publisher.wait_for_catchup(appname) + assert node_subscriber.poll_query_until( + "select (idx_scan = 4) from pg_stat_all_indexes " + "where indexrelname = 'test_replica_id_full_idx';" + ), ( + "Timed out while waiting for check subscriber tap_sub_rep_full " + "deletes 2 rows and updates 2 rows via index" + ) + + # make sure that the subscriber has the correct data after the UPDATE + result = node_subscriber.safe_sql( + "select count(*) from test_replica_id_full WHERE (x = 100 and y = '200')" + ) + assert ( + result == "2" + ), "ensure subscriber has the correct data at the end of the test" + + # make sure that the subscriber has the correct data after the first DELETE + result = node_subscriber.safe_sql( + "select count(*) from test_replica_id_full where x in (5, 6)" + ) + assert ( + result == "0" + ), "ensure subscriber has the correct data at the end of the test" + + # cleanup pub + node_publisher.safe_sql("DROP PUBLICATION tap_pub_rep_full") + node_publisher.safe_sql("DROP TABLE test_replica_id_full") + # cleanup sub + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_rep_full") + node_subscriber.safe_sql("DROP TABLE test_replica_id_full") + + # Testcase end: Subscription can use hash index + # ========================================================================= + + node_subscriber.stop("fast") + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_033_run_as_table_owner.py b/src/test/subscription/pyt/test_033_run_as_table_owner.py new file mode 100644 index 00000000000..d2854a4d1f1 --- /dev/null +++ b/src/test/subscription/pyt/test_033_run_as_table_owner.py @@ -0,0 +1,259 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that logical replication respects permissions.""" + +# Regex matching the permission-denied error logged on the subscriber. +PERMISSION_DENIED_RE = ( + r"ERROR: ( [A-Z0-9]+:)? permission denied for table unpartitioned" +) + + +def test_033_run_as_table_owner(create_pg): + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + # The subscriber log offset used by expect_failure, tracked across calls. + offset = [0] + + # Note: our in-process safe_sql reuses a cached connection, so a SET + # SESSION AUTHORIZATION would persist across calls. Each block that changes + # the session role therefore resets it again before returning, to keep the + # session role isolated between calls. + + def publish_insert(tbl, new_i): + node_publisher.safe_sql( + "SET SESSION AUTHORIZATION regress_alice;\n" + f"INSERT INTO {tbl} (i) VALUES ({new_i});\n" + "RESET SESSION AUTHORIZATION;" + ) + + def publish_update(tbl, old_i, new_i): + node_publisher.safe_sql( + "SET SESSION AUTHORIZATION regress_alice;\n" + f"UPDATE {tbl} SET i = {new_i} WHERE i = {old_i};\n" + "RESET SESSION AUTHORIZATION;" + ) + + def publish_delete(tbl, old_i): + node_publisher.safe_sql( + "SET SESSION AUTHORIZATION regress_alice;\n" + f"DELETE FROM {tbl} WHERE i = {old_i};\n" + "RESET SESSION AUTHORIZATION;" + ) + + def expect_replication(tbl, cnt, minimum, maximum, testname): + node_publisher.wait_for_catchup("admin_sub") + result = node_subscriber.safe_sql(f"SELECT COUNT(i), MIN(i), MAX(i) FROM {tbl}") + assert result == f"{cnt}|{minimum}|{maximum}", testname + + def expect_failure(tbl, cnt, minimum, maximum, re_pat, testname): + offset[0] = node_subscriber.wait_for_log(re_pat, offset[0]) + result = node_subscriber.safe_sql(f"SELECT COUNT(i), MIN(i), MAX(i) FROM {tbl}") + assert result == f"{cnt}|{minimum}|{maximum}", testname + + def revoke_superuser(role): + node_subscriber.safe_sql(f"ALTER ROLE {role} NOSUPERUSER") + + # Create publisher and subscriber nodes with schemas owned and published by + # "regress_alice" but subscribed and replicated by different role + # "regress_admin" and "regress_admin2". For partitioned tables, layout the + # partitions differently on the publisher than on the subscriber. + for node in (node_publisher, node_subscriber): + node.safe_sql( + "CREATE ROLE regress_admin SUPERUSER LOGIN;\n" + "CREATE ROLE regress_admin2 SUPERUSER LOGIN;\n" + "CREATE ROLE regress_alice NOSUPERUSER LOGIN;\n" + "GRANT CREATE ON DATABASE postgres TO regress_alice;\n" + "SET SESSION AUTHORIZATION regress_alice;\n" + "CREATE SCHEMA alice;\n" + "GRANT USAGE ON SCHEMA alice TO regress_admin;\n" + "\n" + "CREATE TABLE alice.unpartitioned (i INTEGER);\n" + "ALTER TABLE alice.unpartitioned REPLICA IDENTITY FULL;\n" + "GRANT SELECT ON TABLE alice.unpartitioned TO regress_admin;\n" + "RESET SESSION AUTHORIZATION;" + ) + + node_publisher.safe_sql( + "SET SESSION AUTHORIZATION regress_alice;\n" + "\n" + "CREATE PUBLICATION alice FOR TABLE alice.unpartitioned\n" + " WITH (publish_via_partition_root = true);\n" + "RESET SESSION AUTHORIZATION;" + ) + + # CREATE SUBSCRIPTION cannot run inside a multi-statement implicit txn, so + # split the SET SESSION AUTHORIZATION from it. Run both on a dedicated + # session and discard it so the role change does not leak into the cached + # session used elsewhere. + admin_sess = node_subscriber.connect() + try: + admin_sess.query_safe("SET SESSION AUTHORIZATION regress_admin") + admin_sess.query_safe( + f"CREATE SUBSCRIPTION admin_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION alice " + "WITH (run_as_owner = true, password_required = false)" + ) + finally: + admin_sess.close() + + # Wait for initial sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "admin_sub") + + # Verify that "regress_admin" can replicate into the tables + publish_insert("alice.unpartitioned", 1) + publish_insert("alice.unpartitioned", 3) + publish_insert("alice.unpartitioned", 5) + publish_update("alice.unpartitioned", 1, 7) + publish_delete("alice.unpartitioned", 3) + expect_replication("alice.unpartitioned", 2, 5, 7, "superuser can replicate") + + # Revoke superuser privilege for "regress_admin", and verify that we now + # fail to replicate an insert. + revoke_superuser("regress_admin") + publish_insert("alice.unpartitioned", 9) + expect_failure( + "alice.unpartitioned", + 2, + 5, + 7, + PERMISSION_DENIED_RE, + "with no privileges cannot replicate", + ) + + # Now grant DML privileges and verify that we can replicate an INSERT. + node_subscriber.safe_sql( + "ALTER ROLE regress_admin NOSUPERUSER;\n" + "SET SESSION AUTHORIZATION regress_alice;\n" + "GRANT INSERT,UPDATE,DELETE ON alice.unpartitioned TO regress_admin;\n" + "REVOKE SELECT ON alice.unpartitioned FROM regress_admin;\n" + "RESET SESSION AUTHORIZATION;" + ) + expect_replication( + "alice.unpartitioned", 3, 5, 9, "with INSERT privilege can replicate INSERT" + ) + + # We can't yet replicate an UPDATE because we don't have SELECT. + publish_update("alice.unpartitioned", 5, 11) + publish_delete("alice.unpartitioned", 9) + expect_failure( + "alice.unpartitioned", + 3, + 5, + 9, + PERMISSION_DENIED_RE, + "without SELECT privilege cannot replicate UPDATE or DELETE", + ) + + # After granting SELECT, replication resumes. + node_subscriber.safe_sql( + "SET SESSION AUTHORIZATION regress_alice;\n" + "GRANT SELECT ON alice.unpartitioned TO regress_admin;\n" + "RESET SESSION AUTHORIZATION;" + ) + expect_replication( + "alice.unpartitioned", 2, 7, 11, "with all privileges can replicate" + ) + + # Remove all privileges again. Instead, give the ability to SET ROLE to + # regress_alice. + node_subscriber.safe_sql( + "SET SESSION AUTHORIZATION regress_alice;\n" + "REVOKE ALL PRIVILEGES ON alice.unpartitioned FROM regress_admin;\n" + "RESET SESSION AUTHORIZATION;\n" + "GRANT regress_alice TO regress_admin WITH INHERIT FALSE, SET TRUE;" + ) + + # Because replication is running as the subscription owner in this test, + # the above grant doesn't help: it gives the ability to SET ROLE, but not + # privileges on the table. + publish_insert("alice.unpartitioned", 13) + expect_failure( + "alice.unpartitioned", + 2, + 7, + 11, + PERMISSION_DENIED_RE, + "with SET ROLE but not INHERIT cannot replicate", + ) + + # Now remove SET ROLE and add INHERIT and check that things start working. + node_subscriber.safe_sql( + "GRANT regress_alice TO regress_admin WITH INHERIT TRUE, SET FALSE;" + ) + expect_replication( + "alice.unpartitioned", 3, 7, 13, "with INHERIT but not SET ROLE can replicate" + ) + + # Similar to the previous test, remove all privileges again and instead, + # give the ability to SET ROLE to regress_alice. + node_subscriber.safe_sql( + "SET SESSION AUTHORIZATION regress_alice;\n" + "REVOKE ALL PRIVILEGES ON alice.unpartitioned FROM regress_admin;\n" + "RESET SESSION AUTHORIZATION;\n" + "GRANT regress_alice TO regress_admin WITH INHERIT FALSE, SET TRUE;" + ) + + # Because replication is running as the subscription owner in this test, + # the above grant doesn't help. + publish_insert("alice.unpartitioned", 14) + expect_failure( + "alice.unpartitioned", + 3, + 7, + 13, + PERMISSION_DENIED_RE, + "with no privileges cannot replicate", + ) + + # Allow the replication to run as table owner and check that things start + # working. + node_subscriber.safe_sql("ALTER SUBSCRIPTION admin_sub SET (run_as_owner = false);") + + expect_replication( + "alice.unpartitioned", + 4, + 7, + 14, + "can replicate after setting run_as_owner to false", + ) + + # Remove the subscrition and truncate the table for the initial data sync + # tests. + # DROP SUBSCRIPTION cannot run inside a transaction block, so it cannot + # share the implicit multi-statement transaction with the TRUNCATE. + node_subscriber.safe_sql("DROP SUBSCRIPTION admin_sub") + node_subscriber.safe_sql("TRUNCATE alice.unpartitioned") + + # Create a new subscription "admin_sub" owned by regress_admin2. It's + # disabled so that we revoke superuser privilege after creation. + admin2_sess = node_subscriber.connect() + try: + admin2_sess.query_safe("SET SESSION AUTHORIZATION regress_admin2") + admin2_sess.query_safe( + f"CREATE SUBSCRIPTION admin_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION alice " + "WITH (run_as_owner = false, password_required = false, " + "copy_data = true, enabled = false)" + ) + finally: + admin2_sess.close() + + # Revoke superuser privilege for "regress_admin2", and give it the + # ability to SET ROLE. Then enable the subscription "admin_sub". + revoke_superuser("regress_admin2") + node_subscriber.safe_sql( + "GRANT regress_alice TO regress_admin2 WITH INHERIT FALSE, SET TRUE;\n" + "ALTER SUBSCRIPTION admin_sub ENABLE;" + ) + + # Because the initial data sync is working as the table owner, all + # data should be copied. + node_subscriber.wait_for_subscription_sync(node_publisher, "admin_sub") + expect_replication( + "alice.unpartitioned", 4, 7, 14, "table owner can do the initial data copy" + ) diff --git a/src/test/subscription/pyt/test_034_temporal.py b/src/test/subscription/pyt/test_034_temporal.py new file mode 100644 index 00000000000..12d04ae73b8 --- /dev/null +++ b/src/test/subscription/pyt/test_034_temporal.py @@ -0,0 +1,714 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Logical replication tests for temporal tables.""" + +# A table can use a temporal PRIMARY KEY or UNIQUE index as its REPLICA +# IDENTITY. This is a GiST index (not B-tree) and its last element uses +# WITHOUT OVERLAPS. That element restricts other rows with overlaps +# semantics instead of equality, but it is always at least as restrictive +# as a normal non-null unique index. Therefore we can still apply logical +# decoding messages to the subscriber. + +import re + + +def _create_tables(node_publisher, node_subscriber): + # create tables on publisher + + node_publisher.safe_sql( + "CREATE TABLE temporal_no_key (id int4range, valid_at daterange, a text)" + ) + + node_publisher.safe_sql( + "CREATE TABLE temporal_pk (id int4range, valid_at daterange, a text, " + "PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))" + ) + + node_publisher.safe_sql( + "CREATE TABLE temporal_unique (id int4range, valid_at daterange, a text, " + "UNIQUE (id, valid_at WITHOUT OVERLAPS))" + ) + + # create tables on subscriber + + node_subscriber.safe_sql( + "CREATE TABLE temporal_no_key (id int4range, valid_at daterange, a text)" + ) + + node_subscriber.safe_sql( + "CREATE TABLE temporal_pk (id int4range, valid_at daterange, a text, " + "PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))" + ) + + node_subscriber.safe_sql( + "CREATE TABLE temporal_unique (id int4range, valid_at daterange, a text, " + "UNIQUE (id, valid_at WITHOUT OVERLAPS))" + ) + + +def _drop_everything(node_publisher, node_subscriber): + node_publisher.safe_sql("DROP TABLE IF EXISTS temporal_no_key") + node_publisher.safe_sql("DROP TABLE IF EXISTS temporal_pk") + node_publisher.safe_sql("DROP TABLE IF EXISTS temporal_unique") + node_publisher.safe_sql("DROP PUBLICATION pub1") + node_subscriber.safe_sql("DROP TABLE IF EXISTS temporal_no_key") + node_subscriber.safe_sql("DROP TABLE IF EXISTS temporal_pk") + node_subscriber.safe_sql("DROP TABLE IF EXISTS temporal_unique") + node_subscriber.safe_sql("DROP SUBSCRIPTION sub1") + + +def _assert_no_replica_identity_error(node, query, op, table): + # Run in-process via sql() and inspect the returned error message; the + # libpq error lacks the "psql::1:" prefix, so match the substance. + res = node.sql(query) + stderr = res.error_message or "" + assert re.search( + rf'ERROR: ( [A-Z0-9]+:)? cannot {op} (from )?table "{table}" because ' + rf"it does not have a replica identity and publishes {op}s", + stderr, + ), stderr + + +def test_034_temporal(create_pg): + # setup + + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + # ################################# + # Test with REPLICA IDENTITY DEFAULT: + # ################################# + + _create_tables(node_publisher, node_subscriber) + + # sync initial data: + + node_publisher.safe_sql( + "INSERT INTO temporal_no_key (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + node_publisher.safe_sql( + "INSERT INTO temporal_pk (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + node_publisher.safe_sql( + "INSERT INTO temporal_unique (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("CREATE PUBLICATION pub1 FOR ALL TABLES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_no_key ORDER BY id, valid_at" + ) + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_no_key DEFAULT" + + result = node_subscriber.safe_sql("SELECT * FROM temporal_pk ORDER BY id, valid_at") + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_pk DEFAULT" + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_unique ORDER BY id, valid_at" + ) + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_unique DEFAULT" + + # replicate with no key: + + node_publisher.safe_sql( + "INSERT INTO temporal_no_key (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + _assert_no_replica_identity_error( + node_publisher, + "UPDATE temporal_no_key SET a = 'b' WHERE id = '[2,3)'", + "update", + "temporal_no_key", + ) + # No need to test again with FOR PORTION OF + + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_no_key WHERE id = '[3,4)'", + "delete", + "temporal_no_key", + ) + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_no_key FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'", + "delete", + "temporal_no_key", + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_no_key ORDER BY id, valid_at" + ) + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2010-01-01)|a\n" + "[3,4)|[2000-01-01,2010-01-01)|a\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_no_key DEFAULT" + + # replicate with a primary key: + + node_publisher.safe_sql( + "INSERT INTO temporal_pk (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("UPDATE temporal_pk SET a = 'b' WHERE id = '[2,3)'") + node_publisher.safe_sql( + "UPDATE temporal_pk FOR PORTION OF valid_at " + "FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'" + ) + + node_publisher.safe_sql("DELETE FROM temporal_pk WHERE id = '[3,4)'") + node_publisher.safe_sql( + "DELETE FROM temporal_pk FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'" + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM temporal_pk ORDER BY id, valid_at") + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2001-01-01)|b\n" + "[2,3)|[2001-01-01,2002-01-01)|c\n" + "[2,3)|[2003-01-01,2010-01-01)|b\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_pk DEFAULT" + + # replicate with a unique key: + + node_publisher.safe_sql( + "INSERT INTO temporal_unique (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + _assert_no_replica_identity_error( + node_publisher, + "UPDATE temporal_unique SET a = 'b' WHERE id = '[2,3)'", + "update", + "temporal_unique", + ) + # No need to test again with FOR PORTION OF + + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_unique WHERE id = '[3,4)'", + "delete", + "temporal_unique", + ) + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_unique FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'", + "delete", + "temporal_unique", + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_unique ORDER BY id, valid_at" + ) + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2010-01-01)|a\n" + "[3,4)|[2000-01-01,2010-01-01)|a\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_unique DEFAULT" + + # cleanup + + _drop_everything(node_publisher, node_subscriber) + + # ################################# + # Test with REPLICA IDENTITY FULL: + # ################################# + + _create_tables(node_publisher, node_subscriber) + + node_publisher.safe_sql("ALTER TABLE temporal_no_key REPLICA IDENTITY FULL") + node_publisher.safe_sql("ALTER TABLE temporal_pk REPLICA IDENTITY FULL") + node_publisher.safe_sql("ALTER TABLE temporal_unique REPLICA IDENTITY FULL") + + node_subscriber.safe_sql("ALTER TABLE temporal_no_key REPLICA IDENTITY FULL") + node_subscriber.safe_sql("ALTER TABLE temporal_pk REPLICA IDENTITY FULL") + node_subscriber.safe_sql("ALTER TABLE temporal_unique REPLICA IDENTITY FULL") + + # sync initial data: + + node_publisher.safe_sql( + "INSERT INTO temporal_no_key (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + node_publisher.safe_sql( + "INSERT INTO temporal_pk (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + node_publisher.safe_sql( + "INSERT INTO temporal_unique (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("CREATE PUBLICATION pub1 FOR ALL TABLES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_no_key ORDER BY id, valid_at" + ) + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_no_key FULL" + + result = node_subscriber.safe_sql("SELECT * FROM temporal_pk ORDER BY id, valid_at") + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_pk FULL" + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_unique ORDER BY id, valid_at" + ) + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_unique FULL" + + # replicate with no key: + + node_publisher.safe_sql( + "INSERT INTO temporal_no_key (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("UPDATE temporal_no_key SET a = 'b' WHERE id = '[2,3)'") + node_publisher.safe_sql( + "UPDATE temporal_no_key FOR PORTION OF valid_at " + "FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'" + ) + + node_publisher.safe_sql("DELETE FROM temporal_no_key WHERE id = '[3,4)'") + node_publisher.safe_sql( + "DELETE FROM temporal_no_key FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'" + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_no_key ORDER BY id, valid_at" + ) + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2001-01-01)|b\n" + "[2,3)|[2001-01-01,2002-01-01)|c\n" + "[2,3)|[2003-01-01,2010-01-01)|b\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_no_key FULL" + + # replicate with a primary key: + + node_publisher.safe_sql( + "INSERT INTO temporal_pk (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("UPDATE temporal_pk SET a = 'b' WHERE id = '[2,3)'") + node_publisher.safe_sql( + "UPDATE temporal_pk FOR PORTION OF valid_at " + "FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'" + ) + + node_publisher.safe_sql("DELETE FROM temporal_pk WHERE id = '[3,4)'") + node_publisher.safe_sql( + "DELETE FROM temporal_pk FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'" + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM temporal_pk ORDER BY id, valid_at") + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2001-01-01)|b\n" + "[2,3)|[2001-01-01,2002-01-01)|c\n" + "[2,3)|[2003-01-01,2010-01-01)|b\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_pk FULL" + + # replicate with a unique key: + + node_publisher.safe_sql( + "INSERT INTO temporal_unique (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("UPDATE temporal_unique SET a = 'b' WHERE id = '[2,3)'") + node_publisher.safe_sql( + "UPDATE temporal_unique FOR PORTION OF valid_at " + "FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'" + ) + + node_publisher.safe_sql("DELETE FROM temporal_unique WHERE id = '[3,4)'") + node_publisher.safe_sql( + "DELETE FROM temporal_unique FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'" + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_unique ORDER BY id, valid_at" + ) + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2001-01-01)|b\n" + "[2,3)|[2001-01-01,2002-01-01)|c\n" + "[2,3)|[2003-01-01,2010-01-01)|b\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_unique FULL" + + # cleanup + + _drop_everything(node_publisher, node_subscriber) + + # ################################# + # Test with REPLICA IDENTITY USING INDEX + # ################################# + + # create tables on publisher + + node_publisher.safe_sql( + "CREATE TABLE temporal_pk (id int4range, valid_at daterange, a text, " + "PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))" + ) + node_publisher.safe_sql( + "ALTER TABLE temporal_pk REPLICA IDENTITY USING INDEX temporal_pk_pkey" + ) + + node_publisher.safe_sql( + "CREATE TABLE temporal_unique (id int4range NOT NULL, " + "valid_at daterange NOT NULL, a text, " + "UNIQUE (id, valid_at WITHOUT OVERLAPS))" + ) + node_publisher.safe_sql( + "ALTER TABLE temporal_unique REPLICA IDENTITY USING INDEX " + "temporal_unique_id_valid_at_key" + ) + + # create tables on subscriber + + node_subscriber.safe_sql( + "CREATE TABLE temporal_pk (id int4range, valid_at daterange, a text, " + "PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))" + ) + node_subscriber.safe_sql( + "ALTER TABLE temporal_pk REPLICA IDENTITY USING INDEX temporal_pk_pkey" + ) + + node_subscriber.safe_sql( + "CREATE TABLE temporal_unique (id int4range NOT NULL, " + "valid_at daterange NOT NULL, a text, " + "UNIQUE (id, valid_at WITHOUT OVERLAPS))" + ) + node_subscriber.safe_sql( + "ALTER TABLE temporal_unique REPLICA IDENTITY USING INDEX " + "temporal_unique_id_valid_at_key" + ) + + # sync initial data: + + node_publisher.safe_sql( + "INSERT INTO temporal_pk (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + node_publisher.safe_sql( + "INSERT INTO temporal_unique (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("CREATE PUBLICATION pub1 FOR ALL TABLES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "sub1") + + result = node_subscriber.safe_sql("SELECT * FROM temporal_pk ORDER BY id, valid_at") + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_pk USING INDEX" + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_unique ORDER BY id, valid_at" + ) + assert ( + result == "[1,2)|[2000-01-01,2010-01-01)|a" + ), "synced temporal_unique USING INDEX" + + # replicate with a primary key: + + node_publisher.safe_sql( + "INSERT INTO temporal_pk (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("UPDATE temporal_pk SET a = 'b' WHERE id = '[2,3)'") + node_publisher.safe_sql( + "UPDATE temporal_pk FOR PORTION OF valid_at " + "FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'" + ) + + node_publisher.safe_sql("DELETE FROM temporal_pk WHERE id = '[3,4)'") + node_publisher.safe_sql( + "DELETE FROM temporal_pk FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'" + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM temporal_pk ORDER BY id, valid_at") + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2001-01-01)|b\n" + "[2,3)|[2001-01-01,2002-01-01)|c\n" + "[2,3)|[2003-01-01,2010-01-01)|b\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_pk USING INDEX" + + # replicate with a unique key: + + node_publisher.safe_sql( + "INSERT INTO temporal_unique (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("UPDATE temporal_unique SET a = 'b' WHERE id = '[2,3)'") + node_publisher.safe_sql( + "UPDATE temporal_unique FOR PORTION OF valid_at " + "FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'" + ) + + node_publisher.safe_sql("DELETE FROM temporal_unique WHERE id = '[3,4)'") + node_publisher.safe_sql( + "DELETE FROM temporal_unique FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'" + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_unique ORDER BY id, valid_at" + ) + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2001-01-01)|b\n" + "[2,3)|[2001-01-01,2002-01-01)|c\n" + "[2,3)|[2003-01-01,2010-01-01)|b\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_unique USING INDEX" + + # cleanup + + _drop_everything(node_publisher, node_subscriber) + + # ################################# + # Test with REPLICA IDENTITY NOTHING + # ################################# + + _create_tables(node_publisher, node_subscriber) + + node_publisher.safe_sql("ALTER TABLE temporal_no_key REPLICA IDENTITY NOTHING") + node_publisher.safe_sql("ALTER TABLE temporal_pk REPLICA IDENTITY NOTHING") + node_publisher.safe_sql("ALTER TABLE temporal_unique REPLICA IDENTITY NOTHING") + + node_subscriber.safe_sql("ALTER TABLE temporal_no_key REPLICA IDENTITY NOTHING") + node_subscriber.safe_sql("ALTER TABLE temporal_pk REPLICA IDENTITY NOTHING") + node_subscriber.safe_sql("ALTER TABLE temporal_unique REPLICA IDENTITY NOTHING") + + # sync initial data: + + node_publisher.safe_sql( + "INSERT INTO temporal_no_key (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + node_publisher.safe_sql( + "INSERT INTO temporal_pk (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + node_publisher.safe_sql( + "INSERT INTO temporal_unique (id, valid_at, a)\n" + " VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')" + ) + + node_publisher.safe_sql("CREATE PUBLICATION pub1 FOR ALL TABLES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_no_key ORDER BY id, valid_at" + ) + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_no_key NOTHING" + + result = node_subscriber.safe_sql("SELECT * FROM temporal_pk ORDER BY id, valid_at") + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_pk NOTHING" + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_unique ORDER BY id, valid_at" + ) + assert result == "[1,2)|[2000-01-01,2010-01-01)|a", "synced temporal_unique NOTHING" + + # replicate with no key: + + node_publisher.safe_sql( + "INSERT INTO temporal_no_key (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + _assert_no_replica_identity_error( + node_publisher, + "UPDATE temporal_no_key SET a = 'b' WHERE id = '[2,3)'", + "update", + "temporal_no_key", + ) + # No need to test again with FOR PORTION OF + + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_no_key WHERE id = '[3,4)'", + "delete", + "temporal_no_key", + ) + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_no_key FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'", + "delete", + "temporal_no_key", + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_no_key ORDER BY id, valid_at" + ) + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2010-01-01)|a\n" + "[3,4)|[2000-01-01,2010-01-01)|a\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_no_key NOTHING" + + # replicate with a primary key: + + node_publisher.safe_sql( + "INSERT INTO temporal_pk (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + _assert_no_replica_identity_error( + node_publisher, + "UPDATE temporal_pk SET a = 'b' WHERE id = '[2,3)'", + "update", + "temporal_pk", + ) + # No need to test again with FOR PORTION OF + + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_pk WHERE id = '[3,4)'", + "delete", + "temporal_pk", + ) + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_pk FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'", + "delete", + "temporal_pk", + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql("SELECT * FROM temporal_pk ORDER BY id, valid_at") + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2010-01-01)|a\n" + "[3,4)|[2000-01-01,2010-01-01)|a\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_pk NOTHING" + + # replicate with a unique key: + + node_publisher.safe_sql( + "INSERT INTO temporal_unique (id, valid_at, a)\n" + " VALUES ('[2,3)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[3,4)', '[2000-01-01,2010-01-01)', 'a'),\n" + " ('[4,5)', '[2000-01-01,2010-01-01)', 'a')" + ) + + _assert_no_replica_identity_error( + node_publisher, + "UPDATE temporal_unique SET a = 'b' WHERE id = '[2,3)'", + "update", + "temporal_unique", + ) + # No need to test again with FOR PORTION OF + + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_unique WHERE id = '[3,4)'", + "delete", + "temporal_unique", + ) + _assert_no_replica_identity_error( + node_publisher, + "DELETE FROM temporal_unique FOR PORTION OF valid_at " + "FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'", + "delete", + "temporal_unique", + ) + + node_publisher.wait_for_catchup("sub1") + + result = node_subscriber.safe_sql( + "SELECT * FROM temporal_unique ORDER BY id, valid_at" + ) + assert result == ( + "[1,2)|[2000-01-01,2010-01-01)|a\n" + "[2,3)|[2000-01-01,2010-01-01)|a\n" + "[3,4)|[2000-01-01,2010-01-01)|a\n" + "[4,5)|[2000-01-01,2010-01-01)|a" + ), "replicated temporal_unique NOTHING" + + # cleanup + + _drop_everything(node_publisher, node_subscriber) diff --git a/src/test/subscription/pyt/test_035_conflicts.py b/src/test/subscription/pyt/test_035_conflicts.py new file mode 100644 index 00000000000..8fb49b638c6 --- /dev/null +++ b/src/test/subscription/pyt/test_035_conflicts.py @@ -0,0 +1,666 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test conflicts in logical replication.""" + +import re + + +def psql_stderr(node, sql): + """Run *sql* on *node* and return the client-side stderr. + + ERROR/WARNING/NOTICE messages emitted by the server are captured here + (notices via the notice processor, the ERROR via the session's last + error). + """ + sess = node.connect() + try: + sess.query(sql) + return sess.get_stderr() + finally: + sess.close() + + +def wait_apply_worker_stopped(node): + """Wait for the logical replication apply worker to stop on *node*.""" + node.poll_query_until( + "SELECT count(*) = 0 FROM pg_stat_activity " + "WHERE backend_type = 'logical replication apply worker'" + ) + + +def test_035_conflicts(create_pg): + ############################### + # Setup + ############################### + + # Create a publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create a subscriber node + node_subscriber = create_pg("subscriber", allows_streaming="logical") + + # Create a table on publisher + node_publisher.safe_sql( + "CREATE TABLE conf_tab (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);" + ) + + node_publisher.safe_sql( + "CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int UNIQUE, c int UNIQUE);" + ) + + # Create same table on subscriber + node_subscriber.safe_sql( + "CREATE TABLE conf_tab (a int PRIMARY key, b int UNIQUE, c int UNIQUE);" + ) + + node_subscriber.safe_sql( + """ + CREATE TABLE conf_tab_2 (a int PRIMARY KEY, b int, c int, unique(a,b)) PARTITION BY RANGE (a); + CREATE TABLE conf_tab_2_p1 PARTITION OF conf_tab_2 FOR VALUES FROM (MINVALUE) TO (100); + """ + ) + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION pub_tab FOR TABLE conf_tab, conf_tab_2") + + # Create the subscription + appname = "sub_tab" + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub_tab " + f"CONNECTION '{publisher_connstr} application_name={appname}' " + "PUBLICATION pub_tab;" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, appname) + + ################################################## + # INSERT data on Pub and Sub + ################################################## + + # Insert data in the publisher table + node_publisher.safe_sql("INSERT INTO conf_tab VALUES (1,1,1);") + + # Insert data in the subscriber table + node_subscriber.safe_sql("INSERT INTO conf_tab VALUES (2,2,2), (3,3,3), (4,4,4);") + + ################################################## + # Test multiple_unique_conflicts due to INSERT + ################################################## + log_offset = node_subscriber.log_position() + + node_publisher.safe_sql("INSERT INTO conf_tab VALUES (2,3,4);") + + # Confirm that this causes an error on the subscriber + node_subscriber.wait_for_log( + r'conflict detected on relation "public.conf_tab": conflict=multiple_unique_conflicts.*\n' + r".*Could not apply remote change: remote row \(2, 3, 4\).*\n" + r'.*Key already exists in unique index "conf_tab_pkey", modified in transaction .*: key \(a\)=\(2\), local row \(2, 2, 2\).*\n' + r'.*Key already exists in unique index "conf_tab_b_key", modified in transaction .*: key \(b\)=\(3\), local row \(3, 3, 3\).*\n' + r'.*Key already exists in unique index "conf_tab_c_key", modified in transaction .*: key \(c\)=\(4\), local row \(4, 4, 4\).', + log_offset, + ) + + # pass('multiple_unique_conflicts detected during insert') + + # Truncate table to get rid of the error + node_subscriber.safe_sql("TRUNCATE conf_tab;") + + ################################################## + # Test multiple_unique_conflicts due to UPDATE + ################################################## + log_offset = node_subscriber.log_position() + + # Insert data in the publisher table + node_publisher.safe_sql("INSERT INTO conf_tab VALUES (5,5,5);") + + # Insert data in the subscriber table + node_subscriber.safe_sql("INSERT INTO conf_tab VALUES (6,6,6), (7,7,7), (8,8,8);") + + node_publisher.safe_sql("UPDATE conf_tab set a=6, b=7, c=8 where a=5;") + + # Confirm that this causes an error on the subscriber + node_subscriber.wait_for_log( + r'conflict detected on relation "public.conf_tab": conflict=multiple_unique_conflicts.*\n' + r".*Could not apply remote change: remote row \(6, 7, 8\), replica identity \(a\)=\(5\).*\n" + r'.*Key already exists in unique index "conf_tab_pkey", modified in transaction .*: key \(a\)=\(6\), local row \(6, 6, 6\).*\n' + r'.*Key already exists in unique index "conf_tab_b_key", modified in transaction .*: key \(b\)=\(7\), local row \(7, 7, 7\).*\n' + r'.*Key already exists in unique index "conf_tab_c_key", modified in transaction .*: key \(c\)=\(8\), local row \(8, 8, 8\).', + log_offset, + ) + + # pass('multiple_unique_conflicts detected during update') + + # Truncate table to get rid of the error + node_subscriber.safe_sql("TRUNCATE conf_tab;") + + ################################################## + # Test multiple_unique_conflicts due to INSERT on a leaf partition + ################################################## + + # Insert data in the subscriber table + node_subscriber.safe_sql("INSERT INTO conf_tab_2 VALUES (55,2,3);") + + # Insert data in the publisher table + node_publisher.safe_sql("INSERT INTO conf_tab_2 VALUES (55,2,3);") + + node_subscriber.wait_for_log( + r'conflict detected on relation "public.conf_tab_2_p1": conflict=multiple_unique_conflicts.*\n' + r".*Could not apply remote change: remote row \(55, 2, 3\).*\n" + r'.*Key already exists in unique index "conf_tab_2_p1_pkey", modified in transaction .*: key \(a\)=\(55\), local row \(55, 2, 3\).*\n' + r'.*Key already exists in unique index "conf_tab_2_p1_a_b_key", modified in transaction .*: key \(a, b\)=\(55, 2\), local row \(55, 2, 3\).', + log_offset, + ) + + # pass('multiple_unique_conflicts detected on a leaf partition during insert') + + ########################################################################### + # Setup a bidirectional logical replication between node_A & node_B + ########################################################################### + + # Initialize nodes. Enable the track_commit_timestamp on both nodes to + # detect the conflict when attempting to update a row that was previously + # modified by a different origin. + + # node_A. Increase the log_min_messages setting to DEBUG2 to debug test + # failures. Disable autovacuum to avoid generating xid that could affect + # the replication slot's xmin value. + node_A = node_publisher + node_A.append_conf( + """track_commit_timestamp = on + autovacuum = off + log_min_messages = 'debug2'""" + ) + node_A.restart() + + # node_B + node_B = node_subscriber + node_B.append_conf("track_commit_timestamp = on") + node_B.restart() + + # Create table on node_A + node_A.safe_sql("CREATE TABLE tab (a int PRIMARY KEY, b int)") + + # Create the same table on node_B + node_B.safe_sql("CREATE TABLE tab (a int PRIMARY KEY, b int)") + + subname_AB = "tap_sub_a_b" + subname_BA = "tap_sub_b_a" + + # Setup logical replication + # node_A (pub) -> node_B (sub) + connstr_A = f"host={node_A.host} port={node_A.port} dbname=postgres" + node_A.safe_sql("CREATE PUBLICATION tap_pub_A FOR TABLE tab") + node_B.safe_sql( + f"CREATE SUBSCRIPTION {subname_BA} " + f"CONNECTION '{connstr_A} application_name={subname_BA}' " + "PUBLICATION tap_pub_A " + "WITH (origin = none, retain_dead_tuples = true)" + ) + + # node_B (pub) -> node_A (sub) + connstr_B = f"host={node_B.host} port={node_B.port} dbname=postgres" + node_B.safe_sql("CREATE PUBLICATION tap_pub_B FOR TABLE tab") + node_A.safe_sql( + f"CREATE SUBSCRIPTION {subname_AB} " + f"CONNECTION '{connstr_B} application_name={subname_AB}' " + "PUBLICATION tap_pub_B " + "WITH (origin = none, copy_data = off)" + ) + + # Wait for initial table sync to finish + node_A.wait_for_subscription_sync(node_B, subname_AB) + node_B.wait_for_subscription_sync(node_A, subname_BA) + + # is(1, 1, 'Bidirectional replication setup is complete') + + # Confirm that the conflict detection slot is created on Node B and the + # xmin value is valid. + assert node_B.poll_query_until( + "SELECT xmin IS NOT NULL from pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the xmin value of slot 'pg_conflict_detection' is valid on Node B" + + ################################################## + # Check that the retain_dead_tuples option can be enabled only for disabled + # subscriptions. Validate the NOTICE message during the subscription DDL, + # and ensure the conflict detection slot is created upon enabling the + # retain_dead_tuples option. + ################################################## + + # Alter retain_dead_tuples for enabled subscription + stderr = psql_stderr( + node_A, + f"ALTER SUBSCRIPTION {subname_AB} SET (retain_dead_tuples = true)", + ) + assert re.search( + r'ERROR: cannot set option "retain_dead_tuples" for enabled subscription', + stderr, + ), "altering retain_dead_tuples is not allowed for enabled subscription" + + # Disable the subscription + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} DISABLE;") + + # Wait for the apply worker to stop + wait_apply_worker_stopped(node_A) + + # Enable retain_dead_tuples for disabled subscription + stderr = psql_stderr( + node_A, + f"ALTER SUBSCRIPTION {subname_AB} SET (retain_dead_tuples = true);", + ) + assert re.search( + r"NOTICE: deleted rows to detect conflicts would not be removed until the subscription is enabled", + stderr, + ), "altering retain_dead_tuples is allowed for disabled subscription" + + # Re-enable the subscription + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} ENABLE;") + + # Confirm that the conflict detection slot is created on Node A and the + # xmin value is valid. + assert node_A.poll_query_until( + "SELECT xmin IS NOT NULL from pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the xmin value of slot 'pg_conflict_detection' is valid on Node A" + + ################################################## + # Check the WARNING when changing the origin to ANY, if retain_dead_tuples + # is enabled. This warns of the possibility of receiving changes from + # origins other than the publisher. + ################################################## + + stderr = psql_stderr(node_A, f"ALTER SUBSCRIPTION {subname_AB} SET (origin = any);") + assert re.search( + r'WARNING: subscription "tap_sub_a_b" enabled retain_dead_tuples but might not reliably detect conflicts for changes from different origins', + stderr, + ), "warn of the possibility of receiving changes from origins other than the publisher" + + # Reset the origin to none + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} SET (origin = none);") + + ########################################################################### + # Check that dead tuples on node A cannot be cleaned by VACUUM until the + # concurrent transactions on Node B have been applied and flushed on Node A. + # Also, check that an update_deleted conflict is detected when updating a + # row that was deleted by a different origin. + ########################################################################### + + # Insert a record + node_A.safe_sql("INSERT INTO tab VALUES (1, 1), (2, 2);") + node_A.wait_for_catchup(subname_BA) + + result = node_B.safe_sql("SELECT * FROM tab;") + assert result == "1|1\n2|2", "check replicated insert on node B" + + # Disable the logical replication from node B to node A + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} DISABLE") + + # Wait for the apply worker to stop + wait_apply_worker_stopped(node_A) + + log_location = node_B.log_position() + + node_B.safe_sql("UPDATE tab SET b = 3 WHERE a = 1;") + node_A.safe_sql("DELETE FROM tab WHERE a = 1;") + + stderr = psql_stderr(node_A, "VACUUM (verbose) public.tab;") + assert re.search( + r"1 are dead but not yet removable", stderr + ), "the deleted column is non-removable" + + # Ensure the DELETE is replayed on Node B + node_A.wait_for_catchup(subname_BA) + + # Check the conflict detected on Node B + logfile = node_B.log_content()[log_location:] + assert re.search( + r'conflict detected on relation "public.tab": conflict=delete_origin_differs.*\n' + r".*DETAIL:.* Deleting the row that was modified locally in transaction [0-9]+ at .*: local row \(1, 3\), replica identity \(a\)=\(1\).", + logfile, + ), "delete target row was modified in tab" + + log_location = node_A.log_position() + + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} ENABLE;") + node_B.wait_for_catchup(subname_AB) + + logfile = node_A.log_content()[log_location:] + assert re.search( + r'conflict detected on relation "public.tab": conflict=update_deleted.*\n' + r".*DETAIL:.* Could not find the row to be updated: remote row \(1, 3\), replica identity \(a\)=\(1\).\n" + r".*The row to be updated was deleted locally in transaction [0-9]+ at .*", + logfile, + ), "update target row was deleted in tab" + + # Remember the next transaction ID to be assigned + next_xid = node_A.safe_sql("SELECT txid_current() + 1;") + + # Confirm that the xmin value is advanced to the latest nextXid. If no + # transactions are running, the apply worker selects nextXid as the + # candidate for the non-removable xid. See GetOldestActiveTransactionId(). + assert node_A.poll_query_until( + f"SELECT xmin = {next_xid} from pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the xmin value of slot 'pg_conflict_detection' is updated on Node A" + + ########################################################################### + # Ensure that the deleted tuple needed to detect an update_deleted conflict + # is accessible via a sequential table scan. + ########################################################################### + + # Drop the primary key from tab on node A and set REPLICA IDENTITY to FULL + # to enforce sequential scanning of the table. + node_A.safe_sql("ALTER TABLE tab REPLICA IDENTITY FULL") + node_B.safe_sql("ALTER TABLE tab REPLICA IDENTITY FULL") + node_A.safe_sql("ALTER TABLE tab DROP CONSTRAINT tab_pkey;") + + # Disable the logical replication from node B to node A + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} DISABLE") + + # Wait for the apply worker to stop + wait_apply_worker_stopped(node_A) + + node_B.safe_sql("UPDATE tab SET b = 4 WHERE a = 2;") + node_A.safe_sql("DELETE FROM tab WHERE a = 2;") + + log_location = node_A.log_position() + + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} ENABLE;") + node_B.wait_for_catchup(subname_AB) + + logfile = node_A.log_content()[log_location:] + assert re.search( + r'conflict detected on relation "public.tab": conflict=update_deleted.*\n' + r".*DETAIL:.* Could not find the row to be updated: remote row \(2, 4\), replica identity full \(2, 2\).*\n" + r".*The row to be updated was deleted locally in transaction [0-9]+ at .*", + logfile, + ), "update target row was deleted in tab" + + ########################################################################### + # Check that the xmin value of the conflict detection slot can be advanced + # when the subscription has no tables. + ########################################################################### + + # Remove the table from the publication + node_B.safe_sql("ALTER PUBLICATION tap_pub_B DROP TABLE tab") + + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} REFRESH PUBLICATION") + + # Remember the next transaction ID to be assigned + next_xid = node_A.safe_sql("SELECT txid_current() + 1;") + + # Confirm that the xmin value is advanced to the latest nextXid. If no + # transactions are running, the apply worker selects nextXid as the + # candidate for the non-removable xid. See GetOldestActiveTransactionId(). + assert node_A.poll_query_until( + f"SELECT xmin = {next_xid} from pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the xmin value of slot 'pg_conflict_detection' is updated on Node A" + + # Re-add the table to the publication for further tests + node_B.safe_sql("ALTER PUBLICATION tap_pub_B ADD TABLE tab") + + node_A.safe_sql( + f"ALTER SUBSCRIPTION {subname_AB} REFRESH PUBLICATION WITH (copy_data = false)" + ) + + ########################################################################### + # Test that publisher's transactions marked with DELAY_CHKPT_IN_COMMIT + # prevent concurrently deleted tuples on the subscriber from being removed. + # This test also acts as a safeguard to prevent developers from moving the + # commit timestamp acquisition before marking DELAY_CHKPT_IN_COMMIT in + # RecordTransactionCommitPrepared. + ########################################################################### + + injection_points_supported = ( + node_B.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + != "0" + ) + + # This test depends on an injection point to block the prepared transaction + # commit after marking DELAY_CHKPT_IN_COMMIT flag. + if injection_points_supported: + node_B.append_conf( + """shared_preload_libraries = 'injection_points' + max_prepared_transactions = 1""" + ) + node_B.restart() + + # Disable the subscription on Node B for testing only one-way + # replication. + node_B.safe_sql(f"ALTER SUBSCRIPTION {subname_BA} DISABLE;") + + # Wait for the apply worker to stop + wait_apply_worker_stopped(node_B) + + # Truncate the table to cleanup existing dead rows in the table. Then + # insert a new row. + node_B.safe_sql( + """ + TRUNCATE tab; + INSERT INTO tab VALUES(1, 1); + """ + ) + + node_B.wait_for_catchup(subname_AB) + + # Create the injection_points extension on the publisher node and + # attach to the commit-after-delay-checkpoint injection point. + node_B.safe_sql( + "CREATE EXTENSION injection_points;" + "SELECT injection_points_attach('commit-after-delay-checkpoint', 'wait');" + ) + + # Start a background session on the publisher node to perform an update + # and pause at the injection point. + pub_session = node_B.connect() + pub_session.do( + "BEGIN", + "UPDATE tab SET b = 2 WHERE a = 1", + "PREPARE TRANSACTION 'txn_with_later_commit_ts'", + ) + # COMMIT PREPARED will block on the injection point + pub_session.do_async("COMMIT PREPARED 'txn_with_later_commit_ts'") + + # Wait until the backend enters the injection point + node_B.wait_for_event("client backend", "commit-after-delay-checkpoint") + + # Confirm the update is suspended + result = node_B.safe_sql("SELECT * FROM tab WHERE a = 1") + assert result == "1|1", "publisher sees the old row" + + # Delete the row on the subscriber. The deleted row should be retained + # due to a transaction on the publisher, which is currently marked with + # the DELAY_CHKPT_IN_COMMIT flag. + node_A.safe_sql("DELETE FROM tab WHERE a = 1;") + + # Get the commit timestamp for the delete + sub_ts = node_A.safe_sql("SELECT timestamp FROM pg_last_committed_xact();") + + log_location = node_A.log_position() + + # Confirm that the apply worker keeps requesting publisher status, while + # awaiting the prepared transaction to commit. Thus, the request log + # should appear more than once. + node_A.wait_for_log(r"sending publisher status request message", log_location) + + log_location = node_A.log_position() + + node_A.wait_for_log(r"sending publisher status request message", log_location) + + # Confirm that the dead tuple cannot be removed + stderr = psql_stderr(node_A, "VACUUM (verbose) public.tab;") + assert re.search( + r"1 are dead but not yet removable", stderr + ), "the deleted column is non-removable" + + log_location = node_A.log_position() + + # Wakeup and detach the injection point on the publisher node. The + # prepared transaction should now commit. + node_B.safe_sql( + "SELECT injection_points_wakeup('commit-after-delay-checkpoint');" + "SELECT injection_points_detach('commit-after-delay-checkpoint');" + ) + + # Wait for the async query to complete and close the background session + pub_session.wait_for_completion() + pub_session.close() + + # Confirm that the transaction committed + result = node_B.safe_sql("SELECT * FROM tab WHERE a = 1") + assert result == "1|2", "publisher sees the new row" + + # Ensure the UPDATE is replayed on subscriber + node_B.wait_for_catchup(subname_AB) + + logfile = node_A.log_content()[log_location:] + assert re.search( + r'conflict detected on relation "public.tab": conflict=update_deleted.*\n' + r".*DETAIL:.* Could not find the row to be updated: remote row \(1, 2\), replica identity full \(1, 1\).*\n" + r".*The row to be updated was deleted locally in transaction [0-9]+ at .*", + logfile, + ), "update target row was deleted in tab" + + # Remember the next transaction ID to be assigned + next_xid = node_A.safe_sql("SELECT txid_current() + 1;") + + # Confirm that the xmin value is advanced to the latest nextXid after + # the prepared transaction on the publisher has been committed. + assert node_A.poll_query_until( + f"SELECT xmin = {next_xid} from pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the xmin value of slot 'pg_conflict_detection' is updated on subscriber" + + # Get the commit timestamp for the publisher's update + pub_ts = node_B.safe_sql( + "SELECT pg_xact_commit_timestamp(xmin) from tab where a=1;" + ) + + # Check that the commit timestamp for the update on the publisher is + # later than or equal to the timestamp of the local deletion, as the + # commit timestamp should be assigned after marking the + # DELAY_CHKPT_IN_COMMIT flag. + result = node_B.safe_sql( + f"SELECT '{pub_ts}'::timestamp >= '{sub_ts}'::timestamp" + ) + assert ( + result == "t" + ), "pub UPDATE's timestamp is later than that of sub's DELETE" + + # Re-enable the subscription for further tests + node_B.safe_sql(f"ALTER SUBSCRIPTION {subname_BA} ENABLE;") + + ########################################################################### + # Check that dead tuple retention stops due to the wait time surpassing + # max_retention_duration. + ########################################################################### + + # Create a physical slot + node_B.safe_sql("SELECT * FROM pg_create_physical_replication_slot('blocker');") + + # Add the inactive physical slot to synchronized_standby_slots + node_B.append_conf("synchronized_standby_slots = 'blocker'") + node_B.reload() + + # Enable failover to activate the synchronized_standby_slots setting + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} DISABLE;") + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} SET (failover = true);") + node_A.safe_sql(f"ALTER SUBSCRIPTION {subname_AB} ENABLE;") + + # Insert a record + node_B.safe_sql("INSERT INTO tab VALUES (5, 5);") + + # Advance the xid on Node A to trigger the next cycle of + # oldest_nonremovable_xid advancement. + node_A.safe_sql("SELECT txid_current() + 1;") + + log_offset = node_A.log_position() + + # Set max_retention_duration to a minimal value to initiate retention stop. + node_A.safe_sql( + f"ALTER SUBSCRIPTION {subname_AB} SET (max_retention_duration = 1);" + ) + + # Confirm that the retention is stopped + node_A.wait_for_log( + r'logical replication worker for subscription "tap_sub_a_b" has stopped retaining the information for detecting conflicts', + log_offset, + ) + + assert node_A.poll_query_until( + "SELECT xmin IS NULL from pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the xmin value of slot 'pg_conflict_detection' is invalid on Node A" + + result = node_A.safe_sql( + f"SELECT subretentionactive FROM pg_subscription WHERE subname='{subname_AB}';" + ) + assert result == "f", "retention is inactive" + + ########################################################################### + # Check that dead tuple retention resumes when the max_retention_duration + # is set 0. + ########################################################################### + + log_offset = node_A.log_position() + + # Set max_retention_duration to 0 + node_A.safe_sql( + f"ALTER SUBSCRIPTION {subname_AB} SET (max_retention_duration = 0);" + ) + + # Drop the physical slot and reset the synchronized_standby_slots setting. + # We change this after setting max_retention_duration to 0, ensuring + # consistent results in the test as the resumption becomes possible + # immediately after resetting synchronized_standby_slots, due to the + # smaller max_retention_duration value of 1ms. + node_B.safe_sql("SELECT * FROM pg_drop_replication_slot('blocker');") + # adjust_conf: reset synchronized_standby_slots to ''. Appending a later + # assignment overrides the earlier one (last value in postgresql.conf + # wins). + node_B.append_conf("synchronized_standby_slots = ''") + node_B.reload() + + # Confirm that the retention resumes + node_A.wait_for_log( + r'logical replication worker for subscription "tap_sub_a_b" will resume retaining the information for detecting conflicts\n' + r".*DETAIL:.* Retention is re-enabled because max_retention_duration has been set to unlimited.*", + log_offset, + ) + + assert node_A.poll_query_until( + "SELECT xmin IS NOT NULL from pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the xmin value of slot 'pg_conflict_detection' is valid on Node A" + + result = node_A.safe_sql( + f"SELECT subretentionactive FROM pg_subscription WHERE subname='{subname_AB}';" + ) + assert result == "t", "retention is active" + + ########################################################################### + # Check that the replication slot pg_conflict_detection is dropped after + # removing all the subscriptions. + ########################################################################### + + node_B.safe_sql(f"DROP SUBSCRIPTION {subname_BA}") + + assert node_B.poll_query_until( + "SELECT count(*) = 0 FROM pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the slot 'pg_conflict_detection' has been dropped on Node B" + + node_A.safe_sql(f"DROP SUBSCRIPTION {subname_AB}") + + assert node_A.poll_query_until( + "SELECT count(*) = 0 FROM pg_replication_slots " + "WHERE slot_name = 'pg_conflict_detection'" + ), "the slot 'pg_conflict_detection' has been dropped on Node A" diff --git a/src/test/subscription/pyt/test_036_sequences.py b/src/test/subscription/pyt/test_036_sequences.py new file mode 100644 index 00000000000..79b8e39920b --- /dev/null +++ b/src/test/subscription/pyt/test_036_sequences.py @@ -0,0 +1,227 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test that sequences are synced correctly to the subscriber.""" + + +def test_036_sequences(create_pg): + # Initialize publisher node + # + # No extra authentication setup is needed to allow connections from + # regress_seq_repl: the framework initdb's with trust auth, which covers + # both the local socket and loopback TCP. + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Initialize subscriber node + node_subscriber = create_pg("subscriber") + + # Setup structure on the publisher + node_publisher.safe_sql( + "CREATE TABLE regress_seq_test (v BIGINT);\n" + "CREATE SEQUENCE regress_s1;\n" + 'CREATE SEQUENCE "regress\'quote";' + ) + + # Setup the same structure on the subscriber, plus some extra sequences + # that we'll create on the publisher later + node_subscriber.safe_sql( + "CREATE TABLE regress_seq_test (v BIGINT);\n" + "CREATE SEQUENCE regress_s1;\n" + "CREATE SEQUENCE regress_s2;\n" + "CREATE SEQUENCE regress_s3;\n" + 'CREATE SEQUENCE "regress\'quote";' + ) + + # Insert initial test data + node_publisher.safe_sql( + "-- generate a number of values using the sequence\n" + "INSERT INTO regress_seq_test SELECT nextval('regress_s1') " + "FROM generate_series(1,100);\n" + "INSERT INTO regress_seq_test SELECT nextval('\"regress''quote\"') " + "FROM generate_series(1,100);" + ) + + # Setup logical replication pub/sub + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION regress_seq_pub FOR ALL SEQUENCES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION regress_seq_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION regress_seq_pub" + ) + + # Wait for initial sync to finish + synced_query = ( + "SELECT count(1) = 0 FROM pg_subscription_rel WHERE srsubstate NOT IN ('r');" + ) + assert node_subscriber.poll_query_until( + synced_query + ), "Timed out while waiting for subscriber to synchronize data" + + # Check the initial data on subscriber + result = node_subscriber.safe_sql("SELECT last_value, is_called FROM regress_s1;") + assert result == "100|t", "initial test data replicated" + + result = node_subscriber.safe_sql( + 'SELECT last_value, is_called FROM "regress\'quote";' + ) + assert ( + result == "100|t" + ), "initial test data replicated for sequence name having quotes" + + ########## + # ALTER SUBSCRIPTION ... REFRESH PUBLICATION should cause sync of new + # sequences of the publisher, but changes to existing sequences should + # not be synced. + ########## + + # Create a new sequence 'regress_s2', and update existing sequence + # 'regress_s1' + node_publisher.safe_sql( + "CREATE SEQUENCE regress_s2;\n" + "INSERT INTO regress_seq_test SELECT nextval('regress_s2') " + "FROM generate_series(1,100);\n" + "-- Existing sequence\n" + "INSERT INTO regress_seq_test SELECT nextval('regress_s1') " + "FROM generate_series(1,100);" + ) + + # Do ALTER SUBSCRIPTION ... REFRESH PUBLICATION + node_subscriber.safe_sql("ALTER SUBSCRIPTION regress_seq_sub REFRESH PUBLICATION;") + assert node_subscriber.poll_query_until( + synced_query + ), "Timed out while waiting for subscriber to synchronize data" + + result = node_publisher.safe_sql("SELECT last_value, is_called FROM regress_s1;") + assert result == "200|t", "Check sequence value in the publisher" + + # Check - existing sequence ('regress_s1') is not synced + result = node_subscriber.safe_sql("SELECT last_value, is_called FROM regress_s1;") + assert result == "100|t", "REFRESH PUBLICATION will not sync existing sequence" + + # Check - newly published sequence ('regress_s2') is synced + result = node_subscriber.safe_sql("SELECT last_value, is_called FROM regress_s2;") + assert result == "100|t", "REFRESH PUBLICATION will sync newly published sequence" + + ########## + # Test: REFRESH SEQUENCES and REFRESH PUBLICATION (copy_data = false) + # + # 1. ALTER SUBSCRIPTION ... REFRESH SEQUENCES should re-synchronize all + # existing sequences, but not synchronize newly added ones. + # 2. ALTER SUBSCRIPTION ... REFRESH PUBLICATION with (copy_data = false) + # should also not update sequence values for newly added sequences. + ########## + + # Create a new sequence 'regress_s3', and update the existing sequence + # 'regress_s2'. + node_publisher.safe_sql( + "CREATE SEQUENCE regress_s3;\n" + "INSERT INTO regress_seq_test SELECT nextval('regress_s3') " + "FROM generate_series(1,100);\n" + "-- Existing sequence\n" + "INSERT INTO regress_seq_test SELECT nextval('regress_s2') " + "FROM generate_series(1,100);" + ) + + # 1. Do ALTER SUBSCRIPTION ... REFRESH SEQUENCES + node_subscriber.safe_sql("ALTER SUBSCRIPTION regress_seq_sub REFRESH SEQUENCES;") + assert node_subscriber.poll_query_until( + synced_query + ), "Timed out while waiting for subscriber to synchronize data" + + # Check - existing sequences ('regress_s1' and 'regress_s2') are synced + result = node_subscriber.safe_sql("SELECT last_value, is_called FROM regress_s1;") + assert result == "200|t", "REFRESH SEQUENCES will sync existing sequences" + result = node_subscriber.safe_sql("SELECT last_value, is_called FROM regress_s2;") + assert result == "200|t", "REFRESH SEQUENCES will sync existing sequences" + + # Check - newly published sequence ('regress_s3') is not synced + result = node_subscriber.safe_sql("SELECT last_value, is_called FROM regress_s3;") + assert result == "1|f", "REFRESH SEQUENCES will not sync newly published sequence" + + # 2. Do ALTER SUBSCRIPTION ... REFRESH PUBLICATION with copy_data as false + node_subscriber.safe_sql( + "ALTER SUBSCRIPTION regress_seq_sub REFRESH PUBLICATION " + "WITH (copy_data = false);" + ) + assert node_subscriber.poll_query_until( + synced_query + ), "Timed out while waiting for subscriber to synchronize data" + + # Check - newly published sequence ('regress_s3') is not synced with + # copy_data as false. + result = node_subscriber.safe_sql("SELECT last_value, is_called FROM regress_s3;") + assert result == "1|f", ( + "REFRESH PUBLICATION will not sync newly published sequence with " + "copy_data as false" + ) + + ########## + # ALTER SUBSCRIPTION ... REFRESH PUBLICATION should report an error when: + # a) sequence definitions differ between the publisher and subscriber, or + # b) a sequence is missing on the publisher. + ########## + + # Create a new sequence 'regress_s4' whose START value is not the same in + # the publisher and subscriber. + node_publisher.safe_sql("CREATE SEQUENCE regress_s4 START 1 INCREMENT 2;") + + node_subscriber.safe_sql("CREATE SEQUENCE regress_s4 START 10 INCREMENT 2;") + + log_offset = node_subscriber.log_position() + + # Do ALTER SUBSCRIPTION ... REFRESH PUBLICATION + node_subscriber.safe_sql("ALTER SUBSCRIPTION regress_seq_sub REFRESH PUBLICATION") + + # Verify that an error is logged for parameter differences on sequence + # ('regress_s4'). + node_subscriber.wait_for_log( + r"WARNING: ( [A-Z0-9]+:)? mismatched or renamed sequence on " + r'subscriber \("public.regress_s4"\)', + log_offset, + ) + + # Verify that an error is logged for the missing sequence ('regress_s4'). + node_publisher.safe_sql("DROP SEQUENCE regress_s4;") + + node_subscriber.wait_for_log( + r"WARNING: ( [A-Z0-9]+:)? missing sequence on publisher " + r'\("public.regress_s4"\)', + log_offset, + ) + + # Recreate regress_s4 so later tests that reuse the subscription do not + # keep reporting the intentionally-missing sequence from the previous test. + node_publisher.safe_sql("CREATE SEQUENCE regress_s4 START 10 INCREMENT 2;") + + ########## + # Ensure that insufficient privileges on the publisher for a sequence do + # not disrupt the subscriber. The subscriber should log a warning and + # continue retrying. + ########## + + node_publisher.safe_sql( + "CREATE ROLE regress_seq_repl LOGIN REPLICATION;\n" + "GRANT USAGE ON SCHEMA public TO regress_seq_repl;\n" + "GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO regress_seq_repl;\n" + "REVOKE ALL ON SEQUENCE regress_s2 FROM regress_seq_repl;" + ) + + publisher_limited_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} " + "dbname=postgres user=regress_seq_repl" + ) + log_offset = node_subscriber.log_position() + + node_subscriber.safe_sql( + "ALTER SUBSCRIPTION regress_seq_sub CONNECTION " + f"'{publisher_limited_connstr}'" + ) + + node_subscriber.safe_sql("ALTER SUBSCRIPTION regress_seq_sub REFRESH SEQUENCES") + + node_subscriber.wait_for_log( + r"WARNING: ( [A-Z0-9]+:)? missing sequence on publisher " + r'\("public.regress_s2"\)', + log_offset, + ) diff --git a/src/test/subscription/pyt/test_037_except.py b/src/test/subscription/pyt/test_037_except.py new file mode 100644 index 00000000000..acb0f058074 --- /dev/null +++ b/src/test/subscription/pyt/test_037_except.py @@ -0,0 +1,265 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Logical replication tests for publications with EXCEPT clause.""" + + +def _test_except_root_partition( + node_publisher, node_subscriber, publisher_connstr, pubviaroot +): + # If the root partitioned table is in the EXCEPT clause, all its + # partitions are excluded from publication, regardless of the + # publish_via_partition_root setting. + node_publisher.safe_sql( + f"CREATE PUBLICATION tap_pub_part FOR ALL TABLES EXCEPT (TABLE root1) " + f"WITH (publish_via_partition_root = {pubviaroot})" + ) + node_publisher.safe_sql("INSERT INTO root1 VALUES (1), (101)") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_part CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub_part" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub_part") + + # Advance the replication slot to ignore changes generated before this + # point. + node_publisher.safe_sql( + "SELECT slot_name FROM pg_replication_slot_advance('test_slot', " + "pg_current_wal_lsn())" + ) + node_publisher.safe_sql("INSERT INTO root1 VALUES (2), (102)") + + # Verify that data inserted into the partitioned table is not published + # when it is in the EXCEPT clause. + node_publisher.safe_sql( + "SELECT count(*) = 0 FROM pg_logical_slot_get_binary_changes(" + "'test_slot', NULL, NULL, 'proto_version', '1', 'publication_names', " + "'tap_pub_part')" + ) + node_publisher.wait_for_catchup("tap_sub_part") + + # Verify that no rows are replicated to subscriber for root or partitions. + for table in ("root1", "part1", "part2", "part2_1"): + result = node_subscriber.safe_sql(f"SELECT count(*) FROM {table}") + assert result == "0", f"no rows replicated to subscriber for {table}" + + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub_part") + node_publisher.safe_sql("DROP PUBLICATION tap_pub_part") + + +def test_037_except(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Initialize subscriber node + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + # ============================================ + # EXCEPT clause test cases for non-partitioned tables and inherited tables. + # ============================================ + + # Create tables on publisher + node_publisher.safe_sql("CREATE TABLE tab1 AS SELECT generate_series(1,10) AS a") + node_publisher.safe_sql("CREATE TABLE parent (a int)") + node_publisher.safe_sql("CREATE TABLE child (b int) INHERITS (parent)") + node_publisher.safe_sql("CREATE TABLE parent1 (a int)") + node_publisher.safe_sql("CREATE TABLE child1 (b int) INHERITS (parent1)") + + # Create tables on subscriber + node_subscriber.safe_sql("CREATE TABLE tab1 (a int)") + node_subscriber.safe_sql("CREATE TABLE parent (a int)") + node_subscriber.safe_sql("CREATE TABLE child (b int) INHERITS (parent)") + node_subscriber.safe_sql("CREATE TABLE parent1 (a int)") + node_subscriber.safe_sql("CREATE TABLE child1 (b int) INHERITS (parent1)") + + # Exclude tab1 (non-inheritance case), and also exclude parent and ONLY + # parent1 to verify exclusion behavior for inherited tables, including the + # effect of ONLY in the EXCEPT clause. + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub FOR ALL TABLES EXCEPT " + "(TABLE tab1, parent, only parent1)" + ) + + # Create a logical replication slot to help with later tests. + node_publisher.safe_sql( + "SELECT pg_create_logical_replication_slot('test_slot', 'pgoutput')" + ) + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Check the table data does not sync for the tables specified in the EXCEPT + # clause. + result = node_subscriber.safe_sql("SELECT count(*) FROM tab1") + assert result == "0", ( + "check there is no initial data copied for the tables specified in " + "the EXCEPT clause" + ) + + # Insert some data into the table listed in the EXCEPT clause + node_publisher.safe_sql("INSERT INTO tab1 VALUES(generate_series(11,20))") + node_publisher.safe_sql( + "INSERT INTO child VALUES(generate_series(11,20), generate_series(11,20))" + ) + + # Verify that data inserted into a table listed in the EXCEPT clause is + # not published. + result = node_publisher.safe_sql( + "SELECT count(*) = 0 FROM pg_logical_slot_get_binary_changes(" + "'test_slot', NULL, NULL, 'proto_version', '1', 'publication_names', " + "'tap_pub')" + ) + assert result == "t", ( + "verify no changes for table listed in the EXCEPT clause are present " + "in the replication slot" + ) + + # This should be published because ONLY parent1 was specified in the + # EXCEPT clause, so the exclusion applies only to the parent table and not + # to its child. + node_publisher.safe_sql( + "INSERT INTO child1 VALUES(generate_series(11,20), generate_series(11,20))" + ) + + # Verify that data inserted into a table listed in the EXCEPT clause is + # not replicated. + node_publisher.wait_for_catchup("tap_sub") + result = node_subscriber.safe_sql("SELECT count(*) FROM tab1") + assert result == "0", "check replicated inserts on subscriber" + result = node_subscriber.safe_sql("SELECT count(*) FROM child") + assert result == "0", "check replicated inserts on subscriber" + result = node_subscriber.safe_sql("SELECT count(*) FROM child1") + assert result == "10", "check replicated inserts on subscriber" + + node_publisher.safe_sql("CREATE TABLE tab2 AS SELECT generate_series(1,10) AS a") + node_subscriber.safe_sql("CREATE TABLE tab2 (a int)") + + # Replace the table list in the EXCEPT clause so that only tab2 is excluded. + node_publisher.safe_sql( + "ALTER PUBLICATION tap_pub SET ALL TABLES EXCEPT (TABLE tab2)" + ) + + # Refresh the subscription so the subscriber picks up the updated + # publication definition and initiates table synchronization. + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION") + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + # Verify that initial table synchronization does not occur for tables + # listed in the EXCEPT clause. + result = node_subscriber.safe_sql("SELECT count(*) FROM tab2") + assert result == "0", ( + "check there is no initial data copied for the tables specified in " + "the EXCEPT clause" + ) + + # Verify that table synchronization now happens for tab1. Table tab1 is + # included now since the table list of EXCEPT clause is only (tab2). + result = node_subscriber.safe_sql("SELECT count(*) FROM tab1") + assert ( + result == "20" + ), "check that the data is copied as the tab1 is removed from EXCEPT clause" + + # cleanup + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + node_subscriber.safe_sql("TRUNCATE TABLE tab1") + node_subscriber.safe_sql("DROP TABLE parent, parent1, child, child1, tab2") + node_publisher.safe_sql("DROP PUBLICATION tap_pub") + node_publisher.safe_sql("TRUNCATE TABLE tab1") + node_publisher.safe_sql("DROP TABLE parent, parent1, child, child1, tab2") + + # ============================================ + # EXCEPT clause test cases for partitioned tables + # ============================================ + # Setup partitioned table and partitions on the publisher that map to + # normal tables on the subscriber. + node_publisher.safe_sql("CREATE TABLE root1(a int) PARTITION BY RANGE(a)") + node_publisher.safe_sql( + "CREATE TABLE part1 PARTITION OF root1 FOR VALUES FROM (0) TO (100)" + ) + node_publisher.safe_sql( + "CREATE TABLE part2 PARTITION OF root1 FOR VALUES FROM (100) TO (200) " + "PARTITION BY RANGE(a)" + ) + node_publisher.safe_sql( + "CREATE TABLE part2_1 PARTITION OF part2 FOR VALUES FROM (100) TO (150)" + ) + + node_subscriber.safe_sql("CREATE TABLE root1(a int)") + node_subscriber.safe_sql("CREATE TABLE part1(a int)") + node_subscriber.safe_sql("CREATE TABLE part2(a int)") + node_subscriber.safe_sql("CREATE TABLE part2_1(a int)") + + # Validate the behaviour with both publish_via_partition_root as true and + # false + _test_except_root_partition( + node_publisher, node_subscriber, publisher_connstr, "false" + ) + _test_except_root_partition( + node_publisher, node_subscriber, publisher_connstr, "true" + ) + + # ============================================ + # Test when a subscription is subscribing to multiple publications + # ============================================ + + # OK when a table is excluded by pub1 EXCEPT clause, but it is included by + # pub2 FOR TABLE. + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub1 FOR ALL TABLES EXCEPT (TABLE tab1)" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub2 FOR TABLE tab1") + node_publisher.safe_sql("INSERT INTO tab1 VALUES(1)") + # use sql() (non-raising) so an error does not abort the test + node_subscriber.sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub1, tap_pub2" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + node_publisher.safe_sql("INSERT INTO tab1 VALUES(2)") + node_publisher.wait_for_catchup("tap_sub") + + result = node_publisher.safe_sql("SELECT * FROM tab1 ORDER BY a") + assert result == "1\n2", ( + "check replication of a table in the EXCEPT clause of one publication " + "but included by another" + ) + node_publisher.safe_sql("DROP PUBLICATION tap_pub2") + node_publisher.safe_sql("TRUNCATE tab1") + node_subscriber.safe_sql("TRUNCATE tab1") + + # OK when a table is excluded by pub1 EXCEPT clause, but it is included by + # pub2 FOR ALL TABLES. + node_publisher.safe_sql("CREATE PUBLICATION tap_pub2 FOR ALL TABLES") + node_publisher.safe_sql("INSERT INTO tab1 VALUES(1)") + # use sql() (non-raising); tap_sub already exists here + node_subscriber.sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub1, tap_pub2" + ) + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + node_publisher.safe_sql("INSERT INTO tab1 VALUES(2)") + node_publisher.wait_for_catchup("tap_sub") + + result = node_publisher.safe_sql("SELECT * FROM tab1 ORDER BY a") + assert result == "1\n2", ( + "check replication of a table in the EXCEPT clause of one publication " + "but included by another" + ) + + node_subscriber.safe_sql("DROP SUBSCRIPTION tap_sub") + node_publisher.safe_sql("DROP PUBLICATION tap_pub1") + node_publisher.safe_sql("DROP PUBLICATION tap_pub2") + + node_publisher.stop("fast") diff --git a/src/test/subscription/pyt/test_038_walsnd_shutdown_timeout.py b/src/test/subscription/pyt/test_038_walsnd_shutdown_timeout.py new file mode 100644 index 00000000000..c6c7c51c604 --- /dev/null +++ b/src/test/subscription/pyt/test_038_walsnd_shutdown_timeout.py @@ -0,0 +1,203 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test publisher shutdown with wal_sender_shutdown_timeout set. + +Checks that the publisher is able to shut down without waiting for sending of +all pending data to the subscriber. +""" + +import os +import re +import signal +import time + +from pypg.util import TIMEOUT_DEFAULT, WINDOWS_OS + +WALSENDER_TIMEOUT_PATTERN = ( + "WARNING: .* terminating walsender process due to replication shutdown timeout" +) + + +def test_038_walsnd_shutdown_timeout(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical", start=False) + node_publisher.append_conf( + """wal_sender_timeout = 1h + wal_sender_shutdown_timeout = 10ms""" + ) + node_publisher.start() + + # Initialize subscriber node + node_subscriber = create_pg("subscriber") + + # Create publication for test table + node_publisher.safe_sql("CREATE TABLE test_tab (id int PRIMARY KEY);") + node_publisher.safe_sql("CREATE PUBLICATION test_pub FOR TABLE test_tab;") + + # Create matching table and subscription on subscriber. These are issued + # as separate statements because the in-process Session would wrap + # a multi-statement string in one implicit transaction, and CREATE + # SUBSCRIPTION (create_slot = true) cannot run inside a transaction block, + # so issue each statement separately. + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} " "dbname=postgres" + ) + node_subscriber.safe_sql("CREATE TABLE test_tab (id int PRIMARY KEY);") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION test_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION test_pub WITH (failover = true);" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "test_sub") + + # Start a background session on the subscriber to run a transaction later + # that will block the logical apply worker on a lock. + sub_session = node_subscriber.connect() + + # Test that when the logical apply worker is blocked on a lock and + # replication is stalled, shutting down the publisher causes the logical + # walsender to exit due to wal_sender_shutdown_timeout, allowing shutdown + # to complete. + + # Cause the logical apply worker to block on a lock by running conflicting + # transactions on the publisher and subscriber. + sub_session.do("BEGIN; INSERT INTO test_tab VALUES (0);") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (0);") + + log_offset = node_publisher.log_position() + + # Verify that the walsender exits due to wal_sender_shutdown_timeout. + node_publisher.stop("fast") + assert node_publisher.log_contains( + WALSENDER_TIMEOUT_PATTERN, log_offset + ), "walsender exits due to wal_sender_shutdown_timeout" + + sub_session.do("ABORT;") + node_publisher.start() + node_publisher.wait_for_catchup("test_sub") + + # Test that when the logical apply worker is blocked on a lock, replication + # is stalled, and the logical walsender's output buffer is full, shutting + # down the publisher causes the walsender to exit due to + # wal_sender_shutdown_timeout, allowing shutdown to complete. + # + # This test differs from the previous one in that the walsender's output + # buffer is full (because pending data cannot be transferred). + + # Run a transaction on the subscriber that blocks the logical apply worker + # on a lock. + sub_session.do("BEGIN; LOCK TABLE test_tab IN EXCLUSIVE MODE;") + + # Generate enough data to fill the logical walsender's output buffer. + node_publisher.safe_sql("INSERT INTO test_tab VALUES (generate_series(1, 20000));") + + # Wait for the logical walsender's output buffer to fill. If the WAL send + # positions do not advance between checks, treat the buffer as full. + last_sent_lsn = node_publisher.safe_sql( + "SELECT sent_lsn FROM pg_stat_replication " + "WHERE application_name = 'test_sub';" + ) + + max_attempts = TIMEOUT_DEFAULT * 10 + while max_attempts >= 0: + max_attempts -= 1 + time.sleep(0.1) + + cur_sent_lsn = node_publisher.safe_sql( + "SELECT sent_lsn FROM pg_stat_replication " + "WHERE application_name = 'test_sub';" + ) + + diff = node_publisher.safe_sql( + f"SELECT pg_wal_lsn_diff('{cur_sent_lsn}', '{last_sent_lsn}');" + ) + if int(diff) == 0: + break + + last_sent_lsn = cur_sent_lsn + + log_offset = node_publisher.log_position() + + # Verify that the walsender exits due to wal_sender_shutdown_timeout. + node_publisher.stop("fast") + assert node_publisher.log_contains( + WALSENDER_TIMEOUT_PATTERN, log_offset + ), "walsender with full output buffer exits due to wal_sender_shutdown_timeout" + + sub_session.do("ABORT;") + + # The remaining scenario stalls physical replication by sending SIGSTOP to + # the standby's walreceiver, which is not portable to Windows; end the test + # here on that platform. + if WINDOWS_OS: + return + + node_publisher.start() + + # Test that wal_sender_shutdown_timeout works correctly when both physical + # and logical replication are active, and slot synchronization is running + # on the standby. + # + # In this scenario, the logical apply worker is blocked on a lock and + # the standby's walreceiver is stopped (via SIGSTOP signal), stalling both + # replication streams. Verify that shutting down the publisher (primary) + # causes both physical and logical walsenders to exit due to + # wal_sender_shutdown_timeout, allowing shutdown to complete. + + # Create the standby with slot synchronization enabled. + node_publisher.backup( + "publisher_backup", + backup_options=[ + "--create-slot", + "--slot", + "test_slot", + "-d", + "dbname=postgres", + "--write-recovery-conf", + ], + ) + + node_publisher.append_conf("synchronized_standby_slots = 'test_slot'") + node_publisher.reload() + + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_publisher, "publisher_backup") + node_standby.append_conf( + """sync_replication_slots = on +hot_standby_feedback = on""" + ) + node_standby.start() + + # Cause the logical apply worker to block on a lock by running conflicting + # transactions on the publisher and subscriber, stalling logical + # replication. + node_publisher.wait_for_catchup("test_sub") + sub_session.do("BEGIN; LOCK TABLE test_tab IN EXCLUSIVE MODE;") + node_publisher.safe_sql("INSERT INTO test_tab VALUES (-1); ") + + # Cause the standby's walreceiver to be blocked with SIGSTOP signal, + # stalling physical replication. + assert node_standby.poll_query_until( + "SELECT EXISTS(SELECT 1 FROM pg_stat_wal_receiver)" + ) + receiverpid = node_standby.safe_sql("SELECT pid FROM pg_stat_wal_receiver") + assert re.match(r"^[0-9]+$", receiverpid), f"have walreceiver pid {receiverpid}" + os.kill(int(receiverpid), signal.SIGSTOP) + + log_offset = node_publisher.log_position() + + # Verify that the walsender exits due to wal_sender_shutdown_timeout + # even when both physical and logical replication are stalled. + node_publisher.safe_sql("INSERT INTO test_tab VALUES (-2);") + node_publisher.stop("fast") + assert node_publisher.log_contains(WALSENDER_TIMEOUT_PATTERN, log_offset), ( + "walsender exits due to wal_sender_shutdown_timeout even when both " + "physical and logical replication are stalled" + ) + + os.kill(int(receiverpid), signal.SIGCONT) + sub_session.close() + + node_subscriber.stop("fast") + node_standby.stop("fast") diff --git a/src/test/subscription/pyt/test_100_bugs.py b/src/test/subscription/pyt/test_100_bugs.py new file mode 100644 index 00000000000..1dbf9fa376f --- /dev/null +++ b/src/test/subscription/pyt/test_100_bugs.py @@ -0,0 +1,596 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Logical replication tests for various bugs found over time.""" + +import re + +from libpq import Session + + +def test_bug_15114_index_predicate_crash(create_pg): + # Bug #15114 + # + # The bug was that determining which columns are part of the replica + # identity index using RelationGetIndexAttrBitmap() would run + # eval_const_expressions() on index expressions and predicates across + # all indexes of the table, which in turn might require a snapshot, + # but there wasn't one set, so it crashes. There were actually two + # separate bugs, one on the publisher and one on the subscriber. The + # fix was to avoid the constant expressions simplification in + # RelationGetIndexAttrBitmap(), so it's safe to call in more contexts. + + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + node_publisher.safe_sql("CREATE TABLE tab1 (a int PRIMARY KEY, b int)") + + node_publisher.safe_sql( + "CREATE FUNCTION double(x int) RETURNS int IMMUTABLE LANGUAGE SQL " + "AS 'select x * 2'" + ) + + # an index with a predicate that lends itself to constant expressions + # evaluation + node_publisher.safe_sql("CREATE INDEX ON tab1 (b) WHERE a > double(1)") + + # and the same setup on the subscriber + node_subscriber.safe_sql("CREATE TABLE tab1 (a int PRIMARY KEY, b int)") + + node_subscriber.safe_sql( + "CREATE FUNCTION double(x int) RETURNS int IMMUTABLE LANGUAGE SQL " + "AS 'select x * 2'" + ) + + node_subscriber.safe_sql("CREATE INDEX ON tab1 (b) WHERE a > double(1)") + + node_publisher.safe_sql("CREATE PUBLICATION pub1 FOR ALL TABLES") + + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + + node_publisher.wait_for_catchup("sub1") + + # This would crash, first on the publisher, and then (if the publisher + # is fixed) on the subscriber. + node_publisher.safe_sql("INSERT INTO tab1 VALUES (1, 2)") + + node_publisher.wait_for_catchup("sub1") + + # pass('index predicates do not cause crash') + + +def test_temp_and_unlogged_tables_for_all_tables(create_pg): + # Handling of temporary and unlogged tables with FOR ALL TABLES + # publications + # + # If a FOR ALL TABLES publication exists, temporary and unlogged + # tables are ignored for publishing changes. The bug was that we + # would still check in that case that such a table has a replica + # identity set before accepting updates. If it did not it would cause + # an error when an update was attempted. + + node_publisher = create_pg("publisher", allows_streaming="logical") + + node_publisher.safe_sql("CREATE PUBLICATION pub FOR ALL TABLES") + + # update to temporary table without replica identity with FOR ALL TABLES + # publication + node_publisher.safe_sql( + "CREATE TEMPORARY TABLE tt1 AS SELECT 1 AS a; UPDATE tt1 SET a = 2;" + ) + + # update to unlogged table without replica identity with FOR ALL TABLES + # publication + node_publisher.safe_sql( + "CREATE UNLOGGED TABLE tu1 AS SELECT 1 AS a; UPDATE tu1 SET a = 2;" + ) + + +def test_bug_16643_initial_sync(create_pg): + # Bug #16643 - https://postgr.es/m/16643-eaadeb2a1a58d28c@postgresql.org + # + # Initial sync doesn't complete; the protocol was not being followed per + # expectations after commit 07082b08cc5d. + node_twoways = create_pg("twoways", allows_streaming="logical") + for db in ("d1", "d2"): + node_twoways.safe_sql(f"CREATE DATABASE {db}") + node_twoways.safe_sql("CREATE TABLE t (f int)", dbname=db) + node_twoways.safe_sql("CREATE TABLE t2 (f int)", dbname=db) + + rows = 3000 + # pg_create_logical_replication_slot cannot run in a transaction that has + # performed writes, so issue each statement separately (psql autocommits + # each statement, but our multi-statement safe_sql is one implicit + # transaction). + node_twoways.safe_sql( + f"INSERT INTO t SELECT * FROM generate_series(1, {rows})", dbname="d1" + ) + node_twoways.safe_sql( + f"INSERT INTO t2 SELECT * FROM generate_series(1, {rows})", dbname="d1" + ) + node_twoways.safe_sql("CREATE PUBLICATION testpub FOR TABLE t", dbname="d1") + node_twoways.safe_sql( + "SELECT pg_create_logical_replication_slot('testslot', 'pgoutput')", dbname="d1" + ) + + d1_connstr = f"host={node_twoways.host} port={node_twoways.port} dbname=d1" + node_twoways.safe_sql( + f"CREATE SUBSCRIPTION testsub CONNECTION '{d1_connstr}' " + "PUBLICATION testpub WITH (create_slot=false, slot_name='testslot')", + dbname="d2", + ) + node_twoways.safe_sql( + f""" + INSERT INTO t SELECT * FROM generate_series(1, {rows}); + INSERT INTO t2 SELECT * FROM generate_series(1, {rows}); + """, + dbname="d1", + ) + node_twoways.safe_sql("ALTER PUBLICATION testpub ADD TABLE t2", dbname="d1") + node_twoways.safe_sql("ALTER SUBSCRIPTION testsub REFRESH PUBLICATION", dbname="d2") + + # We cannot rely solely on wait_for_catchup() here; it isn't sufficient + # when tablesync workers might still be running. So in addition to that, + # verify that tables are synced. + node_twoways.wait_for_subscription_sync(node_twoways, "testsub", "d2") + + result = node_twoways.safe_sql("SELECT count(f) FROM t", dbname="d2") + assert result == str(rows * 2), f"2x{rows} rows in t" + result = node_twoways.safe_sql("SELECT count(f) FROM t2", dbname="d2") + assert result == str(rows * 2), f"2x{rows} rows in t2" + + +def test_cascaded_replication_tablesync(create_pg): + # Verify table data is synced with cascaded replication setup. This is + # mainly to test whether the data written by tablesync worker gets + # replicated. + node_pub = create_pg("testpublisher1", allows_streaming="logical") + node_pub_sub = create_pg("testpublisher_subscriber", allows_streaming="logical") + node_sub = create_pg("testsubscriber1") + + # Create the tables in all nodes. + node_pub.safe_sql("CREATE TABLE tab1 (a int)") + node_pub_sub.safe_sql("CREATE TABLE tab1 (a int)") + node_sub.safe_sql("CREATE TABLE tab1 (a int)") + + # Create a cascaded replication setup like: + # N1 - Create publication testpub1. + # N2 - Create publication testpub2 and also include subscriber which + # subscribes to testpub1. + # N3 - Create subscription testsub2 subscribes to testpub2. + # + # Note that subscription on N3 needs to be created before subscription on + # N2 to test whether the data written by tablesync worker of N2 gets + # replicated. + node_pub.safe_sql("CREATE PUBLICATION testpub1 FOR TABLE tab1") + + node_pub_sub.safe_sql("CREATE PUBLICATION testpub2 FOR TABLE tab1") + + publisher1_connstr = f"host={node_pub.host} port={node_pub.port} dbname=postgres" + publisher2_connstr = ( + f"host={node_pub_sub.host} port={node_pub_sub.port} dbname=postgres" + ) + + node_sub.safe_sql( + f"CREATE SUBSCRIPTION testsub2 CONNECTION '{publisher2_connstr}' " + "PUBLICATION testpub2" + ) + + node_pub_sub.safe_sql( + f"CREATE SUBSCRIPTION testsub1 CONNECTION '{publisher1_connstr}' " + "PUBLICATION testpub1" + ) + + node_pub.safe_sql("INSERT INTO tab1 values(generate_series(1,10))") + + # Verify that the data is cascaded from testpub1 to testsub1 and further + # from testpub2 (which had testsub1) to testsub2. + node_pub.wait_for_catchup("testsub1") + node_pub_sub.wait_for_catchup("testsub2") + + # Drop subscriptions as we don't need them anymore + node_pub_sub.safe_sql("DROP SUBSCRIPTION testsub1") + node_sub.safe_sql("DROP SUBSCRIPTION testsub2") + + # Drop publications as we don't need them anymore + node_pub.safe_sql("DROP PUBLICATION testpub1") + node_pub_sub.safe_sql("DROP PUBLICATION testpub2") + + # Clean up the tables on both publisher and subscriber as we don't need them + node_pub.safe_sql("DROP TABLE tab1") + node_pub_sub.safe_sql("DROP TABLE tab1") + node_sub.safe_sql("DROP TABLE tab1") + + +def test_replica_identity_index_change(create_pg): + # https://postgr.es/m/OS0PR01MB61133CA11630DAE45BC6AD95FB939%40OS0PR01MB6113.jpnprd01.prod.outlook.com + # + # The bug was that when changing the REPLICA IDENTITY INDEX to another one, + # the target table's relcache was not being invalidated. This leads to + # skipping UPDATE/DELETE operations during apply on the subscriber side as + # the columns required to search corresponding rows won't get logged. + + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + node_publisher.safe_sql( + "CREATE TABLE tab_replidentity_index(a int not null, b int not null)" + ) + node_publisher.safe_sql( + "CREATE UNIQUE INDEX idx_replidentity_index_a ON tab_replidentity_index(a)" + ) + node_publisher.safe_sql( + "CREATE UNIQUE INDEX idx_replidentity_index_b ON tab_replidentity_index(b)" + ) + + # use index idx_replidentity_index_a as REPLICA IDENTITY on publisher. + node_publisher.safe_sql( + "ALTER TABLE tab_replidentity_index REPLICA IDENTITY " + "USING INDEX idx_replidentity_index_a" + ) + + node_publisher.safe_sql("INSERT INTO tab_replidentity_index VALUES(1, 1),(2, 2)") + + node_subscriber.safe_sql( + "CREATE TABLE tab_replidentity_index(a int not null, b int not null)" + ) + node_subscriber.safe_sql( + "CREATE UNIQUE INDEX idx_replidentity_index_a ON tab_replidentity_index(a)" + ) + node_subscriber.safe_sql( + "CREATE UNIQUE INDEX idx_replidentity_index_b ON tab_replidentity_index(b)" + ) + # use index idx_replidentity_index_b as REPLICA IDENTITY on subscriber + # because it reflects the future scenario we are testing: changing REPLICA + # IDENTITY INDEX. + node_subscriber.safe_sql( + "ALTER TABLE tab_replidentity_index REPLICA IDENTITY " + "USING INDEX idx_replidentity_index_b" + ) + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql( + "CREATE PUBLICATION tap_pub FOR TABLE tab_replidentity_index" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + result = node_subscriber.safe_sql("SELECT * FROM tab_replidentity_index") + assert result == "1|1\n2|2", "check initial data on subscriber" + + # Set REPLICA IDENTITY to idx_replidentity_index_b on publisher, then run + # UPDATE and DELETE. + node_publisher.safe_sql( + """ + ALTER TABLE tab_replidentity_index REPLICA IDENTITY USING INDEX idx_replidentity_index_b; + UPDATE tab_replidentity_index SET a = -a WHERE a = 1; + DELETE FROM tab_replidentity_index WHERE a = 2; + """ + ) + + node_publisher.wait_for_catchup("tap_sub") + result = node_subscriber.safe_sql("SELECT * FROM tab_replidentity_index") + assert result == "-1|1", "update works with REPLICA IDENTITY" + + +def test_schema_invalidation_by_rename(create_pg): + # Test schema invalidation by renaming the schema + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + # Create tables on publisher + node_publisher.safe_sql("CREATE SCHEMA sch1") + node_publisher.safe_sql("CREATE TABLE sch1.t1 (c1 int)") + + # Create tables on subscriber + node_subscriber.safe_sql("CREATE SCHEMA sch1") + node_subscriber.safe_sql("CREATE TABLE sch1.t1 (c1 int)") + node_subscriber.safe_sql("CREATE SCHEMA sch2") + node_subscriber.safe_sql("CREATE TABLE sch2.t1 (c1 int)") + + # Setup logical replication that will cover t1 under both schema names + node_publisher.safe_sql("CREATE PUBLICATION tap_pub_sch FOR ALL TABLES") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION tap_sub_sch CONNECTION '{publisher_connstr}' " + "PUBLICATION tap_pub_sch" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub_sch") + + # Check what happens to data inserted before and after schema rename + node_publisher.safe_sql( + """begin; +insert into sch1.t1 values(1); +alter schema sch1 rename to sch2; +create schema sch1; +create table sch1.t1(c1 int); +insert into sch1.t1 values(2); +insert into sch2.t1 values(3); +commit;""" + ) + + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub_sch") + + # Subscriber's sch1.t1 should receive the row inserted into the new sch1.t1, + # but not the row inserted into the old sch1.t1 post-rename. + result = node_subscriber.safe_sql("SELECT * FROM sch1.t1") + assert result == "1\n2", "check data in subscriber sch1.t1 after schema rename" + + # Subscriber's sch2.t1 won't have gotten anything yet ... + result = node_subscriber.safe_sql("SELECT * FROM sch2.t1") + assert result == "", "no data yet in subscriber sch2.t1 after schema rename" + + # ... but it should show up after REFRESH. + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub_sch REFRESH PUBLICATION") + + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub_sch") + + result = node_subscriber.safe_sql("SELECT * FROM sch2.t1") + assert result == "1\n3", "check data in subscriber sch2.t1 after schema rename" + + +def test_replica_identity_full_dropped_columns(create_pg): + # The bug was that when the REPLICA IDENTITY FULL is used with dropped + # we fail to apply updates and deletes + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + node_publisher.safe_sql( + """ + CREATE TABLE dropped_cols (a int, b_drop int, c int); + ALTER TABLE dropped_cols REPLICA IDENTITY FULL; + CREATE PUBLICATION pub_dropped_cols FOR TABLE dropped_cols; + -- some initial data + INSERT INTO dropped_cols VALUES (1, 1, 1); + """ + ) + + node_subscriber.safe_sql( + """ + CREATE TABLE dropped_cols (a int, b_drop int, c int); + """ + ) + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub_dropped_cols CONNECTION " + f"'{publisher_connstr}' PUBLICATION pub_dropped_cols" + ) + node_subscriber.wait_for_subscription_sync() + + node_publisher.safe_sql( + """ + ALTER TABLE dropped_cols DROP COLUMN b_drop; + """ + ) + node_subscriber.safe_sql( + """ + ALTER TABLE dropped_cols DROP COLUMN b_drop; + """ + ) + + node_publisher.safe_sql( + """ + UPDATE dropped_cols SET a = 100; + """ + ) + node_publisher.wait_for_catchup("sub_dropped_cols") + + result = node_subscriber.safe_sql("SELECT count(*) FROM dropped_cols WHERE a = 100") + assert result == "1", "replication with RI FULL and dropped columns" + + +def test_pgoutput_missing_attributes_and_slot_drop(create_pg): + # The bug was that pgoutput was incorrectly replacing missing attributes in + # tuples with NULL. This could result in incorrect replication with + # `REPLICA IDENTITY FULL`. + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + # Set up a table with schema `(a int, b bool)` where the `b` attribute is + # missing for one row due to the `ALTER TABLE ... ADD COLUMN ... DEFAULT` + # fast path. + node_publisher.safe_sql( + """ + CREATE TABLE tab_default (a int); + ALTER TABLE tab_default REPLICA IDENTITY FULL; + INSERT INTO tab_default VALUES (1); + ALTER TABLE tab_default ADD COLUMN b bool DEFAULT false NOT NULL; + INSERT INTO tab_default VALUES (2, true); + CREATE PUBLICATION pub1 FOR TABLE tab_default; + """ + ) + + # Replicate to the subscriber. CREATE SUBSCRIPTION (create_slot=true) + # cannot run inside a transaction block, so split it from the CREATE TABLE. + node_subscriber.safe_sql("CREATE TABLE tab_default (a int, b bool)") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION sub1 CONNECTION '{publisher_connstr}' " "PUBLICATION pub1" + ) + + node_subscriber.wait_for_subscription_sync(node_publisher, "sub1") + result = node_subscriber.safe_sql("SELECT a, b FROM tab_default") + assert result == "1|f\n2|t", "check snapshot on subscriber" + + # Update all rows in the table and ensure the rows with the missing `b` + # attribute replicate correctly. + node_publisher.safe_sql("UPDATE tab_default SET a = a + 1") + node_publisher.wait_for_catchup("sub1") + + # When the bug is present, the `1|f` row will not be updated to `2|f` + # because the publisher incorrectly fills in `NULL` for `b` and publishes + # an update for `1|NULL`, which doesn't exist in the subscriber. + result = node_subscriber.safe_sql("SELECT a, b FROM tab_default") + assert result == "2|f\n3|t", "check replicated update on subscriber" + + # Test create and immediate drop of replication slot via replication + # commands (this exposed a memory-management bug in v18) + publisher_host = node_publisher.host + publisher_port = node_publisher.port + connstr_db = ( + f"host={publisher_host} port={publisher_port} " + "replication=database dbname=postgres" + ) + + sess = Session(connstr=connstr_db, libdir=node_publisher.libdir) + try: + res = sess.query( + "CREATE_REPLICATION_SLOT test_slot LOGICAL pgoutput (SNAPSHOT export)" + ) + assert ( + res.error_message is None + ), f"create replication slot: {res.error_message}" + res = sess.query("DROP_REPLICATION_SLOT test_slot") + assert res.error_message is None, f"drop replication slot: {res.error_message}" + finally: + sess.close() + # 'create and immediate drop of replication slot' + + +def test_apply_worker_origin_advance_after_exception(create_pg): + # The bug was that when an ERROR was caught and handled by a (PL/pgSQL) + # function, the apply worker reset the replication origin but continued + # processing subsequent changes. So, we fail to update the replication + # origin during further apply operations. This can lead to the apply + # worker requesting the changes that have been applied again after + # restarting. + node_publisher = create_pg("publisher", allows_streaming="logical") + node_subscriber = create_pg("subscriber") + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + + # Set up a publication with a table + node_publisher.safe_sql( + """ + CREATE TABLE t1 (a int); + CREATE PUBLICATION regress_pub FOR TABLE t1; + """ + ) + + # Set up a subscription which subscribes the publication. CREATE + # SUBSCRIPTION (create_slot=true) cannot run inside a transaction block, so + # split it from the CREATE TABLE. + node_subscriber.safe_sql("CREATE TABLE t1 (a int)") + node_subscriber.safe_sql( + f"CREATE SUBSCRIPTION regress_sub CONNECTION '{publisher_connstr}' " + "PUBLICATION regress_pub" + ) + + node_subscriber.wait_for_subscription_sync(node_publisher, "regress_sub") + + # Create an AFTER INSERT trigger on the table that raises and subsequently + # handles an exception. Subsequent insertions will trigger this exception, + # causing the apply worker to invoke its error callback with an ERROR. + # However, since the error is caught within the trigger, the apply worker + # will continue processing changes. + node_subscriber.safe_sql( + r""" +CREATE FUNCTION handle_exception_trigger() +RETURNS TRIGGER AS $$ +BEGIN + BEGIN + -- Raise an exception + RAISE EXCEPTION 'This is a test exception'; + EXCEPTION + WHEN OTHERS THEN + RETURN NEW; + END; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER silent_exception_trigger +AFTER INSERT OR UPDATE ON t1 +FOR EACH ROW +EXECUTE FUNCTION handle_exception_trigger(); + +ALTER TABLE t1 ENABLE ALWAYS TRIGGER silent_exception_trigger; +""" + ) + + # Obtain current remote_lsn value to check its advancement later + remote_lsn = node_subscriber.safe_sql( + "SELECT remote_lsn FROM pg_replication_origin_status os, " + "pg_subscription s WHERE os.external_id = 'pg_' || s.oid " + "AND s.subname = 'regress_sub'" + ) + + # Insert a tuple to replicate changes + node_publisher.safe_sql("INSERT INTO t1 VALUES (1);") + node_publisher.wait_for_catchup("regress_sub") + + # Confirms the origin can be advanced + result = node_subscriber.safe_sql( + f"SELECT remote_lsn > '{remote_lsn}' FROM " + "pg_replication_origin_status os, pg_subscription s " + "WHERE os.external_id = 'pg_' || s.oid AND s.subname = 'regress_sub'" + ) + assert ( + result == "t" + ), "remote_lsn has advanced for apply worker raising an exception" + + +def test_bug_18988_drop_subscription_deadlock(create_pg): + # BUG #18988 + # The bug happened due to a self-deadlock between the DROP SUBSCRIPTION + # command and the walsender process for accessing pg_subscription. This + # occurred when DROP SUBSCRIPTION attempted to remove a replication slot by + # connecting to a newly created database whose caches are not yet + # initialized. + # + # The bug is fixed by reducing the lock-level during DROP SUBSCRIPTION. + node_publisher = create_pg("publisher", allows_streaming="logical") + + # A publication is referenced by the (non-connecting) subscription. + node_publisher.safe_sql( + "CREATE TABLE t1 (a int); CREATE PUBLICATION regress_pub FOR TABLE t1" + ) + + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} " "dbname=regress_db" + ) + # CREATE DATABASE cannot run inside a transaction block, so split it from + # the CREATE SUBSCRIPTION. + node_publisher.safe_sql("CREATE DATABASE regress_db") + node_publisher.safe_sql( + f"CREATE SUBSCRIPTION regress_sub1 CONNECTION '{publisher_connstr}' " + "PUBLICATION regress_pub WITH (connect=false)" + ) + + res = node_publisher.sql("DROP SUBSCRIPTION regress_sub1") + + assert ( + res.error_message is not None + ), "replication slot does not exist: exit code not 0" + assert re.search( + r'ERROR: could not drop replication slot "regress_sub1" on publisher', + res.error_message, + ), "could not drop replication slot: error message" + + node_publisher.safe_sql("DROP DATABASE regress_db") From 4bc772fc32143dc105e389079391838fe62ddeb2 Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:07 -0400 Subject: [PATCH 11/18] python tests: pytest suites for contrib modules auto_explain, basebackup_to_shell, bloom, dblink, oid2name, pg_prewarm, pg_stash_advice, pg_stat_statements, pg_visibility, test_decoding, vacuumlo, postgres_fdw and sepgsql. --- contrib/auto_explain/meson.build | 5 + .../auto_explain/pyt/test_001_auto_explain.py | 265 +++++++++++++++++ contrib/basebackup_to_shell/meson.build | 7 + .../basebackup_to_shell/pyt/test_001_basic.py | 149 ++++++++++ contrib/bloom/meson.build | 5 + contrib/bloom/pyt/test_001_wal.py | 87 ++++++ contrib/dblink/meson.build | 5 + contrib/dblink/pyt/test_001_auth_scram.py | 280 ++++++++++++++++++ contrib/oid2name/meson.build | 5 + contrib/oid2name/pyt/test_001_basic.py | 9 + contrib/pg_prewarm/meson.build | 5 + contrib/pg_prewarm/pyt/test_001_basic.py | 102 +++++++ contrib/pg_stash_advice/meson.build | 5 + .../pg_stash_advice/pyt/test_001_persist.py | 94 ++++++ contrib/pg_stat_statements/meson.build | 5 + .../pyt/test_010_restart.py | 47 +++ contrib/pg_visibility/meson.build | 6 + .../pyt/test_001_concurrent_transaction.py | 52 ++++ .../pg_visibility/pyt/test_002_corrupt_vm.py | 94 ++++++ contrib/postgres_fdw/meson.build | 6 + .../postgres_fdw/pyt/test_001_auth_scram.py | 219 ++++++++++++++ .../postgres_fdw/pyt/test_010_subscription.py | 94 ++++++ contrib/sepgsql/meson.build | 5 + contrib/sepgsql/pyt/test_001_sepgsql.py | 257 ++++++++++++++++ contrib/test_decoding/meson.build | 5 + .../test_decoding/pyt/test_001_repl_stats.py | 170 +++++++++++ contrib/vacuumlo/meson.build | 5 + contrib/vacuumlo/pyt/test_001_basic.py | 9 + 28 files changed, 1997 insertions(+) create mode 100644 contrib/auto_explain/pyt/test_001_auto_explain.py create mode 100644 contrib/basebackup_to_shell/pyt/test_001_basic.py create mode 100644 contrib/bloom/pyt/test_001_wal.py create mode 100644 contrib/dblink/pyt/test_001_auth_scram.py create mode 100644 contrib/oid2name/pyt/test_001_basic.py create mode 100644 contrib/pg_prewarm/pyt/test_001_basic.py create mode 100644 contrib/pg_stash_advice/pyt/test_001_persist.py create mode 100644 contrib/pg_stat_statements/pyt/test_010_restart.py create mode 100644 contrib/pg_visibility/pyt/test_001_concurrent_transaction.py create mode 100644 contrib/pg_visibility/pyt/test_002_corrupt_vm.py create mode 100644 contrib/postgres_fdw/pyt/test_001_auth_scram.py create mode 100644 contrib/postgres_fdw/pyt/test_010_subscription.py create mode 100644 contrib/sepgsql/pyt/test_001_sepgsql.py create mode 100644 contrib/test_decoding/pyt/test_001_repl_stats.py create mode 100644 contrib/vacuumlo/pyt/test_001_basic.py diff --git a/contrib/auto_explain/meson.build b/contrib/auto_explain/meson.build index d2b0650af1c..24b4e35eb9b 100644 --- a/contrib/auto_explain/meson.build +++ b/contrib/auto_explain/meson.build @@ -31,4 +31,9 @@ tests += { 't/001_auto_explain.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_auto_explain.py', + ], + }, } diff --git a/contrib/auto_explain/pyt/test_001_auto_explain.py b/contrib/auto_explain/pyt/test_001_auto_explain.py new file mode 100644 index 00000000000..f7633692dbf --- /dev/null +++ b/contrib/auto_explain/pyt/test_001_auto_explain.py @@ -0,0 +1,265 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that auto_explain logs plans for executed statements.""" + +import re + +import libpq +import pypg + + +def query_log(node, sql, params=None, user=None): + """Run *sql* and return the server log emitted while it ran. + + *params* is an optional dict mapping GUC names to values; any such + settings are transmitted to the backend via the connection "options" + keyword. *user*, if given, connects as that role. + """ + params = params or {} + + connstr = node.connstr("postgres") + if params: + opts = " ".join(f"-c {name}={value}" for name, value in params.items()) + connstr += f" options='{opts}'" + if user is not None: + connstr += f" user='{user}'" + + offset = node.log_position() + + # A fresh session each time, so the connection-time options take effect. + sess = libpq.Session(connstr=connstr, libdir=node.libdir) + try: + # Send each ';'-terminated statement as a separate simple query, so + # auto_explain logs each statement's own query text (sending the whole + # buffer as one query would log the concatenated text). + for stmt in (s.strip() for s in sql.split(";")): + if stmt: + # Keep the ';' so the logged query text includes the statement + # terminator. + sess.query_safe(stmt + ";") + finally: + sess.close() + + return pypg.util.slurp_file(node.logfile, offset) + + +def test_auto_explain(create_pg): + # create_pg(start=False) runs initdb but does not start the server. The + # cluster is initialized with trust auth, so regress_user1 can connect + # without any extra setup. + node = create_pg("main", start=False) + node.append_conf("session_preload_libraries = 'pg_overexplain,auto_explain'") + node.append_conf("auto_explain.log_min_duration = 0") + node.append_conf("auto_explain.log_analyze = on") + node.start() + + # Simple query. + log_contents = query_log(node, "SELECT * FROM pg_class;") + + assert re.search( + r"Query Text: SELECT \* FROM pg_class;", log_contents + ), "query text logged, text mode" + + assert not re.search( + r"Query Parameters:", log_contents + ), "no query parameters logged when none, text mode" + + assert re.search( + r"Seq Scan on pg_class", log_contents + ), "sequential scan logged, text mode" + + # Prepared query. + log_contents = query_log( + node, + "PREPARE get_proc(name) AS SELECT * FROM pg_proc WHERE proname = $1;" + " EXECUTE get_proc('int4pl');", + ) + + assert re.search( + r"Query Text: PREPARE get_proc\(name\) AS SELECT \* FROM pg_proc" + r" WHERE proname = \$1;", + log_contents, + ), "prepared query text logged, text mode" + + assert re.search( + r"Query Parameters: \$1 = 'int4pl'", log_contents + ), "query parameters logged, text mode" + + assert re.search( + r"Index Scan using pg_proc_proname_args_nsp_index on pg_proc", + log_contents, + ), "index scan logged, text mode" + + # Prepared query with truncated parameters. + log_contents = query_log( + node, + "PREPARE get_type(name) AS SELECT * FROM pg_type WHERE typname = $1;" + " EXECUTE get_type('float8');", + {"auto_explain.log_parameter_max_length": 3}, + ) + + assert re.search( + r"Query Text: PREPARE get_type\(name\) AS SELECT \* FROM pg_type" + r" WHERE typname = \$1;", + log_contents, + ), "prepared query text logged, text mode" + + assert re.search( + r"Query Parameters: \$1 = 'flo\.\.\.'", log_contents + ), "query parameters truncated, text mode" + + # Prepared query with parameter logging disabled. + log_contents = query_log( + node, + "PREPARE get_type(name) AS SELECT * FROM pg_type WHERE typname = $1;" + " EXECUTE get_type('float8');", + {"auto_explain.log_parameter_max_length": 0}, + ) + + assert re.search( + r"Query Text: PREPARE get_type\(name\) AS SELECT \* FROM pg_type" + r" WHERE typname = \$1;", + log_contents, + ), "prepared query text logged, text mode" + + assert not re.search( + r"Query Parameters:", log_contents + ), "query parameters not logged when disabled, text mode" + + # Query Identifier. + # Logging enabled. + log_contents = query_log( + node, + "SELECT * FROM pg_class;", + { + "auto_explain.log_verbose": "on", + "compute_query_id": "on", + }, + ) + + assert re.search( + r"Query Identifier:", log_contents + ), "query identifier logged with compute_query_id=on, text mode" + + # Logging disabled. + log_contents = query_log( + node, + "SELECT * FROM pg_class;", + { + "auto_explain.log_verbose": "on", + "compute_query_id": "regress", + }, + ) + + assert not re.search( + r"Query Identifier:", log_contents + ), "query identifier not logged with compute_query_id=regress, text mode" + + # JSON format. + log_contents = query_log( + node, + "SELECT * FROM pg_class;", + {"auto_explain.log_format": "json"}, + ) + + assert re.search( + r'"Query Text": "SELECT \* FROM pg_class;"', log_contents + ), "query text logged, json mode" + + assert not re.search( + r'"Query Parameters":', log_contents + ), "query parameters not logged when none, json mode" + + assert re.search( + r'"Node Type": "Seq Scan"[^}]*"Relation Name": "pg_class"', + log_contents, + re.DOTALL, + ), "sequential scan logged, json mode" + + # Prepared query in JSON format. + log_contents = query_log( + node, + "PREPARE get_class(name) AS SELECT * FROM pg_class WHERE relname = $1;" + " EXECUTE get_class('pg_class');", + {"auto_explain.log_format": "json"}, + ) + + assert re.search( + r'"Query Text": "PREPARE get_class\(name\) AS SELECT \* FROM pg_class' + r' WHERE relname = \$1;"', + log_contents, + ), "prepared query text logged, json mode" + + assert re.search( + r'"Node Type": "Index Scan"[^}]*"Index Name": "pg_class_relname_nsp_index"', + log_contents, + re.DOTALL, + ), "index scan logged, json mode" + + # Extension options. + log_contents = query_log( + node, + "SELECT 1;", + {"auto_explain.log_extension_options": "debug"}, + ) + + assert re.search( + r"Parallel Safe:", log_contents + ), "extension option produces per-node output" + + assert re.search( + r"Command Type: select", log_contents + ), "extension option produces per-plan output" + + # Check that PGC_SUSET parameters can be set by non-superuser if granted, + # otherwise not + node.safe_sql( + "CREATE USER regress_user1;" + " GRANT SET ON PARAMETER auto_explain.log_format TO regress_user1;" + ) + + # queries run as regress_user1 + log_contents = query_log( + node, + "SELECT * FROM pg_database;", + {"auto_explain.log_format": "json"}, + user="regress_user1", + ) + + assert re.search( + r'"Query Text": "SELECT \* FROM pg_database;"', log_contents + ), "query text logged, json mode selected by non-superuser" + + log_contents = query_log( + node, + "SELECT * FROM pg_database;", + {"auto_explain.log_level": "log"}, + user="regress_user1", + ) + + assert re.search( + r"WARNING: ( 42501:)? permission denied to set parameter" + r' "auto_explain\.log_level"', + log_contents, + ), "permission failure logged" + # end queries run as regress_user1 + + node.safe_sql( + "REVOKE SET ON PARAMETER auto_explain.log_format FROM regress_user1;" + " DROP USER regress_user1;" + ) + + # Test pg_get_loaded_modules() function. This function is particularly + # useful for modules with no SQL presence, such as auto_explain. + res = node.safe_sql( + "SELECT module_name," + " version = current_setting('server_version') as version_ok," + " regexp_replace(file_name, '\\..*', '') as file_name_stripped" + " FROM pg_get_loaded_modules()" + " WHERE module_name = 'auto_explain';" + ) + assert re.search( + r"^auto_explain\|t\|auto_explain$", res + ), "pg_get_loaded_modules() ok" + + node.stop() diff --git a/contrib/basebackup_to_shell/meson.build b/contrib/basebackup_to_shell/meson.build index eb23a9fec81..56a1e11d20b 100644 --- a/contrib/basebackup_to_shell/meson.build +++ b/contrib/basebackup_to_shell/meson.build @@ -27,4 +27,11 @@ tests += { 'env': {'GZIP_PROGRAM': gzip.found() ? gzip.full_path() : '', 'TAR': tar.found() ? tar.full_path() : '' }, }, + 'pytest': { + 'env': {'GZIP_PROGRAM': gzip.found() ? gzip.full_path() : '', + 'TAR': tar.found() ? tar.full_path() : '' }, + 'tests': [ + 'pyt/test_001_basic.py', + ], + }, } diff --git a/contrib/basebackup_to_shell/pyt/test_001_basic.py b/contrib/basebackup_to_shell/pyt/test_001_basic.py new file mode 100644 index 00000000000..076c87c5189 --- /dev/null +++ b/contrib/basebackup_to_shell/pyt/test_001_basic.py @@ -0,0 +1,149 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test the basebackup_to_shell module: streaming a base backup to a shell command.""" + +import os +import subprocess + +import pytest + +# For nearly all pg_basebackup invocations some options should be specified, +# to keep test times reasonable. Used as the leading elements of the argument +# list passed to the node command_* helpers. +PG_BASEBACKUP_DEFS = ["pg_basebackup", "--no-sync", "--checkpoint", "fast"] + + +def test_001_basic(create_pg, tmp_path): + # For testing purposes, we just want basebackup_to_shell to write standard + # input to a file. We use gzip for this, and skip when gzip is not + # available. + gzip = os.environ.get("GZIP_PROGRAM") + if not gzip: + pytest.skip("gzip not available") + # The command is stored in postgresql.conf and run by the server; use + # forward slashes so backslashes in the Windows path are not mangled. + gzip = gzip.replace("\\", "/") + + # allows_streaming=True sets up postgresql.conf for replication. The + # cluster uses trust auth, so backupuser can connect without extra + # pg_hba setup. + node = create_pg("primary", start=False, allows_streaming=True) + + node.append_conf("shared_preload_libraries = 'basebackup_to_shell'") + node.start() + node.safe_sql("CREATE USER backupuser REPLICATION") + node.safe_sql("CREATE ROLE trustworthy") + + # This particular test module generally wants to run with --wal-method + # fetch, because stream is not supported with a backup target, and with + # -U backupuser. + pg_basebackup_cmd = PG_BASEBACKUP_DEFS + [ + "--username", + "backupuser", + "--wal-method", + "fetch", + ] + + # Can't use this module without setting basebackup_to_shell.command. + node.command_fails_like( + pg_basebackup_cmd + ["--target", "shell"], + r"shell command for backup is not configured", + "fails if basebackup_to_shell.command is not set", + ) + + # Configure basebackup_to_shell.command and reload the configuration file. + backup_path = str(tmp_path / "backup") + os.mkdir(backup_path) + backup_path_fwd = backup_path.replace("\\", "/") + shell_command = f'"{gzip}" --fast > "{backup_path_fwd}/%f.gz"' + node.append_conf(f"basebackup_to_shell.command='{shell_command}'") + node.reload() + + # Should work now. + node.command_ok( + pg_basebackup_cmd + ["--target", "shell"], + "backup with no detail: pg_basebackup", + ) + _verify_backup(node, gzip, "", backup_path, tmp_path, "backup with no detail") + + # Should fail with a detail. + node.command_fails_like( + pg_basebackup_cmd + ["--target", "shell:foo"], + r"a target detail is not permitted because the configured command " + r"does not include %d", + "fails if detail provided without %d", + ) + + # Reconfigure to restrict access and require a detail. + shell_command = f'"{gzip}" --fast > "{backup_path_fwd}/%d.%f.gz"' + node.append_conf(f"basebackup_to_shell.command='{shell_command}'") + node.append_conf("basebackup_to_shell.required_role='trustworthy'") + node.reload() + + # Should fail due to lack of permission. + node.command_fails_like( + pg_basebackup_cmd + ["--target", "shell"], + r"permission denied to use basebackup_to_shell", + "fails if required_role not granted", + ) + + # Should fail due to lack of a detail. + node.safe_sql("GRANT trustworthy TO backupuser") + node.command_fails_like( + pg_basebackup_cmd + ["--target", "shell"], + "a target detail is required because the configured command includes %d", + "fails if %d is present and detail not given", + ) + + # Should work. + node.command_ok( + pg_basebackup_cmd + ["--target", "shell:bar"], + "backup with detail: pg_basebackup", + ) + _verify_backup(node, gzip, "bar.", backup_path, tmp_path, "backup with detail") + + +def _verify_backup(node, gzip, prefix, backup_dir, tmp_path, test_name): + """Verify that a gzipped base backup and manifest were created and are valid.""" + assert os.path.isfile( + os.path.join(backup_dir, f"{prefix}backup_manifest.gz") + ), f"{test_name}: backup_manifest.gz was created" + assert os.path.isfile( + os.path.join(backup_dir, f"{prefix}base.tar.gz") + ), f"{test_name}: base.tar.gz was created" + + tar = os.environ.get("TAR") + if not tar: + print("# no tar program available") + return + + # Decompress. + subprocess.run( + [gzip, "-d", os.path.join(backup_dir, f"{prefix}backup_manifest.gz")], + check=True, + ) + subprocess.run( + [gzip, "-d", os.path.join(backup_dir, f"{prefix}base.tar.gz")], + check=True, + ) + + # Untar. + extract_path = str(tmp_path / f"extract_{prefix or 'nodetail'}") + os.mkdir(extract_path) + subprocess.run( + [tar, "xf", os.path.join(backup_dir, f"{prefix}base.tar"), "-C", extract_path], + check=True, + ) + + # Verify. + node.command_ok( + [ + "pg_verifybackup", + "--no-parse-wal", + "--manifest-path", + os.path.join(backup_dir, f"{prefix}backup_manifest"), + "--exit-on-error", + extract_path, + ], + f"{test_name}: backup verifies ok", + ) diff --git a/contrib/bloom/meson.build b/contrib/bloom/meson.build index fa4f4ea796b..60caa0baab2 100644 --- a/contrib/bloom/meson.build +++ b/contrib/bloom/meson.build @@ -42,4 +42,9 @@ tests += { 't/001_wal.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_wal.py', + ], + }, } diff --git a/contrib/bloom/pyt/test_001_wal.py b/contrib/bloom/pyt/test_001_wal.py new file mode 100644 index 00000000000..783562a9dd5 --- /dev/null +++ b/contrib/bloom/pyt/test_001_wal.py @@ -0,0 +1,87 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that generic xlog records correctly replicate bloom indexes.""" + + +def _test_index_replay( + node_primary, node_standby, session_primary, session_standby, test_name +): + # Wait for standby to catch up + node_primary.wait_for_catchup(node_standby) + + queries = ( + "SELECT * FROM tst WHERE i = 0", + "SELECT * FROM tst WHERE i = 3", + "SELECT * FROM tst WHERE t = 'b'", + "SELECT * FROM tst WHERE t = 'f'", + "SELECT * FROM tst WHERE i = 3 AND t = 'c'", + "SELECT * FROM tst WHERE i = 7 AND t = 'e'", + ) + + # Run test queries and compare their result + primary_result = session_primary.query_tuples(*queries) + standby_result = session_standby.query_tuples(*queries) + + assert primary_result == standby_result, f"{test_name}: query result matches" + + +def test_001_wal(create_pg): + # Initialize primary node + node_primary = create_pg("primary", allows_streaming=True) + backup_name = "my_backup" + + # Take backup + node_primary.backup(backup_name) + + # Create streaming standby linking to primary + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, backup_name, has_streaming=True) + node_standby.start() + + # Create and initialize the sessions + session_primary = node_primary.connect() + session_standby = node_standby.connect() + initset = """ + SET enable_seqscan=off; + SET enable_bitmapscan=on; + SET enable_indexscan=on; +""" + session_primary.query_safe(initset) + session_standby.query_safe(initset) + + # Create some bloom index on primary + session_primary.query_safe("CREATE EXTENSION bloom;") + session_primary.query_safe("CREATE TABLE tst (i int4, t text);") + session_primary.query_safe( + "INSERT INTO tst SELECT i%10, substr(encode(sha256(i::text::bytea), " + "'hex'), 1, 1) FROM generate_series(1,10000) i;" + ) + session_primary.query_safe( + "CREATE INDEX bloomidx ON tst USING bloom (i, t) WITH (col1 = 3);" + ) + + # Test that queries give same result + _test_index_replay( + node_primary, node_standby, session_primary, session_standby, "initial" + ) + + # Run 10 cycles of table modification. Run test queries after each + # modification. + for i in range(1, 11): + node_primary.safe_sql(f"DELETE FROM tst WHERE i = {i};") + _test_index_replay( + node_primary, node_standby, session_primary, session_standby, f"delete {i}" + ) + node_primary.safe_sql("VACUUM tst;") + _test_index_replay( + node_primary, node_standby, session_primary, session_standby, f"vacuum {i}" + ) + start = 100001 + (i - 1) * 10000 + end = 100000 + i * 10000 + node_primary.safe_sql( + "INSERT INTO tst SELECT i%10, substr(encode(sha256(i::text::bytea), " + f"'hex'), 1, 1) FROM generate_series({start},{end}) i;" + ) + _test_index_replay( + node_primary, node_standby, session_primary, session_standby, f"insert {i}" + ) diff --git a/contrib/dblink/meson.build b/contrib/dblink/meson.build index e2489f41229..3b8545b41c7 100644 --- a/contrib/dblink/meson.build +++ b/contrib/dblink/meson.build @@ -41,4 +41,9 @@ tests += { 't/001_auth_scram.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_auth_scram.py', + ], + }, } diff --git a/contrib/dblink/pyt/test_001_auth_scram.py b/contrib/dblink/pyt/test_001_auth_scram.py new file mode 100644 index 00000000000..074a9028a9c --- /dev/null +++ b/contrib/dblink/pyt/test_001_auth_scram.py @@ -0,0 +1,280 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test SCRAM authentication when opening a new connection with a foreign server. + +The test is executed by testing the SCRAM authentication on a loopback +connection on the same server and with different servers. + +All queries run in-process via the libpq Session. Queries that connect as +the password role ``user01`` build a dedicated Session with the role and +password embedded in the connection string. +""" + +import os +import re + +import libpq + +USER = "user01" + +DB0 = "db0" # For node1 +DB1 = "db1" # For node1 +DB2 = "db2" # For node2 +FDW_SERVER = "db1_fdw" +FDW_SERVER2 = "db2_fdw" +FDW_SERVER3 = "db1_fdw_override" +FDW_INVALID_SERVER = "db2_fdw_invalid" # For invalid fdw options +FDW_INVALID_SERVER2 = "db2_fdw_invalid2" # For invalid scram keys fdw options + + +# -- helper functions -------------------------------------------------------- + + +def user_session(node, db): + """A libpq Session connected to *db* as the password role USER.""" + connstr = node.connstr(db) + f" user={USER} password=pass" + return libpq.Session(connstr=connstr, libdir=node.libdir) + + +def setup_table(node, db, tbl): + node.safe_sql( + f"CREATE TABLE {tbl} AS SELECT g as a, g + 1 as b " + "FROM generate_series(1,10) g(g)", + dbname=db, + ) + node.safe_sql(f"GRANT USAGE ON SCHEMA public TO {USER}", dbname=db) + node.safe_sql(f"GRANT SELECT ON {tbl} TO {USER}", dbname=db) + + +def setup_fdw_server(node, db, fdw, fdw_node, dbname): + host = fdw_node.host + port = fdw_node.port + node.safe_sql( + f"CREATE SERVER {fdw} FOREIGN DATA WRAPPER dblink_fdw options (" + f"host '{host}', port '{port}', dbname '{dbname}', " + "use_scram_passthrough 'true') ", + dbname=db, + ) + node.safe_sql(f"GRANT USAGE ON FOREIGN SERVER {fdw} TO {USER};", dbname=db) + node.safe_sql(f"GRANT ALL ON SCHEMA public TO {USER}", dbname=db) + + +def setup_invalid_fdw_server(node, db, fdw, fdw_node, dbname): + host = fdw_node.host + port = fdw_node.port + node.safe_sql( + f"CREATE SERVER {fdw} FOREIGN DATA WRAPPER dblink_fdw options (" + f"host '{host}', port '{port}', dbname '{dbname}', " + "use_scram_passthrough 'true', require_auth 'none') ", + dbname=db, + ) + node.safe_sql(f"GRANT USAGE ON FOREIGN SERVER {fdw} TO {USER};", dbname=db) + node.safe_sql(f"GRANT ALL ON SCHEMA public TO {USER}", dbname=db) + + +def setup_user_mapping(node, db, fdw): + node.safe_sql( + f"CREATE USER MAPPING FOR {USER} SERVER {fdw} OPTIONS (user '{USER}');", + dbname=db, + ) + + +def check_fdw_auth(node, db, tbl, fdw, testname): + sess = user_session(node, db) + ret = sess.query_safe( + f"SELECT count(1) FROM dblink('{fdw}', 'SELECT * FROM {tbl}') " + f"AS {tbl}(a int, b int)" + ) + assert ret == "10", testname + sess.close() + + +def check_fdw_auth_with_invalid_overwritten_require_auth(node, fdw): + sess = user_session(node, DB0) + res = sess.query( + f"select * from dblink('{fdw}', 'select * from t') as t(a int, b int)" + ) + assert ( + res.error_message is not None + ), "loopback trust fails when overwriting require_auth" + assert re.search( + r"password or GSSAPI delegated credentials required", + res.error_message, + ), "expected error when connecting to a fdw overwriting the require_auth" + sess.close() + + +def check_scram_keys_is_not_overwritten(node, db, fdw): + sess = user_session(node, db) + + res = sess.query( + f"CREATE USER MAPPING FOR {USER} SERVER {fdw} " + f"OPTIONS (user '{USER}', scram_client_key 'key');" + ) + assert ( + res.error_message is not None + ), "user mapping creation fails when using scram_client_key" + assert re.search( + r'ERROR: invalid option "scram_client_key"', res.error_message + ), "user mapping creation fails when using scram_client_key" + + res = sess.query( + f"CREATE USER MAPPING FOR {USER} SERVER {fdw} " + f"OPTIONS (user '{USER}', scram_server_key 'key');" + ) + assert ( + res.error_message is not None + ), "user mapping creation fails when using scram_server_key" + assert re.search( + r'ERROR: invalid option "scram_server_key"', res.error_message + ), "user mapping creation fails when using scram_server_key" + + sess.close() + + +# -- main test --------------------------------------------------------------- + + +def test_dblink_auth_scram(create_pg): + node1 = create_pg("node1") + node2 = create_pg("node2") + + # Test setup + + node1.safe_sql(f"CREATE USER {USER} WITH password 'pass'") + node2.safe_sql(f"CREATE USER {USER} WITH password 'pass'") + + node1.safe_sql(f"CREATE DATABASE {DB0}") + node1.safe_sql(f"CREATE DATABASE {DB1}") + node2.safe_sql(f"CREATE DATABASE {DB2}") + + setup_table(node1, DB1, "t") + setup_table(node2, DB2, "t2") + + node1.safe_sql("CREATE EXTENSION IF NOT EXISTS dblink", dbname=DB0) + setup_fdw_server(node1, DB0, FDW_SERVER, node1, DB1) + setup_fdw_server(node1, DB0, FDW_SERVER2, node2, DB2) + setup_invalid_fdw_server(node1, DB0, FDW_INVALID_SERVER, node2, DB2) + setup_fdw_server(node1, DB0, FDW_INVALID_SERVER2, node2, DB2) + setup_fdw_server(node1, DB0, FDW_SERVER3, node1, DB1) + + setup_user_mapping(node1, DB0, FDW_SERVER) + setup_user_mapping(node1, DB0, FDW_SERVER2) + setup_user_mapping(node1, DB0, FDW_INVALID_SERVER) + setup_user_mapping(node1, DB0, FDW_SERVER3) + + # Make the user have the same SCRAM key on both servers. Forcing to have + # the same iteration and salt. + rolpassword = node1.safe_sql( + f"SELECT rolpassword FROM pg_authid WHERE rolname = '{USER}';" + ) + node2.safe_sql(f"ALTER ROLE {USER} PASSWORD '{rolpassword}'") + + os.unlink(os.path.join(node1.data_dir, "pg_hba.conf")) + os.unlink(os.path.join(node2.data_dir, "pg_hba.conf")) + + node1.append_conf( + """ +local db0 all scram-sha-256 +local db1 all scram-sha-256 +""", + filename="pg_hba.conf", + ) + node2.append_conf( + """ +local db2 all scram-sha-256 +""", + filename="pg_hba.conf", + ) + + node1.restart() + node2.restart() + + # End of test setup + + check_scram_keys_is_not_overwritten(node1, DB0, FDW_INVALID_SERVER2) + + check_fdw_auth( + node1, + DB0, + "t", + FDW_SERVER, + "SCRAM auth on the same database cluster must succeed", + ) + + check_fdw_auth( + node1, + DB0, + "t2", + FDW_SERVER2, + "SCRAM auth on a different database cluster must succeed", + ) + + check_fdw_auth_with_invalid_overwritten_require_auth(node1, FDW_INVALID_SERVER) + + # Test that use_scram_passthrough=false on user mapping overrides server + # setting. + sess = user_session(node1, DB0) + sess.query_safe( + f"ALTER USER MAPPING FOR {USER} SERVER {FDW_SERVER3} " + "OPTIONS(add use_scram_passthrough 'false')" + ) + + res = sess.query( + f"select * from dblink('{FDW_SERVER3}', 'select * from t') " + "as t(a int, b int)" + ) + assert ( + res.error_message is not None + ), "SCRAM passthrough disabled on user mapping should fail" + assert re.search(r"password", res.error_message, re.IGNORECASE), ( + "expected password-related error when scram passthrough disabled " + "on user mapping" + ) + sess.close() + + # Ensure that trust connections fail without superuser opt-in. + os.unlink(os.path.join(node1.data_dir, "pg_hba.conf")) + os.unlink(os.path.join(node2.data_dir, "pg_hba.conf")) + + node1.append_conf( + """ +local db0 all scram-sha-256 +local db1 all trust +""", + filename="pg_hba.conf", + ) + node2.append_conf( + """ +local all all password +""", + filename="pg_hba.conf", + ) + + node1.restart() + node2.restart() + + sess = user_session(node1, DB0) + res = sess.query( + f"SELECT * FROM dblink('{FDW_SERVER}', 'SELECT * FROM t') " "AS t(a int, b int)" + ) + assert res.error_message is not None, "loopback trust fails on the same cluster" + assert re.search( + r'failed: authentication method requirement "scram-sha-256" failed: ' + r"server did not complete authentication", + res.error_message, + ), "expected error from loopback trust (same cluster)" + + res = sess.query( + f"SELECT * FROM dblink('{FDW_SERVER2}', 'SELECT * FROM t2') " + "AS t2(a int, b int)" + ) + assert ( + res.error_message is not None + ), "loopback password fails on a different cluster" + assert re.search( + r'authentication method requirement "scram-sha-256" failed: ' + r"server requested a cleartext password", + res.error_message, + ), "expected error from loopback password (different cluster)" + sess.close() diff --git a/contrib/oid2name/meson.build b/contrib/oid2name/meson.build index 82b9ba48989..8bf4d06edc1 100644 --- a/contrib/oid2name/meson.build +++ b/contrib/oid2name/meson.build @@ -26,4 +26,9 @@ tests += { 't/001_basic.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + ], + }, } diff --git a/contrib/oid2name/pyt/test_001_basic.py b/contrib/oid2name/pyt/test_001_basic.py new file mode 100644 index 00000000000..a547e2ca6ac --- /dev/null +++ b/contrib/oid2name/pyt/test_001_basic.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group +"""Basic sanity checks for the oid2name command-line program.""" + + +def test_basic(pg_bin): + # Basic checks + pg_bin.program_help_ok("oid2name") + pg_bin.program_version_ok("oid2name") + pg_bin.program_options_handling_ok("oid2name") diff --git a/contrib/pg_prewarm/meson.build b/contrib/pg_prewarm/meson.build index e70546a451b..849986beced 100644 --- a/contrib/pg_prewarm/meson.build +++ b/contrib/pg_prewarm/meson.build @@ -39,4 +39,9 @@ tests += { 't/001_basic.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + ], + }, } diff --git a/contrib/pg_prewarm/pyt/test_001_basic.py b/contrib/pg_prewarm/pyt/test_001_basic.py new file mode 100644 index 00000000000..9420b73789c --- /dev/null +++ b/contrib/pg_prewarm/pyt/test_001_basic.py @@ -0,0 +1,102 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test pg_prewarm's autoprewarm feature: dump and restore of the buffer pool.""" + +import re + +from libpq import Session +from pypg.util import TIMEOUT_DEFAULT, slurp_file, poll_until + + +def _wait_for_log(node, pattern, offset=0, timeout=TIMEOUT_DEFAULT): + """Poll the server log until *pattern* appears at/after *offset*.""" + regex = re.compile(pattern) + + def _found(): + try: + content = slurp_file(node.logfile, offset) + except FileNotFoundError: + return False + return regex.search(content) is not None + + assert poll_until( + _found, timeout=timeout + ), f"timed out waiting for log pattern {pattern!r}" + + +def test_pg_prewarm(create_pg): + node = create_pg("main", start=False) + node.append_conf( + "shared_preload_libraries = 'pg_prewarm'\n" + "pg_prewarm.autoprewarm = true\n" + "pg_prewarm.autoprewarm_interval = 0" + ) + node.start() + + # setup + node.safe_sql( + "CREATE EXTENSION pg_prewarm;\n" + "CREATE TABLE test(c1 int);\n" + "INSERT INTO test SELECT generate_series(1, 100);\n" + "CREATE INDEX test_idx ON test(c1);\n" + "CREATE ROLE test_user LOGIN;" + ) + + # test read mode + result = node.safe_sql("SELECT pg_prewarm('test', 'read');") + assert re.match(r"^[1-9][0-9]*$", result), "read mode succeeded" + + # test buffer_mode + result = node.safe_sql("SELECT pg_prewarm('test', 'buffer');") + assert re.match(r"^[1-9][0-9]*$", result), "buffer mode succeeded" + + # prefetch mode might or might not be available + res = node.sql("SELECT pg_prewarm('test', 'prefetch');") + stdout = res.psqlout + stderr = res.error_message or "" + assert re.match(r"^[1-9][0-9]*$", stdout) or re.search( + r"prefetch is not supported by this build", stderr + ), "prefetch mode succeeded" + + # test_user should be unable to prewarm table/index without privileges + user_sess = Session( + connstr=node.connstr() + " user='test_user'", libdir=node.libdir + ) + res = user_sess.query("SELECT pg_prewarm('test');") + assert re.search( + r"permission denied for table test", res.error_message or "" + ), "pg_prewarm failed as expected" + res = user_sess.query("SELECT pg_prewarm('test_idx');") + assert re.search( + r"permission denied for index test_idx", res.error_message or "" + ), "pg_prewarm failed as expected" + + # test_user should be able to prewarm table/index with privileges + node.safe_sql("GRANT SELECT ON test TO test_user;") + result = user_sess.query_safe("SELECT pg_prewarm('test');") + assert re.match(r"^[1-9][0-9]*$", result), "pg_prewarm succeeded as expected" + result = user_sess.query_safe("SELECT pg_prewarm('test_idx');") + assert re.match(r"^[1-9][0-9]*$", result), "pg_prewarm succeeded as expected" + user_sess.close() + + # test autoprewarm_dump_now() + result = node.safe_sql("SELECT autoprewarm_dump_now();") + assert re.match(r"^[1-9][0-9]*$", result), "autoprewarm_dump_now succeeded" + + # restart, to verify that auto prewarm actually works + node.restart() + + _wait_for_log( + node, + r"autoprewarm successfully prewarmed [1-9][0-9]* of [0-9]+ " + r"previously-loaded blocks", + ) + + node.stop() + + # control file should indicate normal shut down + node.command_like( + ["pg_controldata", node.data_dir], + re.compile(r"Database cluster state:\s*shut down"), + "cluster shut down normally", + ) diff --git a/contrib/pg_stash_advice/meson.build b/contrib/pg_stash_advice/meson.build index 96f485b7729..47a3f025e5e 100644 --- a/contrib/pg_stash_advice/meson.build +++ b/contrib/pg_stash_advice/meson.build @@ -40,4 +40,9 @@ tests += { 't/001_persist.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_persist.py', + ], + }, } diff --git a/contrib/pg_stash_advice/pyt/test_001_persist.py b/contrib/pg_stash_advice/pyt/test_001_persist.py new file mode 100644 index 00000000000..22a22b56b4b --- /dev/null +++ b/contrib/pg_stash_advice/pyt/test_001_persist.py @@ -0,0 +1,94 @@ +# Copyright (c) 2016-2026, PostgreSQL Global Development Group + +"""Test that pg_stash_advice persists advice stashes across a server restart.""" + +import os + + +def test_001_persist(create_pg): + node = create_pg("main", start=False) + node.append_conf( + "shared_preload_libraries = 'pg_plan_advice, pg_stash_advice'\n" + "pg_stash_advice.persist = true\n" + "pg_stash_advice.persist_interval = 0" + ) + node.start() + + node.safe_sql("CREATE EXTENSION pg_stash_advice;\n") + + # Create two stashes: one with 2 entries, one with 1 entry. + node.safe_sql( + """ + SELECT pg_create_advice_stash('stash_a'); + SELECT pg_set_stashed_advice('stash_a', 1001, 'IndexScan(t)'); + SELECT pg_set_stashed_advice('stash_a', 1002, E'line1\\nline2\\ttab\\\\backslash'); + SELECT pg_create_advice_stash('stash_b'); + SELECT pg_set_stashed_advice('stash_b', 2001, 'SeqScan(t)'); + """ + ) + + # Verify before restart. + result = node.safe_sql( + "SELECT stash_name, num_entries FROM pg_get_advice_stashes() " + "ORDER BY stash_name" + ) + assert result == "stash_a|2\nstash_b|1", "stashes present before restart" + + # Restart and verify the data survived. + node.restart() + node.wait_for_log("loaded 2 advice stashes and 3 entries") + + result = node.safe_sql( + "SELECT stash_name, num_entries FROM pg_get_advice_stashes() " + "ORDER BY stash_name" + ) + assert result == "stash_a|2\nstash_b|1", "stashes survived restart" + + # Verify entry contents, including the one with special characters. + result = node.safe_sql( + "SELECT stash_name, query_id, advice_string " + "FROM pg_get_advice_stash_contents(NULL) ORDER BY stash_name, query_id" + ) + assert result == ( + "stash_a|1001|IndexScan(t)\nstash_a|1002|line1\nline2\ttab\\backslash\n" + "stash_b|2001|SeqScan(t)" + ), "entry contents survived restart with special characters intact" + + # Add a third stash with 0 entries. + node.safe_sql( + """ + SELECT pg_create_advice_stash('stash_c'); + """ + ) + + # Restart again and verify all three stashes are present. + node.restart() + node.wait_for_log("loaded 3 advice stashes and 3 entries") + + result = node.safe_sql( + "SELECT stash_name, num_entries FROM pg_get_advice_stashes() " + "ORDER BY stash_name" + ) + assert result == ( + "stash_a|2\nstash_b|1\nstash_c|0" + ), "all three stashes survived second restart" + + # Drop all stashes and verify the dump file is removed after restart. + node.safe_sql( + """ + SELECT pg_drop_advice_stash('stash_a'); + SELECT pg_drop_advice_stash('stash_b'); + SELECT pg_drop_advice_stash('stash_c'); + """ + ) + + node.restart() + + result = node.safe_sql("SELECT count(*) FROM pg_get_advice_stashes()") + assert result == "0", "no stashes after dropping all and restarting" + + assert not os.path.isfile( + os.path.join(node.data_dir, "pg_stash_advice.tsv") + ), "dump file removed after all stashes dropped" + + node.stop() diff --git a/contrib/pg_stat_statements/meson.build b/contrib/pg_stat_statements/meson.build index 9d78cb88b7d..f87f26f8db3 100644 --- a/contrib/pg_stat_statements/meson.build +++ b/contrib/pg_stat_statements/meson.build @@ -72,4 +72,9 @@ tests += { 't/010_restart.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_010_restart.py', + ], + }, } diff --git a/contrib/pg_stat_statements/pyt/test_010_restart.py b/contrib/pg_stat_statements/pyt/test_010_restart.py new file mode 100644 index 00000000000..91aaa661cbd --- /dev/null +++ b/contrib/pg_stat_statements/pyt/test_010_restart.py @@ -0,0 +1,47 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Check that pg_stat_statements contents are preserved across restarts. + +All queries run in-process via the libpq Session. +""" + +_QUERY = ( + "SELECT query FROM pg_stat_statements " + "WHERE query NOT LIKE '%pg_stat_statements%' ORDER BY query" +) + + +def test_pg_stat_statements_across_restart(create_pg): + node = create_pg("main", start=False) + node.append_conf("shared_preload_libraries = 'pg_stat_statements'") + node.start() + + node.safe_sql("CREATE EXTENSION pg_stat_statements") + + node.safe_sql("CREATE TABLE t1 (a int)") + node.safe_sql("SELECT a FROM t1") + + assert ( + node.safe_sql(_QUERY) == "CREATE TABLE t1 (a int)\nSELECT a FROM t1" + ), "pg_stat_statements populated" + + node.restart() + + assert ( + node.safe_sql(_QUERY) == "CREATE TABLE t1 (a int)\nSELECT a FROM t1" + ), "pg_stat_statements data kept across restart" + + node.append_conf("pg_stat_statements.save = false") + node.reload() + + node.restart() + + assert ( + node.safe_sql( + "SELECT count(*) FROM pg_stat_statements " + "WHERE query NOT LIKE '%pg_stat_statements%'" + ) + == "0" + ), "pg_stat_statements data not kept across restart with .save=false" + + node.stop() diff --git a/contrib/pg_visibility/meson.build b/contrib/pg_visibility/meson.build index 8a17050f2ac..d784c95667a 100644 --- a/contrib/pg_visibility/meson.build +++ b/contrib/pg_visibility/meson.build @@ -39,4 +39,10 @@ tests += { 't/002_corrupt_vm.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_concurrent_transaction.py', + 'pyt/test_002_corrupt_vm.py', + ], + }, } diff --git a/contrib/pg_visibility/pyt/test_001_concurrent_transaction.py b/contrib/pg_visibility/pyt/test_001_concurrent_transaction.py new file mode 100644 index 00000000000..5c235b2ea8a --- /dev/null +++ b/contrib/pg_visibility/pyt/test_001_concurrent_transaction.py @@ -0,0 +1,52 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Check that a concurrent transaction doesn't cause false negatives in pg_check_visible().""" + + +def test_001_concurrent_transaction(create_pg): + # Initialize the primary node + node = create_pg("main", allows_streaming=True) + + # Initialize the streaming standby + backup_name = "my_backup" + node.backup(backup_name) + standby = create_pg("standby", start=False) + standby.init_from_backup(node, backup_name, has_streaming=True) + standby.start() + + # Setup another database + node.safe_sql("CREATE DATABASE other_database;") + bsession = node.connect(dbname="other_database") + + # Run a concurrent transaction + bsession.query( + """ + BEGIN; + SELECT txid_current(); + """ + ) + + # Create a sample table and run vacuum + node.safe_sql( + "CREATE EXTENSION pg_visibility;\nCREATE TABLE vacuum_test AS SELECT 42 i;" + ) + node.safe_sql("VACUUM (disable_page_skipping) vacuum_test;") + + # Run pg_check_visible() + result = node.safe_sql("SELECT * FROM pg_check_visible('vacuum_test');") + + # There should be no false negatives + assert result == "", "pg_check_visible() detects no errors" + + # Run pg_check_visible() on standby + node.wait_for_catchup(standby) + result = standby.safe_sql("SELECT * FROM pg_check_visible('vacuum_test');") + + # There should be no false negatives either + assert result == "", "pg_check_visible() detects no errors" + + # Shutdown + bsession.query("COMMIT;") + bsession.close() + node.stop() + standby.stop() diff --git a/contrib/pg_visibility/pyt/test_002_corrupt_vm.py b/contrib/pg_visibility/pyt/test_002_corrupt_vm.py new file mode 100644 index 00000000000..c36bff1a533 --- /dev/null +++ b/contrib/pg_visibility/pyt/test_002_corrupt_vm.py @@ -0,0 +1,94 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Check that pg_check_visible() and pg_check_frozen() report correct TIDs for corruption.""" + +import os +import shutil + + +def test_002_corrupt_vm(create_pg): + node = create_pg("main", start=False) + # Anything holding a snapshot, including auto-analyze of pg_proc, could stop + # VACUUM from updating the visibility map. + node.append_conf("autovacuum=off") + node.start() + + blck_size = node.safe_sql("SHOW block_size;") + + # Create a sample table with at least 10 pages and then run VACUUM. 10 is + # selected manually as it is big enough to select 5 random tuples from the + # relation. + node.safe_sql( + f""" + CREATE EXTENSION pg_visibility; + CREATE TABLE corruption_test + WITH (autovacuum_enabled = false) AS + SELECT + i, + repeat('a', 10) AS data + FROM + generate_series(1, {blck_size}) i; + """ + ) + node.safe_sql("VACUUM (FREEZE, DISABLE_PAGE_SKIPPING) corruption_test;") + + # VACUUM is run, it is safe to get the number of pages. + npages = node.safe_sql( + "SELECT relpages FROM pg_class\n\t\tWHERE relname = 'corruption_test';" + ) + assert int(npages) >= 10, "table has at least 10 pages" + + file = node.safe_sql("SELECT pg_relation_filepath('corruption_test');") + + # Delete the first block to make sure that it will be skipped as it is + # not visible nor frozen. + node.safe_sql("DELETE FROM corruption_test\n\t\tWHERE (ctid::text::point)[0] = 0;") + + # Copy visibility map. + node.stop() + vm_file = os.path.join(node.data_dir, file + "_vm") + shutil.copy(vm_file, vm_file + "_temp") + node.start() + + # Select 5 random tuples that are starting from the second block of the + # relation. The first block is skipped because it is deleted above. + tuples = node.safe_sql( + "SELECT ctid FROM (\n" + "\t\tSELECT ctid FROM corruption_test\n" + "\t\t\tWHERE (ctid::text::point)[0] != 0\n" + "\t\t\tORDER BY random() LIMIT 5)\n" + "\t\tORDER BY ctid ASC;" + ) + + # Do the changes below to use tuples in the query. + # "\n" -> "," + # "(" -> "'(" + # ")" -> ")'" + tuples_query = tuples.replace("\n", ",") + tuples_query = tuples_query.replace("(", "'(") + tuples_query = tuples_query.replace(")", ")'") + + node.safe_sql( + "DELETE FROM corruption_test\n" f"\t\tWHERE ctid in ({tuples_query});" + ) + + # Overwrite visibility map with the old one. + node.stop() + shutil.move(vm_file + "_temp", vm_file) + node.start() + + result = node.safe_sql( + "SELECT DISTINCT t_ctid\n" + "\t\tFROM pg_check_visible('corruption_test')\n" + "\t\tORDER BY t_ctid ASC;" + ) + assert result == tuples, "pg_check_visible must report tuples as corrupted" + + result = node.safe_sql( + "SELECT DISTINCT t_ctid\n" + "\t\tFROM pg_check_frozen('corruption_test')\n" + "\t\tORDER BY t_ctid ASC;" + ) + assert result == tuples, "pg_check_frozen must report tuples as corrupted" + + node.stop() diff --git a/contrib/postgres_fdw/meson.build b/contrib/postgres_fdw/meson.build index 3e2ed06b766..e3677b66045 100644 --- a/contrib/postgres_fdw/meson.build +++ b/contrib/postgres_fdw/meson.build @@ -54,4 +54,10 @@ tests += { 't/010_subscription.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_auth_scram.py', + 'pyt/test_010_subscription.py', + ], + }, } diff --git a/contrib/postgres_fdw/pyt/test_001_auth_scram.py b/contrib/postgres_fdw/pyt/test_001_auth_scram.py new file mode 100644 index 00000000000..61252d96137 --- /dev/null +++ b/contrib/postgres_fdw/pyt/test_001_auth_scram.py @@ -0,0 +1,219 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test SCRAM authentication when opening a new connection with a foreign server. + +The test is executed by testing the SCRAM authentication on a loopback +connection on the same server and with different servers. + +All queries run in-process via the libpq Session. Queries that connect as +the password role ``user01`` build a dedicated Session with the role and +password embedded in the connection string. + +The test exercises ``local`` pg_hba.conf entries with scram-sha-256 over +unix sockets, exercising the SCRAM passthrough code paths. +""" + +import os +import re + +import libpq + +USER = "user01" + +DB0 = "db0" # For node1 +DB1 = "db1" # For node1 +DB2 = "db2" # For node2 +FDW_SERVER = "db1_fdw" +FDW_SERVER2 = "db2_fdw" +FDW_SERVER3 = "db1_fdw_override" + + +# -- helper functions -------------------------------------------------------- + + +def user_session(node, db): + """A libpq Session connected to *db* as the password role USER.""" + connstr = node.connstr(db) + f" user={USER} password=pass" + return libpq.Session(connstr=connstr, libdir=node.libdir) + + +def setup_table(node, db, tbl): + node.safe_sql( + f"CREATE TABLE {tbl} AS SELECT g, g + 1 FROM generate_series(1,10) g(g)", + dbname=db, + ) + node.safe_sql(f"GRANT USAGE ON SCHEMA public TO {USER}", dbname=db) + node.safe_sql(f"GRANT SELECT ON {tbl} TO {USER}", dbname=db) + + +def setup_fdw_server(node, db, fdw, fdw_node, dbname): + host = fdw_node.host + port = fdw_node.port + node.safe_sql( + f"CREATE SERVER {fdw} FOREIGN DATA WRAPPER postgres_fdw options (" + f"host '{host}', port '{port}', dbname '{dbname}', " + "use_scram_passthrough 'true') ", + dbname=db, + ) + + +def setup_user_mapping(node, db, fdw): + node.safe_sql( + f"CREATE USER MAPPING FOR {USER} SERVER {fdw} OPTIONS (user '{USER}');", + dbname=db, + ) + node.safe_sql(f"GRANT USAGE ON FOREIGN SERVER {fdw} TO {USER};", dbname=db) + node.safe_sql(f"GRANT ALL ON SCHEMA public TO {USER}", dbname=db) + + +def setup_pghba(node, body): + os.unlink(os.path.join(node.data_dir, "pg_hba.conf")) + node.append_conf(body, filename="pg_hba.conf") + node.restart() + + +def check_auth(node, db, tbl, testname): + """Connect as USER and assert the foreign table returns its 10 rows.""" + sess = user_session(node, db) + ret = sess.query_safe(f"SELECT count(1) FROM {tbl}") + assert ret == "10", testname + sess.close() + + +def check_fdw_auth(node, db, tbl, fdw, testname): + """Import the remote table over the foreign server, then read it.""" + sess = user_session(node, db) + sess.query_safe( + f"IMPORT FOREIGN SCHEMA public LIMIT TO ({tbl}) " + f"FROM SERVER {fdw} INTO public;" + ) + sess.close() + check_auth(node, db, tbl, testname) + + +# -- main test --------------------------------------------------------------- + + +def test_postgres_fdw_auth_scram(create_pg): + node1 = create_pg("node1") + node2 = create_pg("node2") + + # Test setup + + node1.safe_sql(f"CREATE USER {USER} WITH password 'pass'") + node2.safe_sql(f"CREATE USER {USER} WITH password 'pass'") + + node1.safe_sql(f"CREATE DATABASE {DB0}") + node1.safe_sql(f"CREATE DATABASE {DB1}") + node2.safe_sql(f"CREATE DATABASE {DB2}") + + setup_table(node1, DB1, "t") + setup_table(node2, DB2, "t2") + + node1.safe_sql("CREATE EXTENSION IF NOT EXISTS postgres_fdw", dbname=DB0) + setup_fdw_server(node1, DB0, FDW_SERVER, node1, DB1) + setup_fdw_server(node1, DB0, FDW_SERVER2, node2, DB2) + setup_fdw_server(node1, DB0, FDW_SERVER3, node1, DB1) + + setup_user_mapping(node1, DB0, FDW_SERVER) + setup_user_mapping(node1, DB0, FDW_SERVER2) + setup_user_mapping(node1, DB0, FDW_SERVER3) + + # Make the user have the same SCRAM key on both servers. Forcing to have + # the same iteration and salt. + rolpassword = node1.safe_sql( + f"SELECT rolpassword FROM pg_authid WHERE rolname = '{USER}';" + ) + node2.safe_sql(f"ALTER ROLE {USER} PASSWORD '{rolpassword}'") + + setup_pghba( + node1, + """ +local all all scram-sha-256 +""", + ) + setup_pghba( + node2, + """ +local all all scram-sha-256 +""", + ) + + # End of test setup + + check_fdw_auth( + node1, + DB0, + "t", + FDW_SERVER, + "SCRAM auth on the same database cluster must succeed", + ) + check_fdw_auth( + node1, + DB0, + "t2", + FDW_SERVER2, + "SCRAM auth on a different database cluster must succeed", + ) + check_auth( + node2, + DB2, + "t2", + "SCRAM auth directly on foreign server should still succeed", + ) + + # Test that use_scram_passthrough=false on user mapping overrides server + # setting. + sess = user_session(node1, DB0) + sess.query_safe( + f"ALTER USER MAPPING FOR {USER} SERVER {FDW_SERVER3} " + "OPTIONS(add use_scram_passthrough 'false')" + ) + sess.query_safe( + f"CREATE FOREIGN TABLE override_t (g int, col2 int) " + f"SERVER {FDW_SERVER3} OPTIONS (table_name 't');" + ) + sess.query_safe(f"GRANT SELECT ON override_t TO {USER};") + + res = sess.query("SELECT count(1) FROM override_t") + assert ( + res.error_message is not None + ), "SCRAM passthrough disabled on user mapping should fail" + assert re.search(r"password", res.error_message, re.IGNORECASE), ( + "expected password-related error when scram passthrough disabled " + "on user mapping" + ) + sess.close() + + # Ensure that trust connections fail without superuser opt-in. + setup_pghba( + node1, + """ +local db0 all scram-sha-256 +local db1 all trust +""", + ) + setup_pghba( + node2, + """ +local all all password +""", + ) + + sess = user_session(node1, DB0) + res = sess.query("select count(1) from t") + assert res.error_message is not None, "loopback trust fails on the same cluster" + assert re.search( + r'failed: authentication method requirement "scram-sha-256"', + res.error_message, + ), "expected error from loopback trust (same cluster)" + + res = sess.query("select count(1) from t2") + assert ( + res.error_message is not None + ), "loopback password fails on a different cluster" + assert re.search( + r'failed: authentication method requirement "scram-sha-256"', + res.error_message, + ), "expected error from loopback password (different cluster)" + sess.close() diff --git a/contrib/postgres_fdw/pyt/test_010_subscription.py b/contrib/postgres_fdw/pyt/test_010_subscription.py new file mode 100644 index 00000000000..aba502fe1a1 --- /dev/null +++ b/contrib/postgres_fdw/pyt/test_010_subscription.py @@ -0,0 +1,94 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test a postgres_fdw foreign server for use with a subscription.""" + +PARAM_CHANGE_PATTERN = ( + r'logical replication worker for subscription "tap_sub" ' + r"will restart because of a parameter change" +) + + +def test_010_subscription(create_pg): + # Initialize publisher node + node_publisher = create_pg("publisher", allows_streaming="logical") + + # Create subscriber node + node_subscriber = create_pg("subscriber") + + # Create some preexisting content on publisher + node_publisher.safe_sql( + "CREATE TABLE tab_ins AS " + "SELECT a, a + 1 as b FROM generate_series(1,1002) AS a" + ) + + # Setup structure on subscriber + node_subscriber.safe_sql("CREATE EXTENSION postgres_fdw") + node_subscriber.safe_sql("CREATE TABLE tab_ins (a int, b int)") + + # Setup logical replication + publisher_connstr = ( + f"host={node_publisher.host} port={node_publisher.port} dbname=postgres" + ) + node_publisher.safe_sql("CREATE PUBLICATION tap_pub FOR TABLE tab_ins") + + publisher_host = node_publisher.host + publisher_port = node_publisher.port + node_subscriber.safe_sql( + "CREATE SERVER tap_server FOREIGN DATA WRAPPER postgres_fdw " + f"OPTIONS (host '{publisher_host}', port '{publisher_port}', " + "dbname 'postgres')" + ) + + node_subscriber.safe_sql("CREATE USER MAPPING FOR PUBLIC SERVER tap_server") + + node_subscriber.safe_sql( + "CREATE SUBSCRIPTION tap_sub SERVER tap_server PUBLICATION tap_pub " + "WITH (password_required=false)" + ) + + # Wait for initial table sync to finish + node_subscriber.wait_for_subscription_sync(node_publisher, "tap_sub") + + result = node_subscriber.safe_sql("SELECT MAX(a) FROM tab_ins") + assert result == "1002", "check that initial data was copied to subscriber" + + node_publisher.safe_sql( + "INSERT INTO tab_ins SELECT a, a + 1 FROM generate_series(1003,1050) a" + ) + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT MAX(a) FROM tab_ins") + assert result == "1050", "check that inserted data was copied to subscriber" + + # change to CONNECTION and confirm invalidation + log_offset = node_subscriber.log_position() + node_subscriber.safe_sql( + f"ALTER SUBSCRIPTION tap_sub CONNECTION '{publisher_connstr}'" + ) + node_subscriber.wait_for_log(PARAM_CHANGE_PATTERN, log_offset) + + node_publisher.safe_sql( + "INSERT INTO tab_ins SELECT a, a + 1 FROM generate_series(1051,1057) a" + ) + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT MAX(a) FROM tab_ins") + assert ( + result == "1057" + ), "check subscription after ALTER SUBSCRIPTION ... CONNECTION" + + # change back to SERVER and confirm invalidation + log_offset = node_subscriber.log_position() + node_subscriber.safe_sql("ALTER SUBSCRIPTION tap_sub SERVER tap_server") + node_subscriber.wait_for_log(PARAM_CHANGE_PATTERN, log_offset) + + node_publisher.safe_sql( + "INSERT INTO tab_ins SELECT a, a + 1 FROM generate_series(1058,1073) a" + ) + + node_publisher.wait_for_catchup("tap_sub") + + result = node_subscriber.safe_sql("SELECT MAX(a) FROM tab_ins") + assert result == "1073", "check subscription after ALTER SUBSCRIPTION ... SERVER" diff --git a/contrib/sepgsql/meson.build b/contrib/sepgsql/meson.build index 70f9d768630..29ef02f52db 100644 --- a/contrib/sepgsql/meson.build +++ b/contrib/sepgsql/meson.build @@ -49,4 +49,9 @@ tests += { 't/001_sepgsql.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_sepgsql.py', + ], + }, } diff --git a/contrib/sepgsql/pyt/test_001_sepgsql.py b/contrib/sepgsql/pyt/test_001_sepgsql.py new file mode 100644 index 00000000000..3456033548e --- /dev/null +++ b/contrib/sepgsql/pyt/test_001_sepgsql.py @@ -0,0 +1,257 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Exercise the SELinux integration (sepgsql). + +This is a potentially unsafe test that exercises the SELinux integration +(sepgsql). It only runs when ``sepgsql`` is listed in PG_TEST_EXTRA AND the +host is running a suitable SELinux environment (enforcing mode, the +sepgsql-regtest policy module loaded, the right booleans turned on, and the +launching user in the unconfined_t domain). When any of those preconditions +is not met, the test skips with a helpful diagnostic, since the operator +opted in but the environment was not actually ready. + +The regression tests themselves are driven by pg_regress with a custom +``launcher`` script, which uses ``runcon`` to enter the right security +context. +""" + +import os +import subprocess + +import pytest + +# 1) PG_TEST_EXTRA must opt in to this potentially unsafe test. +if "sepgsql" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip( + "Potentially unsafe test sepgsql not enabled in PG_TEST_EXTRA", + allow_module_level=True, + ) + + +# Directory holding this test (where the launcher script lives), and the +# sepgsql source directory. +_TEST_DIR = os.path.dirname(os.path.abspath(__file__)) +_SEPGSQL_DIR = os.path.abspath(os.path.join(_TEST_DIR, "..")) + + +def _run(cmd, **kwargs): + """Run *cmd* (a string for shell, list otherwise) capturing all output. + + Returns the completed process; never raises on a nonzero exit. + """ + shell = isinstance(cmd, str) + return subprocess.run( + cmd, + shell=shell, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + **kwargs, + ) + + +def _capture(cmd): + """Run *cmd* and return stdout text (empty string on failure).""" + proc = subprocess.run( + cmd, + shell=isinstance(cmd, str), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + return proc.stdout if proc.returncode == 0 else "" + + +def _check_selinux_environment(): + """Verify the SELinux environment, skipping with a diagnostic if unready. + + Runs a chain of preflight checks. Each failed check calls pytest.skip() + with an explanation so a host that opted in but is not actually ready does + not produce a spurious failure. + """ + # matchpathcon must be present to assess whether the installation + # environment is OK. + if _run("matchpathcon -n . >/dev/null 2>&1").returncode != 0: + pytest.skip( + "The matchpathcon command must be available.\n" + "Please install it or update your PATH to include it\n" + "(it is typically in '/usr/sbin', which might not be in your " + "PATH).\n" + "matchpathcon is typically included in the libselinux-utils " + "package." + ) + + # runcon must be present to launch psql using the correct environment. + if _run("runcon --help >/dev/null 2>&1").returncode != 0: + pytest.skip( + "The runcon command must be available.\n" + "runcon is typically included in the coreutils package." + ) + + # check sestatus too, since that lives in yet another package. + if _run("sestatus >/dev/null 2>&1").returncode != 0: + pytest.skip( + "The sestatus command must be available.\n" + "sestatus is typically included in the policycoreutils package." + ) + + # check that the user is running in the unconfined_t domain. + id_z = _capture("id -Z 2>/dev/null") + parts = id_z.split(":") + domain = parts[2] if len(parts) > 2 else "" + if domain != "unconfined_t": + pytest.skip( + "The regression tests must be launched from the unconfined_t " + "domain.\n" + "The unconfined_t domain is typically the default domain for " + "user shell processes." + ) + + # SELinux must be configured in enforcing mode. + current_mode = "" + for line in _capture("LANG=C sestatus").splitlines(): + if line.startswith("Current mode:"): + current_mode = line.split(":", 1)[1].strip() + break + if current_mode == "enforcing": + pass # OK + elif current_mode in ("permissive", "disabled"): + pytest.skip( + "Before running the regression tests, SELinux must be enabled " + "and must be running in enforcing mode.\n" + "If SELinux is currently running in permissive mode, you can " + "switch to enforcing mode using 'sudo setenforce 1'." + ) + else: + pytest.skip( + "Unable to determine the current selinux operating mode. " + "Please verify that the sestatus command is installed and in " + "your PATH." + ) + + # 'sepgsql-regtest' policy module must be loaded. + selinux_mnt = "" + for line in _capture("sestatus").splitlines(): + if line.startswith("SELinuxfs mount:"): + selinux_mnt = line.split(":", 1)[1].strip() + break + if selinux_mnt == "": + pytest.skip( + "Unable to find SELinuxfs mount point.\n" + "The sestatus command should report the location where " + "SELinuxfs is mounted, but did not do so." + ) + if not os.path.exists( + os.path.join(selinux_mnt, "booleans", "sepgsql_regression_test_mode") + ): + pytest.skip( + "The 'sepgsql-regtest' policy module appears not to be " + "installed.\nWithout this policy installed, the regression " + "tests will fail.\nYou can install it with:\n" + " $ make -f /usr/share/selinux/devel/Makefile\n" + " $ sudo semodule -u sepgsql-regtest.pp" + ) + + # Verify that the required SELinux booleans are active. + for policy in ("sepgsql_regression_test_mode", "sepgsql_enable_users_ddl"): + out = _capture(["getsebool", policy]) + fields = out.split() + status = fields[2] if len(fields) > 2 else "" + if status != "on": + pytest.skip( + f"The SELinux boolean '{policy}' must be turned on in order " + "to enable the rules necessary to run the regression " + "tests.\n" + f" $ sudo setsebool {policy} on" + ) + + +def test_001_sepgsql(create_pg): + # checking selinux environment + _check_selinux_environment() + + # + # checking complete - let's run the tests + # + + # The single-user installation step reads sepgsql.sql from the contrib + # share directory, which the meson/make harness exports as + # share_contrib_dir (cf. test_env in meson.build). + share_contrib_dir = os.environ.get("share_contrib_dir") + if not share_contrib_dir: + pytest.skip("share_contrib_dir not set in the environment") + + pg_regress = os.environ.get("PG_REGRESS") + if not pg_regress: + pytest.skip("PG_REGRESS not set; pg_regress is unavailable") + + node = create_pg("test", start=False) + node.append_conf("log_statement=none") + + # Run the sepgsql installation script in single-user mode against + # template0. postgres --single reads the SQL from stdin. + sepgsql_sql = os.path.join(share_contrib_dir, "sepgsql.sql") + with open(sepgsql_sql, "r", encoding="utf-8") as fh: + sql = fh.read() + proc = subprocess.run( + [ + os.path.join(node.bindir, "postgres"), + "--single", + "-F", + "-c", + "exit_on_error=true", + "-D", + node.data_dir, + "template0", + ], + input=sql, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + assert proc.returncode == 0, f"sepgsql installation script\n{proc.stdout}" + + node.append_conf("shared_preload_libraries=sepgsql") + node.start() + + tests = ["label", "dml", "ddl", "alter", "misc"] + + # Check if the truncate permission exists in the loaded policy, and if so, + # run the truncate test. + if os.path.isfile("/sys/fs/selinux/class/db_table/perms/truncate"): + tests.append("truncate") + + # Drive the sepgsql regression tests via pg_regress with the launcher + # script (which uses runcon to enter the right security context). Run + # from the sepgsql source directory so that --inputdir '.' and the + # ./launcher relative path resolve. pg_regress is given this node's + # host/port so the launched psql connects to it. + env = dict(os.environ) + env["PGHOST"] = node.host + env["PGPORT"] = str(node.port) + proc = subprocess.run( + [ + pg_regress, + "--bindir", + "", + "--inputdir", + ".", + "--launcher", + "./launcher", + f"--host={node.host}", + f"--port={node.port}", + *tests, + ], + cwd=_SEPGSQL_DIR, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + assert ( + proc.returncode == 0 + ), f"sepgsql tests\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" diff --git a/contrib/test_decoding/meson.build b/contrib/test_decoding/meson.build index ac655853d26..c8b76b291f5 100644 --- a/contrib/test_decoding/meson.build +++ b/contrib/test_decoding/meson.build @@ -78,4 +78,9 @@ tests += { 't/001_repl_stats.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_repl_stats.py', + ], + }, } diff --git a/contrib/test_decoding/pyt/test_001_repl_stats.py b/contrib/test_decoding/pyt/test_001_repl_stats.py new file mode 100644 index 00000000000..c2f7b3e94cb --- /dev/null +++ b/contrib/test_decoding/pyt/test_001_repl_stats.py @@ -0,0 +1,170 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that pg_stat_replication_slots data is sane after dropping a slot and restarting.""" + +import os +import shutil + + +def _test_slot_stats(node, expected, msg): + # Check that replication slot stats are expected. + result = node.safe_sql( + """ + SELECT slot_name, total_txns > 0 AS total_txn, + total_bytes > 0 AS total_bytes + FROM pg_stat_replication_slots + ORDER BY slot_name""" + ) + assert result == expected, msg + + +def test_001_repl_stats(create_pg): + # Test set-up + node = create_pg("test", allows_streaming="logical", start=False) + node.append_conf("synchronous_commit = on") + node.start() + + # Create table. + node.safe_sql("CREATE TABLE test_repl_stat(col1 int)") + + # Create replication slots. + node.safe_sql( + """ + SELECT pg_create_logical_replication_slot('regression_slot1', 'test_decoding'); + SELECT pg_create_logical_replication_slot('regression_slot2', 'test_decoding'); + SELECT pg_create_logical_replication_slot('regression_slot3', 'test_decoding'); + SELECT pg_create_logical_replication_slot('regression_slot4', 'test_decoding'); + """ + ) + + # Insert some data. + node.safe_sql("INSERT INTO test_repl_stat values(generate_series(1, 5));") + + node.safe_sql( + """ + SELECT data FROM pg_logical_slot_get_changes('regression_slot1', NULL, + NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + SELECT data FROM pg_logical_slot_get_changes('regression_slot2', NULL, + NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + SELECT data FROM pg_logical_slot_get_changes('regression_slot3', NULL, + NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + SELECT data FROM pg_logical_slot_get_changes('regression_slot4', NULL, + NULL, 'include-xids', '0', 'skip-empty-xacts', '1'); + """ + ) + + # Wait for the statistics to be updated. + assert node.poll_query_until( + """ + SELECT count(slot_name) >= 4 FROM pg_stat_replication_slots + WHERE slot_name ~ 'regression_slot' + AND total_txns > 0 AND total_bytes > 0; + """ + ), "Timed out while waiting for statistics to be updated" + + # Test to drop one of the replication slot and verify replication statistics + # data is fine after restart. + node.safe_sql("SELECT pg_drop_replication_slot('regression_slot4')") + + node.stop() + node.start() + + # Verify statistics data present in pg_stat_replication_slots are sane after + # restart. + _test_slot_stats( + node, + "regression_slot1|t|t\nregression_slot2|t|t\nregression_slot3|t|t", + "check replication statistics are updated", + ) + + # Test to remove one of the replication slots and adjust + # max_replication_slots accordingly to the number of slots. This leads + # to a mismatch between the number of slots present in the stats file and the + # number of stats present in shared memory. We verify + # replication statistics data is fine after restart. + + node.stop() + datadir = node.data_dir + slot3_replslotdir = os.path.join(datadir, "pg_replslot", "regression_slot3") + + shutil.rmtree(slot3_replslotdir) + + node.append_conf("max_replication_slots = 2") + node.start() + + # Verify statistics data present in pg_stat_replication_slots are sane after + # restart. + _test_slot_stats( + node, + "regression_slot1|t|t\nregression_slot2|t|t", + "check replication statistics after removing the slot file", + ) + + # cleanup + node.safe_sql("DROP TABLE test_repl_stat") + node.safe_sql("SELECT pg_drop_replication_slot('regression_slot1')") + node.safe_sql("SELECT pg_drop_replication_slot('regression_slot2')") + + # shutdown + node.stop() + + # Test replication slot stats persistence in a single session. The slot + # is dropped and created concurrently of a session peeking at its data + # repeatedly, hence holding in its local cache a reference to the stats. + node.start() + + slot_name_restart = "regression_slot5" + node.safe_sql( + f"SELECT pg_create_logical_replication_slot('{slot_name_restart}', 'test_decoding');" + ) + + # Look at slot data, with a persistent connection. + bgsession = node.connect() + + # Look at slot data on this persistent session, incrementing the refcount + # of the stats entry. Run it to completion (so the slot is no longer + # active and can be dropped) while the session stays open to keep the + # stats reference held. + bgsession.query( + f"SELECT pg_logical_slot_peek_binary_changes('{slot_name_restart}', NULL, NULL)" + ) + + # Drop the slot entry. The stats entry is not dropped yet as the previous + # session still holds a reference to it. + node.safe_sql(f"SELECT pg_drop_replication_slot('{slot_name_restart}')") + + # Create again the same slot. The stats entry is reinitialized, not marked + # as dropped anymore. + node.safe_sql( + f"SELECT pg_create_logical_replication_slot('{slot_name_restart}', 'test_decoding');" + ) + + # Look again at the slot data. The local stats reference should be refreshed + # to the reinitialized entry. + bgsession.query( + f"SELECT pg_logical_slot_peek_binary_changes('{slot_name_restart}', NULL, NULL)" + ) + # Drop again the slot, the entry is not dropped yet as the previous session + # still has a refcount on it. + node.safe_sql(f"SELECT pg_drop_replication_slot('{slot_name_restart}')") + + # Shutdown the node, which should happen cleanly with the stats file written + # to disk. Note that the background session created previously needs to be + # hold *while* the node is shutting down to check that it drops the stats + # entry of the slot before writing the stats file. + node.stop() + + # Make sure that the node is correctly shut down. Checking the control file + # is not enough, as the node may detect that something is incorrect after the + # control file has been updated and the shutdown checkpoint is finished, so + # also check that the stats file has been written out. + node.command_like( + ["pg_controldata", node.data_dir], + r"Database cluster state:\s+shut down\n", + "node shut down ok", + ) + + stats_file = os.path.join(datadir, "pg_stat", "pgstat.stat") + assert os.path.isfile(stats_file), "stats file must exist after shutdown" + + bgsession.close() diff --git a/contrib/vacuumlo/meson.build b/contrib/vacuumlo/meson.build index 4ee5b048575..142ebec7514 100644 --- a/contrib/vacuumlo/meson.build +++ b/contrib/vacuumlo/meson.build @@ -26,4 +26,9 @@ tests += { 't/001_basic.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + ], + }, } diff --git a/contrib/vacuumlo/pyt/test_001_basic.py b/contrib/vacuumlo/pyt/test_001_basic.py new file mode 100644 index 00000000000..8063ec0e2d8 --- /dev/null +++ b/contrib/vacuumlo/pyt/test_001_basic.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic sanity checks for the vacuumlo command-line program.""" + + +def test_vacuumlo_basic(pg_bin): + pg_bin.program_help_ok("vacuumlo") + pg_bin.program_version_ok("vacuumlo") + pg_bin.program_options_handling_ok("vacuumlo") From db741017c7dfd0cd8ab8efb966926850d1c448bd Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:08 -0400 Subject: [PATCH 12/18] python tests: pytest suites for src/test/modules Includes the test_checksums DataChecksums helper (pyt/conftest.py) and the injection-point-gated suites (test_aio, test_misc, xid_wraparound, ...). --- src/test/modules/brin/meson.build | 6 + .../modules/brin/pyt/test_01_workitems.py | 79 + .../brin/pyt/test_02_wal_consistency.py | 64 + src/test/modules/commit_ts/meson.build | 8 + .../modules/commit_ts/pyt/test_001_base.py | 36 + .../modules/commit_ts/pyt/test_002_standby.py | 62 + .../commit_ts/pyt/test_003_standby_2.py | 66 + .../modules/commit_ts/pyt/test_004_restart.py | 150 ++ src/test/modules/libpq_pipeline/meson.build | 6 + .../pyt/test_001_libpq_pipeline.py | 85 + src/test/modules/test_aio/meson.build | 11 + src/test/modules/test_aio/pyt/test_001_aio.py | 1871 +++++++++++++++++ .../test_aio/pyt/test_002_io_workers.py | 106 + .../modules/test_aio/pyt/test_003_initdb.py | 83 + .../test_aio/pyt/test_004_read_stream.py | 341 +++ src/test/modules/test_autovacuum/meson.build | 8 + .../pyt/test_001_parallel_autovacuum.py | 179 ++ src/test/modules/test_checksums/meson.build | 16 + .../modules/test_checksums/pyt/conftest.py | 102 + .../test_checksums/pyt/test_001_basic.py | 49 + .../test_checksums/pyt/test_002_restarts.py | 105 + .../pyt/test_003_standby_restarts.py | 295 +++ .../test_checksums/pyt/test_004_offline.py | 92 + .../test_checksums/pyt/test_005_injection.py | 73 + .../pyt/test_006_pgbench_single.py | 297 +++ .../pyt/test_007_pgbench_standby.py | 441 ++++ .../test_checksums/pyt/test_008_pitr.py | 116 + .../test_checksums/pyt/test_009_fpi.py | 66 + src/test/modules/test_cloexec/meson.build | 5 + .../test_cloexec/pyt/test_001_cloexec.py | 48 + .../modules/test_custom_rmgrs/meson.build | 5 + .../test_custom_rmgrs/pyt/test_001_basic.py | 69 + .../modules/test_custom_stats/meson.build | 5 + .../pyt/test_001_custom_stats.py | 142 ++ src/test/modules/test_escape/meson.build | 6 + .../test_escape/pyt/test_001_test_escape.py | 49 + src/test/modules/test_extensions/meson.build | 5 + .../pyt/test_001_extension_control_path.py | 169 ++ src/test/modules/test_int128/meson.build | 5 + .../test_int128/pyt/test_001_test_int128.py | 23 + src/test/modules/test_json_parser/meson.build | 13 + .../test_001_test_json_parser_incremental.py | 34 + .../test_json_parser/pyt/test_002_inline.py | 172 ++ .../pyt/test_003_test_semantic.py | 43 + .../pyt/test_004_test_parser_perf.py | 33 + src/test/modules/test_misc/meson.build | 22 + .../pyt/test_001_constraint_validation.py | 378 ++++ .../test_misc/pyt/test_002_tablespace.py | 69 + .../test_misc/pyt/test_003_check_guc.py | 113 + .../test_misc/pyt/test_004_io_direct.py | 82 + .../test_misc/pyt/test_005_timeouts.py | 116 + .../pyt/test_006_signal_autovacuum.py | 104 + .../test_misc/pyt/test_007_catcache_inval.py | 111 + .../pyt/test_008_replslot_single_user.py | 125 ++ .../test_misc/pyt/test_009_log_temp_files.py | 296 +++ .../pyt/test_010_index_concurrently_upsert.py | 874 ++++++++ .../test_misc/pyt/test_011_lock_stats.py | 318 +++ .../test_misc/pyt/test_012_ddlutils.py | 331 +++ .../pyt/test_013_temp_obj_multisession.py | 297 +++ src/test/modules/test_plan_advice/meson.build | 5 + .../pyt/test_001_replan_regress.py | 71 + src/test/modules/test_saslprep/meson.build | 5 + .../pyt/test_001_saslprep_ranges.py | 32 + src/test/modules/test_shmem/meson.build | 5 + .../pyt/test_001_late_shmem_alloc.py | 60 + src/test/modules/test_slru/meson.build | 9 + .../test_slru/pyt/test_001_multixact.py | 64 + .../pyt/test_002_multixact_wraparound.py | 72 + src/test/modules/worker_spi/meson.build | 6 + .../worker_spi/pyt/test_001_worker_spi.py | 174 ++ .../pyt/test_002_worker_terminate.py | 163 ++ src/test/modules/xid_wraparound/meson.build | 8 + .../pyt/test_001_emergency_vacuum.py | 129 ++ .../xid_wraparound/pyt/test_002_limits.py | 142 ++ .../pyt/test_003_wraparounds.py | 48 + .../pyt/test_004_notify_freeze.py | 78 + src/test/postmaster/meson.build | 8 + src/test/postmaster/pyt/test_001_basic.py | 9 + .../pyt/test_002_connection_limits.py | 146 ++ .../postmaster/pyt/test_003_start_stop.py | 116 + src/test/postmaster/pyt/test_004_negotiate.py | 66 + 81 files changed, 10291 insertions(+) create mode 100644 src/test/modules/brin/pyt/test_01_workitems.py create mode 100644 src/test/modules/brin/pyt/test_02_wal_consistency.py create mode 100644 src/test/modules/commit_ts/pyt/test_001_base.py create mode 100644 src/test/modules/commit_ts/pyt/test_002_standby.py create mode 100644 src/test/modules/commit_ts/pyt/test_003_standby_2.py create mode 100644 src/test/modules/commit_ts/pyt/test_004_restart.py create mode 100644 src/test/modules/libpq_pipeline/pyt/test_001_libpq_pipeline.py create mode 100644 src/test/modules/test_aio/pyt/test_001_aio.py create mode 100644 src/test/modules/test_aio/pyt/test_002_io_workers.py create mode 100644 src/test/modules/test_aio/pyt/test_003_initdb.py create mode 100644 src/test/modules/test_aio/pyt/test_004_read_stream.py create mode 100644 src/test/modules/test_autovacuum/pyt/test_001_parallel_autovacuum.py create mode 100644 src/test/modules/test_checksums/pyt/conftest.py create mode 100644 src/test/modules/test_checksums/pyt/test_001_basic.py create mode 100644 src/test/modules/test_checksums/pyt/test_002_restarts.py create mode 100644 src/test/modules/test_checksums/pyt/test_003_standby_restarts.py create mode 100644 src/test/modules/test_checksums/pyt/test_004_offline.py create mode 100644 src/test/modules/test_checksums/pyt/test_005_injection.py create mode 100644 src/test/modules/test_checksums/pyt/test_006_pgbench_single.py create mode 100644 src/test/modules/test_checksums/pyt/test_007_pgbench_standby.py create mode 100644 src/test/modules/test_checksums/pyt/test_008_pitr.py create mode 100644 src/test/modules/test_checksums/pyt/test_009_fpi.py create mode 100644 src/test/modules/test_cloexec/pyt/test_001_cloexec.py create mode 100644 src/test/modules/test_custom_rmgrs/pyt/test_001_basic.py create mode 100644 src/test/modules/test_custom_stats/pyt/test_001_custom_stats.py create mode 100644 src/test/modules/test_escape/pyt/test_001_test_escape.py create mode 100644 src/test/modules/test_extensions/pyt/test_001_extension_control_path.py create mode 100644 src/test/modules/test_int128/pyt/test_001_test_int128.py create mode 100644 src/test/modules/test_json_parser/pyt/test_001_test_json_parser_incremental.py create mode 100644 src/test/modules/test_json_parser/pyt/test_002_inline.py create mode 100644 src/test/modules/test_json_parser/pyt/test_003_test_semantic.py create mode 100644 src/test/modules/test_json_parser/pyt/test_004_test_parser_perf.py create mode 100644 src/test/modules/test_misc/pyt/test_001_constraint_validation.py create mode 100644 src/test/modules/test_misc/pyt/test_002_tablespace.py create mode 100644 src/test/modules/test_misc/pyt/test_003_check_guc.py create mode 100644 src/test/modules/test_misc/pyt/test_004_io_direct.py create mode 100644 src/test/modules/test_misc/pyt/test_005_timeouts.py create mode 100644 src/test/modules/test_misc/pyt/test_006_signal_autovacuum.py create mode 100644 src/test/modules/test_misc/pyt/test_007_catcache_inval.py create mode 100644 src/test/modules/test_misc/pyt/test_008_replslot_single_user.py create mode 100644 src/test/modules/test_misc/pyt/test_009_log_temp_files.py create mode 100644 src/test/modules/test_misc/pyt/test_010_index_concurrently_upsert.py create mode 100644 src/test/modules/test_misc/pyt/test_011_lock_stats.py create mode 100644 src/test/modules/test_misc/pyt/test_012_ddlutils.py create mode 100644 src/test/modules/test_misc/pyt/test_013_temp_obj_multisession.py create mode 100644 src/test/modules/test_plan_advice/pyt/test_001_replan_regress.py create mode 100644 src/test/modules/test_saslprep/pyt/test_001_saslprep_ranges.py create mode 100644 src/test/modules/test_shmem/pyt/test_001_late_shmem_alloc.py create mode 100644 src/test/modules/test_slru/pyt/test_001_multixact.py create mode 100644 src/test/modules/test_slru/pyt/test_002_multixact_wraparound.py create mode 100644 src/test/modules/worker_spi/pyt/test_001_worker_spi.py create mode 100644 src/test/modules/worker_spi/pyt/test_002_worker_terminate.py create mode 100644 src/test/modules/xid_wraparound/pyt/test_001_emergency_vacuum.py create mode 100644 src/test/modules/xid_wraparound/pyt/test_002_limits.py create mode 100644 src/test/modules/xid_wraparound/pyt/test_003_wraparounds.py create mode 100644 src/test/modules/xid_wraparound/pyt/test_004_notify_freeze.py create mode 100644 src/test/postmaster/pyt/test_001_basic.py create mode 100644 src/test/postmaster/pyt/test_002_connection_limits.py create mode 100644 src/test/postmaster/pyt/test_003_start_stop.py create mode 100644 src/test/postmaster/pyt/test_004_negotiate.py diff --git a/src/test/modules/brin/meson.build b/src/test/modules/brin/meson.build index 39a8b2fc925..4628eb41a56 100644 --- a/src/test/modules/brin/meson.build +++ b/src/test/modules/brin/meson.build @@ -15,4 +15,10 @@ tests += { 't/02_wal_consistency.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_01_workitems.py', + 'pyt/test_02_wal_consistency.py', + ], + }, } diff --git a/src/test/modules/brin/pyt/test_01_workitems.py b/src/test/modules/brin/pyt/test_01_workitems.py new file mode 100644 index 00000000000..c27d22502bf --- /dev/null +++ b/src/test/modules/brin/pyt/test_01_workitems.py @@ -0,0 +1,79 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Verify that BRIN autosummarization work items work correctly.""" + +import time + + +def test_01_workitems(create_pg): + node = create_pg("tango", start=False) + node.append_conf("autovacuum_naptime=1s") + node.start() + + node.safe_sql("create extension pageinspect") + + # Create a table with an autosummarizing BRIN index. The in-process + # Session wraps a multi-statement query in one implicit transaction, which + # is fine here (no transaction-incompatible statements), so the original + # newline-separated DDL block is issued as-is. + node.safe_sql( + "create table brin_wi (a int) with (fillfactor = 10);\n" + "create index brin_wi_idx on brin_wi using brin (a) " + "with (pages_per_range=1, autosummarize=on);\n" + ) + # Another table with an index that requires a snapshot to run + node.safe_sql( + "create table journal (d timestamp) with (fillfactor = 10);\n" + "create function packdate(d timestamp) returns text language plpgsql\n" + " as $$ begin return to_char(d, 'yyyymm'); end; $$\n" + " returns null on null input immutable;\n" + "create index brin_packdate_idx on journal using brin (packdate(d))\n" + " with (autosummarize = on, pages_per_range = 1);\n" + ) + + count = node.safe_sql( + "select count(*) from brin_page_items(" + "get_raw_page('brin_wi_idx', 2), 'brin_wi_idx'::regclass)" + ) + assert count == "1", "initial brin_wi_idx index state is correct" + count = node.safe_sql( + "select count(*) from brin_page_items(" + "get_raw_page('brin_packdate_idx', 2), 'brin_packdate_idx'::regclass)" + ) + assert count == "1", "initial brin_packdate_idx index state is correct" + + node.safe_sql("insert into brin_wi select * from generate_series(1, 100)") + node.safe_sql( + "insert into journal select * from generate_series(" + "timestamp '1976-08-01', '1976-10-28', '1 day')" + ) + + # Give a little time for autovacuum to react. This matches the naptime + # configured above. + time.sleep(1) + + assert node.poll_query_until( + "select count(*) > 1 from brin_page_items(" + "get_raw_page('brin_wi_idx', 2), 'brin_wi_idx'::regclass)" + ) + + count = node.safe_sql( + "select count(*) from brin_page_items(" + "get_raw_page('brin_wi_idx', 2), 'brin_wi_idx'::regclass)\n" + " where not placeholder;" + ) + assert int(count) > 1, f"{count} brin_wi_idx ranges got summarized" + + assert node.poll_query_until( + "select count(*) > 1 from brin_page_items(" + "get_raw_page('brin_packdate_idx', 2), 'brin_packdate_idx'::regclass)" + ) + + count = node.safe_sql( + "select count(*) from brin_page_items(" + "get_raw_page('brin_packdate_idx', 2), 'brin_packdate_idx'::regclass)\n" + " where not placeholder;" + ) + assert int(count) > 1, f"{count} brin_packdate_idx ranges got summarized" + + node.stop() diff --git a/src/test/modules/brin/pyt/test_02_wal_consistency.py b/src/test/modules/brin/pyt/test_02_wal_consistency.py new file mode 100644 index 00000000000..e886d6ce2af --- /dev/null +++ b/src/test/modules/brin/pyt/test_02_wal_consistency.py @@ -0,0 +1,64 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Verify WAL consistency for BRIN indexes.""" + + +def test_02_wal_consistency(create_pg): + # Set up primary + whiskey = create_pg("whiskey", start=False, allows_streaming=True) + whiskey.append_conf("wal_consistency_checking = brin") + whiskey.start() + whiskey.safe_sql("create extension pageinspect") + whiskey.safe_sql("create extension pg_walinspect") + res = whiskey.sql("SELECT pg_create_physical_replication_slot('standby_1');") + assert res.error_message is None, "physical slot created on primary" + + # Take backup + backup_name = "brinbkp" + whiskey.backup(backup_name) + + # Create streaming standby linking to primary + charlie = create_pg("charlie", start=False) + charlie.init_from_backup(whiskey, backup_name, has_streaming=True) + charlie.append_conf("primary_slot_name = standby_1") + charlie.start() + + # Now write some WAL in the primary + whiskey.safe_sql( + "create table tbl_timestamp0 (d1 timestamp(0) without time zone) " + "with (fillfactor=10);\n" + "create index on tbl_timestamp0 using brin (d1) " + "with (pages_per_range = 1, autosummarize=false);\n" + ) + start_lsn = whiskey.lsn("insert") + # Run a loop that will end when the second revmap page is created + whiskey.safe_sql( + """ +do +$$ +declare + current timestamp with time zone := '2019-03-27 08:14:01.123456789 UTC'; +begin + loop + insert into tbl_timestamp0 select i from + generate_series(current, current + interval '1 day', '28 seconds') i; + perform brin_summarize_new_values('tbl_timestamp0_d1_idx'); + if (brin_metapage_info(get_raw_page('tbl_timestamp0_d1_idx', 0))).lastrevmappage > 1 then + exit; + end if; + current := current + interval '1 day'; + end loop; +end +$$; +""" + ) + end_lsn = whiskey.lsn("flush") + + out = whiskey.safe_sql( + f"select count(*) from pg_get_wal_records_info('{start_lsn}', '{end_lsn}')\n" + "where resource_manager = 'BRIN' AND\n" + "record_type ILIKE '%revmap%'" + ) + assert int(out) >= 1 + + whiskey.wait_for_replay_catchup(charlie) diff --git a/src/test/modules/commit_ts/meson.build b/src/test/modules/commit_ts/meson.build index d8ee6ec426d..9b5162eecd9 100644 --- a/src/test/modules/commit_ts/meson.build +++ b/src/test/modules/commit_ts/meson.build @@ -20,4 +20,12 @@ tests += { 't/004_restart.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_base.py', + 'pyt/test_002_standby.py', + 'pyt/test_003_standby_2.py', + 'pyt/test_004_restart.py', + ], + }, } diff --git a/src/test/modules/commit_ts/pyt/test_001_base.py b/src/test/modules/commit_ts/pyt/test_001_base.py new file mode 100644 index 00000000000..4faccd97bc6 --- /dev/null +++ b/src/test/modules/commit_ts/pyt/test_001_base.py @@ -0,0 +1,36 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Single-node commit timestamp test. + +Verify that a commit timestamp can be set, and is still present after +crash recovery. +""" + + +def test_001_base(create_pg): + node = create_pg( + "foxtrot", start=False, initdb_extra=["-c", "track_commit_timestamp=on"] + ) + node.start() + + # Create a table, compare "now()" to the commit TS of its xmin + node.safe_sql("create table t as select now from (select now(), pg_sleep(1)) f") + true = node.safe_sql( + "select t.now - ts.* < '1s' from t, pg_class c, " + "pg_xact_commit_timestamp(c.xmin) ts where relname = 't'" + ) + assert true == "t", "commit TS is set" + ts = node.safe_sql( + "select ts.* from pg_class, pg_xact_commit_timestamp(xmin) ts " + "where relname = 't'" + ) + + # Verify that we read the same TS after crash recovery + node.stop("immediate") + node.start() + + recovered_ts = node.safe_sql( + "select ts.* from pg_class, pg_xact_commit_timestamp(xmin) ts " + "where relname = 't'" + ) + assert recovered_ts == ts, "commit TS remains after crash recovery" diff --git a/src/test/modules/commit_ts/pyt/test_002_standby.py b/src/test/modules/commit_ts/pyt/test_002_standby.py new file mode 100644 index 00000000000..2e039dd2318 --- /dev/null +++ b/src/test/modules/commit_ts/pyt/test_002_standby.py @@ -0,0 +1,62 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test a simple commit timestamp scenario involving a standby.""" + +import re + + +def test_002_standby(create_pg): + bkplabel = "backup" + primary = create_pg("primary", start=False, allows_streaming=True) + primary.append_conf( + """ +track_commit_timestamp = on +max_wal_senders = 5 +""" + ) + primary.start() + primary.backup(bkplabel) + + standby = create_pg("standby", start=False) + standby.init_from_backup(primary, bkplabel, has_streaming=True) + standby.start() + + for i in range(1, 11): + primary.safe_sql(f"create table t{i}()") + + primary_ts = primary.safe_sql( + "SELECT ts.* FROM pg_class, pg_xact_commit_timestamp(xmin) AS ts " + "WHERE relname = 't10'" + ) + primary_lsn = primary.safe_sql("select pg_current_wal_lsn()") + assert standby.poll_query_until( + f"SELECT '{primary_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + ), "standby never caught up" + + standby_ts = standby.safe_sql( + "select ts.* from pg_class, pg_xact_commit_timestamp(xmin) ts " + "where relname = 't10'" + ) + assert primary_ts == standby_ts, "standby gives same value as primary" + + primary.append_conf("track_commit_timestamp = off") + primary.restart() + primary.safe_sql("checkpoint") + primary_lsn = primary.safe_sql("select pg_current_wal_lsn()") + assert standby.poll_query_until( + f"SELECT '{primary_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + ), "standby never caught up" + standby.safe_sql("checkpoint") + + # This one should raise an error now + res = standby.sql( + "select ts.* from pg_class, pg_xact_commit_timestamp(xmin) ts " + "where relname = 't10'" + ) + assert ( + res.error_message is not None + ), "standby errors when primary turned feature off" + assert res.psqlout == "", "standby gives no value when primary turned feature off" + assert re.search( + r"could not get commit timestamp data", res.error_message + ), "expected error when primary turned feature off" diff --git a/src/test/modules/commit_ts/pyt/test_003_standby_2.py b/src/test/modules/commit_ts/pyt/test_003_standby_2.py new file mode 100644 index 00000000000..54a8017e863 --- /dev/null +++ b/src/test/modules/commit_ts/pyt/test_003_standby_2.py @@ -0,0 +1,66 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test primary/standby commit timestamps with the GUC toggled repeatedly. + +Exercise a primary/standby scenario where the track_commit_timestamp GUC +is repeatedly toggled on and off. +""" + +import re + + +def test_003_standby_2(create_pg): + bkplabel = "backup" + primary = create_pg("primary", start=False, allows_streaming=True) + primary.append_conf( + """ +track_commit_timestamp = on +max_wal_senders = 5 +""" + ) + primary.start() + primary.backup(bkplabel) + + standby = create_pg("standby", start=False) + standby.init_from_backup(primary, bkplabel, has_streaming=True) + standby.start() + + for i in range(1, 11): + primary.safe_sql(f"create table t{i}()") + + primary.append_conf("track_commit_timestamp = off") + primary.restart() + primary.safe_sql("checkpoint") + primary_lsn = primary.safe_sql("select pg_current_wal_lsn()") + assert standby.poll_query_until( + f"SELECT '{primary_lsn}'::pg_lsn <= pg_last_wal_replay_lsn()" + ), "standby never caught up" + + standby.safe_sql("checkpoint") + standby.restart() + + res = standby.sql( + "SELECT ts.* FROM pg_class, pg_xact_commit_timestamp(xmin) AS ts " + "WHERE relname = 't10'" + ) + assert ( + res.error_message is not None + ), "expect error when getting commit timestamp after restart" + assert res.psqlout == "", "standby does not return a value after restart" + assert re.search( + r"could not get commit timestamp data", res.error_message + ), "expected err msg after restart" + + primary.append_conf("track_commit_timestamp = on") + primary.restart() + primary.append_conf("track_commit_timestamp = off") + primary.restart() + + standby.promote() + + standby.safe_sql("create table t11()") + standby_ts = standby.safe_sql( + "SELECT ts.* FROM pg_class, pg_xact_commit_timestamp(xmin) AS ts " + "WHERE relname = 't11'" + ) + assert standby_ts != "", f"standby gives valid value ({standby_ts}) after promotion" diff --git a/src/test/modules/commit_ts/pyt/test_004_restart.py b/src/test/modules/commit_ts/pyt/test_004_restart.py new file mode 100644 index 00000000000..b3979931909 --- /dev/null +++ b/src/test/modules/commit_ts/pyt/test_004_restart.py @@ -0,0 +1,150 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test preservation of commit timestamps across restarts.""" + +import re + + +def test_004_restart(create_pg): + node_primary = create_pg("primary", start=False, allows_streaming=True) + node_primary.append_conf("track_commit_timestamp = on") + node_primary.start() + + res = node_primary.sql("SELECT pg_xact_commit_timestamp('0');") + assert ( + res.error_message is not None + ), "getting ts of InvalidTransactionId reports error" + assert re.search( + r"cannot retrieve commit timestamp for transaction", res.error_message + ), "expected error from InvalidTransactionId" + + res = node_primary.sql("SELECT pg_xact_commit_timestamp('1');") + assert res.error_message is None, "getting ts of BootstrapTransactionId succeeds" + assert res.psqlout == "", "timestamp of BootstrapTransactionId is null" + + res = node_primary.sql("SELECT pg_xact_commit_timestamp('2');") + assert res.error_message is None, "getting ts of FrozenTransactionId succeeds" + assert res.psqlout == "", "timestamp of FrozenTransactionId is null" + + # Since FirstNormalTransactionId will've occurred during initdb, long before + # we enabled commit timestamps, it'll be null since we have no cts data for + # it but cts are enabled. + assert ( + node_primary.safe_sql("SELECT pg_xact_commit_timestamp('3');") == "" + ), "committs for FirstNormalTransactionId is null" + + node_primary.safe_sql( + "CREATE TABLE committs_test(x integer, y timestamp with time zone);" + ) + + xid = node_primary.safe_sql( + """ + BEGIN; + INSERT INTO committs_test(x, y) VALUES (1, current_timestamp); + SELECT pg_current_xact_id()::xid; + COMMIT; + """ + ) + + before_restart_ts = node_primary.safe_sql( + f"SELECT pg_xact_commit_timestamp('{xid}');" + ) + assert before_restart_ts not in ("", "null"), "commit timestamp recorded" + + node_primary.stop("immediate") + node_primary.start() + + after_crash_ts = node_primary.safe_sql(f"SELECT pg_xact_commit_timestamp('{xid}');") + assert ( + after_crash_ts == before_restart_ts + ), "timestamps before and after crash are equal" + + node_primary.stop("fast") + node_primary.start() + + after_restart_ts = node_primary.safe_sql( + f"SELECT pg_xact_commit_timestamp('{xid}');" + ) + assert ( + after_restart_ts == before_restart_ts + ), "timestamps before and after restart are equal" + + # Now disable commit timestamps + node_primary.append_conf("track_commit_timestamp = off") + node_primary.stop("fast") + + # Start the server, which generates a XLOG_PARAMETER_CHANGE record where + # the parameter change is registered. + node_primary.start() + + # Now restart again the server so as no XLOG_PARAMETER_CHANGE record are + # replayed with the follow-up immediate shutdown. + node_primary.restart() + + # Move commit timestamps across page boundaries. Things should still + # be able to work across restarts with those transactions committed while + # track_commit_timestamp is disabled. + node_primary.safe_sql( + """CREATE PROCEDURE consume_xid(cnt int) +AS $$ +DECLARE + i int; + BEGIN + FOR i in 1..cnt LOOP + EXECUTE 'SELECT pg_current_xact_id()'; + COMMIT; + END LOOP; + END; +$$ +LANGUAGE plpgsql; +""" + ) + node_primary.safe_sql("CALL consume_xid(2000)") + + res = node_primary.sql(f"SELECT pg_xact_commit_timestamp('{xid}');") + assert ( + res.error_message is not None + ), "no commit timestamp from enable tx when cts disabled" + assert re.search( + r"could not get commit timestamp data", res.error_message + ), "expected error from enabled tx when committs disabled" + + # Do a tx while cts disabled + xid_disabled = node_primary.safe_sql( + """ + BEGIN; + INSERT INTO committs_test(x, y) VALUES (2, current_timestamp); + SELECT pg_current_xact_id(); + COMMIT; + """ + ) + + # Should be inaccessible + res = node_primary.sql(f"SELECT pg_xact_commit_timestamp('{xid_disabled}');") + assert res.error_message is not None, "no commit timestamp when disabled" + assert re.search( + r"could not get commit timestamp data", res.error_message + ), "expected error from disabled tx when committs disabled" + + # Re-enable, restart and ensure we can still get the old timestamps + node_primary.append_conf("track_commit_timestamp = on") + + # An immediate shutdown is used here. At next startup recovery will + # replay transactions which committed when track_commit_timestamp was + # disabled, and the facility should be able to work properly. + node_primary.stop("immediate") + node_primary.start() + + after_enable_ts = node_primary.safe_sql( + f"SELECT pg_xact_commit_timestamp('{xid}');" + ) + assert after_enable_ts == "", "timestamp of enabled tx null after re-enable" + + after_enable_disabled_ts = node_primary.safe_sql( + f"SELECT pg_xact_commit_timestamp('{xid_disabled}');" + ) + assert ( + after_enable_disabled_ts == "" + ), "timestamp of disabled tx null after re-enable" + + node_primary.stop() diff --git a/src/test/modules/libpq_pipeline/meson.build b/src/test/modules/libpq_pipeline/meson.build index 5bb895d8548..a23a0117453 100644 --- a/src/test/modules/libpq_pipeline/meson.build +++ b/src/test/modules/libpq_pipeline/meson.build @@ -29,4 +29,10 @@ tests += { ], 'deps': [libpq_pipeline], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_libpq_pipeline.py', + ], + 'deps': [libpq_pipeline], + }, } diff --git a/src/test/modules/libpq_pipeline/pyt/test_001_libpq_pipeline.py b/src/test/modules/libpq_pipeline/pyt/test_001_libpq_pipeline.py new file mode 100644 index 00000000000..61408d568fd --- /dev/null +++ b/src/test/modules/libpq_pipeline/pyt/test_001_libpq_pipeline.py @@ -0,0 +1,85 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test the libpq pipeline mode via the libpq_pipeline client program. + +Runs the build-dir ``libpq_pipeline`` client program against a running server +for each of its self-described subcommands, and for a subset compares the libpq +trace it emits against the expected ``traces/*.trace`` file shipped with the +module. +""" + +import os + +NUMROWS = 700 + +# Tests for which libpq_pipeline can emit a trace file we compare against the +# expected output in the module source traces/ directory. +CMPTRACE = { + "simple_pipeline", + "nosync", + "multi_pipelines", + "prepared", + "singlerow", + "pipeline_abort", + "pipeline_idle", + "transaction", + "disallowed_in_pipeline", +} + +# traces/ lives in the module source directory, one level up from pyt/. +TRACES_DIR = os.path.join(os.path.dirname(__file__), os.pardir, "traces") + + +def _list_tests(pg_bin): + """Return the list of subcommands the libpq_pipeline binary reports.""" + res = pg_bin.result(["libpq_pipeline", "tests"]) + assert res.stderr == "", f"oops: {res.stderr}" + return res.stdout.split() + + +def test_001_libpq_pipeline(pg_bin, pg, tmp_path): + tests = _list_tests(pg_bin) + + tracedir = tmp_path / "traces" + tracedir.mkdir() + + for testname in tests: + extraargs = ["-r", str(NUMROWS)] + cmptrace = testname in CMPTRACE + + traceout = str(tracedir / f"{testname}.trace") + if cmptrace: + extraargs += ["-t", traceout] + + # Execute the test using the latest protocol version. + pg_bin.command_ok( + [ + "libpq_pipeline", + *extraargs, + testname, + pg.connstr("postgres") + " max_protocol_version=latest", + ], + f"libpq_pipeline {testname}", + ) + + # Compare the trace, if requested. + if cmptrace: + expected_path = os.path.join(TRACES_DIR, f"{testname}.trace") + with open(expected_path, encoding="utf-8") as fh: + expected = fh.read() + with open(traceout, encoding="utf-8") as fh: + result = fh.read() + assert result == expected, f"{testname} trace match" + + # There were changes to query cancellation in protocol version 3.2, so + # test separately that it still works the old protocol version too. + pg_bin.command_ok( + [ + "libpq_pipeline", + "cancel", + pg.connstr("postgres") + " max_protocol_version=3.0", + ], + "libpq_pipeline cancel with protocol 3.0", + ) + + pg.stop("fast") diff --git a/src/test/modules/test_aio/meson.build b/src/test/modules/test_aio/meson.build index 909f81d96c1..c6f3cb9c796 100644 --- a/src/test/modules/test_aio/meson.build +++ b/src/test/modules/test_aio/meson.build @@ -36,4 +36,15 @@ tests += { 't/004_read_stream.pl', ], }, + 'pytest': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_aio.py', + 'pyt/test_002_io_workers.py', + 'pyt/test_003_initdb.py', + 'pyt/test_004_read_stream.py', + ], + }, } diff --git a/src/test/modules/test_aio/pyt/test_001_aio.py b/src/test/modules/test_aio/pyt/test_001_aio.py new file mode 100644 index 00000000000..c84ff7c54f2 --- /dev/null +++ b/src/test/modules/test_aio/pyt/test_001_aio.py @@ -0,0 +1,1871 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Exercise the AIO subsystem extensively. + +Load the test_aio extension and drive the AIO subsystem across each supported +io_method (worker, io_uring if supported, sync): the IO handle API, batchmode +API, hard error handling, partial reads, zero buffers, checksum failures, +concurrency, StartReadBuffers(), and injection points. + +Helper subs are reproduced as module functions (never named ``test_*``) and the +per-io_method loop is realized as a pytest parametrization. +""" + +import os +import re +import subprocess + +import pytest + +# -- AIO test helpers -------------------------------------------------------- + + +def configure(node): + """Prepare a cluster for AIO tests.""" + node.append_conf( + "\n".join( + [ + "shared_preload_libraries=test_aio", + "log_min_messages = 'DEBUG3'", + "log_statement=all", + "log_error_verbosity=default", + "restart_after_crash=false", + "temp_buffers=100", + "", + ] + ) + ) + + +def have_io_uring(bindir, libdir): + """Detect whether io_uring is a supported io_method. + + Detect io_uring support by inspecting the list of valid io_method values + reported when assigning an invalid value to the enum GUC. ``-C`` is used + so the superuser check is skipped. + """ + env = dict(os.environ) + if libdir: + env["LD_LIBRARY_PATH"] = libdir + os.pathsep + env.get("LD_LIBRARY_PATH", "") + postgres = os.path.join(bindir, "postgres") + proc = subprocess.run( + [postgres, "-C", "invalid", "-c", "io_method=invalid"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + env=env, + ) + out = proc.stdout + + m = re.search(r"Available values: ([^\.]+)\.", out) + if not m: + raise RuntimeError("can't determine supported io_method values") + return "io_uring" in m.group(1) + + +def supported_io_methods(bindir, libdir): + """Return the list of io_method values supported by this build.""" + methods = ["worker"] + if have_io_uring(bindir, libdir): + methods.append("io_uring") + # Return sync last, as it will least commonly fail. + methods.append("sync") + return methods + + +# -- generic test helpers ---------------------------------------------------- + + +def psql_like(io_method, session, name, sql, expected_stdout, expected_stderr): + """Run *sql* and assert its stdout and stderr match expectations. + + Run *sql* on *session*, assert the stdout matches *expected_stdout* and the + captured stderr (notices + last error) matches *expected_stderr*, then + clear stderr. Returns the stdout. + """ + res = session.query(sql) + output = res.psqlout + + assert re.search( + expected_stdout, output + ), f"{io_method}: {name}: expected stdout /{expected_stdout}/, got {output!r}" + stderr = session.get_stderr() + assert re.search( + expected_stderr, stderr + ), f"{io_method}: {name}: expected stderr /{expected_stderr}/, got {stderr!r}" + session.clear_stderr() + + return output + + +def query_wait_block( + io_method, node, session, name, sql, waitfor, wait_current_session +): + """Issue a query asynchronously and wait for a wait event. + + Issue *sql* asynchronously, then poll until *waitfor* wait event is + observed (in this session's pid, or any session). + """ + pid = session.backend_pid() + + session.do_async(sql) + + if wait_current_session: + waitquery = f"SELECT wait_event FROM pg_stat_activity WHERE pid = {pid}" + else: + waitquery = ( + "SELECT wait_event FROM pg_stat_activity " f"WHERE wait_event = '{waitfor}'" + ) + + assert node.poll_query_until( + waitquery, waitfor + ), f"{io_method}: {name}: observed {waitfor} wait event" + + +def checksum_failures(session, datname=None): + """Return checksum failure stats for a database. + + Return (checksum_failures, checksum_last_failure) for *datname*, or for + shared relations (NULL datname) when *datname* is None. + """ + if datname is not None: + cond = f"datname = '{datname}'" + else: + cond = "datname IS NULL" + + count = session.query_safe( + f"SELECT checksum_failures FROM pg_stat_database WHERE {cond};" + ) + last_failure = session.query_safe( + f"SELECT checksum_last_failure FROM pg_stat_database WHERE {cond};" + ) + return count, last_failure + + +# -- sub-tests --------------------------------------------------------------- + + +def sub_test_handle(io_method, node): + """Sanity checks for the IO handle API.""" + psql = node.connect() + try: + # leak warning: implicit xact + psql_like( + io_method, + psql, + "handle_get() leak in implicit xact", + "SELECT handle_get()", + r"^$", + r"leaked AIO handle", + ) + + # leak warning: explicit xact + psql_like( + io_method, + psql, + "handle_get() leak in explicit xact", + "BEGIN; SELECT handle_get(); COMMIT", + r"^$", + r"leaked AIO handle", + ) + + # leak warning: explicit xact, rollback + psql_like( + io_method, + psql, + "handle_get() leak in explicit xact, rollback", + "BEGIN; SELECT handle_get(); ROLLBACK;", + r"^$", + r"leaked AIO handle", + ) + + # leak warning: subtrans + psql_like( + io_method, + psql, + "handle_get() leak in subxact", + "BEGIN; SAVEPOINT foo; SELECT handle_get(); COMMIT;", + r"^$", + r"leaked AIO handle", + ) + + # leak warning + error: released in different command (thus resowner) + psql_like( + io_method, + psql, + "handle_release() in different command", + "BEGIN; SELECT handle_get(); SELECT handle_release_last(); COMMIT;", + r"^$", + r"(?s)leaked AIO handle.*release in unexpected state", + ) + + # no leak, release in same command + psql_like( + io_method, + psql, + "handle_release() in same command", + "BEGIN; SELECT handle_get() UNION ALL SELECT handle_release_last(); COMMIT;", + r"^$", + r"^$", + ) + + # normal handle use + psql_like( + io_method, + psql, + "handle_get_release()", + "SELECT handle_get_release()", + r"^$", + r"^$", + ) + + # should error out, API violation + psql_like( + io_method, + psql, + "handle_get_twice()", + "SELECT handle_get_twice()", + r"^$", + r"ERROR: API violation: Only one IO can be handed out$", + ) + + # recover after error in implicit xact + psql_like( + io_method, + psql, + "handle error recovery in implicit xact", + "SELECT handle_get_and_error(); SELECT 'ok', handle_get_release()", + r"^|ok$", + r"ERROR.*as you command", + ) + + # recover after error in explicit xact + psql_like( + io_method, + psql, + "handle error recovery in explicit xact", + "BEGIN; SELECT handle_get_and_error(); SELECT handle_get_release(), 'ok'; COMMIT;", + r"^|ok$", + r"ERROR.*as you command", + ) + + # recover after error in subtrans + psql_like( + io_method, + psql, + "handle error recovery in explicit subxact", + "BEGIN; SAVEPOINT foo; SELECT handle_get_and_error(); ROLLBACK TO SAVEPOINT foo; SELECT handle_get_release(); ROLLBACK;", + r"^|ok$", + r"ERROR.*as you command", + ) + finally: + psql.close() + + +def sub_test_batchmode(io_method, node): + """Sanity checks for the batchmode API.""" + psql = node.connect() + try: + # In a build with RELCACHE_FORCE_RELEASE and CATCACHE_FORCE_RELEASE, + # just using SELECT batch_start() causes spurious test failures, + # because the lookup of the type information when printing the result + # tuple also starts a batch. The easiest way around is to not print a + # result tuple. + batch_start_sql = "SELECT WHERE batch_start() IS NULL" + + # leak warning & recovery: implicit xact + psql_like( + io_method, + psql, + "batch_start() leak & cleanup in implicit xact", + batch_start_sql, + r"^$", + r"open AIO batch at end", + ) + + # leak warning & recovery: explicit xact + psql_like( + io_method, + psql, + "batch_start() leak & cleanup in explicit xact", + f"BEGIN; {batch_start_sql}; COMMIT;", + r"^$", + r"open AIO batch at end", + ) + + # leak warning & recovery: explicit xact, rollback + # + # XXX: This doesn't fail right now, due to not getting a chance to do + # something at transaction command commit. That's not a correctness + # issue, it just means it's a bit harder to find buggy code. + # (left commented out intentionally) + + # no warning, batch closed in same command + psql_like( + io_method, + psql, + "batch_start(), batch_end() works", + f"{batch_start_sql} UNION ALL SELECT WHERE batch_end() IS NULL", + r"^$", + r"^$", + ) + finally: + psql.close() + + +def sub_test_io_error(io_method, node): + """Check that simple cases of invalid pages are reported.""" + psql = node.connect() + try: + psql.query_safe( + "CREATE TEMPORARY TABLE tmp_corr(data int not null);\n" + "INSERT INTO tmp_corr SELECT generate_series(1, 10000);\n" + "SELECT modify_rel_block('tmp_corr', 1, corrupt_header=>true);\n" + ) + + for tblname in ("tbl_corr", "tmp_corr"): + if tblname == "tbl_corr": + invalid_page_re = r'invalid page in block 1 of relation "base/\d+/\d+' + else: + invalid_page_re = ( + r'invalid page in block 1 of relation "base/\d+/t\d+_\d+' + ) + + # verify the error is reported in custom C code + psql_like( + io_method, + psql, + f"read_rel_block_ll() of {tblname} page", + f"SELECT read_rel_block_ll('{tblname}', 1)", + r"^$", + invalid_page_re, + ) + + # verify the error is reported for bufmgr reads, seq scan + psql_like( + io_method, + psql, + f"sequential scan of {tblname} block fails", + f"SELECT count(*) FROM {tblname}", + r"^$", + invalid_page_re, + ) + + # verify the error is reported for bufmgr reads, tid scan + psql_like( + io_method, + psql, + f"tid scan of {tblname} block fails", + f"SELECT count(*) FROM {tblname} WHERE ctid = '(1, 1)'", + r"^$", + invalid_page_re, + ) + finally: + psql.close() + + +def sub_test_startwait_io(io_method, node): + """Exercise the interplay of StartBufferIO/TerminateBufferIO.""" + psql_a = node.connect() + psql_b = node.connect() + try: + # --- Verify behavior for normal tables --- + + # create a buffer we can play around with + buf_id = psql_like( + io_method, + psql_a, + "creation of toy buffer succeeds", + "SELECT buffer_create_toy('tbl_ok', 1)", + r"^\d+$", + r"^$", + ) + + # check that one backend can perform StartBufferIO + psql_like( + io_method, + psql_a, + "first StartBufferIO", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>true);", + r"^t$", + r"^$", + ) + + # but not twice on the same buffer (non-waiting) + psql_like( + io_method, + psql_a, + "second StartBufferIO fails, same session", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>false);", + r"^f$", + r"^$", + ) + psql_like( + io_method, + psql_b, + "second StartBufferIO fails, other session", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>false);", + r"^f$", + r"^$", + ) + + # start io in a different session, will block + query_wait_block( + io_method, + node, + psql_b, + "blocking start buffer io", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>true);", + "BufferIo", + True, + ) + + # Terminate the IO, without marking it as success, this should trigger + # the waiting session to be able to start the io + psql_like( + io_method, + psql_a, + "blocking start buffer io, terminating io, not valid", + f"SELECT buffer_call_terminate_io({buf_id}, for_input=>true, succeed=>false, io_error=>false, release_aio=>false)", + r"^$", + r"^$", + ) + + # Because the IO was terminated, but not marked as valid, second + # session should get the right to start io + out = psql_b.get_async_result().psqlout + assert re.search( + r"t", out + ), f"{io_method}: blocking start buffer io, can start io" + + # terminate the IO again + psql_b.query_safe( + f"SELECT buffer_call_terminate_io({buf_id}, for_input=>true, succeed=>false, io_error=>false, release_aio=>false);" + ) + + # same as the above scenario, but mark IO as having succeeded + psql_like( + io_method, + psql_a, + "blocking buffer io w/ success: first start buffer io", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>true);", + r"^t$", + r"^$", + ) + + # start io in a different session, will block + query_wait_block( + io_method, + node, + psql_b, + "blocking start buffer io", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>true);", + "BufferIo", + True, + ) + + # Terminate the IO, marking it as success + psql_like( + io_method, + psql_a, + "blocking start buffer io, terminating io, valid", + f"SELECT buffer_call_terminate_io({buf_id}, for_input=>true, succeed=>true, io_error=>false, release_aio=>false)", + r"^$", + r"^$", + ) + + # Because the IO was terminated, and marked as valid, second session + # should complete but not need io + out = psql_b.get_async_result().psqlout + assert re.search( + r"f", out + ), f"{io_method}: blocking start buffer io, no need to start io" + + # buffer is valid now, make it invalid again + psql_a.query_safe("SELECT buffer_create_toy('tbl_ok', 1);") + + # --- Verify behavior for temporary tables --- + + # create a buffer we can play around with + psql_a.query_safe( + "CREATE TEMPORARY TABLE tmp_ok(data int not null);\n" + "INSERT INTO tmp_ok SELECT generate_series(1, 10000);\n" + ) + buf_id = psql_a.query_safe("SELECT buffer_create_toy('tmp_ok', 3);") + + # check that one backend can perform StartLocalBufferIO + psql_like( + io_method, + psql_a, + "first StartLocalBufferIO", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>false);", + r"^t$", + r"^$", + ) + + # Because local buffers don't use IO_IN_PROGRESS, a second + # StartLocalBufferIO succeeds as well. + psql_like( + io_method, + psql_a, + "second StartLocalBufferIO succeeds, same session", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>false);", + r"^t$", + r"^$", + ) + + # Terminate the IO again, without marking it as a success + psql_a.query_safe( + f"SELECT buffer_call_terminate_io({buf_id}, for_input=>true, succeed=>false, io_error=>false, release_aio=>false);" + ) + psql_like( + io_method, + psql_a, + "StartLocalBufferIO after not marking valid succeeds, same session", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>false);", + r"^t$", + r"^$", + ) + + # Terminate the IO again, marking it as a success + psql_a.query_safe( + f"SELECT buffer_call_terminate_io({buf_id}, for_input=>true, succeed=>true, io_error=>false, release_aio=>false);" + ) + + # Now another StartLocalBufferIO should fail, this time because the + # buffer is already valid. + psql_like( + io_method, + psql_a, + "StartLocalBufferIO after marking valid fails", + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>true);", + r"^f$", + r"^$", + ) + finally: + psql_a.close() + psql_b.close() + + +def sub_test_complete_foreign(io_method, node): + """Test completion of an IO by a backend other than its initiator. + + If the backend issuing a read doesn't wait for the IO's completion, + another backend can complete the IO. + """ + psql_a = node.connect() + psql_b = node.connect() + try: + # Issue IO without waiting for completion, then sleep + psql_a.query_safe( + "SELECT read_rel_block_ll('tbl_ok', 1, wait_complete=>false);" + ) + + # Check that another backend can read the relevant block + psql_like( + io_method, + psql_b, + "completing read started by sleeping backend", + "SELECT count(*) FROM tbl_ok WHERE ctid = '(1,1)' LIMIT 1", + r"^1$", + r"^$", + ) + + # Issue IO without waiting for completion, then exit. + psql_a.query_safe( + "SELECT read_rel_block_ll('tbl_ok', 1, wait_complete=>false);" + ) + psql_a.reconnect_and_clear() + + # Check that another backend can read the relevant block. This verifies + # that the exiting backend left the AIO in a sane state. + psql_like( + io_method, + psql_b, + "read buffer started by exited backend", + "SELECT count(*) FROM tbl_ok WHERE ctid = '(1,1)' LIMIT 1", + r"^1$", + r"^$", + ) + + # Read a tbl_corr block, then sleep. The other session will retry the + # IO and also fail. + log_location = node.log_position() + psql_a.query_safe( + "SELECT read_rel_block_ll('tbl_corr', 1, wait_complete=>false);" + ) + + psql_like( + io_method, + psql_b, + "completing read of tbl_corr block started by other backend", + "SELECT count(*) FROM tbl_corr WHERE ctid = '(1,1)' LIMIT 1", + r"^$", + r"invalid page in block", + ) + + # The log message issued for the read_rel_block_ll() should be a LOG + node.wait_for_log(r"LOG[^\n]+invalid page in", log_location) + + # But for the SELECT, it should be an ERROR + node.wait_for_log(r"ERROR[^\n]+invalid page in", log_location) + finally: + psql_a.close() + psql_b.close() + + +def sub_test_close_fd(io_method, node): + """Test FDs closed while IO is in progress.""" + psql = node.connect() + try: + psql_like( + io_method, + psql, + "close all FDs after read, waiting for results", + "\n\t\t\t\tSELECT read_rel_block_ll('tbl_ok', 1,\n" + "\t\t\t\t\twait_complete=>true,\n" + "\t\t\t\t\tbatchmode_enter=>true,\n" + "\t\t\t\t\tsmgrreleaseall=>true,\n" + "\t\t\t\t\tbatchmode_exit=>true\n" + "\t\t\t\t);", + r"^$", + r"^$", + ) + + psql_like( + io_method, + psql, + "close all FDs after read, no waiting", + "\n\t\t\t\tSELECT read_rel_block_ll('tbl_ok', 1,\n" + "\t\t\t\t\twait_complete=>false,\n" + "\t\t\t\t\tbatchmode_enter=>true,\n" + "\t\t\t\t\tsmgrreleaseall=>true,\n" + "\t\t\t\t\tbatchmode_exit=>true\n" + "\t\t\t\t);", + r"^$", + r"^$", + ) + + # Check that another backend can read the relevant block + psql_like( + io_method, + psql, + "close all FDs after read, no waiting, query works", + "SELECT count(*) FROM tbl_ok WHERE ctid = '(1,1)' LIMIT 1", + r"^1$", + r"^$", + ) + finally: + psql.close() + + +def sub_test_inject(io_method, node): + """Exercise hard IO errors via injection points.""" + psql = node.connect() + try: + # injected what we'd expect + psql.query_safe("SELECT inj_io_short_read_attach(8192);") + psql.query_safe("SELECT invalidate_rel_block('tbl_ok', 2);") + psql_like( + io_method, + psql, + "injection point not triggering failure", + "SELECT count(*) FROM tbl_ok WHERE ctid = '(2, 1)'", + r"^1$", + r"^$", + ) + + # injected a read shorter than a single block, expecting error + psql.query_safe("SELECT inj_io_short_read_attach(17);") + psql.query_safe("SELECT invalidate_rel_block('tbl_ok', 2);") + psql_like( + io_method, + psql, + "single block short read fails", + "SELECT count(*) FROM tbl_ok WHERE ctid = '(2, 1)'", + r"^$", + r'ERROR:.*could not read blocks 2\.\.2 in file "base/.*": read only 0 of 8192 bytes', + ) + + # shorten multi-block read to a single block, should retry + inval_query = ( + "SELECT invalidate_rel_block('tbl_ok', 0);\n" + "SELECT invalidate_rel_block('tbl_ok', 1);\n" + "SELECT invalidate_rel_block('tbl_ok', 2);\n" + "SELECT invalidate_rel_block('tbl_ok', 3);\n" + "/* gap */\n" + "SELECT invalidate_rel_block('tbl_ok', 5);\n" + "SELECT invalidate_rel_block('tbl_ok', 6);\n" + "SELECT invalidate_rel_block('tbl_ok', 7);\n" + "SELECT invalidate_rel_block('tbl_ok', 8);" + ) + + psql.query_safe(inval_query) + psql.query_safe("SELECT inj_io_short_read_attach(8192);") + psql_like( + io_method, + psql, + "multi block short read (1 block) is retried", + "SELECT count(*) FROM tbl_ok", + r"^10000$", + r"^$", + ) + + # shorten multi-block read to two blocks, should retry + psql.query_safe(inval_query) + psql.query_safe("SELECT inj_io_short_read_attach(8192*2);") + psql_like( + io_method, + psql, + "multi block short read (2 blocks) is retried", + "SELECT count(*) FROM tbl_ok", + r"^10000$", + r"^$", + ) + + # verify that page verification errors are detected even as part of a + # shortened multi-block read (tbl_corr, block 1 is corrupted) + psql.query_safe( + "SELECT invalidate_rel_block('tbl_corr', 0);\n" + "SELECT invalidate_rel_block('tbl_corr', 1);\n" + "SELECT invalidate_rel_block('tbl_corr', 2);\n" + "SELECT inj_io_short_read_attach(8192);\n" + ) + + psql_like( + io_method, + psql, + "shortened multi-block read detects invalid page", + "SELECT count(*) FROM tbl_corr WHERE ctid < '(2, 1)'", + r"^$", + r'ERROR:.*invalid page in block 1 of relation "base/.*', + ) + + # trigger a hard error, should error out + psql.query_safe( + "SELECT inj_io_short_read_attach(-errno_from_string('EIO'));\n" + "SELECT invalidate_rel_block('tbl_ok', 2);\n" + ) + + psql_like( + io_method, + psql, + "first hard IO error is reported", + "SELECT count(*) FROM tbl_ok", + r"^$", + r'ERROR:.*could not read blocks 2\.\.2 in file "base/.*": (?:I/O|Input/output) error', + ) + + psql_like( + io_method, + psql, + "second hard IO error is reported", + "SELECT count(*) FROM tbl_ok", + r"^$", + r'ERROR:.*could not read blocks 2\.\.2 in file "base/.*": (?:I/O|Input/output) error', + ) + + psql.query_safe("SELECT inj_io_short_read_detach()") + + # now the IO should be ok. + psql_like( + io_method, + psql, + "recovers after hard error", + "SELECT count(*) FROM tbl_ok", + r"^10000$", + r"^$", + ) + + # trigger a different hard error, should error out + psql.query_safe( + "SELECT inj_io_short_read_attach(-errno_from_string('EROFS'));\n" + "SELECT invalidate_rel_block('tbl_ok', 2);\n" + ) + psql_like( + io_method, + psql, + "different hard IO error is reported", + "SELECT count(*) FROM tbl_ok", + r"^$", + r'ERROR:.*could not read blocks 2\.\.2 in file "base/.*": Read-only file system', + ) + psql.query_safe("SELECT inj_io_short_read_detach()") + finally: + psql.close() + + +def sub_test_inject_worker(io_method, node): + """Test worker-only reopen-failure injection.""" + psql = node.connect() + try: + # trigger a failure to reopen, should error out, but should recover + psql.query_safe( + "SELECT inj_io_reopen_attach();\n" + "SELECT invalidate_rel_block('tbl_ok', 1);\n" + ) + + psql_like( + io_method, + psql, + "failure to open: detected", + "SELECT count(*) FROM tbl_ok", + r"^$", + r'ERROR:.*could not read blocks 1\.\.1 in file "base/.*": No such file or directory', + ) + + psql.query_safe("SELECT inj_io_reopen_detach();") + + # check that we indeed recover + psql_like( + io_method, + psql, + "failure to open: recovers", + "SELECT count(*) FROM tbl_ok", + r"^10000$", + r"^$", + ) + finally: + psql.close() + + +def sub_test_invalidate(io_method, node): + """Test buffer invalidation while IO is in progress. + + Relation getting removed (rollback or DROP TABLE) while IO is ongoing. + """ + psql = node.connect() + try: + for persistency in ("normal", "unlogged", "temporary"): + sql_persistency = "" if persistency == "normal" else persistency + tblname = persistency + "_transactional" + + create_sql = ( + f"CREATE {sql_persistency} TABLE {tblname} " + "(id int not null, data text not null) " + "WITH (AUTOVACUUM_ENABLED = false);\n" + f"INSERT INTO {tblname}(id, data) " + "SELECT generate_series(1, 10000) as id, repeat('a', 200);\n" + ) + + # Verify that outstanding read IO does not cause problems with + # AbortTransaction -> smgrDoPendingDeletes -> smgrdounlinkall -> + # ... -> Invalidate[Local]Buffer. + psql.query_safe(f"BEGIN; {create_sql};") + psql.query_safe( + f"SELECT read_rel_block_ll('{tblname}', 1, wait_complete=>false);\n" + ) + psql_like( + io_method, + psql, + f"rollback of newly created {persistency} table with outstanding IO", + "ROLLBACK", + r"^$", + r"^$", + ) + + # Verify that outstanding read IO does not cause problems with + # CommitTransaction -> smgrDoPendingDeletes -> smgrdounlinkall -> + # ... -> Invalidate[Local]Buffer. + psql.query_safe(f"BEGIN; {create_sql}; COMMIT;") + psql.query_safe( + "BEGIN;\n" + f"SELECT read_rel_block_ll('{tblname}', 1, wait_complete=>false);\n" + ) + + psql_like( + io_method, + psql, + f"drop {persistency} table with outstanding IO", + f"DROP TABLE {tblname}", + r"^$", + r"^$", + ) + + psql_like( + io_method, + psql, + f"commit after drop {persistency} table with outstanding IO", + "COMMIT", + r"^$", + r"^$", + ) + finally: + psql.close() + + +def sub_test_zero(io_method, node): + """Test ZERO_ON_ERROR and zero_damaged_pages behavior.""" + psql_a = node.connect() + psql_b = node.connect() + try: + for persistency in ("normal", "temporary"): + sql_persistency = "" if persistency == "normal" else persistency + + psql_a.query_safe( + f"CREATE {sql_persistency} TABLE tbl_zero(id int) " + "WITH (AUTOVACUUM_ENABLED = false);\n" + "INSERT INTO tbl_zero SELECT generate_series(1, 10000);\n" + ) + + psql_a.query_safe( + "SELECT modify_rel_block('tbl_zero', 0, corrupt_header=>true);\n" + ) + + # Check that page validity errors are detected + psql_like( + io_method, + psql_a, + f"{persistency}: test reading of invalid block 0", + "\nSELECT read_rel_block_ll('tbl_zero', 0, zero_on_error=>false)", + r"^$", + r'^(?:psql::\d+: )?ERROR: invalid page in block 0 of relation "base/.*/.*$', + ) + + # Check that page validity errors are zeroed + psql_like( + io_method, + psql_a, + f"{persistency}: test zeroing of invalid block 0", + "\nSELECT read_rel_block_ll('tbl_zero', 0, zero_on_error=>true)", + r"^$", + r'^(?:psql::\d+: )?WARNING: invalid page in block 0 of relation "base/.*/.*"; zeroing out page$', + ) + + # And that once the corruption is fixed, we can read again + psql_a.query("SELECT modify_rel_block('tbl_zero', 0, zero=>true);\n") + psql_a.clear_stderr() + + psql_like( + io_method, + psql_a, + f"{persistency}: test re-read of block 0", + "\nSELECT read_rel_block_ll('tbl_zero', 0, zero_on_error=>false)", + r"^$", + r"^$", + ) + + # Check a page validity error in another block, to ensure we report + # the correct block number + psql_a.query_safe( + "SELECT modify_rel_block('tbl_zero', 3, corrupt_header=>true);\n" + ) + psql_like( + io_method, + psql_a, + f"{persistency}: test zeroing of invalid block 3", + "SELECT read_rel_block_ll('tbl_zero', 3, zero_on_error=>true);", + r"^$", + r'^(?:psql::\d+: )?WARNING: invalid page in block 3 of relation "base/.*/.*"; zeroing out page$', + ) + + # Check one read reporting multiple invalid blocks + psql_a.query_safe( + "SELECT modify_rel_block('tbl_zero', 2, corrupt_header=>true);\n" + "SELECT modify_rel_block('tbl_zero', 3, corrupt_header=>true);\n" + ) + # First test error + psql_like( + io_method, + psql_a, + f"{persistency}: test reading of invalid block 2,3 in larger read", + "SELECT read_rel_block_ll('tbl_zero', 1, nblocks=>4, zero_on_error=>false)", + r"^$", + r'^(?:psql::\d+: )?ERROR: 2 invalid pages among blocks 1..4 of relation "base/.*/.*\nDETAIL: Block 2 held the first invalid page\.\nHINT:[^\n]+$', + ) + + # Then test zeroing via ZERO_ON_ERROR flag + psql_like( + io_method, + psql_a, + f"{persistency}: test zeroing of invalid block 2,3 in larger read, ZERO_ON_ERROR", + "SELECT read_rel_block_ll('tbl_zero', 1, nblocks=>4, zero_on_error=>true)", + r"^$", + r'^(?:psql::\d+: )?WARNING: zeroing out 2 invalid pages among blocks 1..4 of relation "base/.*/.*\nDETAIL: Block 2 held the first zeroed page\.\nHINT:[^\n]+$', + ) + + # Then test zeroing via zero_damaged_pages + psql_like( + io_method, + psql_a, + f"{persistency}: test zeroing of invalid block 2,3 in larger read, zero_damaged_pages", + "\nBEGIN;\n" + "SET LOCAL zero_damaged_pages = true;\n" + "SELECT read_rel_block_ll('tbl_zero', 1, nblocks=>4, zero_on_error=>false)\n" + "COMMIT;\n", + r"^$", + r'^(?:psql::\d+: )?WARNING: zeroing out 2 invalid pages among blocks 1..4 of relation "base/.*/.*\nDETAIL: Block 2 held the first zeroed page\.\nHINT:[^\n]+$', + ) + + psql_a.query_safe("COMMIT") + + # Verify that bufmgr.c IO detects page validity errors + psql_a.query( + "SELECT invalidate_rel_block('tbl_zero', g.i)\n" + "FROM generate_series(0, 15) g(i);\n" + "SELECT modify_rel_block('tbl_zero', 3, zero=>true);\n" + ) + psql_a.clear_stderr() + + psql_like( + io_method, + psql_a, + f"{persistency}: verify reading zero_damaged_pages=off", + "\nSELECT count(*) FROM tbl_zero", + r"^$", + r'^(?:psql::\d+: )?ERROR: invalid page in block 2 of relation "base/.*/.*$', + ) + + # Verify that bufmgr.c IO zeroes out pages with page validity errors + psql_like( + io_method, + psql_a, + f"{persistency}: verify zero_damaged_pages=on", + "\nBEGIN;\n" + "SET LOCAL zero_damaged_pages = true;\n" + "SELECT count(*) FROM tbl_zero;\n" + "COMMIT;\n", + r"^\d+$", + r'^(?:psql::\d+: )?WARNING: invalid page in block 2 of relation "base/.*/.*$', + ) + + # Check that warnings/errors about page validity in an IO started by + # session A that session B might complete aren't logged visibly to + # session B. + # + # This requires cross-session access to the same relation, hence + # the restriction to non-temporary table. + if sql_persistency != "temporary": + # Create a corruption and then read the block without waiting + # for completion. + psql_a.query( + "SELECT modify_rel_block('tbl_zero', 1, corrupt_header=>true);\n" + "SELECT read_rel_block_ll('tbl_zero', 1, wait_complete=>false, zero_on_error=>true)\n" + ) + + psql_like( + io_method, + psql_b, + f"{persistency}: test completing read by other session doesn't generate warning", + "SELECT count(*) > 0 FROM tbl_zero;", + r"^t$", + r"^$", + ) + + # Clean up + psql_a.query_safe("DROP TABLE tbl_zero;\n") + + psql_a.clear_stderr() + finally: + psql_a.close() + psql_b.close() + + +def sub_test_checksum(io_method, node): + """Detect checksum failures and report them.""" + psql_a = node.connect() + try: + # Split multi-statement query into separate calls to match psql + # behavior where errors in one statement don't prevent subsequent + # statements. + psql_a.query_safe( + "CREATE TABLE tbl_normal(id int) WITH (AUTOVACUUM_ENABLED = false)" + ) + psql_a.query_safe("INSERT INTO tbl_normal SELECT generate_series(1, 5000)") + psql_a.query_safe( + "SELECT modify_rel_block('tbl_normal', 3, corrupt_checksum=>true)" + ) + psql_a.query_safe( + "CREATE TEMPORARY TABLE tbl_temp(id int) WITH (AUTOVACUUM_ENABLED = false)" + ) + psql_a.query_safe("INSERT INTO tbl_temp SELECT generate_series(1, 5000)") + psql_a.query_safe( + "SELECT modify_rel_block('tbl_temp', 3, corrupt_checksum=>true)" + ) + psql_a.query_safe( + "SELECT modify_rel_block('tbl_temp', 4, corrupt_checksum=>true)" + ) + + # To be able to test checksum failures on shared rels we need a shared + # rel with invalid pages - which is a bit scary. pg_shseclabel seems + # like a good bet, as it's not accessed in a default configuration. + psql_a.query_safe( + "SELECT grow_rel('pg_shseclabel', 4);\n" + "SELECT modify_rel_block('pg_shseclabel', 2, corrupt_checksum=>true);\n" + "SELECT modify_rel_block('pg_shseclabel', 3, corrupt_checksum=>true);\n" + ) + + # normal rel: page validity errors detected, checksums stats increase + cs_count_before, _cs_ts_before = checksum_failures(psql_a, "postgres") + psql_like( + io_method, + psql_a, + "normal rel: test reading of invalid block 3", + "\nSELECT read_rel_block_ll('tbl_normal', 3, nblocks=>1, zero_on_error=>false);", + r"^$", + r'^(?:psql::\d+: )?ERROR: invalid page in block 3 of relation "base/\d+/\d+"$', + ) + + cs_count_after, cs_ts_after = checksum_failures(psql_a, "postgres") + + assert int(cs_count_before) + 1 <= int( + cs_count_after + ), f"{io_method}: normal rel: checksum count increased" + assert ( + cs_ts_after != "" + ), f"{io_method}: normal rel: checksum timestamp is not null" + + # temp rel: page validity errors detected, checksums stats increase + cs_count_after, cs_ts_after = checksum_failures(psql_a, "postgres") + psql_like( + io_method, + psql_a, + "temp rel: test reading of invalid block 4, valid block 5", + "\nSELECT read_rel_block_ll('tbl_temp', 4, nblocks=>2, zero_on_error=>false);", + r"^$", + r'^(?:psql::\d+: )?ERROR: invalid page in block 4 of relation "base/\d+/t\d+_\d+"$', + ) + + cs_count_after, cs_ts_after = checksum_failures(psql_a, "postgres") + + assert int(cs_count_before) + 1 <= int( + cs_count_after + ), f"{io_method}: temp rel: checksum count increased" + assert ( + cs_ts_after != "" + ), f"{io_method}: temp rel: checksum timestamp is not null" + + # shared rel: page validity errors detected, checksums stats increase + cs_count_before, cs_ts_after = checksum_failures(psql_a) + psql_like( + io_method, + psql_a, + "shared rel: reading of invalid blocks 2+3", + "\nSELECT read_rel_block_ll('pg_shseclabel', 2, nblocks=>2, zero_on_error=>false);", + r"^$", + r'^(?:psql::\d+: )?ERROR: 2 invalid pages among blocks 2..3 of relation "global/\d+"\nDETAIL: Block 2 held the first invalid page\.\nHINT:[^\n]+$', + ) + + cs_count_after, cs_ts_after = checksum_failures(psql_a) + + assert int(cs_count_before) + 1 <= int( + cs_count_after + ), f"{io_method}: shared rel: checksum count increased" + assert ( + cs_ts_after != "" + ), f"{io_method}: shared rel: checksum timestamp is not null" + + # and restore sanity + psql_a.query( + "SELECT modify_rel_block('pg_shseclabel', 1, zero=>true);\n" + "DROP TABLE tbl_normal;\n" + ) + psql_a.clear_stderr() + finally: + psql_a.close() + + +def sub_test_checksum_createdb(io_method, node): + """Test checksum handling when creating a database via CREATE DATABASE. + + Checksum handling when creating a database from a database with an invalid + block. Also a minimal check that cross-database IO is handled reasonably. + """ + psql = node.connect() + try: + node.safe_sql("CREATE DATABASE regression_createdb_source") + + # CREATE DATABASE ... TEMPLATE below requires no other connection to + # the source DB. Use a short-lived connection that disconnects when + # done, rather than the cached per-dbname session. + src = node.connect(dbname="regression_createdb_source") + try: + src.query_safe( + "CREATE EXTENSION test_aio;\n" + "CREATE TABLE tbl_cs_fail(data int not null) " + "WITH (AUTOVACUUM_ENABLED = false);\n" + "INSERT INTO tbl_cs_fail SELECT generate_series(1, 1000);\n" + "SELECT modify_rel_block('tbl_cs_fail', 1, corrupt_checksum=>true);\n" + ) + finally: + src.close() + + createdb_sql = ( + "\nCREATE DATABASE regression_createdb_target\n" + "TEMPLATE regression_createdb_source\n" + "STRATEGY wal_log;\n" + ) + + # Verify that CREATE DATABASE of an invalid database fails and is + # accounted for accurately. + cs_count_before, _cs_ts_before = checksum_failures( + psql, "regression_createdb_source" + ) + psql_like( + io_method, + psql, + "create database w/ wal strategy, invalid source", + createdb_sql, + r"^$", + r'^(?:psql::\d+: )?ERROR: invalid page in block 1 of relation "base/\d+/\d+"$', + ) + cs_count_after, _cs_ts_after = checksum_failures( + psql, "regression_createdb_source" + ) + assert int(cs_count_before) + 1 <= int(cs_count_after), ( + f"{io_method}: create database w/ wal strategy, invalid source: " + "checksum count increased" + ) + + # Verify that CREATE DATABASE of the fixed database succeeds. Again + # use a transient connection so the source DB has no other session. + src = node.connect(dbname="regression_createdb_source") + try: + src.query_safe("SELECT modify_rel_block('tbl_cs_fail', 1, zero=>true);\n") + finally: + src.close() + psql_like( + io_method, + psql, + "create database w/ wal strategy, valid source", + createdb_sql, + r"^$", + r"^$", + ) + finally: + psql.close() + + +def sub_test_ignore_checksum(io_method, node): + """Detect and report checksum failures with ignore_checksum_failure. + + In several places we make sure the server log contains individual + information for each block involved in the IO. + """ + psql = node.connect() + try: + # Test setup + psql.query_safe( + "CREATE TABLE tbl_cs_fail(id int) WITH (AUTOVACUUM_ENABLED = false);\n" + "INSERT INTO tbl_cs_fail SELECT generate_series(1, 10000);\n" + ) + + count_sql = "SELECT count(*) FROM tbl_cs_fail" + invalidate_sql = ( + "\nSELECT invalidate_rel_block('tbl_cs_fail', g.i)\n" + "FROM generate_series(0, 6) g(i);\n" + ) + + expect = psql.query_safe(count_sql) + + # Very basic tests for ignore_checksum_failure=off / on + psql.query_safe( + "SELECT modify_rel_block('tbl_cs_fail', 1, corrupt_checksum=>true);\n" + "SELECT modify_rel_block('tbl_cs_fail', 5, corrupt_checksum=>true);\n" + "SELECT modify_rel_block('tbl_cs_fail', 6, corrupt_checksum=>true);\n" + ) + + psql.query_safe(invalidate_sql) + psql_like( + io_method, + psql, + "reading block w/ wrong checksum with ignore_checksum_failure=off fails", + count_sql, + r"^$", + r"ERROR: invalid page in block", + ) + + psql.query_safe("SET ignore_checksum_failure=on") + + psql.query_safe(invalidate_sql) + psql_like( + io_method, + psql, + "reading block w/ wrong checksum with ignore_checksum_failure=off succeeds", + count_sql, + rf"^{expect}$", + r"WARNING: ignoring (checksum failure|\d checksum failures)", + ) + + # Verify that ignore_checksum_failure=off works in multi-block reads + psql.query_safe( + "SELECT modify_rel_block('tbl_cs_fail', 2, zero=>true);\n" + "SELECT modify_rel_block('tbl_cs_fail', 3, corrupt_checksum=>true);\n" + "SELECT modify_rel_block('tbl_cs_fail', 4, corrupt_header=>true);\n" + ) + + log_location = node.log_position() + psql_like( + io_method, + psql, + "test reading of checksum failed block 3, with ignore", + "\nSELECT read_rel_block_ll('tbl_cs_fail', 3, nblocks=>1, zero_on_error=>false);", + r"^$", + r"^(?:psql::\d+: )?WARNING: ignoring checksum failure in block 3", + ) + + # Check that the log contains a LOG message about the failure + log_location = node.wait_for_log( + r"LOG: ignoring checksum failure", log_location + ) + + # check that we error + psql_like( + io_method, + psql, + "test reading of valid block 2, checksum failed 3, invalid 4, zero=false with ignore", + "\nSELECT read_rel_block_ll('tbl_cs_fail', 2, nblocks=>3, zero_on_error=>false);", + r"^$", + r'^(?:psql::\d+: )?ERROR: invalid page in block 4 of relation "base/\d+/\d+"$', + ) + + # Test multi-block read with different problems in different blocks + psql.query( + "SELECT modify_rel_block('tbl_cs_fail', 1, zero=>true);\n" + "SELECT modify_rel_block('tbl_cs_fail', 2, corrupt_checksum=>true);\n" + "SELECT modify_rel_block('tbl_cs_fail', 3, corrupt_checksum=>true, corrupt_header=>true);\n" + "SELECT modify_rel_block('tbl_cs_fail', 4, corrupt_header=>true);\n" + "SELECT modify_rel_block('tbl_cs_fail', 5, corrupt_header=>true);\n" + ) + psql.clear_stderr() + + log_location = node.log_position() + psql_like( + io_method, + psql, + "test reading of valid block 1, checksum failed 2, 3, invalid 3-5, zero=true", + "\nSELECT read_rel_block_ll('tbl_cs_fail', 1, nblocks=>5, zero_on_error=>true);", + r"^$", + r'^(?:psql::\d+: )?WARNING: zeroing 3 page\(s\) and ignoring 2 checksum failure\(s\) among blocks 1..5 of relation "', + ) + + # Unfortunately have to scan the whole log since determining + # log_location above in each of the tests, as wait_for_log() returns + # the size of the file. + node.wait_for_log(r"LOG: ignoring checksum failure in block 2", log_location) + + node.wait_for_log( + r'LOG: invalid page in block 3 of relation "base.*"; zeroing out page', + log_location, + ) + + node.wait_for_log( + r'LOG: invalid page in block 4 of relation "base.*"; zeroing out page', + log_location, + ) + + node.wait_for_log( + r'LOG: invalid page in block 5 of relation "base.*"; zeroing out page', + log_location, + ) + + # Reading a page with both an invalid header and an invalid checksum + psql.query( + "SELECT modify_rel_block('tbl_cs_fail', 3, corrupt_checksum=>true, corrupt_header=>true);\n" + ) + psql.clear_stderr() + + psql_like( + io_method, + psql, + "test reading of block with both invalid header and invalid checksum, zero=false", + "\nSELECT read_rel_block_ll('tbl_cs_fail', 3, nblocks=>1, zero_on_error=>false);", + r"^$", + r'^(?:psql::\d+: )?ERROR: invalid page in block 3 of relation "', + ) + + psql_like( + io_method, + psql, + "test reading of block 3 with both invalid header and invalid checksum, zero=true", + "\nSELECT read_rel_block_ll('tbl_cs_fail', 3, nblocks=>1, zero_on_error=>true);", + r"^$", + r'^(?:psql::\d+: )?WARNING: invalid page in block 3 of relation "base/.*"; zeroing out page', + ) + finally: + psql.close() + + +def sub_test_read_buffers(io_method, node): + """Tests for StartReadBuffers().""" + psql_a = node.connect() + psql_b = node.connect() + try: + psql_a.query_safe( + "CREATE TEMPORARY TABLE tmp_ok(data int not null);\n" + "INSERT INTO tmp_ok SELECT generate_series(1, 5000);\n" + ) + + for persistency in ("normal", "temporary"): + table = "tbl_ok" if persistency == "normal" else "tmp_ok" + + # check that consecutive misses are combined into one read + psql_a.query_safe(f"SELECT evict_rel('{table}')") + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, combine, block 0-1", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 0, 2)", + r"^0\|0\|t\|2$", + r"^$", + ) + + # but if we do it again, i.e. it's in the buffer pool, there will + # be two operations + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, doesn't combine hits, block 0-1", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 0, 2)", + r"^0\|0\|f\|1\n1\|1\|f\|1$", + r"^$", + ) + + # Check that a larger read interrupted by a hit works + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, prep, block 3", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 3, 1)", + r"^0\|3\|t\|1$", + r"^$", + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, interrupted by hit on 3, block 2-5", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 2, 4)", + r"^0\|2\|t\|1\n1\|3\|f\|1\n2\|4\|t\|2$", + r"^$", + ) + + # Verify that a read with an initial buffer hit works + psql_a.query_safe(f"SELECT evict_rel('{table}')") + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, miss, block 0", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 0, 1)", + r"^0\|0\|t\|1$", + r"^$", + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, hit, block 0", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 0, 1)", + r"^0\|0\|f\|1$", + r"^$", + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, miss, block 1", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 1, 1)", + r"^0\|1\|t\|1$", + r"^$", + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, hit, block 1", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 1, 1)", + r"^0\|1\|f\|1$", + r"^$", + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, hit, block 0-1", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 0, 2)", + r"^0\|0\|f\|1\n1\|1\|f\|1$", + r"^$", + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, hit 0-1, miss 2", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 0, 3)", + r"^0\|0\|f\|1\n1\|1\|f\|1\n2\|2\|t\|1$", + r"^$", + ) + + # Verify that a read with an initial miss and trailing hit(s) works + psql_a.query_safe(f"SELECT invalidate_rel_block('{table}', 0)") + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, miss 0, hit 1-2", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 0, 3)", + r"^0\|0\|t\|1\n1\|1\|f\|1\n2\|2\|f\|1$", + r"^$", + ) + psql_a.query_safe(f"SELECT invalidate_rel_block('{table}', 1)") + psql_a.query_safe(f"SELECT invalidate_rel_block('{table}', 2)") + psql_a.query_safe(f"SELECT * FROM read_buffers('{table}', 3, 2)") + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, miss 1-2, hit 3-4", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 1, 4)", + r"^0\|1\|t\|2\n2\|3\|f\|1\n3\|4\|f\|1$", + r"^$", + ) + + # Verify that we aren't doing reads larger than io_combine_limit. + psql_a.query_safe(f"SELECT evict_rel('{table}')") + psql_a.query_safe("SET io_combine_limit=3") + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, io_combine_limit has effect", + f"SELECT blockoff, blocknum, io_reqd, nblocks FROM read_buffers('{table}', 1, 5)", + r"^0\|1\|t\|3\n3\|4\|t\|2$", + r"^$", + ) + psql_a.query_safe("RESET io_combine_limit") + + # Test encountering buffer IO we started in the first block of the + # range. A foreign IO is treated as not having needed to do IO. + psql_a.query_safe(f"SELECT evict_rel('{table}')") + psql_a.query_safe( + f"SELECT read_rel_block_ll('{table}', 1, wait_complete=>false)" + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, in-progress 1, read 1-3", + f"SELECT blockoff, blocknum, io_reqd and not foreign_io, nblocks FROM read_buffers('{table}', 1, 3)", + r"^0\|1\|f\|1\n1\|2\|t\|2$", + r"^$", + ) + + # Test in-progress IO in the middle block of the range + psql_a.query_safe(f"SELECT evict_rel('{table}')") + psql_a.query_safe( + f"SELECT read_rel_block_ll('{table}', 2, wait_complete=>false)" + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, in-progress 2, read 1-3", + f"SELECT blockoff, blocknum, io_reqd and not foreign_io, nblocks FROM read_buffers('{table}', 1, 3)", + r"^0\|1\|t\|1\n1\|2\|f\|1\n2\|3\|t\|1$", + r"^$", + ) + + # Test in-progress IO on the last block of the range + psql_a.query_safe(f"SELECT evict_rel('{table}')") + psql_a.query_safe( + f"SELECT read_rel_block_ll('{table}', 3, wait_complete=>false)" + ) + psql_like( + io_method, + psql_a, + f"{persistency}: read buffers, in-progress 3, read 1-3", + f"SELECT blockoff, blocknum, io_reqd and not foreign_io, nblocks FROM read_buffers('{table}', 1, 3)", + r"^0\|1\|t\|2\n2\|3\|f\|1$", + r"^$", + ) + + # The remaining tests don't make sense for temp tables, as they are + # concerned with multiple sessions interacting with each other. + table = "tbl_ok" + persistency = "normal" + + # Test start buffer IO will split IO if there's IO in progress. We + # can't observe this with sync, as that does not start the IO operation + # in StartReadBuffers(). + if io_method != "sync": + psql_a.query_safe(f"SELECT evict_rel('{table}')") + + buf_id = psql_b.query_oneval(f"SELECT buffer_create_toy('{table}', 3)") + psql_b.query_safe( + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>true)" + ) + + query_wait_block( + io_method, + node, + psql_a, + f"{persistency}: read buffers blocks waiting for concurrent IO", + f"SELECT blockoff, blocknum, io_reqd, foreign_io, nblocks FROM read_buffers('{table}', 1, 5);\n", + "BufferIo", + True, + ) + psql_b.query_safe( + f"SELECT buffer_call_terminate_io({buf_id}, for_input=>true, succeed=>false, io_error=>false, release_aio=>false)" + ) + # Because no IO wref was assigned, block 3 should not report + # foreign IO + expected = r"0\|1\|t\|f\|2\n2\|3\|t\|f\|3" + out = psql_a.get_async_result().psqlout + assert re.search( + expected, out + ), f"{io_method}: {persistency}: IO was split due to concurrent failed IO" + + # Same as before, except the concurrent IO succeeds this time + psql_a.query_safe(f"SELECT evict_rel('{table}')") + buf_id = psql_b.query_oneval(f"SELECT buffer_create_toy('{table}', 3)") + psql_b.query_safe( + f"SELECT buffer_call_start_io({buf_id}, for_input=>true, wait=>true)" + ) + + query_wait_block( + io_method, + node, + psql_a, + f"{persistency}: read buffers blocks waiting for concurrent IO", + f"SELECT blockoff, blocknum, io_reqd, foreign_io, nblocks FROM read_buffers('{table}', 1, 5);\n", + "BufferIo", + True, + ) + psql_b.query_safe( + f"SELECT buffer_call_terminate_io({buf_id}, for_input=>true, succeed=>true, io_error=>false, release_aio=>false)" + ) + # Because no IO wref was assigned, block 3 should not report + # foreign IO + expected = r"0\|1\|t\|f\|2\n2\|3\|f\|f\|1\n3\|4\|t\|f\|2" + out = psql_a.get_async_result().psqlout + assert re.search( + expected, out + ), f"{io_method}: {persistency}: IO was split due to concurrent successful IO" + finally: + psql_a.close() + psql_b.close() + + +def sub_test_read_buffers_inject(io_method, node): + """Tests for StartReadBuffers() that depend on injection point support.""" + psql_a = node.connect() + psql_b = node.connect() + psql_c = node.connect() + try: + # We can't easily test waiting for foreign IOs on temporary tables, as + # the waiting in the completion hook will just stall the backend. + table = "tbl_ok" + persistency = "normal" + + # --- + # Test if a read buffers encounters AIO in progress by another backend, + # it recognizes that other IO as a foreign IO. + # --- + psql_a.query_safe(f"SELECT evict_rel('{table}')") + + # B: Trigger wait in the next AIO read for block 1. + psql_b.query_safe( + "SELECT inj_io_completion_wait(pid=>pg_backend_pid(),\n" + f"\t\t relfilenode=>pg_relation_filenode('{table}'),\n" + "\t\t blockno=>1);" + ) + + # B: Read block 1 and wait for the completion hook to be reached + query_wait_block( + io_method, + node, + psql_b, + f"{persistency}: wait in completion of block 1", + f"SELECT read_rel_block_ll('{table}', blockno=>1, nblocks=>1)", + "completion_wait", + False, + ) + + # A: Start read, wait until we're waiting for IO completion + query_wait_block( + io_method, + node, + psql_a, + f"{persistency}: read 1-4, blocked on in-progress 1", + f"SELECT blockoff, blocknum, io_reqd, foreign_io, nblocks FROM read_buffers('{table}', 1, 4)", + "AioIoCompletion", + True, + ) + + # C: Release B from completion hook + psql_c.query_safe("SELECT inj_io_completion_continue()") + + # A: Check that we recognized the foreign IO wait, if possible + if io_method != "sync": + # A foreign IO covering block 1, and one IO covering blocks 2-4. + expected = r"0\|1\|t\|t\|1\n1\|2\|t\|f\|3" + else: + # One IO covering everything, as that's what StartReadBuffers() + # will return for something with misses in sync mode. + expected = r"0\|1\|t\|f\|4" + out = psql_a.get_async_result().psqlout + assert re.search( + expected, out + ), f"{io_method}: {persistency}: read 1-3, blocked on in-progress 1, see expected result" + + # B's low-level read has completed now that C released it; drain its + # result before B is reused below. + psql_b.wait_for_completion() + + # --- + # Test if a read buffers encounters AIO in progress by another backend, + # it recognizes that other IO as a foreign IO. This time we encounter + # the foreign IO multiple times. + # --- + psql_a.query_safe(f"SELECT evict_rel('{table}')") + + # B: Trigger wait in the next AIO read for block 3. + psql_b.query_safe( + "SELECT inj_io_completion_wait(pid=>pg_backend_pid(),\n" + f"\t\t relfilenode=>pg_relation_filenode('{table}'),\n" + "\t\t blockno=>3);" + ) + + # B: Read block 2-3 and wait for the completion hook to be reached + query_wait_block( + io_method, + node, + psql_b, + f"{persistency}: wait in completion of block 2+3", + f"SELECT read_rel_block_ll('{table}', blockno=>2, nblocks=>2)", + "completion_wait", + False, + ) + + # A: Start read, wait until we're waiting for IO completion. + # Note that we need to defer waiting for IO until the end of + # read_buffers(), to be able to see that the IO on 3 is still in + # progress. + query_wait_block( + io_method, + node, + psql_a, + f"{persistency}: read 0-3, blocked on in-progress 2+3", + f"SELECT blockoff, blocknum, io_reqd, foreign_io, nblocks FROM\nread_buffers('{table}', 0, 4)", + "AioIoCompletion", + True, + ) + + # C: Release B from completion hook + psql_c.query_safe("SELECT inj_io_completion_continue()") + + # A: Check that we recognized the foreign IO wait, if possible + if io_method != "sync": + # One IO covering blocks 0-1, A foreign IO covering block 2, and a + # foreign IO covering block 3 (same wref as for block 2). + expected = r"0\|0\|t\|f\|2\n2\|2\|t\|t\|1\n3\|3\|t\|t\|1" + else: + # One IO covering everything. + expected = r"0\|0\|t\|f\|4" + out = psql_a.get_async_result().psqlout + assert re.search( + expected, out + ), f"{io_method}: {persistency}: read 0-3, blocked on in-progress 2+3, see expected result" + + # Drain B's now-completed low-level read before closing. + psql_b.wait_for_completion() + finally: + psql_a.close() + psql_b.close() + psql_c.close() + + +# -- per-io_method entrypoint ------------------------------------------------ + + +def run_io_method(io_method, node, injection_points_available): + """Run all sub-tests for a node configured with a given io_method.""" + assert ( + node.safe_sql("SHOW io_method") == io_method + ), f"{io_method}: io_method set correctly" + + node.safe_sql( + "CREATE EXTENSION test_aio;\n" + "CREATE TABLE tbl_corr(data int not null) WITH (AUTOVACUUM_ENABLED = false);\n" + "CREATE TABLE tbl_ok(data int not null) WITH (AUTOVACUUM_ENABLED = false);\n" + "\n" + "INSERT INTO tbl_corr SELECT generate_series(1, 10000);\n" + "INSERT INTO tbl_ok SELECT generate_series(1, 10000);\n" + "SELECT grow_rel('tbl_corr', 16);\n" + "SELECT grow_rel('tbl_ok', 16);\n" + "\n" + "SELECT modify_rel_block('tbl_corr', 1, corrupt_header=>true);\n" + "CHECKPOINT;\n" + ) + + sub_test_handle(io_method, node) + sub_test_io_error(io_method, node) + sub_test_batchmode(io_method, node) + sub_test_startwait_io(io_method, node) + sub_test_complete_foreign(io_method, node) + sub_test_close_fd(io_method, node) + sub_test_invalidate(io_method, node) + sub_test_zero(io_method, node) + sub_test_checksum(io_method, node) + sub_test_ignore_checksum(io_method, node) + sub_test_checksum_createdb(io_method, node) + sub_test_read_buffers(io_method, node) + + # generic injection tests + if injection_points_available: + sub_test_inject(io_method, node) + sub_test_read_buffers_inject(io_method, node) + + # worker specific injection tests + if io_method == "worker" and injection_points_available: + sub_test_inject_worker(io_method, node) + + +# -- pytest test ------------------------------------------------------------- + + +def test_001_aio(create_pg, bindir, libdir): + methods = supported_io_methods(bindir, libdir) + + # Create and configure one instance for each io_method. A fresh data + # directory per method matters: the tests corrupt the shared catalog + # pg_shseclabel, so reusing a + # data dir across methods would carry corruption forward. + nodes = {} + for method in methods: + node = create_pg(method, start=False) + configure(node) + node.append_conf(f"io_method={method}\n") + nodes[method] = node + + # Just to have one test not use the default auto-tuning. + if "sync" in nodes: + nodes["sync"].append_conf("io_max_concurrency=4\n") + + # Determine extension/injection-point availability once, using the first + # node (any node would do; this only inspects pg_available_extensions). + probe = nodes[methods[0]] + probe.start() + extension_available = ( + probe.safe_sql( + "SELECT count(*) FROM pg_available_extensions WHERE name = 'test_aio'" + ) + != "0" + ) + + # The injection tests are gated on the enable_injection_points build + # flag. An injection-points build installs the injection_points extension; + # treat its availability (or the env var) as the signal. + injection_points_available = ( + probe.safe_sql( + "SELECT count(*) > 0 FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "t" + ) + if os.environ.get("enable_injection_points", "no") == "yes": + injection_points_available = True + probe.stop() + + # Skip faithfully if the test_aio module is not installed in this install. + if not extension_available: + pytest.skip("Extension test_aio not installed") + + # Execute the tests for each io_method. + for method in methods: + node = nodes[method] + node.start() + try: + run_io_method(method, node, injection_points_available) + finally: + node.stop() diff --git a/src/test/modules/test_aio/pyt/test_002_io_workers.py b/src/test/modules/test_aio/pyt/test_002_io_workers.py new file mode 100644 index 00000000000..9563f247db9 --- /dev/null +++ b/src/test/modules/test_aio/pyt/test_002_io_workers.py @@ -0,0 +1,106 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test dynamic management of AIO io worker processes. + +Verify that io worker backends start, stop, and respawn correctly as the +io_workers GUC is changed and when individual workers are terminated. +""" + +import random + + +def check_io_worker_count(node, worker_count): + """Poll until the number of 'io worker' backends equals worker_count.""" + assert node.poll_query_until( + "SELECT COUNT(*) FROM pg_stat_activity WHERE backend_type = 'io worker'", + str(worker_count), + ), f"io worker count is {worker_count}" + + +def terminate_io_worker(node, worker_count): + """Terminate a random io worker with SIGINT and check it exits.""" + # Select a random io worker + pid = node.safe_sql( + "SELECT pid FROM pg_stat_activity WHERE " + "backend_type = 'io worker' ORDER BY RANDOM() LIMIT 1" + ) + + # terminate IO worker with SIGINT + node.pg_bin.command_ok( + ["pg_ctl", "kill", "INT", pid], + "random io worker process signalled with INT", + ) + + # Check that worker exits + assert node.poll_query_until( + f"SELECT COUNT(*) FROM pg_stat_activity WHERE pid = {pid}", "0" + ), "random io worker process exited after signal" + + +def change_number_of_io_workers(node, worker_count, prev_worker_count, expect_failure): + """Change io_min_workers and verify the resulting state. + + Returns the new effective worker count. + """ + res = node.session().query(f"ALTER SYSTEM SET io_min_workers = {worker_count}") + node.safe_sql("SELECT pg_reload_conf()") + + if expect_failure: + stderr = res.error_message or "" + assert ( + f"{worker_count} is outside the valid range for parameter " + f'"io_min_workers"' in stderr + ), f"updating io_min_workers to {worker_count} failed, as expected" + return prev_worker_count + + assert node.poll_query_until("SHOW io_min_workers", str(worker_count)), ( + f"updating number of io_min_workers from {prev_worker_count} " + f"to {worker_count}" + ) + + check_io_worker_count(node, worker_count) + terminate_io_worker(node, worker_count) + check_io_worker_count(node, worker_count) + + return worker_count + + +def number_of_io_workers_dynamic(node): + prev_worker_count = node.safe_sql("SHOW io_min_workers") + + # Verify that worker count can't be set to 0 + change_number_of_io_workers(node, 0, prev_worker_count, 1) + + # Verify that worker count can't be set to 33 (above the max) + change_number_of_io_workers(node, 33, prev_worker_count, 1) + + # Try changing IO workers to a random value and verify that the worker + # count ends up as expected. Always test the min/max of workers. + # + # Valid range for io_workers is [1, 32]. 8 tests in total seems + # reasonable. + io_workers_range = list(range(1, 33)) + random.shuffle(io_workers_range) + for worker_count in (1, 32, io_workers_range[0], io_workers_range[6]): + prev_worker_count = change_number_of_io_workers( + node, worker_count, prev_worker_count, 0 + ) + + +def test_002_io_workers(create_pg): + node = create_pg("worker", start=False) + node.append_conf( + """ +io_method=worker +io_worker_idle_timeout=0ms +io_worker_launch_interval=0ms +io_max_workers=32 +""" + ) + node.start() + + # Test changing the number of I/O worker processes while also evaluating + # the handling of their termination. + number_of_io_workers_dynamic(node) + + node.stop() diff --git a/src/test/modules/test_aio/pyt/test_003_initdb.py b/src/test/modules/test_aio/pyt/test_003_initdb.py new file mode 100644 index 00000000000..919f7acf66f --- /dev/null +++ b/src/test/modules/test_aio/pyt/test_003_initdb.py @@ -0,0 +1,83 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test initdb for each IO method. This is done separately from 001_aio, as it +isn't fast. This way the more commonly failing / hacked-on 001_aio can be +iterated on more quickly. +""" + +import re + + +# --------------------------------------------------------------------------- +# AIO test helpers +# --------------------------------------------------------------------------- + + +def _have_io_uring(pg_bin): + """Return whether io_uring is a supported io_method. + + To detect if io_uring is supported, we + look at the error message for assigning an invalid value to an enum GUC, + which lists all the valid options. We use -C to deal with running as + administrator on Windows, as the superuser check is omitted if -C is used. + """ + res = pg_bin.result(["postgres", "-C", "invalid", "-c", "io_method=invalid"]) + match = re.search(r"Available values: ([^\.]+)\.", res.stderr) + assert match is not None, "can't determine supported io_method values" + methods = match.group(1) + print(f"# supported io_method values are: {methods}") + return "io_uring" in methods + + +def _supported_io_methods(pg_bin): + """Return the list of supported values for the io_method GUC.""" + methods = ["worker"] + if _have_io_uring(pg_bin): + methods.append("io_uring") + # Return sync last, as it will least commonly fail. + methods.append("sync") + return methods + + +def _configure(node): + """Prepare a cluster for AIO tests.""" + node.append_conf( + """ +shared_preload_libraries=test_aio +log_min_messages = 'DEBUG3' +log_statement=all +log_error_verbosity=default +restart_after_crash=false +temp_buffers=100 +""" + ) + + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- + + +def test_003_initdb(pg_bin, create_pg): + # Want to test initdb for each IO method, otherwise we could just reuse + # the cluster. + for io_method in _supported_io_methods(pg_bin): + node = create_pg( + io_method, + start=False, + initdb_extra=["-c", f"io_method={io_method}"], + ) + + _configure(node) + + # Even though we used -c io_method=... above, the test config may + # override the setting persisted at initdb time. While using (and + # later verifying) the setting from initdb provides some verification + # of having used the io_method during initdb, it's probably not worth + # the complication of only appending conditionally. + node.append_conf(f"\nio_method={io_method}\n") + + # ok: initdb succeeded for this io_method + node.start() + node.stop() + # ok: start & stop succeeded for this io_method diff --git a/src/test/modules/test_aio/pyt/test_004_read_stream.py b/src/test/modules/test_aio/pyt/test_004_read_stream.py new file mode 100644 index 00000000000..1585066fd1c --- /dev/null +++ b/src/test/modules/test_aio/pyt/test_004_read_stream.py @@ -0,0 +1,341 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Exercise the read_stream / AIO machinery. + +Drive the AIO code paths via the SQL functions provided by the test_aio +extension, across each supported io_method. +""" + +import os +import re +import subprocess + +import pytest + +# -- AIO test helpers -------------------------------------------------------- + + +def configure(node): + """Prepare a cluster for AIO tests.""" + node.append_conf( + "\n".join( + [ + "shared_preload_libraries=test_aio", + "log_min_messages = 'DEBUG3'", + "log_statement=all", + "log_error_verbosity=default", + "restart_after_crash=false", + "temp_buffers=100", + "", + ] + ) + ) + + +def have_io_uring(bindir, libdir): + """Detect whether io_uring is a supported io_method. + + Detect io_uring support by inspecting the list of valid io_method values + reported when assigning an invalid value to the enum GUC. ``-C`` is used + so the superuser check is skipped. + """ + env = dict(os.environ) + if libdir: + env["LD_LIBRARY_PATH"] = libdir + os.pathsep + env.get("LD_LIBRARY_PATH", "") + postgres = os.path.join(bindir, "postgres") + proc = subprocess.run( + [postgres, "-C", "invalid", "-c", "io_method=invalid"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + out = proc.stdout + + m = re.search(r"Available values: ([^\.]+)\.", out) + if not m: + raise RuntimeError("can't determine supported io_method values") + return "io_uring" in m.group(1) + + +def supported_io_methods(bindir, libdir): + """Return the list of io_method values supported by this build.""" + methods = ["worker"] + if have_io_uring(bindir, libdir): + methods.append("io_uring") + # Return sync last, as it will least commonly fail. + methods.append("sync") + return methods + + +# -- per-io_method test bodies ---------------------------------------------- + + +def do_setup(node): + """Create the extension and a large-ish table.""" + node.safe_sql( + "CREATE EXTENSION test_aio;\n" + "\n" + "CREATE TABLE largeish(k int not null) WITH (FILLFACTOR=10);\n" + "INSERT INTO largeish(k) SELECT generate_series(1, 10000);\n" + ) + + +def do_repeated_blocks(io_method, node): + """Test repeated reads of the same blocks via the read stream.""" + psql = node.connect() + try: + # Preventing larger reads makes testing easier. + psql.query_safe("SET io_combine_limit = 1") + + # test miss of the same block twice in a row + psql.query_safe("SELECT evict_rel('largeish');") + + # block 0 grows the distance enough that the stream will look ahead and + # try to start a pending read for block 2 (and later block 4) twice + # before returning any buffers. + psql.query_safe( + "SELECT * FROM read_stream_for_blocks('largeish', ARRAY[0, 2, 2, 4, 4]);" + ) + + psql.query_safe( + "SELECT * FROM read_stream_for_blocks('largeish', ARRAY[0, 2, 2, 4, 4]);" + ) + + # test hit of the same block twice in a row + psql.query_safe("SELECT evict_rel('largeish');") + psql.query_safe( + "SELECT * FROM read_stream_for_blocks('largeish', " + "ARRAY[0, 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1, 0]);" + ) + + # Test repeated blocks with a temp table, using invalidate_rel_block() + # to evict individual local buffers. + psql.query_safe( + "CREATE TEMP TABLE largeish_temp(k int not null) " + "WITH (FILLFACTOR=10);\n" + "INSERT INTO largeish_temp(k) SELECT generate_series(1, 200);\n" + ) + + # Evict the specific blocks we'll request to force misses. + psql.query_safe("SELECT invalidate_rel_block('largeish_temp', 0);") + psql.query_safe("SELECT invalidate_rel_block('largeish_temp', 2);") + psql.query_safe("SELECT invalidate_rel_block('largeish_temp', 4);") + + psql.query_safe( + "SELECT * FROM read_stream_for_blocks('largeish_temp', " + "ARRAY[0, 2, 2, 4, 4]);" + ) + + # Now the blocks are cached, so repeated access should be hits. + psql.query_safe( + "SELECT * FROM read_stream_for_blocks('largeish_temp', " + "ARRAY[0, 2, 2, 4, 4]);" + ) + finally: + psql.close() + + +def do_inject_foreign(io_method, node): + """Test a read stream encountering buffers undergoing IO in another backend.""" + psql_a = node.connect() + psql_b = node.connect() + try: + pid_a = psql_a.query_oneval("SELECT pg_backend_pid();") + + # + # Test read stream encountering buffers undergoing IO in another + # backend, with the other backend's reads succeeding. + # + psql_a.query_safe("SELECT evict_rel('largeish');") + + psql_b.query_safe( + "SELECT inj_io_completion_wait(pid=>pg_backend_pid(), " + "relfilenode=>pg_relation_filenode('largeish'));" + ) + + psql_b.do_async("SELECT read_rel_block_ll('largeish', blockno=>5, nblocks=>1);") + + assert node.poll_query_until( + "SELECT wait_event FROM pg_stat_activity " + "WHERE wait_event = 'completion_wait';", + "completion_wait", + ) + + # Block 5 is undergoing IO in session b, so session a will move on to + # start a new IO for block 7. + psql_a.do_async( + "SELECT array_agg(blocknum) FROM " + "read_stream_for_blocks('largeish', ARRAY[0, 2, 5, 7]);" + ) + + assert node.poll_query_until( + f"SELECT wait_event FROM pg_stat_activity WHERE pid = {pid_a}", + "AioIoCompletion", + ) + + node.safe_sql("SELECT inj_io_completion_continue()") + + assert re.search( + r"\{0,2,5,7\}", psql_a.get_async_result().psqlout + ), f"{io_method}: read stream encounters succeeding IO by another backend" + + # Drain session b's now-completed low-level read before reusing it. + psql_b.wait_for_completion() + + # + # Test read stream encountering buffers undergoing IO in another + # backend, with the other backend's reads failing. + # + psql_a.query_safe("SELECT evict_rel('largeish');") + + psql_b.query_safe( + "SELECT inj_io_completion_wait(pid=>pg_backend_pid(), " + "relfilenode=>pg_relation_filenode('largeish'));" + ) + + psql_b.query_safe( + "SELECT inj_io_short_read_attach(-errno_from_string('EIO'), " + "pid=>pg_backend_pid(), " + "relfilenode=>pg_relation_filenode('largeish'));" + ) + + psql_b.do_async("SELECT read_rel_block_ll('largeish', blockno=>5, nblocks=>1);") + + assert node.poll_query_until( + "SELECT wait_event FROM pg_stat_activity " + "WHERE wait_event = 'completion_wait';", + "completion_wait", + ) + + psql_a.do_async( + "SELECT array_agg(blocknum) FROM " + "read_stream_for_blocks('largeish', ARRAY[0, 2, 5, 7]);" + ) + + assert node.poll_query_until( + f"SELECT wait_event FROM pg_stat_activity WHERE pid = {pid_a}", + "AioIoCompletion", + ) + + node.safe_sql("SELECT inj_io_completion_continue()") + + assert re.search( + r"\{0,2,5,7\}", psql_a.get_async_result().psqlout + ), f"{io_method}: read stream encounters failing IO by another backend" + + # Session b's low-level read hits the injected error. + res_b = psql_b.get_async_result() + + assert res_b.error_message is not None and re.search( + r"ERROR.*could not read blocks 5\.\.5", res_b.error_message + ), f"{io_method}: injected error occurred (got {res_b.error_message!r})" + psql_b.clear_stderr() + psql_b.query_safe("SELECT inj_io_short_read_detach();") + + # + # Test read stream encountering two buffers that are undergoing the + # same IO, started by another backend. + # + psql_a.query_safe("SELECT evict_rel('largeish');") + + psql_b.query_safe( + "SELECT inj_io_completion_wait(pid=>pg_backend_pid(), " + "relfilenode=>pg_relation_filenode('largeish'));" + ) + + psql_b.do_async("SELECT read_rel_block_ll('largeish', blockno=>2, nblocks=>3);") + + assert node.poll_query_until( + "SELECT wait_event FROM pg_stat_activity " + "WHERE wait_event = 'completion_wait';", + "completion_wait", + ) + + # Blocks 2 and 4 are undergoing IO initiated by session b. + psql_a.do_async( + "SELECT array_agg(blocknum) FROM " + "read_stream_for_blocks('largeish', ARRAY[0, 2, 4]);" + ) + + assert node.poll_query_until( + f"SELECT wait_event FROM pg_stat_activity WHERE pid = {pid_a}", + "AioIoCompletion", + ) + + node.safe_sql("SELECT inj_io_completion_continue()") + + assert re.search( + r"\{0,2,4\}", psql_a.get_async_result().psqlout + ), f"{io_method}: read stream encounters two buffer read in one IO" + + # Drain session b's now-completed low-level read. + psql_b.wait_for_completion() + finally: + psql_a.close() + psql_b.close() + + +def run_io_method(io_method, node, injection_points_available): + """Run all sub-tests for a node configured with a given io_method.""" + assert ( + node.safe_sql("SHOW io_method") == io_method + ), f"{io_method}: io_method set correctly" + + do_repeated_blocks(io_method, node) + + if not injection_points_available: + return + do_inject_foreign(io_method, node) + + +# -- entrypoint -------------------------------------------------------------- + + +def test_004_read_stream(create_pg, bindir, libdir): + node = create_pg("test", start=False) + + configure(node) + + node.append_conf("\n".join(["max_connections=8", "io_method=worker", ""])) + + node.start() + + # Skip faithfully if the test_aio module is not installed in this install. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions WHERE name = 'test_aio'" + ) + == "0" + ): + node.stop() + pytest.skip("Extension test_aio not installed") + + do_setup(node) + + # The foreign-injection sub-test is gated on the enable_injection_points + # build flag. An injection-points build installs the injection_points extension, + # which the in-tree install always provides; treat its availability as the + # signal that injection points are usable. + injection_points_available = ( + node.safe_sql( + "SELECT count(*) > 0 FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "t" + ) + if ( + os.environ.get("enable_injection_points", "no") != "yes" + and not injection_points_available + ): + injection_points_available = False + + node.stop() + + for method in supported_io_methods(bindir, libdir): + # adjust_conf(io_method): later values win, so re-appending overrides + # the io_method=worker set above. + node.append_conf(f"io_method={method}\n") + node.start() + run_io_method(method, node, injection_points_available) + node.stop() diff --git a/src/test/modules/test_autovacuum/meson.build b/src/test/modules/test_autovacuum/meson.build index 86e392bc0de..babf2eae993 100644 --- a/src/test/modules/test_autovacuum/meson.build +++ b/src/test/modules/test_autovacuum/meson.build @@ -12,4 +12,12 @@ tests += { 't/001_parallel_autovacuum.pl', ], }, + 'pytest': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_parallel_autovacuum.py', + ], + }, } diff --git a/src/test/modules/test_autovacuum/pyt/test_001_parallel_autovacuum.py b/src/test/modules/test_autovacuum/pyt/test_001_parallel_autovacuum.py new file mode 100644 index 00000000000..38faf0c513d --- /dev/null +++ b/src/test/modules/test_autovacuum/pyt/test_001_parallel_autovacuum.py @@ -0,0 +1,179 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test parallel autovacuum behavior.""" + +import os + +import pytest + + +# Before each test we should disable autovacuum for 'test_autovac' table and +# generate some dead tuples in it. +def _prepare_for_next_test(node, test_number): + node.safe_sql( + f""" + ALTER TABLE test_autovac SET (autovacuum_enabled = false); + UPDATE test_autovac SET col_1 = {test_number}; + """ + ) + + +def test_001_parallel_autovacuum(create_pg): + node = create_pg("main", start=False) + + # Limit to one autovacuum worker and disable autovacuum logging globally + # (enabled only on the test table) so that log checks below match only + # activity on the expected table. + node.append_conf( + """ +autovacuum_max_workers = 1 +autovacuum_worker_slots = 1 +autovacuum_max_parallel_workers = 2 +max_worker_processes = 10 +max_parallel_workers = 10 +log_min_messages = debug2 +autovacuum_naptime = '1s' +min_parallel_index_scan_size = 0 +log_autovacuum_min_duration = -1 +""" + ) + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + injection_points_available = ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + != "0" + ) + if ( + os.environ.get("enable_injection_points", "no") != "yes" + and not injection_points_available + ): + pytest.skip("Injection points not supported by this build") + if not injection_points_available: + pytest.skip("Extension injection_points not installed") + + # Create all functions needed for testing + node.safe_sql( + """ + CREATE EXTENSION injection_points; + """ + ) + + indexes_num = 3 + initial_rows_num = 10_000 + autovacuum_parallel_workers = 2 + + # Create table and fill it with some data + node.safe_sql( + f""" + CREATE TABLE test_autovac ( + id SERIAL PRIMARY KEY, + col_1 INTEGER, col_2 INTEGER, col_3 INTEGER, col_4 INTEGER + ) WITH (autovacuum_parallel_workers = {autovacuum_parallel_workers}, + log_autovacuum_min_duration = 0); + + INSERT INTO test_autovac + SELECT + g AS col1, + g + 1 AS col2, + g + 2 AS col3, + g + 3 AS col4 + FROM generate_series(1, {initial_rows_num}) AS g; + """ + ) + + # Create specified number of b-tree indexes on the table + node.safe_sql( + f""" + DO $$ + DECLARE + i INTEGER; + BEGIN + FOR i IN 1..{indexes_num} LOOP + EXECUTE format('CREATE INDEX idx_col_%s ON test_autovac (col_%s);', i, i); + END LOOP; + END $$; + """ + ) + + # Test 1 : + # Our table has enough indexes and appropriate reloptions, so autovacuum must + # be able to process it in parallel mode. Just check if it can do it. + + _prepare_for_next_test(node, 1) + log_offset = node.log_position() + + node.safe_sql( + """ + ALTER TABLE test_autovac SET (autovacuum_enabled = true); + """ + ) + + # Wait for parallel autovacuum to complete; check worker count matches reloptions. + node.wait_for_log( + r"parallel workers: index vacuum: 2 planned, 2 launched in total", log_offset + ) + assert True, "parallel autovacuum on test_autovac table" + + # Test 2: + # Check whether parallel autovacuum leader can propagate cost-based parameters + # to the parallel workers. + + _prepare_for_next_test(node, 2) + log_offset = node.log_position() + + node.safe_sql( + """ + SELECT injection_points_attach('autovacuum-start-parallel-vacuum', 'wait'); + + ALTER TABLE test_autovac SET (autovacuum_parallel_workers = 1, autovacuum_enabled = true); + """ + ) + + # Wait until parallel autovacuum is inited + node.wait_for_event("autovacuum worker", "autovacuum-start-parallel-vacuum") + + # Update the shared cost-based delay parameters. ALTER SYSTEM cannot run + # inside a transaction block, so issue each statement separately (safe_sql + # sends them as individual commands; libpq's multi-statement exec would wrap + # them in one implicit transaction). + node.safe_sql("ALTER SYSTEM SET autovacuum_vacuum_cost_limit = 500") + node.safe_sql("ALTER SYSTEM SET autovacuum_vacuum_cost_delay = 5") + node.safe_sql("ALTER SYSTEM SET vacuum_cost_page_miss = 10") + node.safe_sql("ALTER SYSTEM SET vacuum_cost_page_dirty = 10") + node.safe_sql("ALTER SYSTEM SET vacuum_cost_page_hit = 10") + node.safe_sql("SELECT pg_reload_conf()") + + # Resume the leader process to update the shared parameters during heap scan (i.e. + # vacuum_delay_point() is called) and launch a parallel vacuum worker, but it stops + # before vacuuming indexes due to the injection point. + node.safe_sql( + """ + SELECT injection_points_wakeup('autovacuum-start-parallel-vacuum'); + """ + ) + + # Check whether parallel worker successfully updated all parameters during + # index processing. + node.wait_for_log( + r"parallel autovacuum worker updated cost params: cost_limit=500, cost_delay=5, cost_page_miss=10, cost_page_dirty=10, cost_page_hit=10", + log_offset, + ) + + # Cleanup + node.safe_sql( + """ + SELECT injection_points_detach('autovacuum-start-parallel-vacuum'); + """ + ) + + assert ( + True + ), "vacuum delay parameter changes are propagated to parallel vacuum workers" + + node.stop() diff --git a/src/test/modules/test_checksums/meson.build b/src/test/modules/test_checksums/meson.build index 9b1421a9b91..ea9bcdfcd91 100644 --- a/src/test/modules/test_checksums/meson.build +++ b/src/test/modules/test_checksums/meson.build @@ -35,4 +35,20 @@ tests += { 't/009_fpi.pl', ], }, + 'pytest': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_restarts.py', + 'pyt/test_003_standby_restarts.py', + 'pyt/test_004_offline.py', + 'pyt/test_005_injection.py', + 'pyt/test_006_pgbench_single.py', + 'pyt/test_007_pgbench_standby.py', + 'pyt/test_008_pitr.py', + 'pyt/test_009_fpi.py', + ], + }, } diff --git a/src/test/modules/test_checksums/pyt/conftest.py b/src/test/modules/test_checksums/pyt/conftest.py new file mode 100644 index 00000000000..c10128630b5 --- /dev/null +++ b/src/test/modules/test_checksums/pyt/conftest.py @@ -0,0 +1,102 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Shared helpers for the data-checksums test suite. + +Provides a small :class:`DataChecksums` helper, exposed through the +``checksums`` fixture, whose methods enable/disable data checksums on a running +cluster, poll the data_checksums GUC, and add randomized sleeps and stop modes +to shake out race conditions. + +All queries run in-process through the libpq Session (node.safe_sql / +poll_query_until). +""" + +import random + +import pytest + +from pypg.util import TIMEOUT_DEFAULT + + +class DataChecksums: + """Utility methods for testing data checksums in a running cluster.""" + + def test_checksum_state(self, node, state): + """Assert the data_checksums GUC at *node* equals *state*. + + Returns True if it matches, else False. + """ + result = node.safe_sql( + "SELECT setting FROM pg_catalog.pg_settings " + "WHERE name = 'data_checksums';" + ) + assert result == state, f"ensure checksums are set to {state} on {node.name}" + return result == state + + def wait_for_checksum_state(self, node, state, timeout=TIMEOUT_DEFAULT): + """Poll the data_checksums GUC until it equals *state* or times out.""" + res = node.poll_query_until( + "SELECT setting FROM pg_catalog.pg_settings " + "WHERE name = 'data_checksums';", + state, + timeout=timeout, + ) + assert res, f"ensure data checksums are transitioned to {state} on {node.name}" + return res + + def _wait_for_launcher_exit(self, node): + node.poll_query_until( + "SELECT count(*) = 0 FROM pg_catalog.pg_stat_activity " + "WHERE backend_type = 'datachecksums launcher';" + ) + + def enable_data_checksums(self, node, cost_delay=0, cost_limit=100, wait=None): + """Enable data checksums in the cluster running at *node*. + + *cost_delay*/*cost_limit* are passed to pg_enable_data_checksums(). If + *wait* is given, wait for that state (and, for 'on'/'off', for the + launcher to exit) before returning. + """ + node.safe_sql(f"SELECT pg_enable_data_checksums({cost_delay}, {cost_limit});") + if wait is not None: + self.wait_for_checksum_state(node, wait) + if wait in ("on", "off"): + self._wait_for_launcher_exit(node) + + def disable_data_checksums(self, node, wait=None): + """Disable data checksums in the cluster running at *node*. + + If *wait* is given (its value is ignored, unlike enable), wait for the + state to become 'off' and for the launcher to exit before returning. + """ + node.safe_sql("SELECT pg_disable_data_checksums();") + if wait is not None: + self.wait_for_checksum_state(node, "off") + self._wait_for_launcher_exit(node) + + def cointoss(self): + """Return 0 or 1 with even probability.""" + return int(random.random() < 0.5) + + def random_sleep(self, max_seconds=3): + """Sleep a random (0, max_seconds) interval, sometimes. + + Injects unpredictable sleeps to avoid timing patterns that mask race + conditions. A *max_seconds* of 0 disables the sleep entirely. + """ + import time + + if max_seconds == 0: + return + if self.cointoss(): + time.sleep(random.randint(0, max_seconds - 1) if max_seconds > 1 else 0) + + def stopmode(self): + """Randomly select a valid stop mode.""" + return "immediate" if self.cointoss() else "fast" + + +@pytest.fixture +def checksums(): + """Yield a :class:`DataChecksums` helper for the data-checksums tests.""" + return DataChecksums() diff --git a/src/test/modules/test_checksums/pyt/test_001_basic.py b/src/test/modules/test_checksums/pyt/test_001_basic.py new file mode 100644 index 00000000000..36d64458831 --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_001_basic.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test suite for testing enabling data checksums in an online cluster.""" + + +def test_001_basic(create_pg, checksums): + # Initialize node with checksums disabled. + node = create_pg("basic_node", start=False, initdb_extra=["--no-data-checksums"]) + node.start() + + # Create some content to have un-checksummed data in the cluster + node.safe_sql("CREATE TABLE t AS SELECT generate_series(1,10000) AS a;") + + # Ensure that checksums are turned off + checksums.test_checksum_state(node, "off") + + # Enable data checksums and wait for the state transition to 'on' + checksums.enable_data_checksums(node, wait="on") + + # Run a dummy query just to make sure we can read back data + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1 ") + assert result == "9999", "ensure checksummed pages can be read back" + + # Enable data checksums again which should be a no-op so we explicitly don't + # wait for any state transition as none should happen here. + checksums.enable_data_checksums(node) + checksums.test_checksum_state(node, "on") + # ..and make sure we can still read/write data + node.safe_sql("UPDATE t SET a = a + 1;") + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "10000", "ensure checksummed pages can be read back" + + # Disable checksums again and wait for the state transition + checksums.disable_data_checksums(node, wait=1) + + # Test reading data again + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "10000", "ensure previously checksummed pages can be read back" + + # Re-enable checksums and make sure that the underlying data has changed to + # ensure that checksums will be different. + node.safe_sql("UPDATE t SET a = a + 1;") + checksums.enable_data_checksums(node, wait="on") + + # Run a dummy query just to make sure we can read back the data + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "10000", "ensure checksummed pages can be read back" + + node.stop() diff --git a/src/test/modules/test_checksums/pyt/test_002_restarts.py b/src/test/modules/test_checksums/pyt/test_002_restarts.py new file mode 100644 index 00000000000..ad0a0a85fac --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_002_restarts.py @@ -0,0 +1,105 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test enabling data checksums in an online cluster across restarts. + +Exercises that a restart which breaks checksum processing leaves the cluster +in a sane state, and that checksum enablement can complete and be disabled +afterwards. +""" + +import os +import time + + +# The temporary-table barrier portion is only exercised under the extended +# suite; the surrounding basic checks always run. +def test_002_restarts(create_pg, checksums): + # Initialize node with checksums disabled. + node = create_pg("restarts_node", start=False, initdb_extra=["--no-data-checksums"]) + node.start() + + # Create some content to have un-checksummed data in the cluster + node.safe_sql("CREATE TABLE t AS SELECT generate_series(1,10000) AS a;") + + # Ensure that checksums are disabled + checksums.test_checksum_state(node, "off") + + extra = os.environ.get("PG_TEST_EXTRA", "") + run_extended = "checksum_extended" in extra.split() + + # The temporary-table barrier and restart-breaks-processing path only runs + # under the extended suite. We guard it with a conditional so the basic + # enable/readback/disable checks below always execute. + if run_extended: + # Create a barrier for checksum enablement to block on, in this case a + # pre-existing temporary table which is kept open while processing is + # started. We keep a dedicated session open holding the temporary + # table as we enable checksums through the node's regular session in + # another connection. + # + # This is a similar test to the synthetic variant in test_005_injection + # which fakes this scenario. + bsession = node.connect() + bsession.do("CREATE TEMPORARY TABLE tt (a integer);") + + # In another session, make sure we can see the blocking temp table but + # start processing anyways and check that we are blocked with a proper + # wait event. + result = node.safe_sql( + "SELECT relpersistence FROM pg_catalog.pg_class WHERE relname = 'tt';" + ) + assert result == "t", "ensure we can see the temporary table" + + # Enabling data checksums shouldn't work as the process is blocked on + # the temporary table held open by bsession. Ensure that we reach + # inprogress-on before we do more tests. + checksums.enable_data_checksums(node, wait="inprogress-on") + + # Wait for processing to finish and the worker waiting for leftover + # temp relations to be able to actually finish + assert node.poll_query_until( + "SELECT wait_event FROM pg_catalog.pg_stat_activity " + "WHERE backend_type = 'datachecksums worker';", + "ChecksumEnableTemptableWait", + ) + + # The datachecksumsworker waits for temporary tables to disappear for 3 + # seconds before retrying, so sleep for 4 seconds to be guaranteed to + # see a retry cycle + time.sleep(4) + + # Re-check the wait event to ensure we are blocked on the right thing. + result = node.safe_sql( + "SELECT wait_event FROM pg_catalog.pg_stat_activity " + "WHERE backend_type = 'datachecksums worker';" + ) + assert ( + result == "ChecksumEnableTemptableWait" + ), "ensure the correct wait condition is set" + checksums.test_checksum_state(node, "inprogress-on") + + # Stop the cluster while bsession is still attached. We can't close + # the session first since the brief period between closing and stopping + # might be enough for checksums to get enabled. + node.stop() + bsession.close() + node.start() + + # Ensure the checksums aren't enabled across the restart. This leaves + # the cluster in the same state as before we entered the block. + checksums.test_checksum_state(node, "off") + + checksums.enable_data_checksums(node, wait="on") + + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "9999", "ensure checksummed pages can be read back" + + assert node.poll_query_until( + "SELECT count(*) FROM pg_stat_activity " + "WHERE backend_type LIKE 'datachecksums%';", + "0", + ), "await datachecksums worker/launcher termination" + + checksums.disable_data_checksums(node, wait=1) + + node.stop() diff --git a/src/test/modules/test_checksums/pyt/test_003_standby_restarts.py b/src/test/modules/test_checksums/pyt/test_003_standby_restarts.py new file mode 100644 index 00000000000..a118c04e1cc --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_003_standby_restarts.py @@ -0,0 +1,295 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test enabling data checksums in an online cluster with streaming replication. + +Toggles data checksums on the primary and verifies the state propagates to the +standby across restarts and promotions. +""" + +import os +import re + + +def _build_conninfo(primary, standby): + """Build an unquoted primary_conninfo string pointing at *primary*. + + The standby's application_name is set to its node name so wait_for_catchup + can locate it in pg_stat_replication. + """ + return ( + f"host={primary.host} port={primary.port} " + f"dbname=postgres application_name={standby.name}" + ) + + +def test_003_standby_restarts(create_pg, checksums): + # Initialize primary node + node_primary = create_pg( + "primary", + start=False, + allows_streaming=True, + initdb_extra=["--no-data-checksums"], + ) + node_primary.start() + + slotname = "physical_slot" + node_primary.safe_sql(f"SELECT pg_create_physical_replication_slot('{slotname}')") + + # Take backup + backup_name = "my_backup" + node_primary.backup(backup_name) + + # Create streaming standby linking to primary + node_standby = create_pg("standby", start=False) + node_standby.init_from_backup(node_primary, backup_name) + node_standby.append_conf( + f"\nprimary_conninfo='{_build_conninfo(node_primary, node_standby)}'\n" + ) + node_standby.append_conf(f"\nprimary_slot_name = '{slotname}'\n") + node_standby.set_standby_mode() + node_standby.start() + + # Create some content on the primary to have un-checksummed data + node_primary.safe_sql("CREATE TABLE t AS SELECT generate_series(1,10000) AS a;") + + # Wait for standby to catch up + node_primary.wait_for_catchup(node_standby, "replay", node_primary.lsn("insert")) + + # Check that checksums are turned off on all nodes + checksums.test_checksum_state(node_primary, "off") + checksums.test_checksum_state(node_standby, "off") + + # ----------------------------------------------------------------------- + # Enable checksums for the cluster, and make sure that both the primary + # and standby change state. + + # Initiate enabling of checksums and ensure that the primary switches to + # either "inprogress-on" or "on" + checksums.enable_data_checksums(node_primary) + assert node_primary.poll_query_until( + "SELECT setting = 'off' FROM pg_catalog.pg_settings " + "WHERE name = 'data_checksums';", + "f", + ), "ensure primary has transitioned from off" + # Wait for checksum enable to be replayed + node_primary.wait_for_catchup(node_standby, "replay") + + # Ensure that the standby has switched to "inprogress-on" or "on". + # Normally it would be "inprogress-on", but it is theoretically possible + # for the primary to complete the checksum enabling *and* have the standby + # replay that record before we reach the check below. + assert node_standby.poll_query_until( + "SELECT setting = 'off' FROM pg_catalog.pg_settings " + "WHERE name = 'data_checksums';", + "f", + ), "ensure standby has absorbed the inprogress-on barrier" + result = node_standby.safe_sql( + "SELECT setting FROM pg_catalog.pg_settings WHERE name = 'data_checksums';" + ) + assert result in ( + "inprogress-on", + "on", + ), "ensure checksums are on, or in progress, on standby_1" + + # Insert some more data which should be checksummed on INSERT + node_primary.safe_sql("INSERT INTO t VALUES (generate_series(1, 10000));") + + # Wait for checksums enabled on the primary and standby + checksums.wait_for_checksum_state(node_primary, "on") + checksums.wait_for_checksum_state(node_standby, "on") + + result = node_primary.safe_sql("SELECT count(a) FROM t WHERE a > 1") + assert result == "19998", "ensure we can safely read all data with checksums" + + assert node_primary.poll_query_until( + "SELECT count(*) FROM pg_stat_activity " + "WHERE backend_type LIKE 'datachecksums%';", + "0", + ), "await datachecksums worker/launcher termination" + + # ----------------------------------------------------------------------- + # Disable checksums and ensure it's propagated to standby and that we can + # still read all data + + # Disable checksums and wait for the operation to be replayed + checksums.disable_data_checksums(node_primary) + node_primary.wait_for_catchup(node_standby, "replay") + # Ensure that the primary and standby has switched to off + checksums.wait_for_checksum_state(node_primary, "off") + checksums.wait_for_checksum_state(node_standby, "off") + # Double-check reading data without errors + result = node_primary.safe_sql("SELECT count(a) FROM t WHERE a > 1") + assert result == "19998", "ensure we can safely read all data without checksums" + + # ----------------------------------------------------------------------- + # Test that enabling checksums does not emit WAL for unlogged relations. + # Unlogged relations are wiped on recovery, so FPIs for them would be + # pointless and waste WAL traffic / standby I/O. + # + # Additionally, exercise standby promotion to ensure the init fork of an + # unlogged relation is still WAL-logged during checksum enable -- otherwise + # the standby keeps a stale init fork and the post-promotion main fork + # fails verification on every read (see ResetUnloggedRelations()). Both + # tables must exist BEFORE enable_data_checksums() so that their init forks + # get re-checksummed during the enable sweep. + + node_primary.safe_sql( + "CREATE UNLOGGED TABLE unlogged_tbl AS SELECT generate_series(1,1000) AS a;" + ) + # Use a btree index so the init fork is non-trivial (one metapage). + node_primary.safe_sql( + """ + CREATE UNLOGGED TABLE unlogged_promo (id int PRIMARY KEY, + payload text); + INSERT INTO unlogged_promo + SELECT g, repeat('x', 100) FROM generate_series(1, 1000) g; + CREATE INDEX unlogged_promo_payload_idx ON unlogged_promo (payload); + """ + ) + node_primary.wait_for_catchup(node_standby, "replay", node_primary.lsn("insert")) + + # Get the relfilenode and database OID so we can inspect the filesystem + unlogged_rfn = node_primary.safe_sql( + "SELECT relfilenode FROM pg_class WHERE relname = 'unlogged_tbl';" + ) + db_oid = node_primary.safe_sql( + "SELECT oid FROM pg_database WHERE datname = 'postgres';" + ) + + # Verify the standby only has the init fork (no main fork) + standby_datadir = node_standby.data_dir + main_fork = os.path.join(standby_datadir, "base", db_oid, unlogged_rfn) + assert not os.path.isfile( + main_fork + ), "standby has no main fork for unlogged table before enable" + + # Re-enable data checksums + checksums.enable_data_checksums(node_primary, wait="on") + checksums.wait_for_checksum_state(node_standby, "on") + + # After standby replays, the unlogged main file must still not exist. + # If the bug were present, FPI replay would materialize the full table. + node_primary.wait_for_catchup(node_standby, "replay", node_primary.lsn("insert")) + assert not os.path.isfile( + main_fork + ), "standby has no main fork for unlogged table after enable" + + # Verify unlogged relation size is 0 on the standby (main fork missing) + standby_size = node_standby.safe_sql( + "SELECT pg_relation_size('unlogged_tbl', 'main');" + ) + assert ( + standby_size == "0" + ), "unlogged table has zero size on standby after checksum enable" + + # Unlogged table should still be readable on primary + result = node_primary.safe_sql("SELECT count(*) FROM unlogged_tbl;") + assert result == "1000", "unlogged table readable on primary after checksum enable" + + # Alter persistence to logged, and make sure we can read it on both the + # primary and standby without any page verification errors in the logfiles. + node_primary.safe_sql("ALTER TABLE unlogged_tbl SET logged;") + node_primary.wait_for_catchup(node_standby, "replay", node_primary.lsn("insert")) + + result = node_primary.safe_sql("SELECT sum(a) FROM unlogged_tbl;") + assert result == "500500", "previously unlogged table can be read on primary" + result = node_standby.safe_sql("SELECT sum(a) FROM unlogged_tbl;") + assert result == "500500", "previously unlogged table can be read on standby" + + # ----------------------------------------------------------------------- + # Promote the standby and verify the unlogged_promo relation (created above + # before the enable sweep) is still usable. Without the init-fork WAL fix, + # every read of the index would fail with "page verification failed, + # calculated checksum X but expected 0". + node_primary.stop() + node_standby.promote() + + result = node_standby.safe_sql("SELECT count(*) FROM unlogged_promo;") + assert ( + result == "0" + ), "unlogged table readable on promoted standby (truncated as expected)" + + node_standby.safe_sql( + "INSERT INTO unlogged_promo " + "SELECT g, repeat('y',100) FROM generate_series(1,100) g;" + ) + result = node_standby.safe_sql( + "SET enable_seqscan = off; SELECT id FROM unlogged_promo WHERE id = 50;" + ) + assert result == "50", "indexed lookup on promoted standby returns expected row" + + node_standby.stop() + + # Perform one final pass over the logs and hunt for unexpected errors + page_verify_re = re.compile(r"page verification failed,.+\d$", re.MULTILINE) + log = node_primary.log_content() + assert not page_verify_re.search( + log + ), "no checksum validation errors in primary log" + log = node_standby.log_content() + assert not page_verify_re.search( + log + ), "no checksum validation errors in standby log" + + # ----------------------------------------------------------------------- + # Test that enforced state transitions during promotion (via StartupXLOG) + # are performed as expected. When the primary crashes during inprogress-on + # the standby should revert to off at promotion. In order to check the + # transition the test keeps an open session with the standby during + # promotion. + + # The cluster is currently broken down from the previous test. Start up + # the primary as primary, disable checksums and create a new standby from + # that state. + node_standby.teardown() + node_primary.start() + checksums.disable_data_checksums(node_primary, wait="off") + + # Re-create a new streaming standby linking to primary. The replication + # slot name is reused from earlier but a fresh backup is taken. + backup_name = "my_new_backup" + node_primary.backup(backup_name) + node_standby = create_pg("standby2", start=False) + node_standby.init_from_backup(node_primary, backup_name) + node_standby.append_conf( + f"\nprimary_conninfo='{_build_conninfo(node_primary, node_standby)}'\n" + ) + node_standby.append_conf(f"\nprimary_slot_name = '{slotname}'\n") + node_standby.set_standby_mode() + node_standby.start() + node_primary.wait_for_catchup(node_standby, "replay") + + # Open a background connection on the primary and inject a barrier to block + # progress to keep the state from advancing past inprogress-on. + node_primary_bpsql = node_primary.connect("postgres") + node_primary_bpsql.query_safe("CREATE TEMPORARY TABLE tt (a integer);") + # Also open a background connection to the standby to make sure we have an + # active backend during promotion. + node_standby_bpsql = node_standby.connect("postgres") + + # Start to enable checksums and wait until both primary and standby have + # moved to the inprogress-on state. Processing will block here as the + # temporary rel barrier will block the primary from finishing. + checksums.enable_data_checksums(node_primary, wait="inprogress-on") + node_primary.wait_for_catchup(node_standby, "replay") + checksums.test_checksum_state(node_standby, "inprogress-on") + + # Crash the primary before checksums are enabled and promote the standby. + # The new primary node will now revert the state to 'off' since checksums + # weren't fully enabled during the crash. + node_primary.stop("immediate") + node_standby.promote() + checksums.wait_for_checksum_state(node_standby, "off") + + # Ensure that any backend which was active before, and during, promotion + # sees the new state. + result = node_standby_bpsql.query_safe("SHOW data_checksums;") + assert ( + result == "off" + ), "ensure checksums are set to off after promotion during inprogress-on" + + # The primary's session was kept open only to hold the blocking temp table; + # close it explicitly (its backend is already gone after the crash). + node_primary_bpsql.close() + node_standby_bpsql.close() + node_standby.stop() diff --git a/src/test/modules/test_checksums/pyt/test_004_offline.py b/src/test/modules/test_checksums/pyt/test_004_offline.py new file mode 100644 index 00000000000..ba364bf8aa8 --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_004_offline.py @@ -0,0 +1,92 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test enabling data checksums offline from various states of checksum processing. + +Uses the pg_checksums binary on a stopped cluster. +""" + + +def _checksum_enable_offline(node, pg_bin): + """Enable data page checksums in an offline cluster with pg_checksums.""" + pg_bin.command_ok( + ["pg_checksums", "-D", node.data_dir, "-e"], + f"enable checksums offline in {node.name}", + ) + + +def _checksum_disable_offline(node, pg_bin): + """Disable data page checksums in an offline cluster with pg_checksums.""" + pg_bin.command_ok( + ["pg_checksums", "-D", node.data_dir, "-d"], + f"disable checksums offline in {node.name}", + ) + + +def test_offline(create_pg, pg_bin, checksums): + """Enable/disable/verify data checksums offline from various states.""" + # Initialize node with checksums disabled. + node = create_pg("offline_node", initdb_extra=["--no-data-checksums"]) + + # Create some content to have un-checksummed data in the cluster. + node.safe_sql("CREATE TABLE t AS SELECT generate_series(1,10000) AS a;") + + # Ensure that checksums are disabled. + checksums.test_checksum_state(node, "off") + + # Enable checksums offline using pg_checksums. + node.stop() + _checksum_enable_offline(node, pg_bin) + node.start() + + # Ensure that checksums are enabled. + checksums.test_checksum_state(node, "on") + + # Run a dummy query just to make sure we can read back some data. + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "9999", "ensure checksummed pages can be read back" + + # Disable checksums offline again using pg_checksums. + node.stop() + _checksum_disable_offline(node, pg_bin) + node.start() + + # Ensure that checksums are disabled. + checksums.test_checksum_state(node, "off") + + # Create a barrier for checksum enablement to block on, in this case a + # pre-existing temporary table which is kept open while processing is + # started. We accomplish this with a dedicated session that keeps the + # temporary table created as we enable checksums in another session. + bsession = node.connect() + bsession.do("CREATE TEMPORARY TABLE tt (a integer);") + + # In another session, make sure we can see the blocking temp table but + # start processing anyways and check that we are blocked with a proper + # wait event. + result = node.safe_sql( + "SELECT relpersistence FROM pg_catalog.pg_class WHERE relname = 'tt';" + ) + assert result == "t", "ensure we can see the temporary table" + + # Enable, but stop waiting at inprogress-on since it will sit there until + # the above temporary table is removed. + checksums.enable_data_checksums(node, wait="inprogress-on") + + # Turn the cluster off and enable checksums offline, then start back up. + # Stop the cluster before closing the background session since otherwise + # checksums might have time to get enabled before shutting down the + # cluster. + node.stop("fast") + bsession.close() + _checksum_enable_offline(node, pg_bin) + node.start() + + # Ensure that checksums are now enabled even though processing wasn't + # restarted. + checksums.test_checksum_state(node, "on") + + # Run a dummy query just to make sure we can read back some data. + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "9999", "ensure checksummed pages can be read back" + + node.stop() diff --git a/src/test/modules/test_checksums/pyt/test_005_injection.py b/src/test/modules/test_checksums/pyt/test_005_injection.py new file mode 100644 index 00000000000..960826707ee --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_005_injection.py @@ -0,0 +1,73 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test enabling data checksums in an online cluster with injection points. + +Injects failures into checksum processing via injection points. +""" + +import os +import re + +import pytest + + +def test_005_injection(create_pg, checksums): + # Skip unless the build supports injection points, signalled via + # the enable_injection_points env var. An injection-points build installs + # the injection_points extension; treat its availability as a fallback + # signal that injection points are usable. + if os.environ.get("enable_injection_points", "no") != "yes": + pytest.skip("Injection points not supported by this build") + + # ----------------------------------------------------------------------- + # Test cluster setup + + node = create_pg( + "injection_node", start=False, initdb_extra=["--no-data-checksums"] + ) + node.start() + + # Set up test environment + node.safe_sql("CREATE EXTENSION test_checksums;") + node.safe_sql("CREATE EXTENSION injection_points;") + + # ----------------------------------------------------------------------- + # Inducing failures and crashes in processing + + # Force enabling checksums to fail by marking one of the databases as + # having failed in processing. + checksums.disable_data_checksums(node, wait=1) + node.safe_sql( + "SELECT injection_points_attach(" + "'datachecksumsworker-fail-db-result','notice');" + ) + checksums.enable_data_checksums(node, wait="off") + node.safe_sql( + "SELECT injection_points_detach('datachecksumsworker-fail-db-result');" + ) + + # Make sure that disabling after a failure works + checksums.disable_data_checksums(node) + checksums.test_checksum_state(node, "off") + + # ----------------------------------------------------------------------- + # Timing and retry related tests + + pg_test_extra = os.environ.get("PG_TEST_EXTRA", "") + if pg_test_extra and re.search(r"\bchecksum_extended\b", pg_test_extra): + # Inject a delay in the barrier for enabling checksums + checksums.disable_data_checksums(node, wait=1) + node.safe_sql("SELECT dcw_inject_delay_barrier();") + checksums.enable_data_checksums(node, wait="on") + + # Fake the existence of a temporary table at the start of processing, + # which will force the processing to wait and retry in order to wait + # for it to disappear. + checksums.disable_data_checksums(node, wait=1) + node.safe_sql( + "SELECT injection_points_attach(" + "'datachecksumsworker-fake-temptable-wait', 'notice');" + ) + checksums.enable_data_checksums(node, wait="on") + + node.stop() diff --git a/src/test/modules/test_checksums/pyt/test_006_pgbench_single.py b/src/test/modules/test_checksums/pyt/test_006_pgbench_single.py new file mode 100644 index 00000000000..3dd632a3003 --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_006_pgbench_single.py @@ -0,0 +1,297 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test enabling data checksums in an online cluster under concurrent pgbench load. + +A pgbench workload runs in the background while the +main thread repeatedly toggles data checksums on and off, randomly restarts and +power-cycles the node, and verifies that data pages can always be read back and +that no checksum verification failures appear in the server log. + +The installed ``pgbench`` binary is run as a background subprocess (via the +node's ``pg_bin`` environment). Any SQL the test needs runs in-process through +the libpq Session (``node.safe_sql``/``node.poll_query_until``). +""" + +import os +import random +import re +import subprocess + +import pytest + +from pypg.util import TIMEOUT_DEFAULT + +# Regex matching a checksum verification failure in the server log. +_PAGE_VERIFICATION_FAILED = re.compile(r"page verification failed,.+\d$", re.M) + + +def _check_no_checksum_errors(node, offset, label): + """Assert no page-verification failures in the log at/after *offset*. + + Returns the new log size to use as the next offset. + """ + log = node.log_content()[offset:] + assert not _PAGE_VERIFICATION_FAILED.search( + log + ), f"no checksum validation errors in primary log ({label})" + return node.log_position() + + +def _background_rw_pgbench(node, extended, checksums, current): + """Start a pgbench run in the background against *node*. + + If a previous pgbench is still running it is shut down first. Returns the + new Popen handle. + """ + # If a previous pgbench is still running, start by shutting it down. + if current is not None: + if current.poll() is None: + current.terminate() + current.wait() + + clients = 1 + runtime = 2 + if extended: + # Randomize the number of pgbench clients a bit (range 1-16) + clients = 1 + int(random.random() * 15) + runtime = 600 + + cmd = ["pgbench", "-p", str(node.port), "-T", str(runtime), "-c", str(clients)] + # Randomize whether we spawn connections or not + if extended and checksums.cointoss(): + cmd.append("-C") + # Finally add the database name to use + cmd.append("postgres") + + pg_bin = node.pg_bin + argv = [pg_bin.resolve(cmd[0]), *cmd[1:]] + print("# Running (background): " + " ".join(argv)) + devnull = subprocess.DEVNULL + return subprocess.Popen( + argv, + env=pg_bin.command_env(None), + stdin=devnull, + stdout=devnull, + stderr=devnull, + ) + + +def _flip_data_checksums(node, state, extended, checksums): + """Invert the data checksum state of the cluster, validating before/after. + + Returns the new state string. + """ + temptablewait = False + + # First, make sure the cluster is in the state we expect it to be + checksums.test_checksum_state(node, state) + + if state == "off": + # Coin-toss to see if we are injecting a retry due to a temptable + if checksums.cointoss(): + node.safe_sql( + "SELECT injection_points_attach(" + "'datachecksumsworker-fake-temptable-wait', 'notice');" + ) + temptablewait = True + + # log LSN right before we start changing checksums + result = node.safe_sql("SELECT pg_current_wal_lsn()") + print(f"# LSN before enabling: {result}") + + # Ensure that the primary switches to "inprogress-on" + checksums.enable_data_checksums(node, wait="inprogress-on") + + if extended: + checksums.random_sleep() + + # Wait for checksums enabled on the primary + checksums.wait_for_checksum_state(node, "on") + + # log LSN right after the primary flips checksums to "on" + result = node.safe_sql("SELECT pg_current_wal_lsn()") + print(f"# LSN after enabling: {result}") + + if extended: + checksums.random_sleep() + + if temptablewait: + node.safe_sql( + "SELECT injection_points_detach(" + "'datachecksumsworker-fake-temptable-wait');" + ) + return "on" + + if state == "on": + if extended: + checksums.random_sleep() + + # log LSN right before we start changing checksums + result = node.safe_sql("SELECT pg_current_wal_lsn()") + print(f"# LSN before disabling: {result}") + + checksums.disable_data_checksums(node) + + # Wait for checksums disabled on the primary + checksums.wait_for_checksum_state(node, "off") + + # log LSN right after the primary flips checksums to "off" + result = node.safe_sql("SELECT pg_current_wal_lsn()") + print(f"# LSN after disabling: {result}") + + if extended: + checksums.random_sleep() + return "off" + + # This should only happen due to programmer error when hacking on the test + # code, but since that might pass subtly we error out. + raise AssertionError(f"data_checksum_state variable has invalid state: {state}") + + +def test_006_pgbench_single(create_pg, pg_bin, checksums): + # This test suite is expensive, or very expensive, to execute. There are + # two PG_TEST_EXTRA options for running it, "checksum" for a pared-down + # suite and "checksum_extended" for the full suite. The full suite can run + # for hours on slow or constrained systems. + pg_test_extra = os.environ.get("PG_TEST_EXTRA", "") + extended = bool(re.search(r"\bchecksum_extended\b", pg_test_extra)) + if not re.search(r"\bchecksum(_extended)?\b", pg_test_extra): + pytest.skip("Expensive data checksums test disabled") + + if os.environ.get("enable_injection_points", "no") != "yes": + pytest.skip("Injection points not supported by this build") + + # The number of full test iterations which will be performed. The exact + # number of tests performed and the wall time taken is non-deterministic as + # the test performs a lot of randomized actions, but 10 iterations will be + # a long test run regardless. + test_iterations = 10 if extended else 1 + + # Variables which record the current state of the cluster + data_checksum_state = "off" + pgbench = None + node_loglocation = 0 + + # Create and start a cluster with one node + node = create_pg( + "pgbench_single_main", + start=False, + allows_streaming=True, + initdb_extra=["--no-data-checksums"], + ) + # max_connections need to be bumped in order to accommodate for pgbench + # clients and log_statement is dialled down since it otherwise will + # generate enormous amounts of logging. Page verification failures are + # still logged. + node.append_conf("max_connections = 100\nlog_statement = none\n") + node.start() + + try: + node.safe_sql("CREATE EXTENSION test_checksums;") + node.safe_sql("CREATE EXTENSION injection_points;") + # Create some content to have un-checksummed data in the cluster + node.safe_sql("CREATE TABLE t AS SELECT generate_series(1, 100000) AS a;") + + # Initialize pgbench + scalefactor = 10 if extended else 1 + node.pg_bin.command_ok( + [ + "pgbench", + "-p", + str(node.port), + "-i", + "-s", + str(scalefactor), + "-q", + "postgres", + ], + "pgbench initialization", + ) + + # Start the test suite with pgbench running. + pgbench = _background_rw_pgbench(node, extended, checksums, pgbench) + + # Main test suite. This loop will start a pgbench run on the cluster + # and while that's running flip the state of data checksums + # concurrently. It will then randomly restart the cluster and then + # check for the desired state. The idea behind doing things randomly is + # to stress out any timing related issues by subjecting the cluster to + # varied workloads. + for i in range(test_iterations): + print(f"# iteration {i + 1} of {test_iterations}") + + if not node.postmaster_alive(): + # Start, to do recovery, and stop + node.start() + node.stop("fast") + + # Since the log isn't being written to now, parse the log and + # check for instances of checksum verification failures. + node_loglocation = _check_no_checksum_errors( + node, + node_loglocation, + "during WAL recovery", + ) + + # Randomize the WAL size, to trigger checkpoints less/more often + sb = 64 + int(random.random() * 1024) + node.append_conf(f"max_wal_size = {sb}\n") + print(f"# changing max_wal_size to {sb}") + + node.start() + + # Start a pgbench in the background against the primary + pgbench = _background_rw_pgbench(node, extended, checksums, pgbench) + + node.safe_sql("UPDATE t SET a = a + 1;") + + data_checksum_state = _flip_data_checksums( + node, data_checksum_state, extended, checksums + ) + if extended: + checksums.random_sleep() + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "100000", "ensure data pages can be read back on primary" + + if extended: + checksums.random_sleep() + + # Potentially powercycle the node + if checksums.cointoss(): + node.stop(checksums.stopmode()) + + node.pg_bin.command_ok( + ["pg_controldata", node.data_dir], + "pg_controldata", + ) + + node_loglocation = _check_no_checksum_errors( + node, + node_loglocation, + "outside WAL recovery", + ) + + if extended: + checksums.random_sleep() + + # Make sure the node is running + if not node.postmaster_alive(): + node.start() + + # Testrun is over, ensure that data reads back as expected and perform a + # final verification of the data checksum state. + result = node.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "100000", "ensure data pages can be read back on primary" + checksums.test_checksum_state(node, data_checksum_state) + + # Perform one final pass over the logs and hunt for unexpected errors + node_loglocation = _check_no_checksum_errors(node, node_loglocation, "final") + finally: + if pgbench is not None: + if pgbench.poll() is None: + pgbench.terminate() + try: + pgbench.wait(timeout=TIMEOUT_DEFAULT) + except subprocess.TimeoutExpired: + pgbench.kill() + pgbench.wait() diff --git a/src/test/modules/test_checksums/pyt/test_007_pgbench_standby.py b/src/test/modules/test_checksums/pyt/test_007_pgbench_standby.py new file mode 100644 index 00000000000..9a47a599590 --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_007_pgbench_standby.py @@ -0,0 +1,441 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test enabling data checksums on a primary/standby pair under pgbench load. + +Test suite for enabling data checksums in an online cluster comprising a +primary and a replicated standby, with concurrent activity via pgbench runs. + +The installed ``pgbench`` binary is run as a background subprocess. All other +SQL goes through the in-process libpq Session (safe_sql / poll_query_until). + +This suite is expensive. It skips entirely unless PG_TEST_EXTRA contains +"checksum" (pared-down, one iteration) or "checksum_extended" (full suite, +five iterations and long-running pgbench). It also requires an +injection-points build. +""" + +import os +import random +import re +import subprocess + +import pytest + +# The number of full test iterations which will be performed. The exact +# number of tests performed and the wall time taken is non-deterministic as the +# test performs a lot of randomized actions, but 5 iterations will be a long +# test run regardless. +TEST_ITERATIONS_DEFAULT = 1 +TEST_ITERATIONS_EXTENDED = 5 + +# Physical replication slot used by the standby. +NODE_PRIMARY_SLOT = "physical_slot" + +# Regex matching a checksum verification failure in a server log. +PAGE_VERIFICATION_FAILED = re.compile(r"page verification failed,.+\d$", re.M) + + +class _PgbenchRunner: + """Manages a single background pgbench process against one node. + + Terminates any currently-running pgbench before launching a new one. + """ + + def __init__(self, bindir, extended): + self._pgbench = os.path.join(bindir, "pgbench") + if not os.path.exists(self._pgbench): + self._pgbench = "pgbench" + self._extended = extended + self._proc = None + # Held open for the runner's lifetime; closed in close(). + self._devnull = open(os.devnull, "r+b") # pylint: disable=consider-using-with + + def start(self, node, standby): + # Terminate any currently running pgbench process before continuing. + self.finish() + + clients = 1 + runtime = 5 + if self._extended: + # Randomize the number of pgbench clients a bit (range 1-16). + clients = 1 + random.randint(0, 14) + runtime = 600 + + cmd = [ + self._pgbench, + "-h", + node.host, + "-p", + str(node.port), + "-T", + str(runtime), + "-c", + str(clients), + ] + # Randomize whether we spawn connections or not. + if self._extended and random.random() < 0.5: + cmd.append("-C") + # If we run on a standby it needs to be a read-only benchmark. + if standby: + cmd.append("-S") + cmd.append("-n") + # Finally add the database name to use. + cmd.append("postgres") + + # Background process; reaped later by finish(). + self._proc = subprocess.Popen( # pylint: disable=consider-using-with + cmd, + stdin=self._devnull, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def finish(self): + if self._proc is not None: + self._proc.terminate() + try: + self._proc.wait(timeout=30) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc.wait() + self._proc = None + + def close(self): + self.finish() + self._devnull.close() + + +def _flip_data_checksums(primary, standby, checksums, state, extended): + """Invert the state of data checksums in the cluster. + + If data checksums are on then disable them and vice versa. Validates the + before and after state on both nodes. Returns the new state. + """ + temptablewait = False + + # First, make sure the cluster is in the state we expect it to be. + checksums.test_checksum_state(primary, state) + checksums.test_checksum_state(standby, state) + + if state == "off": + # Coin-toss to see if we are injecting a retry due to a temptable. + if checksums.cointoss(): + primary.safe_sql( + "SELECT injection_points_attach(" + "'datachecksumsworker-fake-temptable-wait', 'notice');" + ) + temptablewait = True + + # Log LSN right before we start changing checksums. + result = primary.safe_sql("SELECT pg_current_wal_lsn()") + print(f"# LSN before enabling: {result}") + + # Ensure that the primary switches to "inprogress-on". + checksums.enable_data_checksums(primary, wait="inprogress-on") + + if extended: + checksums.random_sleep() + + # Wait for checksum enable to be replayed. The standby connects with + # application_name=standby, which is how wait_for_catchup locates it. + primary.wait_for_catchup("standby", "replay") + + # Ensure that the standby has switched to "inprogress-on" or "on". + # Normally it would be "inprogress-on", but it is theoretically + # possible for the primary to complete the checksum enabling *and* have + # the standby replay that record before we reach the check below. + res = standby.poll_query_until( + "SELECT setting = 'off' FROM pg_catalog.pg_settings " + "WHERE name = 'data_checksums';", + "f", + ) + assert res, "ensure standby has absorbed the inprogress-on barrier" + result = standby.safe_sql( + "SELECT setting FROM pg_catalog.pg_settings " + "WHERE name = 'data_checksums';" + ) + assert result in ("inprogress-on", "on"), ( + "ensure checksums are on, or in progress, on standby_1, got: " + result + ) + + # Wait for checksums enabled on the primary and standby. + checksums.wait_for_checksum_state(primary, "on") + + # Log LSN right after the primary flips checksums to "on". + result = primary.safe_sql("SELECT pg_current_wal_lsn()") + print(f"# LSN after enabling: {result}") + + if extended: + checksums.random_sleep() + checksums.wait_for_checksum_state(standby, "on") + + if temptablewait: + primary.safe_sql( + "SELECT injection_points_detach(" + "'datachecksumsworker-fake-temptable-wait');" + ) + return "on" + + if state == "on": + if extended: + checksums.random_sleep() + + # Log LSN right before we start changing checksums. + result = primary.safe_sql("SELECT pg_current_wal_lsn()") + print(f"# LSN before disabling: {result}") + + checksums.disable_data_checksums(primary) + primary.wait_for_catchup("standby", "replay") + + # Wait for checksums disabled on the primary and standby. + if extended: + checksums.random_sleep() + checksums.wait_for_checksum_state(primary, "off") + checksums.wait_for_checksum_state(standby, "off") + + # Log LSN right after the primary flips checksums to "off". + result = primary.safe_sql("SELECT pg_current_wal_lsn()") + print(f"# LSN after disabling: {result}") + + return "off" + + # This should only happen due to programmer error when hacking on the test + # code, but since that might pass subtly we error out. + raise AssertionError(f"data_checksum_state variable has invalid state: {state}") + + +def _check_no_verification_failures(node, offset, msg): + """Assert the node's log has no page-verification failures past *offset*. + + Returns the new offset (current log size). + """ + log = node.log_content()[offset:] + assert not PAGE_VERIFICATION_FAILED.search(log), msg + return node.log_position() + + +def test_007_pgbench_standby(create_pg, pg_bin, checksums, bindir): + # This test suite is expensive, or very expensive, to execute. There are + # two PG_TEST_EXTRA options for running it, "checksum" for a pared-down + # test suite and "checksum_extended" for the full suite. + pg_test_extra = os.environ.get("PG_TEST_EXTRA", "") + extended = bool(re.search(r"\bchecksum_extended\b", pg_test_extra)) + if not re.search(r"\bchecksum(_extended)?\b", pg_test_extra): + pytest.skip("Expensive data checksums test disabled") + + if os.environ.get("enable_injection_points", "no") != "yes": + pytest.skip("Injection points not supported by this build") + + test_iterations = TEST_ITERATIONS_EXTENDED if extended else TEST_ITERATIONS_DEFAULT + + # Variables which record the current state of the cluster. + data_checksum_state = "off" + primary_loglocation = 0 + standby_loglocation = 0 + + # ----------------------------------------------------------------------- + # Create and start a cluster with one primary and one standby node, and + # ensure they are caught up and in sync. + node_primary = create_pg( + "pgbench_standby_main", + start=False, + allows_streaming=True, + initdb_extra=["--no-data-checksums"], + ) + # max_connections needs to be bumped in order to accommodate the pgbench + # clients and log_statement is dialled down since it otherwise will + # generate enormous amounts of logging. Page verification failures are + # still logged. + node_primary.append_conf( + "\n" + "max_connections = 30\n" + "log_statement = none\n" + "hot_standby_feedback = on\n" + ) + node_primary.start() + node_primary.safe_sql("CREATE EXTENSION test_checksums;") + node_primary.safe_sql("CREATE EXTENSION injection_points;") + # Create some content to have un-checksummed data in the cluster. + node_primary.safe_sql("CREATE TABLE t AS SELECT generate_series(1, 100000) AS a;") + node_primary.safe_sql( + f"SELECT pg_create_physical_replication_slot('{NODE_PRIMARY_SLOT}');" + ) + node_primary.backup("primary_backup") + + node_standby = create_pg("pgbench_standby_standby", start=False) + node_standby.init_from_backup(node_primary, "primary_backup") + + # Build primary_conninfo without nested single quotes (the value is itself + # single-quoted in postgresql.conf). application_name=standby lets + # wait_for_catchup locate this node in pg_stat_replication. + connstr_primary = ( + f"host={node_primary.host} port={node_primary.port} " + "dbname=postgres application_name=standby" + ) + node_standby.append_conf( + f"\nprimary_conninfo='{connstr_primary}'\n" + f"primary_slot_name = '{NODE_PRIMARY_SLOT}'\n" + ) + node_standby.set_standby_mode() + node_standby.start() + + pgbench_primary = _PgbenchRunner(bindir, extended) + pgbench_standby = _PgbenchRunner(bindir, extended) + + try: + # Initialize pgbench and wait for the objects to be created on the + # standby. + scalefactor = 10 if extended else 1 + pg_bin.command_ok( + [ + "pgbench", + "-h", + node_primary.host, + "-p", + str(node_primary.port), + "-i", + "-s", + str(scalefactor), + "-q", + "postgres", + ], + "pgbench initialization", + ) + node_primary.wait_for_catchup("standby", "replay") + + # Start the test suite with pgbench running on all nodes. + pgbench_standby.start(node_standby, standby=True) + pgbench_primary.start(node_primary, standby=False) + + # Main test suite. This loop will start a pgbench run on the cluster + # and while that's running flip the state of data checksums + # concurrently. It will then randomly restart the cluster and check + # for the desired state. The idea behind doing things randomly is to + # stress out any timing related issues by subjecting the cluster to + # varied workloads. + for i in range(test_iterations): + print(f"# iteration {i + 1} of {test_iterations}") + + if not node_primary.postmaster_alive(): + # Start, to do recovery, and stop. + node_primary.start() + node_primary.stop("fast") + + # Since the log isn't being written to now, parse the log and + # check for instances of checksum verification failures. + primary_loglocation = _check_no_verification_failures( + node_primary, + primary_loglocation, + "no checksum validation errors in primary log " + "(during WAL recovery)", + ) + + # Randomize the WAL size, to trigger checkpoints less/more + # often. + sb = 32 + random.randint(0, 959) + node_primary.append_conf(f"\nmax_wal_size = {sb}\n") + print(f"# changing primary max_wal_size to {sb}") + node_primary.start() + + # Start a pgbench in the background against the primary. + pgbench_primary.start(node_primary, standby=False) + + if not node_standby.postmaster_alive(): + node_standby.start() + node_standby.stop("fast") + + # Since the log isn't being written to now, parse the log and + # check for instances of checksum verification failures. + standby_loglocation = _check_no_verification_failures( + node_standby, + standby_loglocation, + "no checksum validation errors in standby_1 log " + "(during WAL recovery)", + ) + + # Randomize the WAL size, to trigger checkpoints less/more + # often. + sb = 32 + random.randint(0, 959) + node_standby.append_conf(f"\nmax_wal_size = {sb}\n") + print(f"# changing standby max_wal_size to {sb}") + node_standby.start() + + # Start a read-only pgbench in the background on the standby. + pgbench_standby.start(node_standby, standby=True) + + node_primary.safe_sql("UPDATE t SET a = a + 1;") + node_primary.wait_for_catchup("standby", "write") + + data_checksum_state = _flip_data_checksums( + node_primary, + node_standby, + checksums, + data_checksum_state, + extended, + ) + if extended: + checksums.random_sleep() + result = node_primary.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "100000", "ensure data pages can be read back on primary" + checksums.random_sleep() + + # Potentially powercycle the cluster (the nodes independently). + if extended and checksums.cointoss(): + node_primary.stop(checksums.stopmode()) + + # Slurp the file after shutdown, so that it doesn't interfere + # with the recovery. + primary_loglocation = _check_no_verification_failures( + node_primary, + primary_loglocation, + "no checksum validation errors in primary log " + "(outside WAL recovery)", + ) + + if extended: + checksums.random_sleep() + + if extended and checksums.cointoss(): + node_standby.stop(checksums.stopmode()) + + # Slurp the file after shutdown, so that it doesn't interfere + # with the recovery. + standby_loglocation = _check_no_verification_failures( + node_standby, + standby_loglocation, + "no checksum validation errors in standby_1 log " + "(outside WAL recovery)", + ) + + # Make sure the nodes are running. + if not node_primary.postmaster_alive(): + node_primary.start() + if not node_standby.postmaster_alive(): + node_standby.start() + + # Stop the background pgbench runs before final verification so they + # don't keep mutating the data being checked. + pgbench_primary.finish() + pgbench_standby.finish() + + # Testrun is over, ensure that data reads back as expected and perform + # a final verification of the data checksum state. + result = node_primary.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "100000", "ensure data pages can be read back on primary" + checksums.test_checksum_state(node_primary, data_checksum_state) + checksums.test_checksum_state(node_standby, data_checksum_state) + + # Perform one final pass over the logs and hunt for unexpected errors. + primary_loglocation = _check_no_verification_failures( + node_primary, + primary_loglocation, + "no checksum validation errors in primary log", + ) + standby_loglocation = _check_no_verification_failures( + node_standby, + standby_loglocation, + "no checksum validation errors in standby_1 log", + ) + finally: + pgbench_primary.close() + pgbench_standby.close() diff --git a/src/test/modules/test_checksums/pyt/test_008_pitr.py b/src/test/modules/test_checksums/pyt/test_008_pitr.py new file mode 100644 index 00000000000..8a442155c61 --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_008_pitr.py @@ -0,0 +1,116 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Tests point-in-time recovery interaction with online checksum enabling. A +base backup is taken from a primary started with checksums disabled, then +checksums are enabled (recording the WAL LSN right after the flip), more work +is done, and a restore point is created. A recovery node is initialized from +the backup and recovered to the recorded LSN, after which we verify that the +checksum state and table data match what the primary had at that point and +that no checksum validation errors were logged. +""" + +import os +import re + +import pytest + + +def test_008_pitr(create_pg, checksums): + # This test suite is expensive to execute. It honours two PG_TEST_EXTRA + # options, "checksum" (pared-down) and "checksum_extended" (full); without + # either it skips entirely. + pg_test_extra = os.environ.get("PG_TEST_EXTRA", "") + if not re.search(r"\bchecksum(_extended)?\b", pg_test_extra): + pytest.skip("Expensive data checksums test disabled") + + data_checksum_state = "off" + + # Invert the state of data checksums in the cluster. Returns the WAL LSNs + # recorded immediately before and after the flip. + def flip_data_checksums(node): + nonlocal data_checksum_state + + # First, make sure the cluster is in the state we expect it to be. + checksums.test_checksum_state(node, data_checksum_state) + + if data_checksum_state == "off": + lsn_pre = node.safe_sql("SELECT pg_current_wal_lsn()") + checksums.enable_data_checksums(node, wait="on") + lsn_post = node.safe_sql("SELECT pg_current_wal_lsn()") + data_checksum_state = "on" + else: + lsn_pre = node.safe_sql("SELECT pg_current_wal_lsn()") + checksums.disable_data_checksums(node, wait=1) + lsn_post = node.safe_sql("SELECT pg_current_wal_lsn()") + data_checksum_state = "off" + + return lsn_pre, lsn_post + + # Start a primary node with WAL archiving enabled and with enough + # connections available to handle the workload. + node_primary = create_pg( + "pitr_main", + start=False, + initdb_extra=["--no-data-checksums"], + has_archiving=True, + allows_streaming=True, + ) + node_primary.append_conf( + "\n".join( + [ + "max_connections = 100", + "log_statement = none", + ] + ) + ) + node_primary.start() + + # Prime the cluster with a bit of known data which we can read back to + # check for data consistency as well as page verification faults in the + # logfile. + node_primary.safe_sql("CREATE TABLE t AS SELECT generate_series(1, 100000) AS a;") + + # Take a backup to use for PITR. + backup_name = "my_backup" + node_primary.backup(backup_name) + + _, post_lsn = flip_data_checksums(node_primary) + + node_primary.safe_sql("UPDATE t SET a = a + 1;") + node_primary.safe_sql("SELECT pg_create_restore_point('a');") + node_primary.safe_sql("UPDATE t SET a = a + 1;") + node_primary.stop("fast") + + # Recover from the backup up to the LSN recorded just after the checksum + # flip, so the recovered cluster should have checksums enabled but should + # not include the post-flip updates. + node_pitr = create_pg("pitr_backup", start=False) + node_pitr.init_from_backup( + node_primary, backup_name, standby=False, has_restoring=True + ) + node_pitr.append_conf( + "\n".join( + [ + f"recovery_target_lsn = '{post_lsn}'", + "recovery_target_action = 'promote'", + "recovery_target_inclusive = on", + ] + ) + ) + + node_pitr.start() + + assert node_pitr.poll_query_until( + "SELECT pg_is_in_recovery() = 'f';" + ), "Timed out while waiting for PITR promotion" + + checksums.test_checksum_state(node_pitr, data_checksum_state) + result = node_pitr.safe_sql("SELECT count(*) FROM t WHERE a > 1") + assert result == "99999", "ensure data pages can be read back on primary" + + node_pitr.stop() + + log = node_pitr.log_content() + assert not re.search( + r"page verification failed,.+\d$", log, re.MULTILINE + ), "no checksum validation errors in pitr log" diff --git a/src/test/modules/test_checksums/pyt/test_009_fpi.py b/src/test/modules/test_checksums/pyt/test_009_fpi.py new file mode 100644 index 00000000000..ba838b3c902 --- /dev/null +++ b/src/test/modules/test_checksums/pyt/test_009_fpi.py @@ -0,0 +1,66 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Exercise full-page-image / WAL behaviour around data checksum changes. + +Toggles data checksums and full_page_writes across restarts and verifies that +no checksum validation errors are logged. +""" + +import re + + +def test_009_fpi(create_pg, checksums): + # Create and start a cluster with one node, checksums disabled. + node = create_pg( + "fpi_node", + start=False, + initdb_extra=["--no-data-checksums"], + allows_streaming=True, + ) + # max_connections need to be bumped in order to accommodate for pgbench + # clients and log_statement is dialled down since it otherwise will + # generate enormous amounts of logging. Page verification failures are + # still logged. + node.append_conf( + "\n".join( + [ + "max_connections = 100", + "log_statement = none", + ] + ) + ) + node.start() + node.safe_sql("CREATE EXTENSION test_checksums;") + # Create some content to have un-checksummed data in the cluster + node.safe_sql("CREATE TABLE t AS SELECT generate_series(1, 1000000) AS a;") + + # Enable data checksums and wait for the state transition to 'on' + checksums.enable_data_checksums(node, wait="on") + + node.safe_sql("UPDATE t SET a = a + 1;") + + checksums.disable_data_checksums(node, wait=1) + + node.append_conf("full_page_writes = off") + node.restart() + checksums.test_checksum_state(node, "off") + + node.safe_sql("UPDATE t SET a = a + 1;") + node.safe_sql("DELETE FROM t WHERE a < 10000;") + + # adjust_conf(full_page_writes => on); appending wins since the last + # setting in postgresql.conf takes effect. + node.append_conf("full_page_writes = on") + node.restart() + checksums.test_checksum_state(node, "off") + + checksums.enable_data_checksums(node, wait="on") + + result = node.safe_sql("SELECT count(*) FROM t;") + assert result == "990003", "Reading back all data from table t" + + node.stop() + log = node.log_content() + assert not re.search( + r"page verification failed,.+\d$", log, re.MULTILINE + ), "no checksum validation errors in server log" diff --git a/src/test/modules/test_cloexec/meson.build b/src/test/modules/test_cloexec/meson.build index 63c8658b04e..2a4dd924768 100644 --- a/src/test/modules/test_cloexec/meson.build +++ b/src/test/modules/test_cloexec/meson.build @@ -23,4 +23,9 @@ tests += { 't/001_cloexec.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_cloexec.py', + ], + }, } diff --git a/src/test/modules/test_cloexec/pyt/test_001_cloexec.py b/src/test/modules/test_cloexec/pyt/test_001_cloexec.py new file mode 100644 index 00000000000..fcd03a39827 --- /dev/null +++ b/src/test/modules/test_cloexec/pyt/test_001_cloexec.py @@ -0,0 +1,48 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test O_CLOEXEC flag handling on Windows. + +This test verifies that file handles opened with O_CLOEXEC are not +inherited by child processes, while handles opened without O_CLOEXEC +are inherited. +""" + +import os +import re +import sys + +import pytest + + +def test_cloexec(pg_bin): + if sys.platform != "win32": + pytest.skip("test is Windows-specific") + + # Locate the test program on PATH, falling back to the cwd. + test_prog = None + for directory in os.environ.get("PATH", "").split(os.pathsep): + candidate = os.path.join(directory, "test_cloexec.exe") + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + test_prog = candidate + break + + if not test_prog: + test_prog = os.path.join(".", "test_cloexec.exe") + + if not os.path.isfile(test_prog): + pytest.fail(f"test program not found: {test_prog}") + + print(f"# Using test program: {test_prog}") + + res = pg_bin.result([test_prog]) + + print("# Test program output:") + if res.stdout: + print(res.stdout) + if res.stderr: + print("# Test program stderr:") + print(res.stderr) + + assert res.returncode == 0 and re.search( + r"SUCCESS.*O_CLOEXEC behavior verified", res.stdout, re.S + ), "O_CLOEXEC prevents handle inheritance" diff --git a/src/test/modules/test_custom_rmgrs/meson.build b/src/test/modules/test_custom_rmgrs/meson.build index ef26d24a1ba..3ef3545a521 100644 --- a/src/test/modules/test_custom_rmgrs/meson.build +++ b/src/test/modules/test_custom_rmgrs/meson.build @@ -30,4 +30,9 @@ tests += { 't/001_basic.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + ], + }, } diff --git a/src/test/modules/test_custom_rmgrs/pyt/test_001_basic.py b/src/test/modules/test_custom_rmgrs/pyt/test_001_basic.py new file mode 100644 index 00000000000..029af82faeb --- /dev/null +++ b/src/test/modules/test_custom_rmgrs/pyt/test_001_basic.py @@ -0,0 +1,69 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic test of a custom WAL resource manager. + +Insert a custom WAL record via the test_custom_rmgrs extension and verify, +using pg_walinspect, that the custom resource manager registered and decoded +the record correctly. +""" + + +def test_001_basic(create_pg): + node = create_pg("main", start=False) + + node.append_conf( + "wal_level = 'replica'\n" + "max_wal_senders = 4\n" + "shared_preload_libraries = 'test_custom_rmgrs'\n" + ) + node.start() + + # setup + node.safe_sql("CREATE EXTENSION test_custom_rmgrs") + + # pg_walinspect is required only for verifying test_custom_rmgrs output. + # test_custom_rmgrs doesn't use/depend on it internally. + node.safe_sql("CREATE EXTENSION pg_walinspect") + + # make sure checkpoints don't interfere with the test. + start_lsn = node.safe_sql( + "SELECT lsn FROM pg_create_physical_replication_slot(" + "'regress_test_slot1', true, false);" + ) + + # write and save the WAL record's returned end LSN for verifying it later + record_end_lsn = node.safe_sql( + "SELECT * FROM test_custom_rmgrs_insert_wal_record('payload123')" + ) + + # ensure the WAL is written and flushed to disk + node.safe_sql("SELECT pg_switch_wal()") + + end_lsn = node.safe_sql("SELECT pg_current_wal_flush_lsn()") + + # check if our custom WAL resource manager has successfully registered with + # the server + row_count = node.safe_sql( + "SELECT count(*) FROM pg_get_wal_resource_managers() " + "WHERE rm_name = 'test_custom_rmgrs';" + ) + assert ( + row_count == "1" + ), "custom WAL resource manager has successfully registered with the server" + + # check if our custom WAL resource manager has successfully written a WAL + # record + expected = ( + f"{record_end_lsn}|test_custom_rmgrs|TEST_CUSTOM_RMGRS_MESSAGE|0|" + "payload (10 bytes): payload123" + ) + result = node.safe_sql( + "SELECT end_lsn, resource_manager, record_type, fpi_length, description " + f"FROM pg_get_wal_records_info('{start_lsn}', '{end_lsn}') " + "WHERE resource_manager = 'test_custom_rmgrs';" + ) + assert ( + result == expected + ), "custom WAL resource manager has successfully written a WAL record" + + node.stop() diff --git a/src/test/modules/test_custom_stats/meson.build b/src/test/modules/test_custom_stats/meson.build index e458f6bc65f..ee9e83e286d 100644 --- a/src/test/modules/test_custom_stats/meson.build +++ b/src/test/modules/test_custom_stats/meson.build @@ -52,4 +52,9 @@ tests += { ], 'runningcheck': false, }, + 'pytest': { + 'tests': [ + 'pyt/test_001_custom_stats.py', + ], + }, } diff --git a/src/test/modules/test_custom_stats/pyt/test_001_custom_stats.py b/src/test/modules/test_custom_stats/pyt/test_001_custom_stats.py new file mode 100644 index 00000000000..27e288b124e --- /dev/null +++ b/src/test/modules/test_custom_stats/pyt/test_001_custom_stats.py @@ -0,0 +1,142 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test custom pgstats functionality. + +This script includes tests for both variable and fixed-sized custom +pgstats: +- Creation, updates, and reporting. +- Persistence across restarts. +- Loss after crash recovery. +- Resets for fixed-sized stats. +""" + + +def _safe_sql_oneshot(node, query): + """Run *query* on a fresh connection and close it. + + safe_sql spawns a separate psql process (and hence a + separate backend) for every statement. Custom stats are accumulated in + backend-local pending memory and flushed to shared memory on backend exit, + so each statement's effect becomes visible to subsequent connections. The + in-process cached Session would instead keep one long-lived backend whose + pending stats never flush between calls, so use a short-lived connection + here to faithfully reproduce the per-statement flush semantics. + """ + sess = node.connect() + try: + sess.query_safe(query) + finally: + sess.close() + + +def test_001_custom_stats(create_pg): + node = create_pg("main", start=False) + node.append_conf( + "shared_preload_libraries = 'test_custom_var_stats, test_custom_fixed_stats'" + ) + node.start() + + _safe_sql_oneshot(node, "CREATE EXTENSION test_custom_var_stats") + _safe_sql_oneshot(node, "CREATE EXTENSION test_custom_fixed_stats") + + # Create entries for variable-sized stats. + _safe_sql_oneshot( + node, "select test_custom_stats_var_create('entry1', 'Test entry 1')" + ) + _safe_sql_oneshot( + node, "select test_custom_stats_var_create('entry2', 'Test entry 2')" + ) + _safe_sql_oneshot( + node, "select test_custom_stats_var_create('entry3', 'Test entry 3')" + ) + _safe_sql_oneshot( + node, "select test_custom_stats_var_create('entry4', 'Test entry 4')" + ) + + # Update counters: entry1=2, entry2=3, entry3=2, entry4=3, fixed=3 + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry1')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry1')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry2')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry2')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry2')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry3')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry3')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry4')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry4')") + _safe_sql_oneshot(node, "select test_custom_stats_var_update('entry4')") + _safe_sql_oneshot(node, "select test_custom_stats_fixed_update()") + _safe_sql_oneshot(node, "select test_custom_stats_fixed_update()") + _safe_sql_oneshot(node, "select test_custom_stats_fixed_update()") + + # Test data reports. + result = node.safe_sql("select * from test_custom_stats_var_report('entry1')") + assert result == "entry1|2|Test entry 1", "report for variable-sized data of entry1" + + result = node.safe_sql("select * from test_custom_stats_var_report('entry2')") + assert result == "entry2|3|Test entry 2", "report for variable-sized data of entry2" + + result = node.safe_sql("select * from test_custom_stats_var_report('entry3')") + assert result == "entry3|2|Test entry 3", "report for variable-sized data of entry3" + + result = node.safe_sql("select * from test_custom_stats_var_report('entry4')") + assert result == "entry4|3|Test entry 4", "report for variable-sized data of entry4" + + result = node.safe_sql("select * from test_custom_stats_fixed_report()") + assert result == "3|", "report for fixed-sized stats" + + # Test drop of variable-sized stats. + _safe_sql_oneshot(node, "select * from test_custom_stats_var_drop('entry3')") + result = node.safe_sql("select * from test_custom_stats_var_report('entry3')") + assert result == "", "entry3 not found after drop" + _safe_sql_oneshot(node, "select * from test_custom_stats_var_drop('entry4')") + result = node.safe_sql("select * from test_custom_stats_var_report('entry4')") + assert result == "", "entry4 not found after drop" + + # Test persistence across clean restart. + node.stop() + node.start() + + result = node.safe_sql("select * from test_custom_stats_var_report('entry1')") + assert ( + result == "entry1|2|Test entry 1" + ), "variable-sized stats persist after clean restart" + + result = node.safe_sql("select * from test_custom_stats_var_report('entry2')") + assert ( + result == "entry2|3|Test entry 2" + ), "variable-sized stats persist after clean restart" + + result = node.safe_sql("select * from test_custom_stats_fixed_report()") + assert result == "3|", "fixed-sized stats persist after clean restart" + + # Test persistence after crash recovery. + node.stop("immediate") + node.start() + + result = node.safe_sql("select * from test_custom_stats_var_report('entry1')") + assert result == "", "variable-sized stats of entry1 lost after crash recovery" + result = node.safe_sql("select * from test_custom_stats_var_report('entry2')") + assert result == "", "variable-sized stats of entry2 lost after crash recovery" + + # Crash recovery sets the reset timestamp. + result = node.safe_sql( + "select numcalls from test_custom_stats_fixed_report() " + "where stats_reset is not null" + ) + assert result == "0", "fixed-sized stats are reset after crash recovery" + + # Test reset of fixed-sized stats. + _safe_sql_oneshot(node, "select test_custom_stats_fixed_update()") + _safe_sql_oneshot(node, "select test_custom_stats_fixed_update()") + _safe_sql_oneshot(node, "select test_custom_stats_fixed_update()") + + result = node.safe_sql("select numcalls from test_custom_stats_fixed_report()") + assert result == "3", "report of fixed-sized before manual reset" + + _safe_sql_oneshot(node, "select test_custom_stats_fixed_reset()") + + result = node.safe_sql( + "select numcalls from test_custom_stats_fixed_report() " + "where stats_reset is not null" + ) + assert result == "0", "report of fixed-sized after manual reset" diff --git a/src/test/modules/test_escape/meson.build b/src/test/modules/test_escape/meson.build index a21341d5067..8831931b413 100644 --- a/src/test/modules/test_escape/meson.build +++ b/src/test/modules/test_escape/meson.build @@ -28,4 +28,10 @@ tests += { ], 'deps': [test_escape], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_test_escape.py', + ], + 'deps': [test_escape], + }, } diff --git a/src/test/modules/test_escape/pyt/test_001_test_escape.py b/src/test/modules/test_escape/pyt/test_001_test_escape.py new file mode 100644 index 00000000000..6d282a39197 --- /dev/null +++ b/src/test/modules/test_escape/pyt/test_001_test_escape.py @@ -0,0 +1,49 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test the string escaping functions. + +Runs the test_escape C program against a running node and maps its +TAP-style stdout into pytest assertions. +""" + +import re + + +def test_001_test_escape(create_pg, pg_bin): + node = create_pg("node") + + node.safe_sql( + 'CREATE DATABASE db_sql_ascii ENCODING "sql_ascii" TEMPLATE template0;' + ) + + conninfo = node.connstr("db_sql_ascii") + cmd = ["test_escape", "--conninfo", conninfo] + + # There currently is no good other way to transport test results from a C + # program that requires just the node being set-up... + res = pg_bin.result(cmd) + + assert res.returncode == 0, "test_escape returns 0" + assert res.stderr == "", "test_escape stderr is empty" + + failures = [] + for line in res.stdout.split("\n"): + if not line: + continue + m = re.match(r"^ok \d+ ?(.*)", line) + if m: + print(f"# ok {m.group(1)}") + continue + m = re.match(r"^not ok \d+ ?(.*)", line) + if m: + failures.append(m.group(1)) + continue + m = re.match(r"^# ?(.*)", line) + if m: + print(f"# {m.group(1)}") + continue + if re.match(r"^\d+\.\.\d+$", line): + continue + raise AssertionError(f"no unmapped lines, got {line}") + + assert not failures, "test_escape subtests failed:\n" + "\n".join(failures) diff --git a/src/test/modules/test_extensions/meson.build b/src/test/modules/test_extensions/meson.build index 2c7cea189e2..9eec13ab748 100644 --- a/src/test/modules/test_extensions/meson.build +++ b/src/test/modules/test_extensions/meson.build @@ -75,4 +75,9 @@ tests += { 't/001_extension_control_path.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_extension_control_path.py', + ], + }, } diff --git a/src/test/modules/test_extensions/pyt/test_001_extension_control_path.py b/src/test/modules/test_extensions/pyt/test_001_extension_control_path.py new file mode 100644 index 00000000000..33ae580412f --- /dev/null +++ b/src/test/modules/test_extensions/pyt/test_001_extension_control_path.py @@ -0,0 +1,169 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test the extension_control_path GUC.""" + +import os + +from pypg.util import WINDOWS_OS + + +def _create_extension(ext_dir, ext_name, directory=None): + """Write a .control and a --1.0.sql file for *ext_name* under *ext_dir*.""" + control_file = os.path.join(ext_dir, "extension", f"{ext_name}.control") + if directory is not None: + sql_file = os.path.join(ext_dir, directory, f"{ext_name}--1.0.sql") + else: + sql_file = os.path.join(ext_dir, "extension", f"{ext_name}--1.0.sql") + + with open(control_file, "w", encoding="utf-8") as cf: + cf.write("comment = 'Test extension_control_path'\n") + cf.write("default_version = '1.0'\n") + cf.write("relocatable = true\n") + if directory is not None: + cf.write(f"directory = {directory}") + + with open(sql_file, "w", encoding="utf-8") as sqlf: + sqlf.write(f"/* {sql_file} */\n") + sqlf.write( + "-- complain if script is sourced in psql, rather than via " + "CREATE EXTENSION\n" + ) + sqlf.write( + f'\\echo Use "CREATE EXTENSION {ext_name}" to load this file. \\quit\n' + ) + + +def test_extension_control_path(create_pg, tmp_path): + # create_pg runs initdb; start=False so we can append config first. + node = create_pg("node", start=False) + + # Create temporary directories for the extension control files + ext_dir = str(tmp_path / "ext_dir") + os.makedirs(os.path.join(ext_dir, "extension")) + ext_dir2 = str(tmp_path / "ext_dir2") + os.makedirs(os.path.join(ext_dir2, "extension")) + + ext_name = "test_custom_ext_paths" + _create_extension(ext_dir, ext_name) + _create_extension(ext_dir2, ext_name) + + ext_name2 = "test_custom_ext_paths_using_directory" + os.makedirs(os.path.join(ext_dir, ext_name2)) + _create_extension(ext_dir, ext_name2, directory=ext_name2) + + # The GUC path-list separator is ":" on Unix but ";" on Windows (":" + # collides with drive letters). pg_available_extensions reports the + # canonicalized path, which on Windows uses forward slashes. Backslashes + # in the postgresql.conf string value must be doubled so the configuration + # parser preserves them. + if WINDOWS_OS: + sep = ";" + ext_dir_canonicalized = ext_dir.replace("\\", "/") + ext_dir_conf = ext_dir.replace("\\", "\\\\") + ext_dir2_conf = ext_dir2.replace("\\", "\\\\") + else: + sep = ":" + ext_dir_canonicalized = ext_dir + ext_dir_conf = ext_dir + ext_dir2_conf = ext_dir2 + + node.append_conf( + f"extension_control_path = '$system{sep}{ext_dir_conf}{sep}{ext_dir2_conf}'\n" + ) + + # Start node + node.start() + + # Create a user to test permissions to read extension locations. + user = "user01" + node.safe_sql(f"CREATE USER {user}") + + ecp = node.safe_sql("show extension_control_path;") + assert ( + ecp == f"$system{sep}{ext_dir}{sep}{ext_dir2}" + ), "custom extension control directory path configured" + + node.safe_sql(f"CREATE EXTENSION {ext_name}") + node.safe_sql(f"CREATE EXTENSION {ext_name2}") + + ret = node.safe_sql( + f"select * from pg_available_extensions where name = '{ext_name}'" + ) + assert ret == ( + f"test_custom_ext_paths|1.0|1.0|{ext_dir_canonicalized}/extension|" + "Test extension_control_path" + ), "extension is shown correctly in pg_available_extensions" + + ret = node.safe_sql( + f"select * from pg_available_extension_versions where name = '{ext_name}'" + ) + assert ret == ( + f"test_custom_ext_paths|1.0|t|t|f|t|||{ext_dir_canonicalized}/extension|" + "Test extension_control_path" + ), "extension is shown correctly in pg_available_extension_versions" + + ret = node.safe_sql( + f"select * from pg_available_extensions where name = '{ext_name2}'" + ) + assert ret == ( + f"test_custom_ext_paths_using_directory|1.0|1.0|" + f"{ext_dir_canonicalized}/extension|Test extension_control_path" + ), "extension is shown correctly in pg_available_extensions" + + ret = node.safe_sql( + "select * from pg_available_extension_versions " f"where name = '{ext_name2}'" + ) + assert ret == ( + f"test_custom_ext_paths_using_directory|1.0|t|t|f|t|||" + f"{ext_dir_canonicalized}/extension|Test extension_control_path" + ), "extension is shown correctly in pg_available_extension_versions" + + # Test that a non-superuser is not able to read the extension location in + # pg_available_extensions + user_sess = node.connect(user=user) + try: + ret = user_sess.query_safe( + "select location from pg_available_extensions " + f"where name = '{ext_name2}'" + ) + assert ret == "", ( + "extension location is hidden in pg_available_extensions for users " + "with insufficient privilege" + ) + + # Test that a non-superuser is not able to read the extension location in + # pg_available_extension_versions + ret = user_sess.query_safe( + "select location from pg_available_extension_versions " + f"where name = '{ext_name2}'" + ) + assert ret == "", ( + "extension location is hidden in pg_available_extension_versions for " + "users with insufficient privilege" + ) + finally: + user_sess.close() + + # Ensure that extensions installed in $system are still visible when used + # with custom extension control path. + ret = node.safe_sql( + "select count(*) > 0 as ok from pg_available_extensions " + "where name = 'plpgsql'" + ) + assert ret == "t", "$system extension is shown correctly in pg_available_extensions" + + ret = node.safe_sql( + "set extension_control_path = ''; " + "select location from pg_available_extensions where name = 'plpgsql'" + ) + assert ret == "$system", ( + "$system location is shown correctly in pg_available_extensions with " + "empty extension_control_path" + ) + + # Test with an extension that does not exist + res = node.sql("CREATE EXTENSION invalid") + assert ( + res.error_message is not None + ), "error creating an extension that does not exist" + assert 'extension "invalid" is not available' in res.error_message diff --git a/src/test/modules/test_int128/meson.build b/src/test/modules/test_int128/meson.build index 74456112433..9f96d6ea92e 100644 --- a/src/test/modules/test_int128/meson.build +++ b/src/test/modules/test_int128/meson.build @@ -30,4 +30,9 @@ tests += { ], 'deps': [test_int128], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_test_int128.py', + ], + }, } diff --git a/src/test/modules/test_int128/pyt/test_001_test_int128.py b/src/test/modules/test_int128/pyt/test_001_test_int128.py new file mode 100644 index 00000000000..d315bba19bb --- /dev/null +++ b/src/test/modules/test_int128/pyt/test_001_test_int128.py @@ -0,0 +1,23 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test the 128-bit integer arithmetic code in int128.h.""" + +import pytest + + +def test_001_test_int128(pg_bin): + # Test 128-bit integer arithmetic code in int128.h + + # Run the test program with 1M iterations + exe = "test_int128" + size = 1_000_000 + + print(f"# testing executable {exe}") + + res = pg_bin.result([exe, str(size)]) + + if "skipping tests" in res.stdout: + pytest.skip("no native int128 type") + + assert res.stdout == "", "test_int128: no stdout" + assert res.stderr == "", "test_int128: no stderr" diff --git a/src/test/modules/test_json_parser/meson.build b/src/test/modules/test_json_parser/meson.build index 2688686e37b..6cdb3af46b6 100644 --- a/src/test/modules/test_json_parser/meson.build +++ b/src/test/modules/test_json_parser/meson.build @@ -67,4 +67,17 @@ tests += { test_json_parser_perf, ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_test_json_parser_incremental.py', + 'pyt/test_002_inline.py', + 'pyt/test_003_test_semantic.py', + 'pyt/test_004_test_parser_perf.py', + ], + 'deps': [ + test_json_parser_incremental, + test_json_parser_incremental_shlib, + test_json_parser_perf, + ], + }, } diff --git a/src/test/modules/test_json_parser/pyt/test_001_test_json_parser_incremental.py b/src/test/modules/test_json_parser/pyt/test_001_test_json_parser_incremental.py new file mode 100644 index 00000000000..7617f145093 --- /dev/null +++ b/src/test/modules/test_json_parser/pyt/test_001_test_json_parser_incremental.py @@ -0,0 +1,34 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test the incremental (table-driven) json parser.""" + +import os +import re + +import pytest + +# tiny.json lives in the module source directory, one level up from pyt/. +TEST_FILE = os.path.join(os.path.dirname(__file__), os.pardir, "tiny.json") + +EXES = [ + ["test_json_parser_incremental"], + ["test_json_parser_incremental", "-o"], + ["test_json_parser_incremental_shlib"], + ["test_json_parser_incremental_shlib", "-o"], +] + + +@pytest.mark.parametrize("exe", EXES, ids=" ".join) +def test_001_test_json_parser_incremental(pg_bin, exe): + print(f"# testing executable {' '.join(exe)}") + + # Test the usage error + res = pg_bin.result([*exe, "-c", "10"]) + assert re.search(r"Usage:", res.stderr), "error message if not enough arguments" + + # Test that we get success for small chunk sizes from 64 down to 1. + for size in range(64, 0, -1): + res = pg_bin.result([*exe, "-c", str(size), TEST_FILE]) + + assert re.search(r"SUCCESS", res.stdout), f"chunk size {size}: test succeeds" + assert res.stderr == "", f"chunk size {size}: no error output" diff --git a/src/test/modules/test_json_parser/pyt/test_002_inline.py b/src/test/modules/test_json_parser/pyt/test_002_inline.py new file mode 100644 index 00000000000..c424ba2f662 --- /dev/null +++ b/src/test/modules/test_json_parser/pyt/test_002_inline.py @@ -0,0 +1,172 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test the incremental JSON parser on small inline inputs. + +Test success or failure of the incremental (table-driven) JSON parser for a +variety of small inputs. +""" + +import re +import shutil +import subprocess + +import pytest + +# (name, json-bytes, error-regex-or-None). The JSON payloads are bytes so we +# can include raw, non-UTF-8 bytes (e.g. the 0xF5 case) exactly as fed to the +# parser. Error patterns are compiled as bytes for the same reason. +CASES = [ + ("number", b"12345", None), + ("string", b'"hello"', None), + ("false", b"false", None), + ("true", b"true", None), + ("null", b"null", None), + ("empty object", b"{}", None), + ("empty array", b"[]", None), + ("array with number", b"[12345]", None), + ("array with numbers", b"[12345,67890]", None), + ("array with null", b"[null]", None), + ("array with string", b'["hello"]', None), + ("array with boolean", b"[false]", None), + ("single pair", b'{"key": "value"}', None), + ("heavily nested array", b"[" * 3200 + b"]" * 3200, None), + ("serial escapes", b'"' + b"\\" * 8 + b'"', None), + ( + "interrupted escapes", + b'"' + b"\\" * 3 + b'"' + b"\\" * 5 + b'"' + b"\\" * 2 + b'"', + None, + ), + ("whitespace", b' "" ', None), + ("unclosed empty object", b"{", rb"input string ended unexpectedly"), + ("bad key", b"{{", rb'Expected string or "}", but found "\{"'), + ("bad key", b"{{}", rb'Expected string or "}", but found "\{"'), + ("numeric key", b"{1234: 2}", rb'Expected string or "}", but found "1234"'), + ( + "second numeric key", + b'{"a": "a", 1234: 2}', + rb'Expected string, but found "1234"', + ), + ( + "unclosed object with pair", + b'{"key": "value"', + rb"input string ended unexpectedly", + ), + ("missing key value", b'{"key": }', rb'Expected JSON value, but found "}"'), + ("missing colon", b'{"key" 12345}', rb'Expected ":", but found "12345"'), + ( + "missing comma", + b'{"key": 12345 12345}', + rb'Expected "," or "}", but found "12345"', + ), + ("overnested array", b"[" * 6401, rb"maximum permitted depth is 6400"), + ("overclosed array", b"[]]", rb'Expected end of input, but found "]"'), + ( + "unexpected token in array", + b"[ }}} ]", + rb'Expected array element or "]", but found "}"', + ), + ("junk punctuation", b"[ ||| ]", rb'Token "\|" is invalid'), + ( + "missing comma in array", + b"[123 123]", + rb'Expected "," or "]", but found "123"', + ), + ("misspelled boolean", b"tru", rb'Token "tru" is invalid'), + ("misspelled boolean in array", b"[tru]", rb'Token "tru" is invalid'), + ("smashed top-level scalar", b"12zz", rb'Token "12zz" is invalid'), + ("smashed scalar in array", b"[12zz]", rb'Token "12zz" is invalid'), + ( + "unknown escape sequence", + b'"hello\\vworld"', + rb'Escape sequence "\\v" is invalid', + ), + ( + "unescaped control", + b'"hello\tworld"', + rb"Character with value 0x09 must be escaped", + ), + ( + "incorrect escape count", + b'"' + b"\\" * 7 + b'"', + rb'Token ""\\\\\\\\\\\\\\"" is invalid', + ), + # Case with three bytes: double-quote, backslash and . + # Both invalid-token and invalid-escape are possible errors, because for + # smaller chunk sizes the incremental parser skips the string parsing when + # it cannot find an ending quote. + ( + "incomplete UTF-8 sequence", + b'"\\\xf5', + rb'(Token|Escape sequence) ""?\\\xf5" is invalid', + ), +] + +# The four executable invocations exercised by this test. +EXES = [ + ["test_json_parser_incremental"], + ["test_json_parser_incremental", "-o"], + ["test_json_parser_incremental_shlib"], + ["test_json_parser_incremental_shlib", "-o"], +] + + +def _exe_id(exe): + return "_".join(exe).replace("-", "") + + +def _case_id(case): + name, json, _err = case + return f"{name}_{len(json)}" + + +def _run(exe, chunk, fname): + """Run the parser in -r mode and return (stdout, stderr) as byte lists. + + Split the null-separated runs of output. In -r + mode the program writes a trailing null after each of the `chunk` + iterations, so a trailing empty fragment from the final null is dropped. + """ + argv = [shutil.which(exe[0]) or exe[0], *exe[1:], "-r", str(chunk), str(fname)] + proc = subprocess.run( + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False + ) + + def split(buf): + parts = buf.split(b"\0") + if parts and parts[-1] == b"": + parts.pop() + return parts + + return split(proc.stdout), split(proc.stderr) + + +@pytest.mark.parametrize("exe", EXES, ids=_exe_id) +@pytest.mark.parametrize("case", CASES, ids=_case_id) +def test_002_inline(exe, case, tmp_path): + if not shutil.which(exe[0]): + pytest.skip(f"{exe[0]} not found on PATH") + + name, json, error = case + + # Test the input with chunk sizes from max(input_size, 64) down to 1. + chunk = min(len(json), 64) + + fname = tmp_path / "input.json" + fname.write_bytes(json) + + stdout, stderr = _run(exe, chunk, fname) + + assert len(stdout) == chunk, f"{name}: stdout has correct number of entries" + assert len(stderr) == chunk, f"{name}: stderr has correct number of entries" + + i = 0 + for size in reversed(range(1, chunk + 1)): + if error is not None: + assert b"SUCCESS" not in stdout[i], f"{name}, chunk size {size}: test fails" + assert re.search( + error, stderr[i] + ), f"{name}, chunk size {size}: correct error output: {stderr[i]!r}" + else: + assert b"SUCCESS" in stdout[i], f"{name}, chunk size {size}: test succeeds" + assert stderr[i] == b"", f"{name}, chunk size {size}: no error output" + i += 1 diff --git a/src/test/modules/test_json_parser/pyt/test_003_test_semantic.py b/src/test/modules/test_json_parser/pyt/test_003_test_semantic.py new file mode 100644 index 00000000000..fe97d5fe4ac --- /dev/null +++ b/src/test/modules/test_json_parser/pyt/test_003_test_semantic.py @@ -0,0 +1,43 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test the incremental JSON parser with semantic routines. + +Run the incremental JSON parser with semantic routines, and compare the +output with the expected output. +""" + +import os + +import pytest + +# The module source directory is the parent of this pyt/ directory, where the +# test data files (tiny.json, tiny.out) live. +_MODULE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_TEST_FILE = os.path.join(_MODULE_DIR, "tiny.json") +_TEST_OUT = os.path.join(_MODULE_DIR, "tiny.out") + +_EXES = ( + ("test_json_parser_incremental",), + ("test_json_parser_incremental", "-o"), + ("test_json_parser_incremental_shlib",), + ("test_json_parser_incremental_shlib", "-o"), +) + + +@pytest.mark.parametrize("exe", _EXES, ids=[" ".join(e) for e in _EXES]) +def test_003_test_semantic(pg_bin, exe): + print(f"# testing executable {' '.join(exe)}") + + res = pg_bin.result([*exe, "-s", _TEST_FILE]) + + # Chomp a single trailing newline off both streams. + stdout = res.stdout[:-1] if res.stdout.endswith("\n") else res.stdout + stderr = res.stderr[:-1] if res.stderr.endswith("\n") else res.stderr + + assert stderr == "", "no error output" + + with open(_TEST_OUT, encoding="utf-8") as f: + expected = f.read() + + # Append a newline to the chomped stdout before diffing. + assert stdout + "\n" == expected, "no output diff" diff --git a/src/test/modules/test_json_parser/pyt/test_004_test_parser_perf.py b/src/test/modules/test_json_parser/pyt/test_004_test_parser_perf.py new file mode 100644 index 00000000000..25d34059633 --- /dev/null +++ b/src/test/modules/test_json_parser/pyt/test_004_test_parser_perf.py @@ -0,0 +1,33 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test the JSON parser performance tester. + +Here we are just checking that the +performance tester can run, both with the standard parser and the incremental +parser. An actual performance test will run with thousands of iterations +instead of just one. +""" + +import os + + +def test_004_test_parser_perf(pg_bin, tmp_path): + exe = "test_json_parser_perf" + + # tiny.json lives in the module source dir, one level up from pyt/. + test_file = os.path.join(os.path.dirname(__file__), "..", "tiny.json") + with open(test_file, encoding="utf-8") as f: + contents = f.read() + + # repeat the input json file 50 times in an array + fname = str(tmp_path / "perf_input.json") + with open(fname, "w", encoding="utf-8") as fh: + fh.write("[" + contents + ("," + contents) * 49 + "]") + + # but only do one iteration + + res = pg_bin.result([exe, "1", fname]) + assert res.returncode == 0, "perf test runs with recursive descent parser" + + res = pg_bin.result([exe, "-i", "1", fname]) + assert res.returncode == 0, "perf test runs with table driven parser" diff --git a/src/test/modules/test_misc/meson.build b/src/test/modules/test_misc/meson.build index 969e90b396d..191d8028a82 100644 --- a/src/test/modules/test_misc/meson.build +++ b/src/test/modules/test_misc/meson.build @@ -26,4 +26,26 @@ tests += { # The injection points are cluster-wide, so disable installcheck 'runningcheck': false, }, + 'pytest': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_constraint_validation.py', + 'pyt/test_002_tablespace.py', + 'pyt/test_003_check_guc.py', + 'pyt/test_004_io_direct.py', + 'pyt/test_005_timeouts.py', + 'pyt/test_006_signal_autovacuum.py', + 'pyt/test_007_catcache_inval.py', + 'pyt/test_008_replslot_single_user.py', + 'pyt/test_009_log_temp_files.py', + 'pyt/test_010_index_concurrently_upsert.py', + 'pyt/test_011_lock_stats.py', + 'pyt/test_012_ddlutils.py', + 'pyt/test_013_temp_obj_multisession.py', + ], + # The injection points are cluster-wide, so disable installcheck + 'runningcheck': false, + }, } diff --git a/src/test/modules/test_misc/pyt/test_001_constraint_validation.py b/src/test/modules/test_misc/pyt/test_001_constraint_validation.py new file mode 100644 index 00000000000..6dead54fcbf --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_001_constraint_validation.py @@ -0,0 +1,378 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Verify that ALTER TABLE optimizes certain operations as expected.""" + +import re + + +def is_table_verified(output): + """Return True if *output* shows that we did a verify pass. + + Matches the DEBUG message emitted when ALTER TABLE has to scan the table + to validate a constraint. + """ + return "DEBUG: verifying table" in output + + +def test_001_constraint_validation(create_pg): + node = create_pg("primary", start=False) + # Turn message level up to DEBUG1 so that we get the messages we want to + # see. The DEBUG messages are delivered to the client as notices and are + # captured via the libpq notice processor. + node.append_conf("client_min_messages = DEBUG1") + node.start() + + # Run a SQL command and return the captured stderr (including DEBUG + # messages). Uses a single libpq session for the whole test, clearing the + # notice buffer before each command so each call returns only that + # command's output. + sess = node.connect() + + def run_sql_command(sql): + sess.clear_notices() + sess.query_safe(sql) + return sess.get_notices_str() + + # --- test alter table set not null ------------------------------------- + + run_sql_command( + "create table atacc1 (test_a int, test_b int);\n" + " insert into atacc1 values (1, 2);" + ) + + output = run_sql_command("alter table atacc1 alter test_a set not null;") + assert is_table_verified(output), "column test_a without constraint will scan table" + + run_sql_command( + "alter table atacc1 alter test_a drop not null;\n" + " alter table atacc1 add constraint atacc1_constr_a_valid\n" + " check(test_a is not null);" + ) + + # normal run will verify table data + output = run_sql_command("alter table atacc1 alter test_a set not null;") + assert not is_table_verified(output), "with constraint will not scan table" + assert re.search( + r'existing constraints on column "atacc1.test_a" are sufficient to ' + r"prove that it does not contain nulls", + output, + ), "test_a proved by constraints" + + run_sql_command("alter table atacc1 alter test_a drop not null;") + + # we have check only for test_a column, so we need verify table for test_b + output = run_sql_command( + "alter table atacc1 alter test_b set not null, alter test_a set not null;" + ) + assert is_table_verified(output), "table was scanned" + # we may miss debug message for test_a constraint because we need verify + # table due test_b + assert not re.search( + r'existing constraints on column "atacc1.test_b" are sufficient to ' + r"prove that it does not contain nulls", + output, + ), "test_b not proved by wrong constraints" + run_sql_command( + "alter table atacc1 alter test_a drop not null, alter test_b drop not null;" + ) + + # test with both columns having check constraints + run_sql_command( + "alter table atacc1 add constraint atacc1_constr_b_valid " + "check(test_b is not null);" + ) + output = run_sql_command( + "alter table atacc1 alter test_b set not null, alter test_a set not null;" + ) + assert not is_table_verified(output), "table was not scanned for both columns" + assert re.search( + r'existing constraints on column "atacc1.test_a" are sufficient to ' + r"prove that it does not contain nulls", + output, + ), "test_a proved by constraints" + assert re.search( + r'existing constraints on column "atacc1.test_b" are sufficient to ' + r"prove that it does not contain nulls", + output, + ), "test_b proved by constraints" + run_sql_command("drop table atacc1;") + + # --- test alter table attach partition --------------------------------- + + run_sql_command( + "CREATE TABLE list_parted2 (\n" + " a int,\n" + " b char\n" + " ) PARTITION BY LIST (a);\n" + " CREATE TABLE part_3_4 (\n" + " LIKE list_parted2,\n" + " CONSTRAINT check_a CHECK (a IN (3)));" + ) + + # need NOT NULL to skip table scan + output = run_sql_command( + "ALTER TABLE list_parted2 ATTACH PARTITION part_3_4 FOR VALUES IN (3, 4);" + ) + assert is_table_verified(output), "table part_3_4 scanned" + + run_sql_command( + "ALTER TABLE list_parted2 DETACH PARTITION part_3_4;\n" + " ALTER TABLE part_3_4 ALTER a SET NOT NULL;" + ) + + output = run_sql_command( + "ALTER TABLE list_parted2 ATTACH PARTITION part_3_4 FOR VALUES IN (3, 4);" + ) + assert not is_table_verified(output), "table part_3_4 not scanned" + assert re.search( + r'partition constraint for table "part_3_4" is implied by existing ' + r"constraints", + output, + ), "part_3_4 verified by existing constraints" + + # test attach default partition + run_sql_command( + "CREATE TABLE list_parted2_def (\n" + " LIKE list_parted2,\n" + " CONSTRAINT check_a CHECK (a IN (5, 6)));" + ) + output = run_sql_command( + "ALTER TABLE list_parted2 ATTACH PARTITION list_parted2_def default;" + ) + assert not is_table_verified(output), "table list_parted2_def not scanned" + assert re.search( + r'partition constraint for table "list_parted2_def" is implied by ' + r"existing constraints", + output, + ), "list_parted2_def verified by existing constraints" + + output = run_sql_command( + "CREATE TABLE part_55_66 PARTITION OF list_parted2 FOR VALUES IN (55, 66);" + ) + assert not is_table_verified(output), "table list_parted2_def not scanned" + assert re.search( + r"updated partition constraint for default partition " + r'"list_parted2_def" is implied by existing constraints', + output, + ), "updated partition constraint for default partition list_parted2_def" + + # test attach another partitioned table + run_sql_command( + "CREATE TABLE part_5 (\n" + " LIKE list_parted2\n" + " ) PARTITION BY LIST (b);\n" + " CREATE TABLE part_5_a PARTITION OF part_5 FOR VALUES IN ('a');\n" + " ALTER TABLE part_5 ADD CONSTRAINT check_a " + "CHECK (a IS NOT NULL AND a = 5);" + ) + output = run_sql_command( + "ALTER TABLE list_parted2 ATTACH PARTITION part_5 FOR VALUES IN (5);" + ) + assert not re.search( + r'verifying table "part_5"', output + ), "table part_5 not scanned" + assert re.search( + r'verifying table "list_parted2_def"', output + ), "list_parted2_def scanned" + assert re.search( + r'partition constraint for table "part_5" is implied by existing ' + r"constraints", + output, + ), "part_5 verified by existing constraints" + + run_sql_command( + "ALTER TABLE list_parted2 DETACH PARTITION part_5;\n" + " ALTER TABLE part_5 DROP CONSTRAINT check_a;" + ) + + # scan should again be skipped, even though NOT NULL is now a column + # property + run_sql_command( + "ALTER TABLE part_5 ADD CONSTRAINT check_a CHECK (a IN (5)),\n" + " ALTER a SET NOT NULL;" + ) + output = run_sql_command( + "ALTER TABLE list_parted2 ATTACH PARTITION part_5 FOR VALUES IN (5);" + ) + assert not re.search( + r'verifying table "part_5"', output + ), "table part_5 not scanned" + assert re.search( + r'verifying table "list_parted2_def"', output + ), "list_parted2_def scanned" + assert re.search( + r'partition constraint for table "part_5" is implied by existing ' + r"constraints", + output, + ), "part_5 verified by existing constraints" + + # Check the case where attnos of the partitioning columns in the table + # being attached differs from the parent. It should not affect the + # constraint-checking logic that allows to skip the scan. + run_sql_command( + "CREATE TABLE part_6 (\n" + " c int,\n" + " LIKE list_parted2,\n" + " CONSTRAINT check_a CHECK (a IS NOT NULL AND a = 6)\n" + " );\n" + " ALTER TABLE part_6 DROP c;" + ) + output = run_sql_command( + "ALTER TABLE list_parted2 ATTACH PARTITION part_6 FOR VALUES IN (6);" + ) + assert not re.search( + r'verifying table "part_6"', output + ), "table part_6 not scanned" + assert re.search( + r'verifying table "list_parted2_def"', output + ), "list_parted2_def scanned" + assert re.search( + r'partition constraint for table "part_6" is implied by existing ' + r"constraints", + output, + ), "part_6 verified by existing constraints" + + # Similar to above, but the table being attached is a partitioned table + # whose partition has still different attnos for the root partitioning + # columns. + run_sql_command( + "CREATE TABLE part_7 (\n" + " LIKE list_parted2,\n" + " CONSTRAINT check_a CHECK (a IS NOT NULL AND a = 7)\n" + " ) PARTITION BY LIST (b);\n" + " CREATE TABLE part_7_a_null (\n" + " c int,\n" + " d int,\n" + " e int,\n" + " LIKE list_parted2, -- a will have attnum = 4\n" + " CONSTRAINT check_b CHECK (b IS NULL OR b = 'a'),\n" + " CONSTRAINT check_a CHECK (a IS NOT NULL AND a = 7)\n" + " );\n" + " ALTER TABLE part_7_a_null DROP c, DROP d, DROP e;" + ) + + output = run_sql_command( + "ALTER TABLE part_7 ATTACH PARTITION part_7_a_null " + "FOR VALUES IN ('a', null);" + ) + assert not is_table_verified(output), "table not scanned" + assert re.search( + r'partition constraint for table "part_7_a_null" is implied by ' + r"existing constraints", + output, + ), "part_7_a_null verified by existing constraints" + output = run_sql_command( + "ALTER TABLE list_parted2 ATTACH PARTITION part_7 FOR VALUES IN (7);" + ) + assert not is_table_verified(output), "tables not scanned" + assert re.search( + r'partition constraint for table "part_7" is implied by existing ' + r"constraints", + output, + ), "part_7 verified by existing constraints" + assert re.search( + r"updated partition constraint for default partition " + r'"list_parted2_def" is implied by existing constraints', + output, + ), "updated partition constraint for default partition list_parted2_def" + + run_sql_command( + "CREATE TABLE range_parted (\n" + " a int,\n" + " b int\n" + " ) PARTITION BY RANGE (a, b);\n" + " CREATE TABLE range_part1 (\n" + " a int NOT NULL CHECK (a = 1),\n" + " b int NOT NULL);" + ) + + output = run_sql_command( + "ALTER TABLE range_parted ATTACH PARTITION range_part1 " + "FOR VALUES FROM (1, 1) TO (1, 10);" + ) + assert is_table_verified(output), "table range_part1 scanned" + assert not re.search( + r'partition constraint for table "range_part1" is implied by existing ' + r"constraints", + output, + ), "range_part1 not verified by existing constraints" + + run_sql_command( + "CREATE TABLE range_part2 (\n" + " a int NOT NULL CHECK (a = 1),\n" + " b int NOT NULL CHECK (b >= 10 and b < 18)\n" + ");" + ) + output = run_sql_command( + "ALTER TABLE range_parted ATTACH PARTITION range_part2 " + "FOR VALUES FROM (1, 10) TO (1, 20);" + ) + assert not is_table_verified(output), "table range_part2 not scanned" + assert re.search( + r'partition constraint for table "range_part2" is implied by existing ' + r"constraints", + output, + ), "range_part2 verified by existing constraints" + + # If a partitioned table being created or an existing table being attached + # as a partition does not have a constraint that would allow validation + # scan to be skipped, but an individual partition does, then the + # partition's validation scan is skipped. + run_sql_command( + "CREATE TABLE quuux (a int, b text) PARTITION BY LIST (a);\n" + " CREATE TABLE quuux_default PARTITION OF quuux DEFAULT " + "PARTITION BY LIST (b);\n" + " CREATE TABLE quuux_default1 PARTITION OF quuux_default (\n" + " CONSTRAINT check_1 CHECK (a IS NOT NULL AND a = 1)\n" + " ) FOR VALUES IN ('b');\n" + " CREATE TABLE quuux1 (a int, b text);" + ) + + output = run_sql_command( + "ALTER TABLE quuux ATTACH PARTITION quuux1 FOR VALUES IN (1);" + ) + assert is_table_verified(output), "quuux1 table scanned" + assert not re.search( + r'partition constraint for table "quuux1" is implied by existing ' + r"constraints", + output, + ), "quuux1 verified by existing constraints" + + run_sql_command("CREATE TABLE quuux2 (a int, b text);") + output = run_sql_command( + "ALTER TABLE quuux ATTACH PARTITION quuux2 FOR VALUES IN (2);" + ) + assert not re.search( + r'verifying table "quuux_default1"', output + ), "quuux_default1 not scanned" + assert re.search(r'verifying table "quuux2"', output), "quuux2 scanned" + assert re.search( + r'updated partition constraint for default partition "quuux_default1" ' + r"is implied by existing constraints", + output, + ), "updated partition constraint for default partition quuux_default1" + run_sql_command("DROP TABLE quuux1, quuux2;") + + # should validate for quuux1, but not for quuux2 + output = run_sql_command( + "CREATE TABLE quuux1 PARTITION OF quuux FOR VALUES IN (1);" + ) + assert not is_table_verified(output), "tables not scanned" + assert not re.search( + r'partition constraint for table "quuux1" is implied by existing ' + r"constraints", + output, + ), "quuux1 verified by existing constraints" + output = run_sql_command( + "CREATE TABLE quuux2 PARTITION OF quuux FOR VALUES IN (2);" + ) + assert not is_table_verified(output), "tables not scanned" + assert re.search( + r'updated partition constraint for default partition "quuux_default1" ' + r"is implied by existing constraints", + output, + ), "updated partition constraint for default partition quuux_default1" + run_sql_command("DROP TABLE quuux;") + + sess.close() + node.stop("fast") diff --git a/src/test/modules/test_misc/pyt/test_002_tablespace.py b/src/test/modules/test_misc/pyt/test_002_tablespace.py new file mode 100644 index 00000000000..33576524be7 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_002_tablespace.py @@ -0,0 +1,69 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Simple tablespace tests. + +These can't be replicated on the same host due to the use of absolute paths, +so we keep them out of the regular regression tests. +""" + +import os + + +def test_002_tablespace(pg): + node = pg + + # Create a couple of directories to use as tablespaces. + ts1_location = os.path.join(node.basedir, "ts1") + os.mkdir(ts1_location) + ts2_location = os.path.join(node.basedir, "ts2") + os.mkdir(ts2_location) + + # Create a tablespace with an absolute path. CREATE TABLESPACE cannot run + # in a transaction block, so each runs as its own safe_sql call. + node.safe_sql(f"CREATE TABLESPACE regress_ts1 LOCATION '{ts1_location}'") + + # Can't create a tablespace where there is one already. + res = node.sql(f"CREATE TABLESPACE regress_ts1 LOCATION '{ts1_location}'") + assert res.error_message is not None, "clobber tablespace with absolute path" + + # Create table in it. + node.safe_sql("CREATE TABLE t () TABLESPACE regress_ts1") + + # Can't drop a tablespace that still has a table in it. + res = node.sql("DROP TABLESPACE regress_ts1") + assert res.error_message is not None, "drop tablespace with absolute path" + + # Drop the table. + node.safe_sql("DROP TABLE t") + + # Drop the tablespace. + node.safe_sql("DROP TABLESPACE regress_ts1") + + # Create two absolute tablespaces and two in-place tablespaces, so we can + # test various kinds of tablespace moves. + node.safe_sql(f"CREATE TABLESPACE regress_ts1 LOCATION '{ts1_location}'") + node.safe_sql(f"CREATE TABLESPACE regress_ts2 LOCATION '{ts2_location}'") + + # In-place tablespaces require allow_in_place_tablespaces. The GUC must be + # set in the same session as the CREATE TABLESPACE, but CREATE TABLESPACE + # cannot run in a transaction block (and a multi-statement string runs as + # one implicit transaction), so issue them as separate statements on one + # persistent connection. + with node.connect() as sess: + sess.query_safe("SET allow_in_place_tablespaces=on") + sess.query_safe("CREATE TABLESPACE regress_ts3 LOCATION ''") + sess.query_safe("CREATE TABLESPACE regress_ts4 LOCATION ''") + + # Create a table and test moving between absolute and in-place tablespaces. + node.safe_sql("CREATE TABLE t () TABLESPACE regress_ts1") + node.safe_sql("ALTER TABLE t SET tablespace regress_ts2") # abs->abs + node.safe_sql("ALTER TABLE t SET tablespace regress_ts3") # abs->in-place + node.safe_sql("ALTER TABLE t SET tablespace regress_ts4") # in-place->in-place + node.safe_sql("ALTER TABLE t SET tablespace regress_ts1") # in-place->abs + + # Drop everything. + node.safe_sql("DROP TABLE t") + node.safe_sql("DROP TABLESPACE regress_ts1") + node.safe_sql("DROP TABLESPACE regress_ts2") + node.safe_sql("DROP TABLESPACE regress_ts3") + node.safe_sql("DROP TABLESPACE regress_ts4") diff --git a/src/test/modules/test_misc/pyt/test_003_check_guc.py b/src/test/modules/test_misc/pyt/test_003_check_guc.py new file mode 100644 index 00000000000..567c4167207 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_003_check_guc.py @@ -0,0 +1,113 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Cross-check the consistency of GUC parameters with postgresql.conf.sample.""" + +import os +import re + + +def test_003_check_guc(create_pg): + node = create_pg("main") + + # Grab the names of all the parameters that can be listed in the + # configuration sample file. config_file is an exception, it is not + # in postgresql.conf.sample but is part of the lists from guc_tables.c. + # Custom GUCs loaded by extensions are excluded. + all_params = node.safe_sql( + "SELECT name\n" + " FROM pg_settings\n" + " WHERE NOT 'NOT_IN_SAMPLE' = ANY (pg_settings_get_flags(name)) AND\n" + " name <> 'config_file' AND category <> 'Customized Options'\n" + " ORDER BY 1" + ) + # Note the lower-case conversion, for consistency. + all_params_array = all_params.lower().split("\n") + + # Grab the names of all parameters marked as NOT_IN_SAMPLE. + not_in_sample = node.safe_sql( + "SELECT name\n" + " FROM pg_settings\n" + " WHERE 'NOT_IN_SAMPLE' = ANY (pg_settings_get_flags(name))\n" + " ORDER BY 1" + ) + not_in_sample_array = not_in_sample.lower().split("\n") + + # use the sample file from the temp install + share_dir = node.pg_bin.result(["pg_config", "--sharedir"]).stdout.strip() + sample_file = os.path.join(share_dir, "postgresql.conf.sample") + + # List of all the GUCs found in the sample file. + gucs_in_file = [] + + # List of all lines with tabs in the sample file. + lines_with_tabs = [] + + # Read the sample file line-by-line, checking its contents to build a list + # of everything known as a GUC. + line_num = 0 + with open(sample_file, encoding="utf-8") as contents: + for line in contents: + line_num += 1 + if "\t" in line: + lines_with_tabs.append(line_num) + + # Check if this line matches a GUC parameter: + # - Each parameter is preceded by "#", but not "# " in the sample + # file. + # - Valid configuration options are followed immediately by " = ", + # with one space before and after the equal sign. + m = re.match(r"^#(\w+) = .*", line) + if m: + # Lower-case conversion matters for some of the GUCs. + param_name = m.group(1).lower() + + # Ignore some exceptions. + if param_name in ("include", "include_dir", "include_if_exists"): + continue + + # Update the list of GUCs found in the sample file, for the + # follow-up tests. + gucs_in_file.append(param_name) + + continue + # Make sure each line starts with either a # or whitespace + assert not re.match( + r"^\s*[^#\s]", line + ), f"{line} missing initial # in postgresql.conf.sample" + + # Cross-check that all the GUCs found in the sample file match the ones + # fetched above. This maps the arrays to a set, making the creation of + # each exclude and intersection list easier. + gucs_in_file_set = set(gucs_in_file) + all_params_set = set(all_params_array) + not_in_sample_set = set(not_in_sample_array) + + missing_from_file = [p for p in all_params_array if p not in gucs_in_file_set] + missing_from_list = [p for p in gucs_in_file if p not in all_params_set] + sample_intersect = [p for p in gucs_in_file if p in not_in_sample_set] + + # These would log some information only on errors. + for param in missing_from_file: + print( + f"found GUC {param} in guc_tables.c, missing from " "postgresql.conf.sample" + ) + for param in missing_from_list: + print( + f"found GUC {param} in postgresql.conf.sample, with incorrect " + "info in guc_tables.c" + ) + for param in sample_intersect: + print( + f"found GUC {param} in postgresql.conf.sample, marked as " "NOT_IN_SAMPLE" + ) + for param in lines_with_tabs: + print(f"found tab in line {param} in postgresql.conf.sample") + + assert ( + len(missing_from_file) == 0 + ), "no parameters missing from postgresql.conf.sample" + assert len(missing_from_list) == 0, "no parameters missing from guc_tables.c" + assert ( + len(sample_intersect) == 0 + ), "no parameters marked as NOT_IN_SAMPLE in postgresql.conf.sample" + assert len(lines_with_tabs) == 0, "no lines with tabs in postgresql.conf.sample" diff --git a/src/test/modules/test_misc/pyt/test_004_io_direct.py b/src/test/modules/test_misc/pyt/test_004_io_direct.py new file mode 100644 index 00000000000..5e806501812 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_004_io_direct.py @@ -0,0 +1,82 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Very simple exercise of the direct I/O GUC.""" + +import os +import sys + +import pytest + + +def direct_io_supported(probe_dir): + """Pre-flight check for direct I/O support. + + We know that macOS has F_NOCACHE, and we know that Windows has + FILE_FLAG_NO_BUFFERING, and we assume that their typical file systems + will accept those flags. For every other system, probe for O_DIRECT + support on the file system where the test scratch directory lives. + + Returns None if supported, or a skip reason string otherwise. + """ + if sys.platform in ("darwin", "win32"): + return None + + # Does this Python/platform know about O_DIRECT in ? + o_direct = getattr(os, "O_DIRECT", None) + if o_direct is None: + return "no O_DIRECT" + + # Can we open a file in O_DIRECT mode in the file system where the test + # scratch directory lives? + path = os.path.join(probe_dir, "test_o_direct_file") + try: + fd = os.open(path, os.O_RDWR | o_direct | os.O_CREAT, 0o600) + except OSError as exc: + return f"pre-flight test if we can open a file with O_DIRECT failed: {exc.strerror}" + os.close(fd) + return None + + +def test_004_io_direct(create_pg, tmp_path): + skip_reason = direct_io_supported(str(tmp_path)) + if skip_reason is not None: + pytest.skip(skip_reason) + + node = create_pg("main", start=False) + node.append_conf( + "\n".join( + [ + "debug_io_direct = 'data,wal,wal_init'", + "shared_buffers = '256kB' # tiny to force I/O", + "wal_level = replica # minimal runs out of shared_buffers when set so tiny", + "", + ] + ) + ) + node.start() + + # Do some work that is bound to generate shared and local writes and reads + # as a simple exercise. + node.safe_sql("create table t1 as select 1 as i from generate_series(1, 10000)") + node.safe_sql("create table t2count (i int)") + node.safe_sql( + "\n".join( + [ + "begin;", + "create temporary table t2 as select 1 as i from generate_series(1, 10000);", + "update t2 set i = i;", + "insert into t2count select count(*) from t2;", + "commit;", + ] + ) + ) + node.safe_sql("update t1 set i = i") + assert node.safe_sql("select count(*) from t1") == "10000", "read back from shared" + assert node.safe_sql("select * from t2count") == "10000", "read back from local" + node.stop("immediate") + + node.start() + assert ( + node.safe_sql("select count(*) from t1") == "10000" + ), "read back from shared after crash recovery" + node.stop() diff --git a/src/test/modules/test_misc/pyt/test_005_timeouts.py b/src/test/modules/test_misc/pyt/test_005_timeouts.py new file mode 100644 index 00000000000..474aaecfc63 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_005_timeouts.py @@ -0,0 +1,116 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test timeouts that will cause FATAL errors. + +This test relies on injection points to await a timeout occurrence. Relying +on sleep proved to be unstable on the buildfarm. It's difficult to rely on +the NOTICE injection point because the backend under FATAL error can behave +differently. +""" + +import os + +import pytest + + +def test_005_timeouts(create_pg): + # Skip unless this is an injection-points build. + if os.environ.get("enable_injection_points", "no") != "yes": + pytest.skip("Injection points not supported by this build") + + node = create_pg("master", start=False) + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION injection_points;") + + # + # 1. Test of the transaction timeout + # + node.safe_sql("SELECT injection_points_attach('transaction-timeout', 'wait');") + + # A persistent backend that issues the blocking query. The async query + # never returns normally: the backend is FATAL'd by the timeout, so the + # session is just closed afterwards without draining results. + psql_session = node.connect() + + psql_session.do("SET transaction_timeout to '10ms';") + + psql_session.do_async( + "BEGIN; DO ' begin loop PERFORM pg_sleep(0.001); end loop; end ';" + ) + + # Wait until the backend enters the timeout injection point. Will raise + # here if anything goes wrong. + node.wait_for_event("client backend", "transaction-timeout") + + log_offset = node.log_position() + + # Remove the injection point. + node.safe_sql("SELECT injection_points_wakeup('transaction-timeout');") + + # Check that the timeout was logged. + node.wait_for_log("terminating connection due to transaction timeout", log_offset) + + psql_session.close() + + # + # 2. Test of the idle in transaction timeout + # + node.safe_sql( + "SELECT injection_points_attach(" + "'idle-in-transaction-session-timeout', 'wait');" + ) + + # We begin a transaction and then hang on the line. + psql_session.reconnect() + psql_session.do("SET idle_in_transaction_session_timeout to '10ms';\nBEGIN;\n") + + # Wait until the backend enters the timeout injection point. + node.wait_for_event("client backend", "idle-in-transaction-session-timeout") + + log_offset = node.log_position() + + # Remove the injection point. + node.safe_sql( + "SELECT injection_points_wakeup('idle-in-transaction-session-timeout');" + ) + + # Check that the timeout was logged. + node.wait_for_log( + "terminating connection due to idle-in-transaction timeout", log_offset + ) + + psql_session.close() + + # + # 3. Test of the idle session timeout + # + node.safe_sql("SELECT injection_points_attach('idle-session-timeout', 'wait');") + + # We just initialize the GUC and wait. No transaction is required. + psql_session.reconnect() + psql_session.do("SET idle_session_timeout to '10ms';\n") + + # Wait until the backend enters the timeout injection point. + node.wait_for_event("client backend", "idle-session-timeout") + + log_offset = node.log_position() + + # Remove the injection point. + node.safe_sql("SELECT injection_points_wakeup('idle-session-timeout');") + + # Check that the timeout was logged. + node.wait_for_log("terminating connection due to idle-session timeout", log_offset) + + psql_session.close() diff --git a/src/test/modules/test_misc/pyt/test_006_signal_autovacuum.py b/src/test/modules/test_misc/pyt/test_006_signal_autovacuum.py new file mode 100644 index 00000000000..0cc57fba391 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_006_signal_autovacuum.py @@ -0,0 +1,104 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test signaling autovacuum worker with pg_signal_autovacuum_worker. + +Only roles with privileges of pg_signal_autovacuum_worker are allowed to +signal autovacuum workers. This test uses an injection point located +at the beginning of the autovacuum worker startup. +""" + +import os +import re + +import pytest + + +def test_006_signal_autovacuum(create_pg): + node = create_pg("node", start=False) + + # This ensures a quick worker spawn. + node.append_conf("autovacuum_naptime = 1\n") + node.start() + + # The whole test is gated on the enable_injection_points build flag. + # An injection-points build installs the injection_points extension; check + # that it is available, as it may be possible that this script is run with + # installcheck, where the module would not be installed by default. + if os.environ.get("enable_injection_points", "no") != "yes": + pytest.skip("Injection points not supported by this build") + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION injection_points;") + + node.safe_sql( + "CREATE ROLE regress_regular_role;\n" + "CREATE ROLE regress_worker_role;\n" + "GRANT pg_signal_autovacuum_worker TO regress_worker_role;\n" + ) + + # From this point, autovacuum worker will wait at startup. + node.safe_sql("SELECT injection_points_attach('autovacuum-worker-start', 'wait');") + + # Accelerate worker creation in case we reach this point before the naptime + # ends. + node.reload() + + # Wait until an autovacuum worker starts. + node.wait_for_event("autovacuum worker", "autovacuum-worker-start") + + # And grab one of them. + av_pid = node.safe_sql( + "SELECT pid FROM pg_stat_activity " + "WHERE backend_type = 'autovacuum worker' " + "AND wait_event = 'autovacuum-worker-start' LIMIT 1;" + ) + + # Regular role cannot terminate autovacuum worker. + sess = node.connect() + try: + sess.do("SET ROLE regress_regular_role") + sess.query(f"SELECT pg_terminate_backend('{av_pid}')") + psql_err = sess.get_stderr() + finally: + sess.close() + + assert re.search( + r"ERROR: permission denied to terminate process\n" + r"DETAIL: Only roles with privileges of the " + r'"pg_signal_autovacuum_worker" role may terminate autovacuum ' + r"workers\.", + psql_err, + ), "autovacuum worker not signaled with regular role" + + offset = node.log_position() + + # Role with pg_signal_autovacuum_worker can terminate autovacuum worker. + sess = node.connect() + try: + sess.do("SET ROLE regress_worker_role") + sess.query(f"SELECT pg_terminate_backend('{av_pid}')") + finally: + sess.close() + + # Wait for the autovacuum worker to exit before scanning the logs. + assert node.poll_query_until( + f"SELECT count(*) = 0 FROM pg_stat_activity " + f"WHERE pid = '{av_pid}' AND backend_type = 'autovacuum worker';" + ) + + # Check that the primary server logs a FATAL indicating that autovacuum + # is terminated. + assert node.log_contains( + r"FATAL: .*terminating autovacuum process due to administrator command", + offset, + ), "autovacuum worker signaled with pg_signal_autovacuum_worker granted" + + # Release injection point. + node.safe_sql("SELECT injection_points_detach('autovacuum-worker-start');") diff --git a/src/test/modules/test_misc/pyt/test_007_catcache_inval.py b/src/test/modules/test_misc/pyt/test_007_catcache_inval.py new file mode 100644 index 00000000000..169396d2741 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_007_catcache_inval.py @@ -0,0 +1,111 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test recursive catalog cache invalidation. + +That is, invalidation while a catalog cache entry is being built. +""" + +import os +import random +import string + +import pytest + +from libpq.constants import PGRES_TUPLES_OK + + +def rand_str(length): + """Return a random alphanumeric string of the given length.""" + chars = string.ascii_letters + string.digits + return "".join(random.choice(chars) for _ in range(length)) + + +def test_007_catcache_inval(create_pg): + # Skip unless the build has injection points enabled. + if os.environ.get("enable_injection_points", "no") != "yes": + pytest.skip("Injection points not supported by this build") + + node = create_pg("node", start=False) + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION injection_points;") + + # Create a function with a large body, so that it is toasted. + longtext = rand_str(10000) + node.safe_sql( + "CREATE FUNCTION foofunc(dummy integer) RETURNS integer AS $$ " + f"SELECT 1; /* {longtext} */ $$ LANGUAGE SQL" + ) + + psql_session = node.connect() + psql_session2 = node.connect() + try: + # Set injection point in the session, to pause while populating the + # catcache list. + psql_session.query_safe("SELECT injection_points_set_local();") + psql_session.query_safe( + "SELECT injection_points_attach(" + "'catcache-list-miss-systable-scan-started', 'wait');" + ) + + # This pauses on the injection point while populating catcache list + # for functions with name "foofunc". + psql_session.do_async("SELECT foofunc(1);") + + # Wait until that backend is actually paused at the injection point + # before invalidating and waking it. do_async returns as soon as the + # query is sent, so without this explicit wait the wakeup below can run + # before the point is reached ("could not find injection point ... to + # wake up"). The TAP test instead relies on the latency of spawning a + # psql for the CREATE FUNCTION, which the in-process layer does not have. + node.wait_for_event( + "client backend", "catcache-list-miss-systable-scan-started" + ) + + # While the first session is building the catcache list, create a new + # function that overloads the same name. This sends a catcache + # invalidation. + node.safe_sql( + "CREATE FUNCTION foofunc() RETURNS integer AS $$ " + "SELECT 123 $$ LANGUAGE SQL" + ) + + # Continue the paused session. It will continue to construct the + # catcache list, and will accept invalidations while doing that. + # + # (The fact that the first function has a large body is crucial, + # because the cache invalidation is accepted during detoasting. If + # the body is not toasted, the invalidation is processed after + # building the catcache list, which avoids the recursion that we are + # trying to exercise here.) + # + # The "SELECT foofunc(1)" query will now finish. + psql_session2.query_safe( + "SELECT injection_points_wakeup(" + "'catcache-list-miss-systable-scan-started');" + ) + psql_session2.query_safe( + "SELECT injection_points_detach(" + "'catcache-list-miss-systable-scan-started');" + ) + + # Test that the new function is visible to the session. + psql_session.wait_for_completion() + res = psql_session.query("SELECT foofunc();") + + assert res.status == PGRES_TUPLES_OK, "got TUPLES_OK" + finally: + psql_session.close() + psql_session2.close() diff --git a/src/test/modules/test_misc/pyt/test_008_replslot_single_user.py b/src/test/modules/test_misc/pyt/test_008_replslot_single_user.py new file mode 100644 index 00000000000..857c379be0d --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_008_replslot_single_user.py @@ -0,0 +1,125 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test manipulations of replication slots with the single-user mode.""" + +import os +import subprocess +import sys + +import pytest + +SLOT_LOGICAL = "slot_logical" +SLOT_PHYSICAL = "slot_physical" + + +def run_single_mode(bindir, node, queries): + """Run a set of queries in single-user mode and return success (exit 0). + + Runs ``postgres --single -F -c exit_on_error=true -D postgres`` + with *queries* fed on stdin. + """ + postgres = os.path.join(bindir, "postgres") + if not os.path.exists(postgres): + postgres = "postgres" + argv = [ + postgres, + "--single", + "-F", + "-c", + "exit_on_error=true", + "-D", + node.data_dir, + "postgres", + ] + print("# Running: " + " ".join(argv)) + proc = subprocess.run( + argv, + input=queries, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if proc.stdout: + print(proc.stdout) + return proc.returncode == 0 + + +def test_008_replslot_single_user(create_pg, bindir): + # Skip on Windows, as single-user mode would fail on permission + # failure with privileged accounts. + if sys.platform == "win32": + pytest.skip("this test is not supported by this platform") + + # Initialize a node + node = create_pg("node", start=True, allows_streaming="logical") + + # Define initial table + node.safe_sql("CREATE TABLE foo (id int)") + + node.stop() + + assert run_single_mode( + bindir, + node, + f"SELECT pg_create_logical_replication_slot('{SLOT_LOGICAL}', 'test_decoding')", + ), "logical slot creation" + + assert run_single_mode( + bindir, + node, + f"SELECT pg_create_physical_replication_slot('{SLOT_PHYSICAL}', true)", + ), "physical slot creation" + + assert run_single_mode( + bindir, + node, + "SELECT pg_create_physical_replication_slot('slot_tmp', true, true)", + ), "temporary physical slot creation" + + assert run_single_mode( + bindir, + node, + f""" +INSERT INTO foo VALUES (1); +SELECT pg_logical_slot_get_changes('{SLOT_LOGICAL}', NULL, NULL); +""", + ), "logical decoding" + + assert run_single_mode( + bindir, + node, + f"SELECT pg_replication_slot_advance('{SLOT_LOGICAL}', pg_current_wal_lsn())", + ), "logical slot advance" + + assert run_single_mode( + bindir, + node, + f"SELECT pg_replication_slot_advance('{SLOT_PHYSICAL}', pg_current_wal_lsn())", + ), "physical slot advance" + + assert run_single_mode( + bindir, + node, + f"SELECT pg_copy_logical_replication_slot('{SLOT_LOGICAL}', 'slot_log_copy')", + ), "logical slot copy" + + assert run_single_mode( + bindir, + node, + f"SELECT pg_copy_physical_replication_slot('{SLOT_PHYSICAL}', 'slot_phy_copy')", + ), "physical slot copy" + + assert run_single_mode( + bindir, + node, + f"SELECT pg_drop_replication_slot('{SLOT_LOGICAL}')", + ), "logical slot drop" + + assert run_single_mode( + bindir, + node, + f"SELECT pg_drop_replication_slot('{SLOT_PHYSICAL}')", + ), "physical slot drop" diff --git a/src/test/modules/test_misc/pyt/test_009_log_temp_files.py b/src/test/modules/test_misc/pyt/test_009_log_temp_files.py new file mode 100644 index 00000000000..7eb851bde71 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_009_log_temp_files.py @@ -0,0 +1,296 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Check how temporary file removals and statement queries are associated. + +Verify the association in the server logs for various query sequences with the +simple and extended query protocols. + +The extended query protocol message sequences are produced in-process via the +libpq C API on the Session's connection (PQsendQueryParams / PQsendPrepare / +PQsendQueryPrepared plus pipeline mode). +""" + +import ctypes + +from libpq.constants import ( + PGRES_FATAL_ERROR, + PGRES_PIPELINE_SYNC, +) + +# This test intentionally exercises the low-level libpq handles on the +# Session, so accessing its private attributes is the point. +# pylint: disable=protected-access + + +# --------------------------------------------------------------------------- +# Low-level extended-query helpers, operating directly on the Session's libpq +# connection. These mirror what psql's \bind / \parse / \bind_named / +# pipeline backslash commands emit on the wire. +# --------------------------------------------------------------------------- + + +def _ensure_send_query_prepared(lib): + """Configure the PQsendQueryPrepared prototype on *lib* if needed. + + PQsendQueryPrepared is not part of the shared bindings table, so set its + ctypes prototype here (idempotent). Used to reproduce psql's + \\bind_named, which calls PQsendQueryPrepared. + """ + fn = lib.PQsendQueryPrepared + fn.restype = ctypes.c_int + fn.argtypes = [ + ctypes.c_void_p, # PGconn * + ctypes.c_char_p, # stmtName + ctypes.c_int, # nParams + ctypes.POINTER(ctypes.c_char_p), # paramValues + ctypes.POINTER(ctypes.c_int), # paramLengths + ctypes.POINTER(ctypes.c_int), # paramFormats + ctypes.c_int, # resultFormat + ] + return fn + + +def _param_array(values): + arr = (ctypes.c_char_p * len(values))() + for i, val in enumerate(values): + arr[i] = None if val is None else str(val).encode("utf-8") + return arr + + +def _drain(sess): + """Consume and discard all outstanding results on the connection.""" + lib = sess._lib + conn = sess._conn + while True: + res = sess._get_result() + if not res: + break + status = lib.PQresultStatus(res) + lib.PQclear(res) + if status == PGRES_FATAL_ERROR: + raise AssertionError( + "unexpected error: " + + (lib.PQerrorMessage(conn).decode("utf-8", "replace") or "") + ) + + +def _send_params(sess, sql, params): + """Issue an extended query with parameters (cf psql \\bind ... \\g).""" + lib = sess._lib + arr = _param_array(params) + ok = lib.PQsendQueryParams( + sess._conn, sql.encode("utf-8"), len(params), None, arr, None, None, 0 + ) + assert ok, "PQsendQueryParams failed" + + +def _send_prepare(sess, name, sql): + """Parse a named prepared statement (cf psql \\parse ).""" + lib = sess._lib + ok = lib.PQsendPrepare( + sess._conn, name.encode("utf-8"), sql.encode("utf-8"), 0, None + ) + assert ok, "PQsendPrepare failed" + + +def _send_prepared(sess, name, params): + """Bind+execute a named prepared statement (cf psql \\bind_named ... \\g).""" + fn = _ensure_send_query_prepared(sess._lib) + arr = _param_array(params) + ok = fn(sess._conn, name.encode("utf-8"), len(params), arr, None, None, 0) + assert ok, "PQsendQueryPrepared failed" + + +def _run_pipeline(sess, sends): + """Run *sends* (callables taking the session) inside one pipeline. + + Reproduces psql's \\startpipeline / \\sendpipeline / \\endpipeline: every + send is queued, then a single sync flushes and the results are drained. + """ + lib = sess._lib + conn = sess._conn + assert lib.PQenterPipelineMode(conn), "failed to enter pipeline mode" + try: + for send in sends: + send(sess) + assert lib.PQpipelineSync(conn), "failed to sync pipeline" + + # Each queued query yields a result followed by a NULL terminator; the + # final PGRES_PIPELINE_SYNC result then closes the pipeline. Keep + # reading across the per-query NULLs until the SYNC arrives, then + # consume its trailing NULL, so PQexitPipelineMode() succeeds. + for _ in sends: + res = sess._get_result() + assert res, "missing pipeline result" + status = lib.PQresultStatus(res) + lib.PQclear(res) + if status == PGRES_FATAL_ERROR: + raise AssertionError( + "unexpected error: " + + (lib.PQerrorMessage(conn).decode("utf-8", "replace") or "") + ) + term = sess._get_result() + assert not term, "expected NULL terminator after pipeline result" + + sync = sess._get_result() + assert ( + sync and lib.PQresultStatus(sync) == PGRES_PIPELINE_SYNC + ), "expected PGRES_PIPELINE_SYNC" + lib.PQclear(sync) + finally: + assert lib.PQexitPipelineMode(conn), "failed to exit pipeline mode" + + +def _run_statements(sess, *statements): + """Run each statement as its own simple query (cf psql script splitting). + + psql sends every statement of a script as a separate simple Query message, + so a temporary file dropped at the end of a statement's processing is + associated with that individual statement. node.safe_sql() instead sends + a whole multi-statement string as one Query, which would misattribute the + drop; running statements individually here reproduces psql's behavior. + """ + for stmt in statements: + res = sess.query(stmt) + if res.error_message is not None: + raise AssertionError(f"statement failed [{stmt}]: {res.error_message}") + + +# Regex fragments shared by the assertions. +def _tempfile_under(stmt): + return r"LOG:\s+temporary file: path.*\n.*\ STATEMENT:\s+" + stmt + + +def test_009_log_temp_files(create_pg): + node = create_pg("primary", start=False) + node.append_conf( + """ +work_mem = 64kB +log_temp_files = 0 +debug_parallel_query = off +log_error_verbosity = default +""" + ) + node.start() + + # Setup table and populate with data. + node.safe_sql( + """ +CREATE UNLOGGED TABLE foo(a int); +INSERT INTO foo(a) SELECT * FROM generate_series(1, 5000); +""" + ) + + # A dedicated session for the extended-protocol cases so the simple-query + # safe_sql() helper (which uses a separate cached session) is unaffected. + sess = node.connect() + try: + # unnamed portal: temporary file dropped under second SELECT query + log_offset = node.log_position() + sess.do("BEGIN") + _send_params(sess, "SELECT a FROM foo ORDER BY a OFFSET $1", [4990]) + _drain(sess) + sess.query("SELECT 'unnamed portal'") + sess.do("END") + node.wait_for_log(_tempfile_under(r"SELECT 'unnamed portal'"), log_offset) + + # bind and implicit transaction: temporary file dropped without query + log_offset = node.log_position() + _send_params(sess, "SELECT a FROM foo ORDER BY a OFFSET $1", [4991]) + _drain(sess) + node.wait_for_log(r"LOG:\s+temporary file:", log_offset) + assert not node.log_contains( + r"STATEMENT:", log_offset + ), "bind and implicit transaction, no statement logged" + + # named portal: temporary file dropped under second SELECT query + log_offset = node.log_position() + sess.do("BEGIN") + _send_prepare(sess, "stmt", "SELECT a FROM foo ORDER BY a OFFSET $1") + _drain(sess) + _send_prepared(sess, "stmt", [4999]) + _drain(sess) + sess.query("SELECT 'named portal'") + sess.do("END") + node.wait_for_log(_tempfile_under(r"SELECT 'named portal'"), log_offset) + + # pipelined query: temporary file dropped under second SELECT query + log_offset = node.log_position() + _run_pipeline( + sess, + [ + lambda s: _send_params( + s, "SELECT a FROM foo ORDER BY a OFFSET $1", [4992] + ), + lambda s: _send_params(s, "SELECT 'pipelined query'", []), + ], + ) + node.wait_for_log(_tempfile_under(r"SELECT 'pipelined query'"), log_offset) + + # parse and bind: temporary file dropped without query + log_offset = node.log_position() + # Use a name distinct from the SQL-level "p1" prepared in the + # prepare/execute case below: all cases share this one connection, + # whereas the original test used a fresh psql per case. + _send_prepare(sess, "p1_ext", "SELECT a, a, a FROM foo ORDER BY a OFFSET $1") + _drain(sess) + _send_prepared(sess, "p1_ext", [4993]) + _drain(sess) + node.wait_for_log(r"LOG:\s+temporary file:", log_offset) + assert not node.log_contains( + r"STATEMENT:", log_offset + ), "parse and bind, no statement logged" + + # simple query: temporary file dropped under SELECT query + log_offset = node.log_position() + _run_statements( + sess, + "BEGIN;", + "SELECT a FROM foo ORDER BY a OFFSET 4994;", + "END;", + ) + node.wait_for_log( + _tempfile_under(r"SELECT a FROM foo ORDER BY a OFFSET 4994;"), log_offset + ) + + # cursor: temporary file dropped under CLOSE + log_offset = node.log_position() + _run_statements( + sess, + "BEGIN;", + "DECLARE mycur CURSOR FOR SELECT a FROM foo ORDER BY a OFFSET 4995;", + "FETCH 10 FROM mycur;", + "SELECT 1;", + "CLOSE mycur;", + "END;", + ) + node.wait_for_log(_tempfile_under(r"CLOSE mycur;"), log_offset) + + # cursor WITH HOLD: temporary file dropped under COMMIT + log_offset = node.log_position() + _run_statements( + sess, + "BEGIN;", + "DECLARE holdcur CURSOR WITH HOLD FOR " + "SELECT a FROM foo ORDER BY a OFFSET 4996;", + "FETCH 10 FROM holdcur;", + "COMMIT;", + "CLOSE holdcur;", + ) + node.wait_for_log(_tempfile_under(r"COMMIT;"), log_offset) + + # prepare/execute: temporary file dropped under EXECUTE + log_offset = node.log_position() + _run_statements( + sess, + "BEGIN;", + "PREPARE p1 AS SELECT a FROM foo ORDER BY a OFFSET 4997;", + "EXECUTE p1;", + "DEALLOCATE p1;", + "END;", + ) + node.wait_for_log(_tempfile_under(r"EXECUTE p1;"), log_offset) + finally: + sess.close() + + node.stop("fast") diff --git a/src/test/modules/test_misc/pyt/test_010_index_concurrently_upsert.py b/src/test/modules/test_misc/pyt/test_010_index_concurrently_upsert.py new file mode 100644 index 00000000000..67bf2e6bf83 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_010_index_concurrently_upsert.py @@ -0,0 +1,874 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test INSERT ON CONFLICT DO UPDATE concurrent with CREATE/REINDEX CONCURRENTLY. + +These tests verify the fix for "duplicate key value violates unique +constraint" errors that occurred when infer_arbiter_indexes() only considered +indisvalid indexes, causing different transactions to use different arbiter +indexes. +""" + +import time + +import pytest + +from libpq.constants import CONNECTION_OK + +# Default timeout (seconds) for waiting on background operations. +TIMEOUT_DEFAULT = 180 + + +# --------------------------------------------------------------------------- +# Helper functions (named non-test_* so pytest does not collect them). +# --------------------------------------------------------------------------- + + +def wait_for_injection_point(node, point_name, timeout=None): + """Wait for a session to hit an injection point. + + Optional *timeout* is in seconds. Returns True if found, False on + timeout. On timeout, logs diagnostic information about all active + queries. + """ + if timeout is None: + timeout = TIMEOUT_DEFAULT / 2 + + for _ in range(int(timeout * 10)): + pid = node.safe_sql( + f""" + SELECT pid FROM pg_stat_activity + WHERE wait_event_type = 'InjectionPoint' + AND wait_event = '{point_name}' + LIMIT 1; + """ + ) + if pid != "": + return True + time.sleep(0.1) + + # Timeout - report diagnostic information + activity = node.safe_sql( + """ + SELECT format('pid=%s, state=%s, wait_event_type=%s, wait_event=%s, backend_xmin=%s, backend_xid=%s, query=%s', + pid, state, wait_event_type, wait_event, backend_xmin, backend_xid, left(query, 100)) + FROM pg_stat_activity + ORDER BY pid; + """ + ) + print( + f"wait_for_injection_point timeout waiting for: {point_name}\n" + f"Current queries in pg_stat_activity:\n{activity}" + ) + return False + + +def ok_injection_point(node, injection_point, testname=None): + """Assert that a wait for the given injection point succeeds.""" + if testname is None: + testname = f"hit injection point {injection_point}" + assert wait_for_injection_point(node, injection_point), testname + + +def wait_for_idle(node, pid, timeout=None): + """Wait for a specific backend to become idle. + + Returns True if idle, False if waiting for injection point or timeout. + """ + if timeout is None: + timeout = TIMEOUT_DEFAULT / 2 + + for _ in range(int(timeout * 10)): + result = node.safe_sql( + f""" + SELECT state, wait_event_type FROM pg_stat_activity WHERE pid = {pid}; + """ + ) + parts = result.split("|", 1) + state = parts[0] if len(parts) > 0 else "" + wait_event_type = parts[1] if len(parts) > 1 else "" + if state == "idle": + return True + if wait_event_type == "InjectionPoint": + return False + time.sleep(0.1) + return False + + +def wakeup_injection_point(node, point_name): + """Detach and wakeup an injection point.""" + node.safe_sql( + f""" +SELECT injection_points_detach('{point_name}'); +SELECT injection_points_wakeup('{point_name}'); +""" + ) + + +def safe_quit(session): + """Wait for any pending query to complete and close the session. + + Returns empty string on success, error message on failure. + """ + # Wait for any async queries to complete + session.wait_for_completion() + + # Check connection status + status = session.conn_status() + + # Close the session + session.close() + + # Return empty string if connection was OK, otherwise return error + return "" if status == CONNECTION_OK else "connection error" + + +def clean_safe_quit_ok(*sessions): + """Verify that the given sessions exit cleanly.""" + for i, session in enumerate(sessions, start=1): + assert safe_quit(session) == "", f"session {i} quit cleanly" + + +# --------------------------------------------------------------------------- +# The test. +# --------------------------------------------------------------------------- + + +def test_010_index_concurrently_upsert(create_pg): + # Node initialization + node = create_pg("node") + + # Check if the extension injection_points is available + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION injection_points;") + + node.safe_sql( + """ +CREATE SCHEMA test; +CREATE UNLOGGED TABLE test.tblpk (i int PRIMARY KEY, updated_at timestamp); +ALTER TABLE test.tblpk SET (parallel_workers=0); + +CREATE TABLE test.tblparted(i int primary key, updated_at timestamp) PARTITION BY RANGE (i); +CREATE TABLE test.tbl_partition PARTITION OF test.tblparted + FOR VALUES FROM (0) TO (10000) + WITH (parallel_workers = 0); + +CREATE UNLOGGED TABLE test.tblexpr(i int, updated_at timestamp); +CREATE UNIQUE INDEX tbl_pkey_special ON test.tblexpr(abs(i)) WHERE i < 1000; +ALTER TABLE test.tblexpr SET (parallel_workers=0); + +""" + ) + + ########################################################################## + print("# Test: REINDEX CONCURRENTLY + UPSERT (wakeup at set-dead phase)") + + # Create sessions for concurrent operations + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + # Setup injection points for each session + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-set-dead', 'wait'); +""" + ) + + # s3 starts REINDEX (will block on reindex-relation-concurrently-before-set-dead) + s3.do_async("REINDEX INDEX CONCURRENTLY test.tblpk_pkey;") + + # Wait for s3 to hit injection point + ok_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + # s1 starts UPSERT (will block on check-exclusion-or-unique-constraint-no-conflict) + s1.do_async( + "INSERT INTO test.tblpk VALUES (13,now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + # Wait for s1 to hit injection point + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + # Wakeup s3 to continue (reindex-relation-concurrently-before-set-dead) + wakeup_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + # s2 starts UPSERT (will block on exec-insert-before-insert-speculative) + s2.do_async( + "INSERT INTO test.tblpk VALUES (13,now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + # Wait for s2 to hit injection point + ok_injection_point(node, "exec-insert-before-insert-speculative") + + # Wakeup s1 (check-exclusion-or-unique-constraint-no-conflict) + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + # Wakeup s2 (exec-insert-before-insert-speculative) + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + + clean_safe_quit_ok(s1, s2, s3) + + # Cleanup test 1 + node.safe_sql("TRUNCATE TABLE test.tblpk") + + ########################################################################## + print("# Test: REINDEX CONCURRENTLY + UPSERT (wakeup at swap phase)") + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); +""" + ) + + s3.do_async("REINDEX INDEX CONCURRENTLY test.tblpk_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-swap") + + s1.do_async( + "INSERT INTO test.tblpk VALUES (13,now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + wakeup_injection_point(node, "reindex-relation-concurrently-before-swap") + + s2.do_async( + "INSERT INTO test.tblpk VALUES (13,now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblpk") + + ########################################################################## + print("# Test: REINDEX CONCURRENTLY + UPSERT (s1 wakes before reindex)") + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-set-dead', 'wait'); +""" + ) + + s3.do_async("REINDEX INDEX CONCURRENTLY test.tblpk_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + s1.do_async( + "INSERT INTO test.tblpk VALUES (13,now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + # Start s2 BEFORE waking reindex (key difference from permutation 1) + s2.do_async( + "INSERT INTO test.tblpk VALUES (13,now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + # Wake s1 first, then reindex, then s2 + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + wakeup_injection_point(node, "reindex-relation-concurrently-before-set-dead") + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblpk") + + ########################################################################## + print("# Test: REINDEX + UPSERT ON CONSTRAINT (set-dead phase)") + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-set-dead', 'wait'); +""" + ) + + s3.do_async("REINDEX INDEX CONCURRENTLY test.tblpk_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + s1.do_async( + "INSERT INTO test.tblpk VALUES (13, now()) ON CONFLICT ON CONSTRAINT tblpk_pkey DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + wakeup_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + s2.do_async( + "INSERT INTO test.tblpk VALUES (13, now()) ON CONFLICT ON CONSTRAINT tblpk_pkey DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblpk") + + ########################################################################## + print("# Test: REINDEX + UPSERT ON CONSTRAINT (swap phase)") + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); +""" + ) + + s3.do_async("REINDEX INDEX CONCURRENTLY test.tblpk_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-swap") + + s1.do_async( + "INSERT INTO test.tblpk VALUES (13, now()) ON CONFLICT ON CONSTRAINT tblpk_pkey DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + wakeup_injection_point(node, "reindex-relation-concurrently-before-swap") + + s2.do_async( + "INSERT INTO test.tblpk VALUES (13, now()) ON CONFLICT ON CONSTRAINT tblpk_pkey DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblpk") + + ########################################################################## + print("# Test: REINDEX + UPSERT ON CONSTRAINT (s1 wakes before reindex)") + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-set-dead', 'wait'); +""" + ) + + s3.do_async("REINDEX INDEX CONCURRENTLY test.tblpk_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + s1.do_async( + "INSERT INTO test.tblpk VALUES (13, now()) ON CONFLICT ON CONSTRAINT tblpk_pkey DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + # Start s2 BEFORE waking reindex + s2.do_async( + "INSERT INTO test.tblpk VALUES (13, now()) ON CONFLICT ON CONSTRAINT tblpk_pkey DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + # Wake s1 first, then reindex, then s2 + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + wakeup_injection_point(node, "reindex-relation-concurrently-before-set-dead") + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblpk") + + ########################################################################## + print("# Test: REINDEX on partitioned table (set-dead phase)") + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-set-dead', 'wait'); +""" + ) + + s3.do_async("REINDEX INDEX CONCURRENTLY test.tbl_partition_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + s1.do_async( + "INSERT INTO test.tblparted VALUES (13, now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + wakeup_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + s2.do_async( + "INSERT INTO test.tblparted VALUES (13, now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblparted") + + ########################################################################## + print("# Test: REINDEX on partitioned table (swap phase)") + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); +""" + ) + + s3.do_async("REINDEX INDEX CONCURRENTLY test.tbl_partition_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-swap") + + s1.do_async( + "INSERT INTO test.tblparted VALUES (13, now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + wakeup_injection_point(node, "reindex-relation-concurrently-before-swap") + + s2.do_async( + "INSERT INTO test.tblparted VALUES (13, now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblparted") + + ########################################################################## + print("# Test: REINDEX on partitioned table (s1 wakes before reindex)") + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-set-dead', 'wait'); +""" + ) + + s3.do_async("REINDEX INDEX CONCURRENTLY test.tbl_partition_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-set-dead") + + s1.do_async( + "INSERT INTO test.tblparted VALUES (13, now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + # Start s2 BEFORE waking reindex + s2.do_async( + "INSERT INTO test.tblparted VALUES (13, now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + # Wake s1 first, then reindex, then s2 + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + wakeup_injection_point(node, "reindex-relation-concurrently-before-set-dead") + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblparted") + + ########################################################################## + print( + "# Test: REINDEX on partitioned table, cache inval between two " + "get_partition_ancestors" + ) + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-init-partition-after-get-partition-ancestors', 'wait'); +""" + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('reindex-relation-concurrently-before-swap', 'wait'); +""" + ) + + s2.do_async("REINDEX INDEX CONCURRENTLY test.tbl_partition_pkey;") + + ok_injection_point(node, "reindex-relation-concurrently-before-swap") + + s1.do_async( + "INSERT INTO test.tblparted VALUES (13, now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-init-partition-after-get-partition-ancestors") + + wakeup_injection_point(node, "reindex-relation-concurrently-before-swap") + + wakeup_injection_point(node, "exec-init-partition-after-get-partition-ancestors") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblparted") + + ########################################################################## + print("# Test: CREATE INDEX CONCURRENTLY + UPSERT") + # Uses invalidate-catalog-snapshot-end to test catalog invalidation + # during UPSERT + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + # Get the session's backend PID before attaching injection points + s1_pid = s1.query_oneval("SELECT pg_backend_pid()") + + # s1 attaches BOTH injection points - the unique constraint check AND catalog snapshot + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + # In cases of cache clobbering, s1 may hit the injection point during attach. + # Start attach asynchronously so we can check if it blocks. + s1.do_async( + "SELECT injection_points_attach('invalidate-catalog-snapshot-end', 'wait');" + ) + + # Wait for that session to become idle (attach completed), or wake it up if + # it becomes stuck on injection point. + if not wait_for_idle(node, s1_pid): + ok_injection_point( + node, + "invalidate-catalog-snapshot-end", + "s1 hit injection point during attach (cache clobbering mode)", + ) + node.safe_sql( + """ + SELECT injection_points_wakeup('invalidate-catalog-snapshot-end'); + """ + ) + # Wait for async command to complete + s1.wait_for_completion() + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('define-index-before-set-valid', 'wait'); +""" + ) + + # s3: Start CREATE INDEX CONCURRENTLY (blocks on define-index-before-set-valid) + s3.do_async("CREATE UNIQUE INDEX CONCURRENTLY tbl_pkey_duplicate ON test.tblpk(i);") + + ok_injection_point(node, "define-index-before-set-valid") + + # s1: Start UPSERT (blocks on invalidate-catalog-snapshot-end) + s1.do_async( + "INSERT INTO test.tblpk VALUES (13,now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "invalidate-catalog-snapshot-end") + + # Wakeup s3 (CREATE INDEX continues, triggers catalog invalidation) + wakeup_injection_point(node, "define-index-before-set-valid") + + # s2: Start UPSERT (blocks on exec-insert-before-insert-speculative) + s2.do_async( + "INSERT INTO test.tblpk VALUES (13,now()) ON CONFLICT (i) DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + + wakeup_injection_point(node, "invalidate-catalog-snapshot-end") + + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblparted") + + ########################################################################## + print("# Test: CREATE INDEX CONCURRENTLY on partial index + UPSERT") + # Uses invalidate-catalog-snapshot-end to test catalog invalidation during UPSERT + + s1 = node.connect() + s2 = node.connect() + s3 = node.connect() + + s1_pid = s1.query_oneval("SELECT pg_backend_pid()") + + # s1 attaches BOTH injection points - the unique constraint check AND catalog snapshot + s1.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('check-exclusion-or-unique-constraint-no-conflict', 'wait'); +""" + ) + + s1.do("SELECT injection_points_attach('invalidate-catalog-snapshot-end', 'wait');") + + # In cases of cache clobbering, s1 may hit the injection point during attach. + # Wait for that session to become idle (attach completed), or wake it up if + # it becomes stuck on injection point. + if not wait_for_idle(node, s1_pid): + ok_injection_point( + node, + "invalidate-catalog-snapshot-end", + "s1 hit injection point during attach (cache clobbering mode)", + ) + node.safe_sql( + """ + SELECT injection_points_wakeup('invalidate-catalog-snapshot-end'); + """ + ) + + s2.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('exec-insert-before-insert-speculative', 'wait'); +""" + ) + + s3.do( + """ +SELECT injection_points_set_local(); +SELECT injection_points_attach('define-index-before-set-valid', 'wait'); +""" + ) + + # s3: Start CREATE INDEX CONCURRENTLY (blocks on define-index-before-set-valid) + s3.do_async( + "CREATE UNIQUE INDEX CONCURRENTLY tbl_pkey_special_duplicate ON test.tblexpr(abs(i)) WHERE i < 10000;" + ) + + ok_injection_point(node, "define-index-before-set-valid") + + # s1: Start UPSERT (blocks on invalidate-catalog-snapshot-end) + s1.do_async( + "INSERT INTO test.tblexpr VALUES(13,now()) ON CONFLICT (abs(i)) WHERE i < 100 DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "invalidate-catalog-snapshot-end") + + # Wakeup s3 (CREATE INDEX continues, triggers catalog invalidation) + wakeup_injection_point(node, "define-index-before-set-valid") + + # s2: Start UPSERT (blocks on exec-insert-before-insert-speculative) + s2.do_async( + "INSERT INTO test.tblexpr VALUES(13,now()) ON CONFLICT (abs(i)) WHERE i < 100 DO UPDATE SET updated_at = now();" + ) + + ok_injection_point(node, "exec-insert-before-insert-speculative") + wakeup_injection_point(node, "invalidate-catalog-snapshot-end") + ok_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + wakeup_injection_point(node, "exec-insert-before-insert-speculative") + wakeup_injection_point(node, "check-exclusion-or-unique-constraint-no-conflict") + + clean_safe_quit_ok(s1, s2, s3) + + node.safe_sql("TRUNCATE TABLE test.tblexpr") diff --git a/src/test/modules/test_misc/pyt/test_011_lock_stats.py b/src/test/modules/test_misc/pyt/test_011_lock_stats.py new file mode 100644 index 00000000000..7f5b75bf89b --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_011_lock_stats.py @@ -0,0 +1,318 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test for the lock statistics and log_lock_waits. + +This test creates multiple locking situations when a session (s2) has to +wait on a lock for longer than deadlock_timeout. The first tests each test a +dedicated lock type. +The last one checks that log_lock_waits has no impact on the statistics +counters. + +This test also checks that log_lock_waits messages are emitted both when +a wait occurs and when the lock is acquired, and that the "still waiting for" +message is logged exactly once per wait, even if the backend wakes due +to signals. +""" + +import re + +import pytest + +DEADLOCK_TIMEOUT = 10 + + +def setup_sessions(node): + """Open the two sessions s1 and s2. + + Returns ``(s1, s2)``. Fresh libpq backends are opened with + ``node.connect()`` (no psql subprocess); the injection point used to hold + a backend in the deadlock-timeout path is attached from s2. + """ + s1 = node.connect() + s2 = node.connect() + + # Setup injection points for the waiting session + s2.query_safe("SELECT injection_points_attach('deadlock-timeout-fired', 'wait');") + return s1, s2 + + +def wait_for_pg_stat_lock(node, lock_type): + """Wait until pg_stat_lock reflects the expected wait. + + Fetch waits and wait_time from pg_stat_lock for a given lock type until + they reach expected values: at least one wait and waiting longer than the + deadlock_timeout. + """ + assert node.poll_query_until( + f""" + SELECT waits > 0 AND wait_time >= {DEADLOCK_TIMEOUT} + FROM pg_stat_lock + WHERE locktype = '{lock_type}'; + """ + ), f"Timed out waiting for pg_stat_lock for {lock_type}" + + +def wait_and_detach(node, point_name): + """Wait for an injection point, then detach it.""" + node.wait_for_event("client backend", point_name) + node.safe_sql( + f""" +SELECT injection_points_detach('{point_name}'); +SELECT injection_points_wakeup('{point_name}'); +""" + ) + + +def test_011_lock_stats(create_pg): + node = create_pg("node", start=False) + node.append_conf(f"deadlock_timeout = {DEADLOCK_TIMEOUT}ms") + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Injection points not supported by this build") + + node.safe_sql("CREATE EXTENSION injection_points;") + + node.safe_sql( + """ +CREATE TABLE test_stat_tab(key text not null, value int); +INSERT INTO test_stat_tab(key, value) VALUES('k0', 1); +""" + ) + + ######################################################################## + + ####### Relation lock + + s1, s2 = setup_sessions(node) + + log_offset = node.log_position() + + s1.query_safe( + """ +SELECT pg_stat_reset_shared('lock'); +BEGIN; +LOCK TABLE test_stat_tab; +""" + ) + + # s2 setup + s2.query_safe( + """ +BEGIN; +SELECT pg_stat_force_next_flush(); +""" + ) + # s2 blocks on LOCK. + s2.do_async("LOCK TABLE test_stat_tab;") + + wait_and_detach(node, "deadlock-timeout-fired") + + # Check that log_lock_waits message is emitted during a lock wait. + node.wait_for_log(r"still waiting for AccessExclusiveLock on relation", log_offset) + + # Wake the backend waiting on the lock and confirm it woke by calling + # pg_log_backend_memory_contexts() and checking for the logged memory + # contexts. This is necessary to test later that the "still waiting for" + # message is logged exactly once per wait, even if the backend wakes + # during the wait. + node.safe_sql( + """SELECT pg_log_backend_memory_contexts(pid) + FROM pg_locks WHERE locktype = 'relation' AND + relation = 'test_stat_tab'::regclass AND NOT granted;""" + ) + node.wait_for_log(r"logging memory contexts", log_offset) + + # deadlock_timeout fired, now commit in s1 and s2 + s1.query_safe("COMMIT") + s2.wait_for_completion() + s2.query_safe("COMMIT") + + # check that pg_stat_lock has been updated + wait_for_pg_stat_lock(node, "relation") + + # Check that log_lock_waits message is emitted when the lock is acquired + # after waiting. + node.wait_for_log(r"acquired AccessExclusiveLock on relation", log_offset) + + # Check that the "still waiting for" message is logged exactly once per + # wait, even if the backend wakes during the wait. + log_contents = node.log_content()[log_offset:] + still_waiting = re.findall(r"still waiting for", log_contents) + assert len(still_waiting) == 1, ( + "still waiting logged exactly once despite wakeups from " + "pg_log_backend_memory_contexts()" + ) + + # close sessions + s1.close() + s2.close() + + ####### transaction lock + + s1, s2 = setup_sessions(node) + + log_offset = node.log_position() + + # The INSERT must autocommit before the explicit transaction is opened, so + # that session s2 can see rows k1/k2/k3 and block on s1's row lock. Send + # it separately from the BEGIN block: a single multi-statement query + # containing BEGIN would run the INSERT inside the still-open transaction, + # leaving the rows invisible to s2 (so its UPDATE would match nothing and + # never wait). + s1.query_safe( + """ +SELECT pg_stat_reset_shared('lock'); +INSERT INTO test_stat_tab(key, value) VALUES('k1', 1), ('k2', 1), ('k3', 1); +""" + ) + s1.query_safe( + """ +BEGIN; +UPDATE test_stat_tab SET value = value + 1 WHERE key = 'k1'; +""" + ) + + # s2 setup + s2.query_safe( + """ +SET log_lock_waits = on; +BEGIN; +SELECT pg_stat_force_next_flush(); +""" + ) + # s2 blocks here on UPDATE + s2.do_async("UPDATE test_stat_tab SET value = value + 1 WHERE key = 'k1';") + + wait_and_detach(node, "deadlock-timeout-fired") + + # Check that log_lock_waits message is emitted during a lock wait. + node.wait_for_log(r"still waiting for ShareLock on transaction", log_offset) + + # deadlock_timeout fired, now commit in s1 and s2 + s1.query_safe("COMMIT") + s2.wait_for_completion() + s2.query_safe("COMMIT") + + # check that pg_stat_lock has been updated + wait_for_pg_stat_lock(node, "transactionid") + + # Check that log_lock_waits message is emitted when the lock is acquired + # after waiting. + node.wait_for_log(r"acquired ShareLock on transaction", log_offset) + + # Close sessions + s1.close() + s2.close() + + ####### advisory lock + + s1, s2 = setup_sessions(node) + + log_offset = node.log_position() + + s1.query_safe( + """ +SELECT pg_stat_reset_shared('lock'); +SELECT pg_advisory_lock(1); +""" + ) + + # s2 setup + s2.query_safe( + """ +SET log_lock_waits = on; +BEGIN; +SELECT pg_stat_force_next_flush(); +""" + ) + # s2 blocks on the advisory lock. + s2.do_async("SELECT pg_advisory_lock(1);") + + wait_and_detach(node, "deadlock-timeout-fired") + + # Check that log_lock_waits message is emitted during a lock wait. + node.wait_for_log(r"still waiting for ExclusiveLock on advisory lock", log_offset) + + # deadlock_timeout fired, now unlock and commit s2 + s1.query_safe("SELECT pg_advisory_unlock(1)") + s2.wait_for_completion() + s2.query_safe( + """ +SELECT pg_advisory_unlock(1); +COMMIT; +""" + ) + + # check that pg_stat_lock has been updated + wait_for_pg_stat_lock(node, "advisory") + + # Check that log_lock_waits message is emitted when the lock is acquired + # after waiting. + node.wait_for_log(r"acquired ExclusiveLock on advisory lock", log_offset) + + # Close sessions + s1.close() + s2.close() + + ####### Ensure log_lock_waits has no impact + + s1, s2 = setup_sessions(node) + + log_offset = node.log_position() + + s1.query_safe( + """ +SELECT pg_stat_reset_shared('lock'); +BEGIN; +LOCK TABLE test_stat_tab; +""" + ) + + # s2 setup + s2.query_safe( + """ +SET log_lock_waits = off; +BEGIN; +SELECT pg_stat_force_next_flush(); +""" + ) + # s2 blocks on LOCK. + s2.do_async("LOCK TABLE test_stat_tab;") + + wait_and_detach(node, "deadlock-timeout-fired") + + # deadlock_timeout fired, now commit in s1 and s2 + s1.query_safe("COMMIT") + s2.wait_for_completion() + s2.query_safe("COMMIT") + + # check that pg_stat_lock has been updated + wait_for_pg_stat_lock(node, "relation") + + # Check that no log_lock_waits messages are emitted + assert not node.log_contains( + "still waiting for AccessExclusiveLock on relation", log_offset + ), "check that no log_lock_waits message is emitted during a lock wait" + assert not node.log_contains( + "acquired AccessExclusiveLock on relation", log_offset + ), ( + "check that no log_lock_waits message is emitted when the lock is " + "acquired after waiting" + ) + + # close sessions + s1.close() + s2.close() + + # cleanup + node.safe_sql("DROP TABLE test_stat_tab;") diff --git a/src/test/modules/test_misc/pyt/test_012_ddlutils.py b/src/test/modules/test_misc/pyt/test_012_ddlutils.py new file mode 100644 index 00000000000..f23b91c3d4e --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_012_ddlutils.py @@ -0,0 +1,331 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Tests for pg_get_database_ddl(), pg_get_tablespace_ddl(), and pg_get_role_ddl(). + +These run as a standalone suite rather than as plain regression tests because +they create databases and tablespaces, which are heavyweight operations that +should run only once rather than being repeated with every invocation of the +core regression suite. +""" + +import re + + +def _ddl_filter(text): + """Strip locale/collation details from DDL output so that results are + stable across platforms.""" + text = re.sub( + r"\s*\bLOCALE_PROVIDER\b\s*=\s*(?:'[^']*'|\"[^\"]*\"|\S+)", + "", + text, + flags=re.IGNORECASE, + ) + text = re.sub( + r"\s*LC_COLLATE\s*=\s*(['\"])[^'\"]*\1", "", text, flags=re.IGNORECASE + ) + text = re.sub(r"\s*LC_CTYPE\s*=\s*(['\"])[^'\"]*\1", "", text, flags=re.IGNORECASE) + text = re.sub( + r"\s*\S*LOCALE\S*\s*=?\s*(['\"])[^'\"]*\1", "", text, flags=re.IGNORECASE + ) + text = re.sub( + r"\s*\S*COLLATION\S*\s*=?\s*(['\"])[^'\"]*\1", "", text, flags=re.IGNORECASE + ) + return text + + +def test_012_ddlutils(create_pg): + node = create_pg("main", start=False) + # Force UTC so that timestamptz values (e.g. VALID UNTIL) render the same + # way regardless of the host's local timezone. + node.append_conf("timezone = 'UTC'\n") + node.start() + + #################################################################### + # pg_get_role_ddl tests + #################################################################### + + # Basic role + node.safe_sql("CREATE ROLE regress_role_ddl_test1") + result = node.safe_sql("SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1')") + assert re.search( + r"CREATE ROLE regress_role_ddl_test1 .* NOLOGIN", result + ), "basic role DDL" + + # Role with multiple privileges + node.safe_sql( + """ + CREATE ROLE regress_role_ddl_test2 + LOGIN SUPERUSER CREATEDB CREATEROLE + CONNECTION LIMIT 5 + VALID UNTIL '2030-12-31 23:59:59+00'""" + ) + result = node.safe_sql("SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2')") + assert "SUPERUSER" in result, "role with SUPERUSER" + assert "CREATEDB" in result, "role with CREATEDB" + assert "CONNECTION LIMIT 5" in result, "role with CONNECTION LIMIT" + assert "VALID UNTIL '2030-12-31" in result, "role with VALID UNTIL" + + # Role with configuration parameters + node.safe_sql( + """ + ALTER ROLE regress_role_ddl_test1 SET work_mem TO '256MB'; + ALTER ROLE regress_role_ddl_test1 SET search_path TO myschema, public""" + ) + result = node.safe_sql("SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1')") + assert "SET work_mem TO '256MB'" in result, "role with work_mem setting" + assert "SET search_path TO" in result, "role with search_path setting" + + # Role with database-specific configuration (needs a real database). + # CREATE DATABASE cannot run inside a transaction block, so it must run + # as its own statement. + node.safe_sql( + """ + CREATE DATABASE regression_ddlutils_test + TEMPLATE template0 ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C'""" + ) + node.safe_sql( + """ + ALTER ROLE regress_role_ddl_test2 + IN DATABASE regression_ddlutils_test SET work_mem TO '128MB'""" + ) + result = node.safe_sql("SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2')") + assert ( + "IN DATABASE regression_ddlutils_test SET work_mem TO '128MB'" in result + ), "role with database-specific setting" + + # Role with special characters (requires quoting) + node.safe_sql('CREATE ROLE "regress_role-with-dash"') + result = node.safe_sql("SELECT * FROM pg_get_role_ddl('regress_role-with-dash')") + assert '"regress_role-with-dash"' in result, "role name requiring quoting" + + # Pretty-printed output + result = node.safe_sql( + "SELECT * FROM pg_get_role_ddl('regress_role_ddl_test2', 'pretty', 'true')" + ) + assert re.search(r"\n\s+SUPERUSER", result), "role pretty-print indents attributes" + + # Role with memberships + node.safe_sql( + """ + CREATE ROLE regress_role_ddl_grantor CREATEROLE; + CREATE ROLE regress_role_ddl_group1; + CREATE ROLE regress_role_ddl_group2; + CREATE ROLE regress_role_ddl_member; + GRANT regress_role_ddl_group1 TO regress_role_ddl_grantor WITH ADMIN TRUE; + GRANT regress_role_ddl_group2 TO regress_role_ddl_grantor WITH ADMIN TRUE; + SET ROLE regress_role_ddl_grantor; + GRANT regress_role_ddl_group1 TO regress_role_ddl_member + WITH INHERIT TRUE, SET FALSE; + GRANT regress_role_ddl_group2 TO regress_role_ddl_member + WITH ADMIN TRUE; + RESET ROLE""" + ) + result = node.safe_sql("SELECT * FROM pg_get_role_ddl('regress_role_ddl_member')") + assert ( + "GRANT regress_role_ddl_group1 TO regress_role_ddl_member" in result + ), "role with memberships includes GRANT" + assert "SET FALSE" in result, "membership includes SET FALSE" + assert "ADMIN TRUE" in result, "membership includes ADMIN TRUE" + + # Memberships suppressed + result = node.safe_sql( + "SELECT * FROM pg_get_role_ddl('regress_role_ddl_member', " + "'memberships', 'false')" + ) + assert "GRANT" not in result, "memberships suppressed" + + # Non-existent role (should error) + res = node.sql("SELECT * FROM pg_get_role_ddl(9999999::oid)") + assert res.error_message is not None, "non-existent role errors" + assert "does not exist" in res.error_message, "non-existent role error message" + + # NULL input (should return no rows) + result = node.safe_sql("SELECT count(*) FROM pg_get_role_ddl(NULL)") + assert result == "0", "NULL role returns no rows" + + # Permission check: revoke SELECT on pg_authid + node.safe_sql( + """ + CREATE ROLE regress_role_ddl_noaccess; + REVOKE SELECT ON pg_authid FROM PUBLIC""" + ) + noaccess = node.connect() + try: + res = noaccess.query( + "SET ROLE regress_role_ddl_noaccess;\n" + "SELECT * FROM pg_get_role_ddl('regress_role_ddl_test1')" + ) + assert res.error_message is not None, "role DDL denied without pg_authid access" + finally: + noaccess.close() + node.safe_sql("GRANT SELECT ON pg_authid TO PUBLIC") + + #################################################################### + # pg_get_database_ddl tests + #################################################################### + + # Set up: the test database was already created above for role tests. + node.safe_sql( + """ + ALTER DATABASE regression_ddlutils_test OWNER TO regress_role_ddl_test2; + ALTER DATABASE regression_ddlutils_test CONNECTION LIMIT 123; + ALTER DATABASE regression_ddlutils_test SET random_page_cost = 2.0; + ALTER ROLE regress_role_ddl_test2 + IN DATABASE regression_ddlutils_test SET random_page_cost = 1.1""" + ) + + # Non-existent database + res = node.sql("SELECT * FROM pg_get_database_ddl('regression_no_such_db')") + assert res.error_message is not None, "non-existent database errors" + + # NULL input + result = node.safe_sql("SELECT count(*) FROM pg_get_database_ddl(NULL)") + assert result == "0", "NULL database returns no rows" + + # Invalid option + res = node.sql( + "SELECT * FROM pg_get_database_ddl('regression_ddlutils_test', " + "'owner', 'invalid')" + ) + assert res.error_message is not None, "invalid boolean option errors" + assert "invalid value" in res.error_message, "invalid option error message" + + # Duplicate option + res = node.sql( + "SELECT * FROM pg_get_database_ddl('regression_ddlutils_test', " + "'owner', 'false', 'owner', 'true')" + ) + assert res.error_message is not None, "duplicate option errors" + + # Basic output (without locale details) + result = _ddl_filter( + node.safe_sql( + "SELECT pg_get_database_ddl " + "FROM pg_get_database_ddl('regression_ddlutils_test')" + ) + ) + assert ( + "CREATE DATABASE regression_ddlutils_test" in result + ), "database DDL includes CREATE" + assert "TEMPLATE = template0" in result, "database DDL includes TEMPLATE" + assert "ENCODING = 'UTF8'" in result, "database DDL includes ENCODING" + assert "OWNER TO regress_role_ddl_test2" in result, "database DDL includes OWNER" + assert "CONNECTION LIMIT = 123" in result, "database DDL includes CONNLIMIT" + assert ( + "SET random_page_cost TO '2.0'" in result + ), "database DDL includes GUC setting" + + # Pretty-printed output + result = _ddl_filter( + node.safe_sql( + "SELECT pg_get_database_ddl " + "FROM pg_get_database_ddl('regression_ddlutils_test', " + "'pretty', 'true', 'tablespace', 'false')" + ) + ) + assert re.search(r"\n\s+WITH TEMPLATE", result), "database DDL pretty-prints WITH" + + # Permission check + node.safe_sql("REVOKE CONNECT ON DATABASE regression_ddlutils_test FROM PUBLIC") + noaccess = node.connect() + try: + res = noaccess.query( + "SET ROLE regress_role_ddl_noaccess;\n" + "SELECT * FROM pg_get_database_ddl('regression_ddlutils_test')" + ) + assert res.error_message is not None, "database DDL denied without CONNECT" + finally: + noaccess.close() + node.safe_sql("GRANT CONNECT ON DATABASE regression_ddlutils_test TO PUBLIC") + + #################################################################### + # pg_get_tablespace_ddl tests + #################################################################### + + # Non-existent tablespace by name + res = node.sql("SELECT * FROM pg_get_tablespace_ddl('regress_nonexistent_tblsp')") + assert res.error_message is not None, "non-existent tablespace errors" + + # Non-existent tablespace by OID + res = node.sql("SELECT * FROM pg_get_tablespace_ddl(0::oid)") + assert res.error_message is not None, "non-existent tablespace OID errors" + + # NULL input (name and OID variants) + result = node.safe_sql("SELECT count(*) FROM pg_get_tablespace_ddl(NULL::name)") + assert result == "0", "NULL tablespace name returns no rows" + result = node.safe_sql("SELECT count(*) FROM pg_get_tablespace_ddl(NULL::oid)") + assert result == "0", "NULL tablespace OID returns no rows" + + # Tablespace name requiring quoting. CREATE TABLESPACE can't run in a + # transaction block and the GUC must be set on the same connection, so set + # the GUC and create the tablespace as separate statements on one + # persistent session. + with node.connect() as ts_sess: + ts_sess.query_safe("SET allow_in_place_tablespaces = true") + ts_sess.query_safe( + """ + CREATE TABLESPACE "regress_ tblsp" OWNER regress_role_ddl_test1 + LOCATION ''""" + ) + result = node.safe_sql("SELECT * FROM pg_get_tablespace_ddl('regress_ tblsp')") + assert '"regress_ tblsp"' in result, "tablespace name is quoted" + + # Rename and add options; reuse this tablespace for the remaining tests + node.safe_sql( + """ + ALTER TABLESPACE "regress_ tblsp" RENAME TO regress_allopt_tblsp; + ALTER TABLESPACE regress_allopt_tblsp + SET (seq_page_cost = '1.5', random_page_cost = '1.1234567890', + effective_io_concurrency = '17', maintenance_io_concurrency = '18')""" + ) + + # Tablespace with multiple options + result = node.safe_sql( + "SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp')" + ) + assert ( + "CREATE TABLESPACE regress_allopt_tblsp" in result + ), "tablespace DDL includes CREATE" + assert "OWNER regress_role_ddl_test1" in result, "tablespace DDL includes OWNER" + assert "seq_page_cost='1.5'" in result, "tablespace DDL includes options" + + # Pretty-printed output + result = node.safe_sql( + "SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp', " + "'pretty', 'true')" + ) + assert re.search(r"\n\s+OWNER", result), "tablespace DDL pretty-prints OWNER" + + # Owner suppressed + result = node.safe_sql( + "SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp', " + "'owner', 'false')" + ) + assert "OWNER" not in result, "tablespace DDL owner suppressed" + + # Lookup by OID + result = node.safe_sql( + """ + SELECT pg_get_tablespace_ddl + FROM pg_get_tablespace_ddl( + (SELECT oid FROM pg_tablespace + WHERE spcname = 'regress_allopt_tblsp'))""" + ) + assert "CREATE TABLESPACE regress_allopt_tblsp" in result, "tablespace DDL by OID" + + # Permission check + node.safe_sql("REVOKE SELECT ON pg_tablespace FROM PUBLIC") + noaccess = node.connect() + try: + res = noaccess.query( + "SET ROLE regress_role_ddl_noaccess;\n" + "SELECT * FROM pg_get_tablespace_ddl('regress_allopt_tblsp')" + ) + assert ( + res.error_message is not None + ), "tablespace DDL denied without pg_tablespace access" + finally: + noaccess.close() + node.safe_sql("GRANT SELECT ON pg_tablespace TO PUBLIC") + + node.stop() diff --git a/src/test/modules/test_misc/pyt/test_013_temp_obj_multisession.py b/src/test/modules/test_misc/pyt/test_013_temp_obj_multisession.py new file mode 100644 index 00000000000..44a25adf965 --- /dev/null +++ b/src/test/modules/test_misc/pyt/test_013_temp_obj_multisession.py @@ -0,0 +1,297 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test that one session cannot read or modify another session's temp table. + +Each session keeps its temp data in its own local buffer pool, and a different +backend has no visibility into those buffers, so any command that needs to look +at the data must be rejected. + +DROP TABLE is intentionally allowed: it does not touch the table's +contents, and autovacuum relies on this to clean up orphaned temp +relations left behind by a crashed backend. + +A regression caught here typically means a new buffer-access entry +point bypasses the RELATION_IS_OTHER_TEMP() check. See +ReadBuffer_common(), StartReadBuffersImpl(), and read_stream_begin_impl() +for the existing checks. When adding a new command or buffer-access +path, also add a corresponding case below. +""" + +import os +import re + + +def _probe_stderr(probe, sql): + """Run *sql* in the probing session and return its combined stderr. + + Returns both notices and any error message produced by the statement, with + the leading notice/error severity prefixes preserved. The probing session keeps a + single backend across calls (distinct from the owner backend), which is + fine because these probes never create persistent temp state. + """ + probe.clear_stderr() + probe.query(sql) + return probe.get_stderr() + + +def test_013_temp_obj_multisession(create_pg): + node = create_pg("temp_lock", start=False) + node.start() + + # Owner session. Created as a persistent libpq session (separate backend) + # so it stays alive while the second session probes its temp objects. Its + # temp objects must persist for the duration of the test, so it must not + # share the cached safe_sql backend. + psql1 = node.connect() + + # Probing session: a dedicated backend, distinct from both the owner and + # the cached safe_sql session, used to attempt cross-session access. + probe = node.connect() + + try: + # Initially create the table without an index, so read paths go + # straight through the read-stream / buffer-manager entry points + # without being masked by an index scan that would hit + # ReadBuffer_common from nbtree. + assert psql1.do("CREATE TEMP TABLE foo AS SELECT 42 AS val;") + + # Resolve the owner's temp schema so the probing session can refer to + # the table by a fully-qualified name. + tempschema = node.safe_sql( + """ + SELECT n.nspname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE relname = 'foo' AND relpersistence = 't'; + """ + ) + assert re.match(r"^pg_temp_\d+$", tempschema), f"got temp schema: {tempschema}" + + # DML and SELECT have to read the table's data and therefore go + # through the buffer manager. With no index on the table, the planner + # cannot use index access, so SELECT/UPDATE/DELETE/MERGE/COPY all run + # through the read-stream path and are caught by + # read_stream_begin_impl(). + + stderr = _probe_stderr(probe, f"SELECT val FROM {tempschema}.foo;") + assert re.search( + r"cannot access temporary tables of other sessions", stderr + ), f"SELECT (seqscan via read_stream): {stderr}" + + # INSERT goes through hio.c which calls ReadBufferExtended() to find a + # page with free space; that hits the existing check before any data + # is written. + stderr = _probe_stderr(probe, f"INSERT INTO {tempschema}.foo VALUES (73);") + assert re.search( + r"cannot access temporary tables of other sessions", stderr + ), f"INSERT (caught via hio.c): {stderr}" + + stderr = _probe_stderr(probe, f"UPDATE {tempschema}.foo SET val = NULL;") + assert re.search( + r"cannot access temporary tables of other sessions", stderr + ), f"UPDATE: {stderr}" + + stderr = _probe_stderr(probe, f"DELETE FROM {tempschema}.foo;") + assert re.search( + r"cannot access temporary tables of other sessions", stderr + ), f"DELETE: {stderr}" + + stderr = _probe_stderr( + probe, + f"MERGE INTO {tempschema}.foo USING (VALUES (42)) AS s(val) " + "ON foo.val = s.val WHEN MATCHED THEN DELETE;", + ) + assert re.search( + r"cannot access temporary tables of other sessions", stderr + ), f"MERGE: {stderr}" + + # We want to probe "COPY foo TO STDOUT". The in-process libpq Session + # has no COPY-out handling, and COPY TO STDOUT puts the connection + # into copy-out mode before the relation's data is read, so it cannot + # be driven through Session.query(). "COPY ... TO " exercises + # the same read path: the RELATION_IS_OTHER_TEMP() buffer-manager + # check fires before any output file is touched, yielding the same + # error as a normal command result. + copy_out = os.path.join(node.data_dir, "t013_copyout.txt") + stderr = _probe_stderr(probe, f"COPY {tempschema}.foo TO '{copy_out}';") + assert re.search( + r"cannot access temporary tables of other sessions", stderr + ), f"COPY: {stderr}" + + # DDL and maintenance commands have their own command-specific checks + # (older than the buffer-manager check above), so they fail with + # command-specific error messages. Verifying them here documents the + # expected behaviour and guards against accidental removal of those + # checks. + + stderr = _probe_stderr(probe, f"TRUNCATE TABLE {tempschema}.foo;") + assert re.search( + r"cannot truncate temporary tables of other sessions", stderr + ), f"TRUNCATE: {stderr}" + + stderr = _probe_stderr( + probe, + f"ALTER TABLE {tempschema}.foo ALTER COLUMN val TYPE bigint;", + ) + assert re.search( + r"cannot alter temporary tables of other sessions", stderr + ), f"ALTER TABLE: {stderr}" + + # VACUUM silently skips other sessions' temp tables (vacuum_rel() + # returns without warning to avoid noise during database-wide VACUUM). + # Verify that no error is reported, and that no buffer-access path is + # hit. + stderr = _probe_stderr(probe, f"VACUUM {tempschema}.foo;") + assert stderr == "", f"VACUUM is silently skipped: {stderr}" + + stderr = _probe_stderr(probe, f"CLUSTER {tempschema}.foo;") + assert re.search( + r"cannot execute CLUSTER on temporary tables of other sessions", + stderr, + ), f"CLUSTER: {stderr}" + + # Now create an index to exercise the index-scan path. nbtree calls + # ReadBuffer (which is ReadBufferExtended -> ReadBuffer_common), so + # this exercises a different chain of buffer-manager entry points. + assert psql1.do("CREATE INDEX ON foo(val);") + + stderr = _probe_stderr( + probe, + "SET enable_seqscan = off; " + f"SELECT val FROM {tempschema}.foo WHERE val = 42;", + ) + assert re.search( + r"cannot access temporary tables of other sessions", stderr + ), f"index scan (ReadBuffer_common via nbtree): {stderr}" + + # ALTER INDEX goes through the same CheckAlterTableIsSafe() path as + # ALTER TABLE, so it produces the same error. + stderr = _probe_stderr( + probe, + f"ALTER INDEX {tempschema}.foo_val_idx SET (fillfactor = 50);", + ) + assert re.search( + r"cannot alter temporary tables of other sessions", stderr + ), f"ALTER INDEX: {stderr}" + + # A function created by the owner in its own pg_temp using its own + # row type can be observed via the catalog by a separate session. + # ALTER FUNCTION and DROP FUNCTION on it must work as catalog + # operations -- they don't read the underlying table -- which + # documents the boundary between catalog and data access for temp + # objects. + assert psql1.do( + "CREATE FUNCTION pg_temp.foo_id(r foo) RETURNS int LANGUAGE SQL " + "AS 'SELECT r.val';" + ) + + stderr = _probe_stderr( + probe, + f"ALTER FUNCTION {tempschema}.foo_id({tempschema}.foo) " + "SET search_path = pg_catalog;", + ) + assert ( + stderr == "" + ), f"ALTER FUNCTION on function over other session's row type: {stderr}" + + stderr = _probe_stderr( + probe, + f"DROP FUNCTION {tempschema}.foo_id({tempschema}.foo);", + ) + assert ( + stderr == "" + ), f"DROP FUNCTION on function over other session's row type: {stderr}" + + # DROP TABLE on another session's temp table is intentionally + # permitted. DROP doesn't touch the table's contents, and autovacuum + # relies on this to remove temp relations orphaned by a crashed + # backend. Verify that the bare DROP succeeds without error. + stderr = _probe_stderr(probe, f"DROP TABLE {tempschema}.foo;") + assert stderr == "", f"DROP TABLE is allowed: {stderr}" + + # Cross-session CREATE FUNCTION scenario. The owner creates a fresh + # temp table foo2 in its pg_temp namespace, and a separate session + # then creates a function whose argument type is that row type. + # PostgreSQL allows this and emits a NOTICE: the function is moved + # into the creator's pg_temp namespace with an auto-dependency on + # the borrowed type, so it disappears together with the session that + # created it. + assert psql1.do("CREATE TEMP TABLE foo2 AS SELECT 42 AS val;") + + stderr = _probe_stderr( + probe, + f"CREATE FUNCTION public.cross_session_func(r {tempschema}.foo2) " + "RETURNS int LANGUAGE SQL AS 'SELECT 1';", + ) + assert re.search( + r'function "cross_session_func" will be effectively temporary', + stderr, + ), ( + "CREATE FUNCTION using other session's row type is effectively " + f"temporary: {stderr}" + ) + + # A bare DROP TABLE on foo2 now fails because cross_session_func + # depends on its row type. This is normal SQL dependency behaviour + # and documents that DROP itself is not blocked by buffer-manager + # checks -- we get a catalog-level error instead. + stderr = _probe_stderr(probe, f"DROP TABLE {tempschema}.foo2;") + assert re.search( + r"cannot drop table .*\.foo2 because other objects depend on it", + stderr, + ), f"DROP TABLE blocked by cross-session dependency: {stderr}" + + foo2_oid = node.safe_sql("SELECT oid FROM pg_class WHERE relname='foo2';") + + # Cross-session LOCK TABLE scenario. Ensure that LockRelationOid is + # working properly for other temp tables since this mechanism is also + # used by autovacuum during orphaned tables cleanup. + psql2 = node.connect() + try: + assert psql2.do( + "BEGIN;", + f"LOCK TABLE {tempschema}.foo2 IN ACCESS SHARE MODE;", + ) + + # When the owner session ends, its temp objects are dropped via + # the normal session-exit cleanup, which cascades through + # DEPENDENCY_NORMAL and also removes the cross-session function + # that depended on the temp row type. This is the same mechanism + # autovacuum relies on to clean up temp relations left behind by a + # crashed backend. + # Access share lock on the foo2 will block session-exit cleanup, + # because an owner will try to acquire deletion lock all its temp + # objects via findDependentObjects. + log_offset = node.log_position() + psql1.close() + + # Check whether session-exit cleanup is blocked. + node.wait_for_log( + rf"waiting for AccessExclusiveLock on relation {foo2_oid}", + log_offset, + ) + + # Release lock on foo2 and allow session-exit cleanup to finish. + assert psql2.do("COMMIT;") + finally: + psql2.close() + + # After releasing the lock, the owner can finally acquire + # AccessExclusiveLock on foo2 and finish session-exit cleanup. Verify + # directly that both foo2 (the locked temp table) and + # cross_session_func (which depended on its row type) have been + # dropped. Both being gone confirms the owner's cleanup got past the + # blocked findDependentObjects() call and completed normally. + assert node.poll_query_until( + f"SELECT NOT EXISTS (SELECT 1 FROM pg_class WHERE oid = {foo2_oid})" + ), "foo2 was not cleaned up after owner session exit" + + assert ( + node.safe_sql( + "SELECT count(*) FROM pg_proc WHERE proname = 'cross_session_func'" + ) + == "0" + ), "cross_session_func cleaned up by cascade from foo2" + finally: + probe.close() + psql1.close() diff --git a/src/test/modules/test_plan_advice/meson.build b/src/test/modules/test_plan_advice/meson.build index 3dfa950ac79..d1c002ccb0c 100644 --- a/src/test/modules/test_plan_advice/meson.build +++ b/src/test/modules/test_plan_advice/meson.build @@ -27,4 +27,9 @@ tests += { ], 'test_kwargs': {'priority': 50} }, + 'pytest': { + 'tests': [ + 'pyt/test_001_replan_regress.py', + ], + }, } diff --git a/src/test/modules/test_plan_advice/pyt/test_001_replan_regress.py b/src/test/modules/test_plan_advice/pyt/test_001_replan_regress.py new file mode 100644 index 00000000000..41736bbe587 --- /dev/null +++ b/src/test/modules/test_plan_advice/pyt/test_001_replan_regress.py @@ -0,0 +1,71 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Run the core regression tests under pg_plan_advice to check for problems.""" + +import os +import subprocess + +import pytest + + +def test_replan_regress(create_pg, tmp_path): + # Initialize the primary node + node = create_pg("main", start=False) + + # Set up our desired configuration. + node.append_conf( + "shared_preload_libraries='test_plan_advice'\n" + "wal_level=replica\n" + "pg_plan_advice.always_explain_supplied_advice=false\n" + "pg_plan_advice.feedback_warnings=true\n" + ) + node.start() + + pg_regress = os.environ.get("PG_REGRESS") + if not pg_regress: + pytest.skip("PG_REGRESS not set in environment") + regress_shlib = os.environ.get("REGRESS_SHLIB") + if not regress_shlib: + pytest.skip("REGRESS_SHLIB not set in environment") + + # The repository root, relative to this test file + # (src/test/modules/test_plan_advice/pyt/). + srcdir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..") + ) + + # --dlpath is needed to be able to find the location of regress.so + # and any libraries the regression tests require. + dlpath = os.path.dirname(regress_shlib) + + # --outputdir points to the path where to place the output files. + outputdir = str(tmp_path) + + # --inputdir points to the path of the input files. + inputdir = os.path.join(srcdir, "src", "test", "regress") + + # Run the tests. + cmd = ( + pg_regress + " " + "--bindir= " + f'--dlpath="{dlpath}" ' + f"--host={node.host} " + f"--port={node.port} " + f"--schedule={srcdir}/src/test/regress/parallel_schedule " + "--max-concurrent-tests=20 " + f'--inputdir="{inputdir}" ' + f'--outputdir="{outputdir}"' + ) + rc = subprocess.run(cmd, shell=True, check=False).returncode + + # Dump out the regression diffs file, if there is one + if rc != 0: + diffs = os.path.join(outputdir, "regression.diffs") + if os.path.exists(diffs): + print(f"=== dumping {diffs} ===") + with open(diffs, "r", encoding="utf-8", errors="replace") as fh: + print(fh.read()) + print("=== EOF ===") + + # Report results + assert rc == 0, "regression tests pass" diff --git a/src/test/modules/test_saslprep/meson.build b/src/test/modules/test_saslprep/meson.build index 2fcc403ca07..8b04894e786 100644 --- a/src/test/modules/test_saslprep/meson.build +++ b/src/test/modules/test_saslprep/meson.build @@ -35,4 +35,9 @@ tests += { 't/001_saslprep_ranges.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_saslprep_ranges.py', + ], + }, } diff --git a/src/test/modules/test_saslprep/pyt/test_001_saslprep_ranges.py b/src/test/modules/test_saslprep/pyt/test_001_saslprep_ranges.py new file mode 100644 index 00000000000..7bda9e30f44 --- /dev/null +++ b/src/test/modules/test_saslprep/pyt/test_001_saslprep_ranges.py @@ -0,0 +1,32 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test all ranges of valid UTF-8 codepoints under SASLprep.""" + +import os +import re + +import pytest + + +def test_saslprep_ranges(create_pg): + # Test all ranges of valid UTF-8 codepoints under SASLprep. + # + # This test is expensive and so is only enabled when PG_TEST_EXTRA + # requests it. + extra = os.environ.get("PG_TEST_EXTRA", "") + if not re.search(r"\bsaslprep\b", extra): + pytest.skip("test saslprep not enabled in PG_TEST_EXTRA") + + # Initialize node + node = create_pg("main") + node.safe_sql("CREATE EXTENSION test_saslprep;") + + # Among all the valid UTF-8 codepoint ranges, our implementation of + # SASLprep should never return an empty password if the operation is + # considered a success. + result = node.safe_sql( + "SELECT * FROM test_saslprep_ranges()\n" + " WHERE status = 'SUCCESS' AND res IN (NULL, '')\n" + ) + + assert result == "", "valid codepoints returning an empty password" diff --git a/src/test/modules/test_shmem/meson.build b/src/test/modules/test_shmem/meson.build index fb4bf328b8f..2e48521b19f 100644 --- a/src/test/modules/test_shmem/meson.build +++ b/src/test/modules/test_shmem/meson.build @@ -30,4 +30,9 @@ tests += { 't/001_late_shmem_alloc.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_late_shmem_alloc.py', + ], + }, } diff --git a/src/test/modules/test_shmem/pyt/test_001_late_shmem_alloc.py b/src/test/modules/test_shmem/pyt/test_001_late_shmem_alloc.py new file mode 100644 index 00000000000..4bf9f35c727 --- /dev/null +++ b/src/test/modules/test_shmem/pyt/test_001_late_shmem_alloc.py @@ -0,0 +1,60 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Test allocating shared memory after startup.""" + + +def _attach_count(node): + """Read the attach counter in a *fresh* backend. + + safe_sql() would run in its own psql process, so every read would land in + a new backend and the shmem attach callback would fire anew. The pytest + framework's node.safe_sql() reuses one cached connection, so here we open a + fresh connection per read to preserve those semantics. + """ + sess = node.connect() + try: + return int(sess.query_oneval("SELECT get_test_shmem_attach_count();")) + finally: + sess.close() + + +def test_late_shmem_alloc(create_pg): + # + # Test allocating memory after startup, i.e. when the library is not + # in shared_preload_libraries + # + # create_pg runs initdb; start it explicitly. + node = create_pg("main", start=False) + node.start() + + node.safe_sql("CREATE EXTENSION test_shmem;") + + # Check that the attach counter is incremented on a new connection + attach_count1 = _attach_count(node) + attach_count2 = _attach_count(node) + assert attach_count2 > attach_count1, "attach callback is called in each backend" + node.stop() + + # + # Test that loading via shared_preload_libraries also works + # + node.append_conf("shared_preload_libraries = 'test_shmem'") + node.start() + + # When loaded via shared_preload_libraries, the attach callback is + # called or not, depending on whether this is an EXEC_BACKEND build. + exec_backend = node.safe_sql("SHOW debug_exec_backend;") == "on" + attach_count1 = _attach_count(node) + attach_count2 = _attach_count(node) + + if exec_backend: + assert attach_count2 > attach_count1, ( + "attach callback is called in each backend when loaded via " + "shared_preload_libraries" + ) + else: + assert attach_count1 == 0 and attach_count2 == 0, ( + "attach callback is not called when loaded via " "shared_preload_libraries" + ) + + node.stop() diff --git a/src/test/modules/test_slru/meson.build b/src/test/modules/test_slru/meson.build index 00f3ee3054d..a33ada8dd7d 100644 --- a/src/test/modules/test_slru/meson.build +++ b/src/test/modules/test_slru/meson.build @@ -42,4 +42,13 @@ tests += { 't/002_multixact_wraparound.pl' ], }, + 'pytest': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_multixact.py', + 'pyt/test_002_multixact_wraparound.py', + ], + }, } diff --git a/src/test/modules/test_slru/pyt/test_001_multixact.py b/src/test/modules/test_slru/pyt/test_001_multixact.py new file mode 100644 index 00000000000..3801638ebde --- /dev/null +++ b/src/test/modules/test_slru/pyt/test_001_multixact.py @@ -0,0 +1,64 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test multixid corner cases.""" + +import pytest + + +def test_001_multixact(create_pg): + node = create_pg("main", start=False) + node.append_conf("shared_preload_libraries = 'test_slru,injection_points'") + node.start() + + # Skip if injection points are not supported by this build. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Injection points not supported by this build") + + node.safe_sql("CREATE EXTENSION injection_points") + node.safe_sql("CREATE EXTENSION test_slru") + + # This test creates three multixacts. The middle one is never + # WAL-logged or recorded on the offsets page, because we pause the + # backend and crash the server before that. After restart, verify that + # the other multixacts are readable, despite the middle one being + # lost. + + # Create the first multixact + bg_session = node.connect() + multi1 = bg_session.query_oneval("SELECT test_create_multixact();") + + # Assign the middle multixact. Use an injection point to prevent it + # from being fully recorded. + node.safe_sql( + "SELECT injection_points_attach('multixact-create-from-members','wait');" + ) + + # Start the second multixact creation asynchronously - it will block at + # the injection point + bg_session.do_async("SELECT test_create_multixact();") + + node.wait_for_event("client backend", "multixact-create-from-members") + node.safe_sql("SELECT injection_points_detach('multixact-create-from-members')") + + # Create the third multixid + multi2 = node.safe_sql("SELECT test_create_multixact();") + + # All set and done, it's time for hard restart. The background session + # will be terminated by the crash. + node.stop("immediate") + node.start() + + # Verify that the recorded multixids are readable + assert ( + node.safe_sql(f"SELECT test_read_multixact('{multi1}');") == "" + ), "first recorded multi is readable" + + assert ( + node.safe_sql(f"SELECT test_read_multixact('{multi2}');") == "" + ), "second recorded multi is readable" diff --git a/src/test/modules/test_slru/pyt/test_002_multixact_wraparound.py b/src/test/modules/test_slru/pyt/test_002_multixact_wraparound.py new file mode 100644 index 00000000000..7d052c98e4c --- /dev/null +++ b/src/test/modules/test_slru/pyt/test_002_multixact_wraparound.py @@ -0,0 +1,72 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test multixact wraparound.""" + +import os +import re + + +def test_002_multixact_wraparound(create_pg): + node = create_pg("main", start=False) + node.append_conf("shared_preload_libraries = 'test_slru'") + + # Set the cluster's next multitransaction close to wraparound + node_pgdata = node.data_dir + node.command_ok( + [ + "pg_resetwal", + "--multixact-ids", + "0xFFFFFFF8,0xFFFFFFF8", + node_pgdata, + ], + "set the cluster's next multitransaction to 0xFFFFFFF8", + ) + + # Extract a few values from pg_resetwal --dry-run output that we need for + # the calculations below + out = node.pg_bin.result(["pg_resetwal", "--dry-run", node.data_dir]).stdout + m = re.search(r"^Database block size: *(\d+)$", out, re.MULTILINE) + assert m, out + blcksz = int(m.group(1)) + m = re.search(r"^Pages per SLRU segment: *(\d+)$", out, re.MULTILINE) + assert m, out + slru_pages_per_segment = int(m.group(1)) + + # Fixup the SLRU files to match the state we reset to. + + # initialize the 'offsets' SLRU file containing the new next multixid + # with zeros + multixact_offsets_per_page = blcksz // 8 # sizeof(MultiXactOffset) == 8 + segno = int(0xFFFFFFF8 / multixact_offsets_per_page / slru_pages_per_segment) + slru_file = os.path.join(node_pgdata, "pg_multixact", "offsets", "%04X" % segno) + bytes_per_seg = slru_pages_per_segment * blcksz + with open(slru_file, "wb") as fh: + written = fh.write(b"\0" * bytes_per_seg) + assert written == bytes_per_seg, f'could not write to "{slru_file}"' + + # remove old file + os.unlink(os.path.join(node_pgdata, "pg_multixact", "offsets", "0000")) + + # Consume multixids to wrap around. We start at 0xFFFFFFF8, so after + # creating 16 multixacts we should definitely have wrapped around. + node.start() + node.safe_sql("CREATE EXTENSION test_slru") + + multixact_ids = [] + for _ in range(1, 17): + multi = node.safe_sql("SELECT test_create_multixact();") + multixact_ids.append(multi) + + # Verify that wraparound occurred (last_multi should be numerically + # smaller than first_multi) + first_multi = multixact_ids[0] + last_multi = multixact_ids[-1] + assert int(last_multi) < int( + first_multi + ), f"multixact wraparound occurred (first: {first_multi}, last: {last_multi})" + + # Verify that all the multixacts we created are readable + for i, multi in enumerate(multixact_ids): + assert ( + node.safe_sql(f"SELECT test_read_multixact('{multi}');") == "" + ), f"multixact {i} (ID: {multi}) is readable after wraparound" diff --git a/src/test/modules/worker_spi/meson.build b/src/test/modules/worker_spi/meson.build index 6475e23f601..b842243c53d 100644 --- a/src/test/modules/worker_spi/meson.build +++ b/src/test/modules/worker_spi/meson.build @@ -34,4 +34,10 @@ tests += { 't/002_worker_terminate.pl' ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_worker_spi.py', + 'pyt/test_002_worker_terminate.py', + ], + }, } diff --git a/src/test/modules/worker_spi/pyt/test_001_worker_spi.py b/src/test/modules/worker_spi/pyt/test_001_worker_spi.py new file mode 100644 index 00000000000..1022b31e755 --- /dev/null +++ b/src/test/modules/worker_spi/pyt/test_001_worker_spi.py @@ -0,0 +1,174 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test the worker_spi module.""" + + +def test_001_worker_spi(create_pg): + node = create_pg("mynode", start=False) + node.start() + + # testing dynamic bgworkers + + node.safe_sql("CREATE EXTENSION worker_spi;") + + # Launch one dynamic worker, then wait for its initialization to complete. + # This consists in making sure that a table name "counted" is created + # on a new schema whose name includes the index defined in input argument + # of worker_spi_launch(). + # By default, dynamic bgworkers connect to the "postgres" database with + # an undefined role, falling back to the GUC defaults (or InvalidOid for + # worker_spi_launch). + result = node.safe_sql("SELECT worker_spi_launch(4) IS NOT NULL;") + assert result == "t", "dynamic bgworker launched" + assert node.poll_query_until( + "SELECT count(*) > 0 FROM information_schema.tables\n" + " WHERE table_schema = 'schema4' AND table_name = 'counted';" + ) + node.safe_sql("INSERT INTO schema4.counted VALUES ('total', 0), ('delta', 1);") + # Issue a SIGHUP on the node to force the worker to loop once, accelerating + # this test. + node.reload() + # Wait until the worker has processed the tuple that has just been inserted. + assert node.poll_query_until( + "SELECT count(*) FROM schema4.counted WHERE type = 'delta';", "0" + ) + result = node.safe_sql("SELECT * FROM schema4.counted;") + assert result == "total|1", "dynamic bgworker correctly consumed tuple data" + + # Check the wait event used by the dynamic bgworker. + assert node.poll_query_until( + "SELECT wait_event FROM pg_stat_activity WHERE backend_type ~ 'worker_spi';", + "WorkerSpiMain", + ), 'dynamic bgworker has reported "WorkerSpiMain" as wait event' + + # Check the wait event used by the dynamic bgworker appears in + # pg_wait_events + result = node.safe_sql( + "SELECT count(*) > 0 from pg_wait_events where type = 'Extension' " + "and name = 'WorkerSpiMain';" + ) + assert result == "t", '"WorkerSpiMain" is reported in pg_wait_events' + + # testing bgworkers loaded with shared_preload_libraries + + # Create the database first so as the workers can connect to it when + # the library is loaded. + node.safe_sql("CREATE DATABASE mydb;") + node.safe_sql("CREATE ROLE myrole SUPERUSER LOGIN;") + node.safe_sql("CREATE EXTENSION worker_spi;", dbname="mydb") + + # Now load the module as a shared library. + # Update max_worker_processes to make room for enough bgworkers, including + # parallel workers these may spawn. + node.append_conf( + """ +shared_preload_libraries = 'worker_spi' +worker_spi.database = 'mydb' +worker_spi.total_workers = 3 +max_worker_processes = 32 +""" + ) + node.restart() + + # Check that bgworkers have been registered and launched. + assert node.poll_query_until( + "SELECT datname, count(datname), wait_event FROM pg_stat_activity\n" + " WHERE backend_type = 'worker_spi' " + "GROUP BY datname, wait_event;", + "mydb|3|WorkerSpiMain", + dbname="mydb", + ), "Timed out while waiting for bgworkers to be launched" + + # Ask worker_spi to launch dynamic bgworkers with the library loaded, then + # check their existence. Use IDs that do not overlap with the schemas + # created by the previous workers. These ones use a new role, on different + # databases. + myrole_id = node.safe_sql( + "SELECT oid FROM pg_roles where rolname = 'myrole';", dbname="mydb" + ) + mydb_id = node.safe_sql( + "SELECT oid FROM pg_database where datname = 'mydb';", dbname="mydb" + ) + postgresdb_id = node.safe_sql( + "SELECT oid FROM pg_database where datname = 'postgres';", dbname="mydb" + ) + worker1_pid = node.safe_sql( + f"SELECT worker_spi_launch(10, {mydb_id}, {myrole_id});", dbname="mydb" + ) + worker2_pid = node.safe_sql( + f"SELECT worker_spi_launch(11, {postgresdb_id}, {myrole_id});", dbname="mydb" + ) + + assert node.poll_query_until( + "SELECT datname, usename, wait_event FROM pg_stat_activity\n" + " WHERE backend_type = 'worker_spi dynamic' AND\n" + f" pid IN ({worker1_pid}, {worker2_pid}) ORDER BY datname;", + "mydb|myrole|WorkerSpiMain\npostgres|myrole|WorkerSpiMain", + dbname="mydb", + ), "Timed out while waiting for dynamic bgworkers to be launched" + + # Check BGWORKER_BYPASS_ALLOWCONN. + node.safe_sql("CREATE DATABASE noconndb ALLOW_CONNECTIONS false;") + noconndb_id = node.safe_sql( + "SELECT oid FROM pg_database where datname = 'noconndb';", dbname="mydb" + ) + log_offset = node.log_position() + + # worker_spi_launch() may be able to detect that the worker has been + # stopped, so do not rely on safe_sql(). + sess = node.connect() + try: + sess.query(f"SELECT worker_spi_launch(12, {noconndb_id}, {myrole_id});") + finally: + sess.close() + node.wait_for_log( + r'database "noconndb" is not currently accepting connections', log_offset + ) + + # bgworker bypasses the connection check, and can be launched. + worker4_pid = node.safe_sql( + f"SELECT worker_spi_launch(12, {noconndb_id}, {myrole_id}, " + "'{\"ALLOWCONN\"}');" + ) + assert node.poll_query_until( + "SELECT datname, usename, wait_event FROM pg_stat_activity\n" + " WHERE backend_type = 'worker_spi dynamic' AND\n" + f" pid IN ({worker4_pid}) ORDER BY datname;", + "noconndb|myrole|WorkerSpiMain", + ), "dynamic bgworker with BYPASS_ALLOWCONN started" + + # Check BGWORKER_BYPASS_ROLELOGINCHECK. + # First create a role without login access. + node.safe_sql( + """ + CREATE ROLE nologrole WITH NOLOGIN; + GRANT CREATE ON DATABASE mydb TO nologrole; +""" + ) + nologrole_id = node.safe_sql( + "SELECT oid FROM pg_roles where rolname = 'nologrole';", dbname="mydb" + ) + log_offset = node.log_position() + + # bgworker cannot be launched with login restriction. + sess = node.connect() + try: + sess.query(f"SELECT worker_spi_launch(13, {mydb_id}, {nologrole_id});") + finally: + sess.close() + node.wait_for_log(r'role "nologrole" is not permitted to log in', log_offset) + + # bgworker bypasses the login restriction, and can be launched. + log_offset = node.log_position() + worker5_pid = node.safe_sql( + f"SELECT worker_spi_launch(13, {mydb_id}, {nologrole_id}, " + "'{\"ROLELOGINCHECK\"}');", + dbname="mydb", + ) + assert node.poll_query_until( + "SELECT datname, usename, wait_event FROM pg_stat_activity\n" + " WHERE backend_type = 'worker_spi dynamic' AND\n" + f" pid = {worker5_pid};", + "mydb|nologrole|WorkerSpiMain", + dbname="mydb", + ), "dynamic bgworker with BYPASS_ROLELOGINCHECK launched" diff --git a/src/test/modules/worker_spi/pyt/test_002_worker_terminate.py b/src/test/modules/worker_spi/pyt/test_002_worker_terminate.py new file mode 100644 index 00000000000..3c2fadecacc --- /dev/null +++ b/src/test/modules/worker_spi/pyt/test_002_worker_terminate.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test that background workers can be terminated by db commands.""" + +import os + +import pytest + + +def launch_bgworker(node, database, testcase, interruptible): + """Ensure the worker_spi dynamic worker is launched on the specified + database. Returns the PID of the worker launched.""" + + # Launch a background worker on the given database. + pid = node.safe_sql( + f"SELECT worker_spi_launch({testcase}, '{database}'::regdatabase, 0, " + f"'{{}}', {interruptible});" + ) + + # Check that the bgworker is initialized and napping. + result = node.poll_query_until( + f"SELECT wait_event FROM pg_stat_activity WHERE pid = {pid};", "WorkerSpiMain" + ) + assert result, f"dynamic bgworker {testcase} launched" + + return pid + + +def run_bgworker_interruptible_test(node, command, testname, pid): + """Run query and verify that the bgworker with the specified PID has been + terminated.""" + offset = node.log_position() + + node.safe_sql(command) + + node.wait_for_log( + r'terminating background worker "worker_spi dynamic" ' + r"due to administrator command", + offset, + ) + + # Postmaster entry reporting the worker as exiting. + node.wait_for_log( + r'LOG: .*background worker "worker_spi dynamic" ' + rf"\(PID {pid}\) exited with exit code", + offset, + ) + + result = node.safe_sql( + f"SELECT count(*) = 0 FROM pg_stat_activity WHERE pid = {pid};" + ) + assert result == "t", f"dynamic bgworker stopped for {testname}" + + +def test_002_worker_terminate(create_pg, tmp_path): + node = create_pg("mynode", start=False) + # The naptime is large enough to give some room on slow machines, so as + # the spawned workers have the time to process the interrupt requests sent + # by the database commands. + node.append_conf( + """ +autovacuum = off +debug_parallel_query = off +log_min_messages = debug1 +worker_spi.naptime = 600 +""" + ) + node.start() + + # This test depends on injection points to detect whether background + # workers remain. Check if the extension injection_points is available, as + # it may be possible that this script is run with installcheck, where the + # module would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION worker_spi;") + + # Launch a background worker without BGWORKER_INTERRUPTIBLE. + pid = launch_bgworker(node, "postgres", 0, "false") + + # Ensure CREATE DATABASE WITH TEMPLATE fails because a non-interruptible + # bgworker exists. + + # The injection point 'procarray-reduce-count' reduces the number of + # backend retries, allowing for shorter test runs. See + # CountOtherDBBackends(). + node.safe_sql("CREATE EXTENSION injection_points;") + node.safe_sql("SELECT injection_points_attach('procarray-reduce-count', 'error');") + + sess = node.connect() + try: + sess.query("CREATE DATABASE testdb WITH TEMPLATE postgres") + stderr = sess.get_stderr() + finally: + sess.close() + assert ( + 'source database "postgres" is being accessed by other users' in stderr + ), "background worker blocked the database creation" + + # Confirm that the non-interruptible bgworker is still running. + result = node.safe_sql( + "SELECT count(1) FROM pg_stat_activity\n" + " WHERE backend_type = 'worker_spi dynamic';" + ) + + assert ( + result == "1" + ), "background worker is still running after CREATE DATABASE WITH TEMPLATE" + + # Terminate the non-interruptible worker for the next tests. + node.safe_sql( + "SELECT pg_terminate_backend(pid)\n" + " FROM pg_stat_activity " + "WHERE backend_type = 'worker_spi dynamic';" + ) + + # The injection point is not used anymore, release it. + node.safe_sql("SELECT injection_points_detach('procarray-reduce-count');") + + # Check that BGWORKER_INTERRUPTIBLE allows background workers to be + # terminated with database-related commands. + + # Test case 1: CREATE DATABASE WITH TEMPLATE + pid = launch_bgworker(node, "postgres", 1, "true") + run_bgworker_interruptible_test( + node, + "CREATE DATABASE testdb WITH TEMPLATE postgres", + "CREATE DATABASE WITH TEMPLATE", + pid, + ) + + # Test case 2: ALTER DATABASE RENAME + pid = launch_bgworker(node, "testdb", 2, "true") + run_bgworker_interruptible_test( + node, "ALTER DATABASE testdb RENAME TO renameddb", "ALTER DATABASE RENAME", pid + ) + + # Preparation for the next test, create a tablespace. + tablespace = str(tmp_path / "test_tablespace") + os.makedirs(tablespace, exist_ok=True) + node.safe_sql(f"CREATE TABLESPACE test_tablespace LOCATION '{tablespace}'") + + # Test case 3: ALTER DATABASE SET TABLESPACE + pid = launch_bgworker(node, "renameddb", 3, "true") + run_bgworker_interruptible_test( + node, + "ALTER DATABASE renameddb SET TABLESPACE test_tablespace", + "ALTER DATABASE SET TABLESPACE", + pid, + ) + + # Test case 4: DROP DATABASE + pid = launch_bgworker(node, "renameddb", 4, "true") + run_bgworker_interruptible_test( + node, "DROP DATABASE renameddb", "DROP DATABASE", pid + ) diff --git a/src/test/modules/xid_wraparound/meson.build b/src/test/modules/xid_wraparound/meson.build index 97ce670f9ac..e68930c05ae 100644 --- a/src/test/modules/xid_wraparound/meson.build +++ b/src/test/modules/xid_wraparound/meson.build @@ -33,4 +33,12 @@ tests += { 't/004_notify_freeze.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_emergency_vacuum.py', + 'pyt/test_002_limits.py', + 'pyt/test_003_wraparounds.py', + 'pyt/test_004_notify_freeze.py', + ], + }, } diff --git a/src/test/modules/xid_wraparound/pyt/test_001_emergency_vacuum.py b/src/test/modules/xid_wraparound/pyt/test_001_emergency_vacuum.py new file mode 100644 index 00000000000..57bb98565db --- /dev/null +++ b/src/test/modules/xid_wraparound/pyt/test_001_emergency_vacuum.py @@ -0,0 +1,129 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test wraparound emergency autovacuum.""" + +import os +import re + +import pytest + +if "xid_wraparound" not in os.environ.get("PG_TEST_EXTRA", ""): + pytest.skip( + "test xid_wraparound not enabled in PG_TEST_EXTRA", + allow_module_level=True, + ) + + +def test_001_emergency_vacuum(create_pg): + # Initialize node + node = create_pg("main", start=False) + + node.append_conf( + """ +autovacuum_naptime = 1s +# so it's easier to verify the order of operations +autovacuum_max_workers = 1 +log_autovacuum_min_duration = 0 +""" + ) + node.start() + node.safe_sql("CREATE EXTENSION xid_wraparound") + + # Create tables for a few different test scenarios. We disable autovacuum + # on these tables to run it only to prevent wraparound. + node.safe_sql( + """ +CREATE TABLE large(id serial primary key, data text, filler text default repeat(random()::text, 10)) + WITH (autovacuum_enabled = off); +INSERT INTO large(data) SELECT generate_series(1,30000); + +CREATE TABLE large_trunc(id serial primary key, data text, filler text default repeat(random()::text, 10)) + WITH (autovacuum_enabled = off); +INSERT INTO large_trunc(data) SELECT generate_series(1,30000); + +CREATE TABLE small(id serial primary key, data text, filler text default repeat(random()::text, 10)) + WITH (autovacuum_enabled = off); +INSERT INTO small(data) SELECT generate_series(1,15000); + +CREATE TABLE small_trunc(id serial primary key, data text, filler text default repeat(random()::text, 10)) + WITH (autovacuum_enabled = off); +INSERT INTO small_trunc(data) SELECT generate_series(1,15000); +""" + ) + + # Start a background session, which holds a transaction open, preventing + # autovacuum from advancing relfrozenxid and datfrozenxid. + background_session = node.connect("postgres") + assert ( + background_session.do( + """ + BEGIN; + DELETE FROM large WHERE id % 2 = 0; + DELETE FROM large_trunc WHERE id > 10000; + DELETE FROM small WHERE id % 2 = 0; + DELETE FROM small_trunc WHERE id > 1000; +""" + ) + is not None + ) + + # Consume 2 billion XIDs, to get us very close to wraparound + node.safe_sql("SELECT consume_xids_until('2000000000'::xid8)") + + # Make sure the latest completed XID is advanced + node.safe_sql("INSERT INTO small(data) SELECT 1") + + # Check that all databases became old enough to trigger failsafe. + ret = node.safe_sql( + """ +SELECT datname, + age(datfrozenxid) > current_setting('vacuum_failsafe_age')::int as old +FROM pg_database ORDER BY 1 +""" + ) + assert ret == "postgres|t\ntemplate0|t\ntemplate1|t", "all tables became old" + + log_offset = node.log_position() + + # Finish the old transaction, to allow vacuum freezing to advance + # relfrozenxid and datfrozenxid again. + background_session.do("COMMIT;") + background_session.close() + + # Wait until autovacuum processed all tables and advanced the + # system-wide oldest-XID. + assert node.poll_query_until( + """ +SELECT NOT EXISTS ( + SELECT * + FROM pg_database + WHERE age(datfrozenxid) > current_setting('autovacuum_freeze_max_age')::int) +""" + ), "timeout waiting for all databases to be vacuumed" + + # Check if these tables are vacuumed. + ret = node.safe_sql( + """ +SELECT relname, age(relfrozenxid) > current_setting('autovacuum_freeze_max_age')::int +FROM pg_class +WHERE relname IN ('large', 'large_trunc', 'small', 'small_trunc') +ORDER BY 1 +""" + ) + + assert ret == ( + "large|f\nlarge_trunc|f\nsmall|f\nsmall_trunc|f" + ), "all tables are vacuumed" + + # Check if vacuum failsafe was triggered for each table. + log_contents = node.log_content()[log_offset:] + for tablename in ("large", "large_trunc", "small", "small_trunc"): + assert re.search( + r"bypassing nonessential maintenance of table " + r'"postgres\.public\.' + + tablename + + r'" as a failsafe after \d+ index scans', + log_contents, + ), f"failsafe vacuum triggered for {tablename}" + + node.stop() diff --git a/src/test/modules/xid_wraparound/pyt/test_002_limits.py b/src/test/modules/xid_wraparound/pyt/test_002_limits.py new file mode 100644 index 00000000000..29df8a4ab35 --- /dev/null +++ b/src/test/modules/xid_wraparound/pyt/test_002_limits.py @@ -0,0 +1,142 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test XID wraparound limits. + +When you get close to XID wraparound, you start to get warnings, and +when you get even closer, the system refuses to assign any more XIDs +until the oldest databases have been vacuumed and datfrozenxid has +been advanced. +""" + +import os +import re + +import pytest + +if "xid_wraparound" not in os.environ.get("PG_TEST_EXTRA", ""): + pytest.skip( + "test xid_wraparound not enabled in PG_TEST_EXTRA", + allow_module_level=True, + ) + + +def test_002_limits(create_pg): + # Initialize node + node = create_pg("wraparound", start=False) + + node.append_conf( + """ +autovacuum_naptime = 1s +log_autovacuum_min_duration = 0 +log_connections = on +log_statement = 'all' +""" + ) + node.start() + node.safe_sql("CREATE EXTENSION xid_wraparound") + + # Create a test table. We disable autovacuum on the table to run it only + # to prevent wraparound. + node.safe_sql( + """ +CREATE TABLE wraparoundtest(t text) WITH (autovacuum_enabled = off); +INSERT INTO wraparoundtest VALUES ('start'); +""" + ) + + # Start a background session, which holds a transaction open, preventing + # autovacuum from advancing relfrozenxid and datfrozenxid. + background_session = node.connect("postgres") + assert ( + background_session.do( + """ + BEGIN; + INSERT INTO wraparoundtest VALUES ('oldxact'); +""" + ) + is not None + ) + + # Consume 2 billion transactions, to get close to wraparound + node.safe_sql("SELECT consume_xids(1000000000)") + node.safe_sql("INSERT INTO wraparoundtest VALUES ('after 1 billion')") + + node.safe_sql("SELECT consume_xids(1000000000)") + node.safe_sql("INSERT INTO wraparoundtest VALUES ('after 2 billion')") + + # We are now just under 150 million XIDs away from wraparound. + # Continue consuming XIDs, in batches of 10 million, until we get + # the warning: + # + # WARNING: database "postgres" must be vacuumed within 3000024 transactions + # HINT: To avoid a database shutdown, execute a database-wide VACUUM in that database. + # You might also need to commit or roll back old prepared transactions, or drop stale replication slots. + warn_limit = 0 + for _i in range(1, 16): + # Use a fresh session so the warnings (notices) for this batch can be + # inspected in isolation, mirroring psql with on_error_die. + sess = node.connect("postgres") + try: + sess.clear_notices() + res = sess.query("SELECT consume_xids(10000000)") + assert res.error_message is None, res.error_message + stderr = sess.get_notices_str() + finally: + sess.close() + + if re.search( + r'WARNING: database "postgres" must be vacuumed within ' + r"[0-9]+ transactions", + stderr, + ): + # Reached the warn-limit + warn_limit = 1 + break + assert warn_limit == 1, "warn-limit reached" + + # We can still INSERT, despite the warnings. + node.safe_sql("INSERT INTO wraparoundtest VALUES ('reached warn-limit')") + + # Keep going. We'll hit the hard "stop" limit. + sess = node.connect("postgres") + try: + res = sess.query("SELECT consume_xids(100000000)") + stderr = (res.error_message or "") + sess.get_notices_str() + finally: + sess.close() + assert re.search( + r"ERROR: database is not accepting commands that assign new " + r'transaction IDs to avoid wraparound data loss in database "postgres"', + stderr, + ), "stop-limit" + + # Finish the old transaction, to allow vacuum freezing to advance + # relfrozenxid and datfrozenxid again. + background_session.do("COMMIT;") + background_session.close() + + # VACUUM, to freeze the tables and advance datfrozenxid. + # + # Autovacuum does this for the other databases, and would do it for + # 'postgres' too, but let's test manual VACUUM. + # + node.safe_sql("VACUUM") + + # Wait until autovacuum has processed the other databases and advanced + # the system-wide oldest-XID. + assert node.poll_query_until( + "INSERT INTO wraparoundtest VALUES ('after VACUUM') RETURNING true" + ) + + # Check the table contents + ret = node.safe_sql("SELECT * from wraparoundtest") + assert ret == ( + "start\n" + "oldxact\n" + "after 1 billion\n" + "after 2 billion\n" + "reached warn-limit\n" + "after VACUUM" + ) + + node.stop() diff --git a/src/test/modules/xid_wraparound/pyt/test_003_wraparounds.py b/src/test/modules/xid_wraparound/pyt/test_003_wraparounds.py new file mode 100644 index 00000000000..ac4fd5dbd9b --- /dev/null +++ b/src/test/modules/xid_wraparound/pyt/test_003_wraparounds.py @@ -0,0 +1,48 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Consume a lot of XIDs, wrapping around a few times.""" + +import os + +import pytest + +if "xid_wraparound" not in os.environ.get("PG_TEST_EXTRA", ""): + pytest.skip( + "test xid_wraparound not enabled in PG_TEST_EXTRA", + allow_module_level=True, + ) + + +def test_003_wraparounds(create_pg): + # Initialize node + node = create_pg("wraparound", start=False) + + node.append_conf( + """ +autovacuum_naptime = 1s +# so it's easier to verify the order of operations +autovacuum_max_workers = 1 +log_autovacuum_min_duration = 0 +""" + ) + node.start() + node.safe_sql("CREATE EXTENSION xid_wraparound") + + # Create a test table. We disable autovacuum on the table to run + # it only to prevent wraparound. + node.safe_sql( + """ +CREATE TABLE wraparoundtest(t text) WITH (autovacuum_enabled = off); +INSERT INTO wraparoundtest VALUES ('beginning'); +""" + ) + + # Burn through 10 billion transactions in total, in batches of 100 million. + for i in range(1, 101): + node.safe_sql("SELECT consume_xids(100000000)") + node.safe_sql(f"INSERT INTO wraparoundtest VALUES ('after {i} batches')") + + ret = node.safe_sql("SELECT COUNT(*) FROM wraparoundtest") + assert ret == "101" + + node.stop() diff --git a/src/test/modules/xid_wraparound/pyt/test_004_notify_freeze.py b/src/test/modules/xid_wraparound/pyt/test_004_notify_freeze.py new file mode 100644 index 00000000000..179272fd4f0 --- /dev/null +++ b/src/test/modules/xid_wraparound/pyt/test_004_notify_freeze.py @@ -0,0 +1,78 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Test freezing XIDs in the async notification queue. + +This isn't really wraparound-related, but the test depends on the +consume_xids() helper function. +""" + +import os + +import pytest + +if "xid_wraparound" not in os.environ.get("PG_TEST_EXTRA", ""): + pytest.skip( + "test xid_wraparound not enabled in PG_TEST_EXTRA", + allow_module_level=True, + ) + + +def test_004_notify_freeze(create_pg): + node = create_pg("node") + + # Setup + node.safe_sql("CREATE EXTENSION xid_wraparound") + node.safe_sql("ALTER DATABASE template0 WITH ALLOW_CONNECTIONS true") + + # Start Session 1 and leave it idle in transaction + session1 = node.connect("postgres") + session1.do("LISTEN s") + session1.do("BEGIN") + + # Send some notifys from other sessions + for i in range(1, 11): + node.safe_sql(f"NOTIFY s, '{i}'") + + # Consume enough XIDs to trigger truncation, and one more with + # 'txid_current' to bump up the freeze horizon. + node.safe_sql("select consume_xids(10000000);") + node.safe_sql("select txid_current()") + + # Remember current datfrozenxid before vacuum freeze so that we can + # check that it is advanced. (Taking the min() this way assumes that + # XID wraparound doesn't happen.) + datafronzenxid = int( + node.safe_sql("select min(datfrozenxid::text::bigint) from pg_database") + ) + + # Execute vacuum freeze on all databases + node.command_ok( + ["vacuumdb", "--all", "--freeze", "--port", str(node.port)], + "vacuumdb --all --freeze", + ) + + # Check that vacuumdb advanced datfrozenxid + datafronzenxid_freeze = int( + node.safe_sql("select min(datfrozenxid::text::bigint) from pg_database") + ) + assert datafronzenxid_freeze > datafronzenxid, "datfrozenxid advanced" + + # On Session 1, commit and ensure that all the notifications are + # received. This depends on correctly freezing the XIDs in the pending + # notification entries. + session1.do("COMMIT") + + notifications = session1.get_all_notifications() + assert len(notifications) == 10, "received all committed notifications" + + expected_payload = 1 + for notify in notifications: + assert ( + notify["channel"] == "s" + ), f"notification {expected_payload} has correct channel" + assert notify["payload"] == str( + expected_payload + ), f"notification {expected_payload} has correct payload" + expected_payload += 1 + + session1.close() diff --git a/src/test/postmaster/meson.build b/src/test/postmaster/meson.build index fa30883b601..1db87bacf28 100644 --- a/src/test/postmaster/meson.build +++ b/src/test/postmaster/meson.build @@ -12,4 +12,12 @@ tests += { 't/004_negotiate.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_002_connection_limits.py', + 'pyt/test_003_start_stop.py', + 'pyt/test_004_negotiate.py', + ], + }, } diff --git a/src/test/postmaster/pyt/test_001_basic.py b/src/test/postmaster/pyt/test_001_basic.py new file mode 100644 index 00000000000..0f66bc1493c --- /dev/null +++ b/src/test/postmaster/pyt/test_001_basic.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Basic sanity checks for the postgres program.""" + + +def test_basic(pg_bin): + pg_bin.program_help_ok("postgres") + pg_bin.program_version_ok("postgres") + pg_bin.program_options_handling_ok("postgres") diff --git a/src/test/postmaster/pyt/test_002_connection_limits.py b/src/test/postmaster/pyt/test_002_connection_limits.py new file mode 100644 index 00000000000..9101c7d4a69 --- /dev/null +++ b/src/test/postmaster/pyt/test_002_connection_limits.py @@ -0,0 +1,146 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test connection limits, i.e. max_connections, reserved_connections and +superuser_reserved_connections. +""" + +import re +import struct + +import pytest + +from libpq.errors import PqConnectionError + + +def _session_as_user(node, user): + """Open a fresh session as *user* (held open to occupy a slot).""" + return node.connect("postgres", user=user) + + +def _connect_fails_wait(node, user, test_name, expected_stderr): + """Like connect_fails(), except that we also wait for the failed backend + to have exited. + + This test needs to wait for client processes to exit because the error + message for a failed connection is reported before the backend has + detached from shared memory. If we didn't wait, subsequent tests might hit + connection limits spuriously. + """ + log_location = node.log_position() + + try: + node.connect("postgres", user=user).close() + raised = None + except PqConnectionError as exc: + raised = str(exc) + assert raised is not None and re.search( + expected_stderr, raised + ), f"{test_name}\nexpected /{expected_stderr}/, got: {raised!r}" + + node.wait_for_log( + r"DEBUG: (00000: )?client backend.*exited with exit code 1", + log_location, + ) + # "$test_name: client backend process exited" + + +def test_002_connection_limits(create_pg): + # Initialize the server with specific low connection limits. With the + # framework's "-A trust" initdb every local role is trusted, so no extra + # pg_hba entries are needed for the three regress_* roles. + node = create_pg("primary", start=False) + node.append_conf("max_connections = 6") + node.append_conf("reserved_connections = 2") + node.append_conf("superuser_reserved_connections = 1") + node.append_conf("log_connections = 'receipt,authentication,authorization'") + node.append_conf("log_min_messages=debug2") + node.start() + + if not node.raw_connect_works(): + pytest.skip("this test requires working raw_connect()") + + node.safe_sql("CREATE USER regress_regular LOGIN") + node.safe_sql("CREATE USER regress_reserved LOGIN") + node.safe_sql("GRANT pg_use_reserved_connections TO regress_reserved") + node.safe_sql("CREATE USER regress_superuser LOGIN SUPERUSER") + + # With the limits we set in postgresql.conf, we can establish: + # - 3 connections for any user with no special privileges + # - 2 more connections for users belonging to "pg_use_reserved_connections" + # - 1 more connection for superuser + + # Restart the server to ensure that any backends launched for the + # initialization steps are gone. Otherwise they could still be using up + # connection slots and mess with our expectations. + node.restart() + + sessions = [] + raw_connections = [] + + sessions.append(_session_as_user(node, "regress_regular")) + sessions.append(_session_as_user(node, "regress_regular")) + sessions.append(_session_as_user(node, "regress_regular")) + _connect_fails_wait( + node, + "regress_regular", + "regular connections limit", + expected_stderr=( + r"FATAL: remaining connection slots are reserved for roles " + r'with privileges of the "pg_use_reserved_connections" role' + ), + ) + + sessions.append(_session_as_user(node, "regress_reserved")) + sessions.append(_session_as_user(node, "regress_reserved")) + _connect_fails_wait( + node, + "regress_reserved", + "reserved_connections limit", + expected_stderr=( + r"FATAL: remaining connection slots are reserved for roles " + r"with the SUPERUSER attribute" + ), + ) + + sessions.append(_session_as_user(node, "regress_superuser")) + _connect_fails_wait( + node, + "regress_superuser", + "superuser_reserved_connections limit", + expected_stderr=r"FATAL: sorry, too many clients already", + ) + + # We can still open TCP (or Unix domain socket) connections, but beyond a + # certain number (roughly 2x max_connections), they will be "dead-end + # backends". + for i in range(0, 21): + sock = node.raw_connect() + + # On a busy system, the server might reject connections if postmaster + # cannot accept() them fast enough. To make this reliable, we attempt + # SSL negotiation on each connection before opening the next one. The + # server will reject the SSL negotiations, but when it does so, we + # know that the backend has been launched and we should be able to + # open another connection. + + # SSLRequest packet consists of packet length followed by + # NEGOTIATE_SSL_CODE. + negotiate_ssl_code = struct.pack("!Ihh", 8, 1234, 5679) + sock.send(negotiate_ssl_code) + + # Read reply. We expect the server to reject it with 'N' + reply = sock.recv(1) + assert reply == b"N", f"dead-end connection {i}" + + raw_connections.append(sock) + + # TODO: test that query cancellation is still possible. A dead-end backend + # can process a query cancellation packet. + + # Clean up + for session in sessions: + session.close() + for sock in raw_connections: + sock.close() + + node.stop() diff --git a/src/test/postmaster/pyt/test_003_start_stop.py b/src/test/postmaster/pyt/test_003_start_stop.py new file mode 100644 index 00000000000..1d243a8cf75 --- /dev/null +++ b/src/test/postmaster/pyt/test_003_start_stop.py @@ -0,0 +1,116 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test postmaster start and stop state machine.""" + +import struct + +import pytest + +from libpq.errors import PqConnectionError +from pypg.util import TIMEOUT_DEFAULT + + +def test_003_start_stop(create_pg): + # + # Test that dead-end backends don't prevent the server from shutting + # down. + # + # Dead-end backends can linger until they reach authentication_timeout. + # We use a long authentication_timeout and a much shorter timeout for the + # "pg_ctl stop" operation, to test that if dead-end backends are killed at + # fast shut down. If they're not, "pg_ctl stop" will error out before the + # authentication timeout kicks in and cleans up the dead-end backends. + authentication_timeout = TIMEOUT_DEFAULT + + # Don't fail due to hitting the max value allowed for authentication_timeout. + authentication_timeout = min(authentication_timeout, 600) + + stop_timeout = authentication_timeout // 2 + + # Initialize the server with low connection limits, to test dead-end + # backends. + node = create_pg("main", start=False) + node.append_conf("max_connections = 5") + node.append_conf("max_wal_senders = 0") + node.append_conf("autovacuum_max_workers = 1") + node.append_conf("max_worker_processes = 1") + node.append_conf("log_connections = 'receipt,authentication,authorization'") + node.append_conf("log_min_messages = debug2") + node.append_conf(f"authentication_timeout = '{authentication_timeout} s'") + node.append_conf("trace_connection_negotiation=on") + node.start() + + if not node.raw_connect_works(): + pytest.skip("this test requires working raw_connect()") + + raw_connections = [] + + # Open a lot of TCP (or Unix domain socket) connections to use up all + # the connection slots. Beyond a certain number (roughly 2x + # max_connections), they will be "dead-end backends". + for i in range(0, 21): + sock = node.raw_connect() + + # On a busy system, the server might reject connections if postmaster + # cannot accept() them fast enough. The exact limit and behavior + # depends on the platform. To make this reliable, we attempt SSL + # negotiation on each connection before opening next one. The server + # will reject the SSL negotiations, but when it does so, we know that + # the backend has been launched and we should be able to open another + # connection. + + # SSLRequest packet consists of packet length followed by + # NEGOTIATE_SSL_CODE. + negotiate_ssl_code = struct.pack("!Ihh", 8, 1234, 5679) + sock.send(negotiate_ssl_code) + + # Read reply. We expect the server to reject it with 'N' + reply = sock.recv(1) + assert reply == b"N", f"dead-end connection {i}" + + raw_connections.append(sock) + + # When all the connection slots are in use, new connections will fail + # before even looking up the user. Hence you now get "sorry, too many + # clients already" instead of "role does not exist" error. Test that to + # ensure that we have used up all the slots. + try: + node.connect("postgres", user="invalid_user").close() + raised = None + except PqConnectionError as exc: + raised = str(exc) + assert ( + raised is not None and "sorry, too many clients already" in raised + ), "connection is rejected when all slots are in use" + + # Open one more connection, to really ensure that we have at least one + # dead-end backend. + sock = node.raw_connect() + + # Test that the dead-end backends don't prevent the server from stopping. + # Use pg_ctl directly so a short stop timeout can be enforced. + node.pg_bin.command_ok( + [ + "pg_ctl", + "-D", + node.data_dir, + "-m", + "fast", + "-w", + "-t", + str(stop_timeout), + "stop", + ], + "fast stop with dead-end backends", + ) + node.running = False + + node.start() + node.connect("postgres").close() # works after restart + + # Clean up + for s in raw_connections: + s.close() + sock.close() + + node.stop() diff --git a/src/test/postmaster/pyt/test_004_negotiate.py b/src/test/postmaster/pyt/test_004_negotiate.py new file mode 100644 index 00000000000..99d7fa3068e --- /dev/null +++ b/src/test/postmaster/pyt/test_004_negotiate.py @@ -0,0 +1,66 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +"""Test the negotiation of combined SSL and GSS requests. This test relies on +both SSL and GSS requests to be rejected first, followed by more requests. +""" + +import struct + +import pytest + + +def test_negotiate(create_pg): + node = create_pg("main", start=False) + node.append_conf("log_min_messages = debug2") + node.append_conf("log_connections = 'receipt,authentication,authorization'") + node.append_conf("trace_connection_negotiation=on") + node.start() + + if not node.raw_connect_works(): + pytest.skip("this test requires working raw_connect()") + + sock = node.raw_connect() + + # SSLRequest: packet length followed by NEGOTIATE_SSL_CODE. + ssl_request = struct.pack("!Ihh", 8, 1234, 5679) + + # GSSENCRequest: packet length followed by NEGOTIATE_GSS_CODE. + gss_request = struct.pack("!Ihh", 8, 1234, 5680) + + # Send SSLRequest, reject or bypass. + sock.send(ssl_request) + reply = sock.recv(1) + if reply != b"N": + sock.close() + pytest.skip("server accepted SSL; test requires SSL to be rejected") + + # Send GSSENCRequest, reject or bypass test. + sock.send(gss_request) + reply = sock.recv(1) + if reply != b"N": + sock.close() + pytest.skip("server accepted GSS; test requires GSS to be rejected") + + log_offset = node.log_position() + + # Send a second SSLRequest, now that we know that both SSL and GSS have + # been rejected for this connection. We are done with both requests, so + # extra requests will be rejected and fail with an invalid protocol + # version, and the connection should be closed by the server. + sock.send(ssl_request) + + # Try to read a response, there should be nothing, and certainly not an + # extra 'N' message indicating a rejection. + reply = sock.recv(1024) + assert ( + reply != b"N" + ), "server does not re-enter SSL negotiation after SSL+GSS were both tried" + + sock.close() + node.wait_for_log(r"FATAL: .* unsupported frontend protocol 1234.5679", log_offset) + + # Check extra connection with a simple query. + result = node.safe_sql("select 1;") + assert result == "1", "server able to accept connection" + + node.stop() From 3c722c8119e0b5609cd311f6d4e3e168d92ede7b Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:08 -0400 Subject: [PATCH 13/18] python tests: pytest suites for the SSL and authentication tests ssl, ldap, ldap_password_func, kerberos, authentication, oauth_validator, libpq-oauth and ssl_passphrase_callback. These use the SSL/LDAP/Kerberos/OAuth helpers and skip cleanly where a daemon or build feature is unavailable. --- src/interfaces/libpq-oauth/meson.build | 6 + .../libpq-oauth/pyt/test_001_oauth.py | 33 + src/test/authentication/meson.build | 14 + .../authentication/pyt/test_001_password.py | 865 ++++++++++++ .../authentication/pyt/test_002_saslprep.py | 142 ++ src/test/authentication/pyt/test_003_peer.py | 359 +++++ .../pyt/test_004_file_inclusion.py | 279 ++++ src/test/authentication/pyt/test_005_sspi.py | 45 + .../pyt/test_006_login_trigger.py | 134 ++ .../authentication/pyt/test_007_pre_auth.py | 100 ++ src/test/kerberos/meson.build | 10 + src/test/kerberos/pyt/test_001_auth.py | 693 ++++++++++ src/test/ldap/meson.build | 10 + src/test/ldap/pyt/test_001_auth.py | 286 ++++ src/test/ldap/pyt/test_002_bindpasswd.py | 83 ++ .../test_003_ldap_connection_param_lookup.py | 307 +++++ .../modules/ldap_password_func/meson.build | 6 + .../pyt/test_001_mutated_bindpasswd.py | 93 ++ src/test/modules/oauth_validator/meson.build | 13 + .../oauth_validator/pyt/test_001_server.py | 937 +++++++++++++ .../oauth_validator/pyt/test_002_client.py | 283 ++++ .../ssl_passphrase_callback/meson.build | 6 + .../pyt/test_001_testfunc.py | 114 ++ src/test/ssl/meson.build | 12 + src/test/ssl/pyt/test_001_ssltests.py | 1162 +++++++++++++++++ src/test/ssl/pyt/test_002_scram.py | 173 +++ src/test/ssl/pyt/test_003_sslinfo.py | 195 +++ src/test/ssl/pyt/test_004_sni.py | 549 ++++++++ 28 files changed, 6909 insertions(+) create mode 100644 src/interfaces/libpq-oauth/pyt/test_001_oauth.py create mode 100644 src/test/authentication/pyt/test_001_password.py create mode 100644 src/test/authentication/pyt/test_002_saslprep.py create mode 100644 src/test/authentication/pyt/test_003_peer.py create mode 100644 src/test/authentication/pyt/test_004_file_inclusion.py create mode 100644 src/test/authentication/pyt/test_005_sspi.py create mode 100644 src/test/authentication/pyt/test_006_login_trigger.py create mode 100644 src/test/authentication/pyt/test_007_pre_auth.py create mode 100644 src/test/kerberos/pyt/test_001_auth.py create mode 100644 src/test/ldap/pyt/test_001_auth.py create mode 100644 src/test/ldap/pyt/test_002_bindpasswd.py create mode 100644 src/test/ldap/pyt/test_003_ldap_connection_param_lookup.py create mode 100644 src/test/modules/ldap_password_func/pyt/test_001_mutated_bindpasswd.py create mode 100644 src/test/modules/oauth_validator/pyt/test_001_server.py create mode 100644 src/test/modules/oauth_validator/pyt/test_002_client.py create mode 100644 src/test/modules/ssl_passphrase_callback/pyt/test_001_testfunc.py create mode 100644 src/test/ssl/pyt/test_001_ssltests.py create mode 100644 src/test/ssl/pyt/test_002_scram.py create mode 100644 src/test/ssl/pyt/test_003_sslinfo.py create mode 100644 src/test/ssl/pyt/test_004_sni.py diff --git a/src/interfaces/libpq-oauth/meson.build b/src/interfaces/libpq-oauth/meson.build index ea3a900f4f1..41721f5ddae 100644 --- a/src/interfaces/libpq-oauth/meson.build +++ b/src/interfaces/libpq-oauth/meson.build @@ -93,4 +93,10 @@ tests += { ], 'deps': libpq_oauth_test_deps, }, + 'pytest': { + 'tests': [ + 'pyt/test_001_oauth.py', + ], + 'deps': libpq_oauth_test_deps, + }, } diff --git a/src/interfaces/libpq-oauth/pyt/test_001_oauth.py b/src/interfaces/libpq-oauth/pyt/test_001_oauth.py new file mode 100644 index 00000000000..6442e7d1ea2 --- /dev/null +++ b/src/interfaces/libpq-oauth/pyt/test_001_oauth.py @@ -0,0 +1,33 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Tests for libpq OAuth support. + +Defer entirely to the ``oauth_tests`` executable, which lives in the build +directory (resolved via PATH) and emits its own TAP on stdout. We do not parse +that TAP; we just run the program, echo its stdout/stderr, and require a zero +exit status. + +``oauth_tests`` only exists when libpq is built with libcurl/OAuth support, so +if the program cannot be found we skip rather than fail. +""" + +import pytest + + +def test_001_oauth(pg_bin): + try: + res = pg_bin.result(["oauth_tests"]) + except FileNotFoundError: + pytest.skip("oauth_tests not built (no libcurl/OAuth support)") + + # Route the executable's output through pytest's capture so our logging + # infrastructure can handle it. + if res.stdout: + print(res.stdout) + if res.stderr: + print(res.stderr) + + assert res.returncode == 0, ( + f"oauth_tests returned {res.returncode}\n" + f"stdout:\n{res.stdout}\nstderr:\n{res.stderr}" + ) diff --git a/src/test/authentication/meson.build b/src/test/authentication/meson.build index 282a5054e2c..5fb68651ee1 100644 --- a/src/test/authentication/meson.build +++ b/src/test/authentication/meson.build @@ -18,4 +18,18 @@ tests += { 't/007_pre_auth.pl', ], }, + 'pytest': { + 'env': { + 'enable_injection_points': get_option('injection_points') ? 'yes' : 'no', + }, + 'tests': [ + 'pyt/test_001_password.py', + 'pyt/test_002_saslprep.py', + 'pyt/test_003_peer.py', + 'pyt/test_004_file_inclusion.py', + 'pyt/test_005_sspi.py', + 'pyt/test_006_login_trigger.py', + 'pyt/test_007_pre_auth.py', + ], + }, } diff --git a/src/test/authentication/pyt/test_001_password.py b/src/test/authentication/pyt/test_001_password.py new file mode 100644 index 00000000000..3052dfeb0f2 --- /dev/null +++ b/src/test/authentication/pyt/test_001_password.py @@ -0,0 +1,865 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Set of tests for authentication and pg_hba.conf. + +The following password methods are checked through this test: + - Plain + - MD5-encrypted + - SCRAM-encrypted + +There's also a few tests of the log_connections GUC here. + +These tests require Unix-domain sockets, so the module is skipped when the +framework is running over TCP (Windows). +""" + +import os +import time + +import pytest + +from libpq import Session +from pypg.util import USE_UNIX_SOCKETS + +pytestmark = pytest.mark.skipif( + not USE_UNIX_SOCKETS, reason="test requires Unix-domain sockets" +) + + +# Delete pg_hba.conf from the given node, add a new entry to it +# and then execute a reload to refresh it. +def reset_pg_hba(node, database, role, hba_method): + os.unlink(os.path.join(node.data_dir, "pg_hba.conf")) + # just for testing purposes, use a continuation line + node.append_conf( + f"local {database} {role}\\\n {hba_method}", filename="pg_hba.conf" + ) + node.reload() + + +# Test access for a connection string, useful to wrap all tests into one. +# Extra named parameters are passed to connect_ok/fails as-is. +# (Named with a leading underscore so pytest does not collect it as a test.) +def _test_conn(node, connstr, method, expected_res, **params): + status_string = "success" if expected_res == 0 else "failed" + + testname = f"authentication {status_string} for method {method}, connstr {connstr}" + + if expected_res == 0: + node.connect_ok(connstr, testname, **params) + else: + # No checks of the error message, only the status code. + node.connect_fails(connstr, testname, **params) + + +def test_001_password(create_pg): + # Initialize primary node + node = create_pg("primary", start=False) + node.append_conf("log_connections = on\n") + # Needed to allow connect_fails to inspect postmaster log: + node.append_conf("log_min_messages = debug2") + node.append_conf("password_expiration_warning_threshold = '1100d'") + node.start() + + # Snapshot/restore env vars we mutate (PGPASSWORD, PGCHANNELBINDING, + # PGPASSFILE) so the rest of the suite is unaffected. + saved_env = { + k: os.environ.get(k) + for k in ("PGPASSWORD", "PGCHANNELBINDING", "PGPASSFILE", "PGDATABASE") + } + try: + _run_body(node) + finally: + for k, v in saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def _run_body(node): + # Set up roles for password_expiration_warning_threshold test + current_year = time.localtime().tm_year + expire_year = current_year - 1 + node.safe_sql( + f"CREATE ROLE expired LOGIN VALID UNTIL '{expire_year}-01-01' PASSWORD 'pass'" + ) + expire_year = current_year + 2 + node.safe_sql( + "CREATE ROLE expiration_warnings LOGIN VALID UNTIL " + f"'{expire_year}-01-01' PASSWORD 'pass'" + ) + expire_year = current_year + 5 + node.safe_sql( + f"CREATE ROLE no_warnings LOGIN VALID UNTIL '{expire_year}-01-01' PASSWORD 'pass'" + ) + + # Test behavior of log_connections GUC + # + # There wasn't another test file where these tests obviously fit, and we + # don't want to incur the cost of spinning up a new cluster just to test + # one GUC. + + # Make a database for the log_connections tests to avoid test fragility if + # other tests are added to this file in the future + node.safe_sql("CREATE DATABASE test_log_connections") + + log_connections = node.safe_sql( + "SHOW log_connections;", dbname="test_log_connections" + ) + assert log_connections == "on", "check log connections has expected value 'on'" + + node.connect_ok( + "dbname=test_log_connections", + "log_connections 'on' works as expected for backwards compatibility", + log_like=[ + r"connection received", + r"connection authenticated", + r"connection authorized: user=\S+ database=test_log_connections", + ], + log_unlike=[r"connection ready"], + ) + + node.safe_sql( + "ALTER SYSTEM SET log_connections = receipt,authorization,setup_durations;", + dbname="test_log_connections", + ) + node.safe_sql("SELECT pg_reload_conf();", dbname="test_log_connections") + + node.connect_ok( + "dbname=test_log_connections", + "log_connections with subset of specified options logs only those aspects", + log_like=[ + r"connection received", + r"connection authorized: user=\S+ database=test_log_connections", + r"connection ready", + ], + log_unlike=[r"connection authenticated"], + ) + + node.safe_sql( + "ALTER SYSTEM SET log_connections = 'all';", + dbname="test_log_connections", + ) + node.safe_sql("SELECT pg_reload_conf();", dbname="test_log_connections") + + node.connect_ok( + "dbname=test_log_connections", + "log_connections 'all' logs all available connection aspects", + log_like=[ + r"connection received", + r"connection authenticated", + r"connection authorized: user=\S+ database=test_log_connections", + r"connection ready", + ], + ) + + # Authentication tests + + # could fail in FIPS mode + md5_works = node.sql("select md5('')").error_message is None + + # Create 3 roles with different password methods for each one. The same + # password is used for all of them. + res = node.sql( + "SET password_encryption='scram-sha-256';" + " CREATE ROLE scram_role LOGIN PASSWORD 'pass';" + ) + assert res.error_message is None, "created user with SCRAM password" + + res = node.sql( + "SET password_encryption='md5'; CREATE ROLE md5_role LOGIN PASSWORD 'pass';" + ) + if md5_works: + assert res.error_message is None, "created user with md5 password" + else: + assert res.error_message is not None, "created user with md5 password" + + # Set up a table for tests of SYSTEM_USER. + node.safe_sql( + "CREATE TABLE sysuser_data (n) AS SELECT NULL FROM generate_series(1, 10);" + " GRANT ALL ON sysuser_data TO scram_role;" + ) + os.environ["PGPASSWORD"] = "pass" + + # Create a role that contains a comma to stress the parsing. + node.safe_sql( + "SET password_encryption='scram-sha-256';" + " CREATE ROLE \"scram,role\" LOGIN PASSWORD 'pass';" + ) + + # Create a role with a non-default iteration count + node.safe_sql( + "SET password_encryption='scram-sha-256';" + " SET scram_iterations=1024;" + " CREATE ROLE scram_role_iter LOGIN PASSWORD 'pass';" + " RESET scram_iterations;" + ) + + res = node.safe_sql( + "SELECT substr(rolpassword,1,19)" + " FROM pg_authid" + " WHERE rolname = 'scram_role_iter'" + ) + assert res == "SCRAM-SHA-256$1024:", "scram_iterations in server side ROLE" + + # set password using PQchangePassword + session = Session(connstr=node.connstr("postgres"), libdir=node.libdir) + try: + session.do( + "SET password_encryption='scram-sha-256';", + "SET scram_iterations=42;", + ) + res = session.set_password("scram_role_iter", "pass") + assert res.status == 1, "set password ok" + res = session.query_oneval( + "SELECT substr(rolpassword,1,17)" + " FROM pg_authid" + " WHERE rolname = 'scram_role_iter'" + ) + assert res == "SCRAM-SHA-256$42:", "scram_iterations correct" + finally: + session.close() + + # Create a database to test regular expression. + node.safe_sql("CREATE database regex_testdb;") + + # For "trust" method, all users should be able to connect. + reset_pg_hba(node, "all", "all", "trust") + _test_conn( + node, + "user=scram_role", + "trust", + 0, + log_like=[r'connection authenticated: user="scram_role" method=trust'], + ) + if md5_works: + _test_conn( + node, + "user=md5_role", + "trust", + 0, + log_like=[r'connection authenticated: user="md5_role" method=trust'], + ) + + # SYSTEM_USER is null when not authenticated. + res = node.safe_sql("SELECT SYSTEM_USER IS NULL;") + assert res == "t", "users with trust authentication use SYSTEM_USER = NULL" + + # Test SYSTEM_USER with parallel workers when not authenticated. + sess = node.connect(user="scram_role") + try: + res = sess.query_oneval( + "SET min_parallel_table_scan_size TO 0;" + "SET parallel_setup_cost TO 0;" + "SET parallel_tuple_cost TO 0;" + "SET max_parallel_workers_per_gather TO 2;" + "SELECT bool_and(SYSTEM_USER IS NOT DISTINCT FROM n) FROM sysuser_data;" + ) + finally: + sess.close() + assert ( + res == "t" + ), "users with trust authentication use SYSTEM_USER = NULL in parallel workers" + + # Explicitly specifying an empty require_auth (the default) should always + # succeed. + node.connect_ok("user=scram_role require_auth=", "empty require_auth succeeds") + + # All these values of require_auth should fail, as trust is expected. + node.connect_fails( + "user=scram_role require_auth=gss", + "GSS authentication required, fails with trust auth", + expected_stderr=r'authentication method requirement "gss" failed: server did not complete authentication', + ) + node.connect_fails( + "user=scram_role require_auth=sspi", + "SSPI authentication required, fails with trust auth", + expected_stderr=r'authentication method requirement "sspi" failed: server did not complete authentication', + ) + node.connect_fails( + "user=scram_role require_auth=password", + "password authentication required, fails with trust auth", + expected_stderr=r'authentication method requirement "password" failed: server did not complete authentication', + ) + node.connect_fails( + "user=scram_role require_auth=md5", + "MD5 authentication required, fails with trust auth", + expected_stderr=r'authentication method requirement "md5" failed: server did not complete authentication', + ) + node.connect_fails( + "user=scram_role require_auth=scram-sha-256", + "SCRAM authentication required, fails with trust auth", + expected_stderr=r'authentication method requirement "scram-sha-256" failed: server did not complete authentication', + ) + node.connect_fails( + "user=scram_role require_auth=password,scram-sha-256", + "password and SCRAM authentication required, fails with trust auth", + expected_stderr=r'authentication method requirement "password,scram-sha-256" failed: server did not complete authentication', + ) + + # These negative patterns of require_auth should succeed. + node.connect_ok( + "user=scram_role require_auth=!gss", + "GSS authentication can be forbidden, succeeds with trust auth", + ) + node.connect_ok( + "user=scram_role require_auth=!sspi", + "SSPI authentication can be forbidden, succeeds with trust auth", + ) + node.connect_ok( + "user=scram_role require_auth=!password", + "password authentication can be forbidden, succeeds with trust auth", + ) + node.connect_ok( + "user=scram_role require_auth=!md5", + "md5 authentication can be forbidden, succeeds with trust auth", + ) + node.connect_ok( + "user=scram_role require_auth=!scram-sha-256", + "SCRAM authentication can be forbidden, succeeds with trust auth", + ) + node.connect_ok( + "user=scram_role require_auth=!password,!scram-sha-256", + "multiple authentication types forbidden, succeeds with trust auth", + ) + + # require_auth=[!]none should interact correctly with trust auth. + node.connect_ok( + "user=scram_role require_auth=none", + "all authentication types forbidden, succeeds with trust auth", + ) + node.connect_fails( + "user=scram_role require_auth=!none", + "any authentication types required, fails with trust auth", + expected_stderr=r"server did not complete authentication", + ) + + # Negative and positive require_auth methods can't be mixed. + node.connect_fails( + "user=scram_role require_auth=scram-sha-256,!md5", + "negative require_auth methods cannot be mixed with positive ones", + expected_stderr=r'negative require_auth method "!md5" cannot be mixed with non-negative methods', + ) + node.connect_fails( + "user=scram_role require_auth=!password,!none,scram-sha-256", + "positive require_auth methods cannot be mixed with negative one", + expected_stderr=r'require_auth method "scram-sha-256" cannot be mixed with negative methods', + ) + + # require_auth methods cannot have duplicated values. + node.connect_fails( + "user=scram_role require_auth=password,md5,password", + "require_auth methods cannot include duplicates, positive case", + expected_stderr=r'require_auth method "password" is specified more than once', + ) + node.connect_fails( + "user=scram_role require_auth=!password,!md5,!password", + "require_auth methods cannot be duplicated, negative case", + expected_stderr=r'require_auth method "!password" is specified more than once', + ) + node.connect_fails( + "user=scram_role require_auth=none,md5,none", + "require_auth methods cannot be duplicated, none case", + expected_stderr=r'require_auth method "none" is specified more than once', + ) + node.connect_fails( + "user=scram_role require_auth=!none,!md5,!none", + "require_auth methods cannot be duplicated, !none case", + expected_stderr=r'require_auth method "!none" is specified more than once', + ) + node.connect_fails( + "user=scram_role require_auth=scram-sha-256,scram-sha-256", + "require_auth methods cannot be duplicated, scram-sha-256 case", + expected_stderr=r'require_auth method "scram-sha-256" is specified more than once', + ) + node.connect_fails( + "user=scram_role require_auth=!scram-sha-256,!scram-sha-256", + "require_auth methods cannot be duplicated, !scram-sha-256 case", + expected_stderr=r'require_auth method "!scram-sha-256" is specified more than once', + ) + + # Unknown value defined in require_auth. + node.connect_fails( + "user=scram_role require_auth=none,abcdefg", + "unknown require_auth methods are rejected", + expected_stderr=r'invalid require_auth value: "abcdefg"', + ) + + # For plain "password" method, all users should also be able to connect. + reset_pg_hba(node, "all", "all", "password") + _test_conn( + node, + "user=scram_role", + "password", + 0, + log_like=[r'connection authenticated: identity="scram_role" method=password'], + ) + if md5_works: + _test_conn( + node, + "user=md5_role", + "password", + 0, + log_like=[r'connection authenticated: identity="md5_role" method=password'], + ) + + # require_auth succeeds here with a plaintext password. + node.connect_ok( + "user=scram_role require_auth=password", + "password authentication required, works with password auth", + ) + node.connect_ok( + "user=scram_role require_auth=!none", + "any authentication required, works with password auth", + ) + node.connect_ok( + "user=scram_role require_auth=scram-sha-256,password,md5", + "multiple authentication types required, works with password auth", + ) + + # require_auth fails for other authentication types. + node.connect_fails( + "user=scram_role require_auth=md5", + "md5 authentication required, fails with password auth", + expected_stderr=r'authentication method requirement "md5" failed: server requested a cleartext password', + ) + node.connect_fails( + "user=scram_role require_auth=scram-sha-256", + "SCRAM authentication required, fails with password auth", + expected_stderr=r'authentication method requirement "scram-sha-256" failed: server requested a cleartext password', + ) + node.connect_fails( + "user=scram_role require_auth=none", + "all authentication forbidden, fails with password auth", + expected_stderr=r'authentication method requirement "none" failed: server requested a cleartext password', + ) + + # Disallowing password authentication fails, even if requested by server. + node.connect_fails( + "user=scram_role require_auth=!password", + "password authentication forbidden, fails with password auth", + expected_stderr=r"server requested a cleartext password", + ) + node.connect_fails( + "user=scram_role require_auth=!password,!md5,!scram-sha-256", + "multiple authentication types forbidden, fails with password auth", + expected_stderr=r' method requirement "!password,!md5,!scram-sha-256" failed: server requested a cleartext password', + ) + + # For "scram-sha-256" method, user "scram_role" should be able to connect. + reset_pg_hba(node, "all", "all", "scram-sha-256") + _test_conn( + node, + "user=scram_role", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="scram_role" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=scram_role_iter", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="scram_role_iter" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=md5_role", + "scram-sha-256", + 2, + log_unlike=[r"connection authenticated:"], + ) + + # require_auth should succeed with SCRAM when it is required. + node.connect_ok( + "user=scram_role require_auth=scram-sha-256", + "SCRAM authentication required, works with SCRAM auth", + ) + node.connect_ok( + "user=scram_role require_auth=!none", + "any authentication required, works with SCRAM auth", + ) + node.connect_ok( + "user=scram_role require_auth=password,scram-sha-256,md5", + "multiple authentication types required, works with SCRAM auth", + ) + + # Authentication fails for other authentication types. + node.connect_fails( + "user=scram_role require_auth=password", + "password authentication required, fails with SCRAM auth", + expected_stderr=r'authentication method requirement "password" failed: server requested SASL authentication', + ) + node.connect_fails( + "user=scram_role require_auth=md5", + "md5 authentication required, fails with SCRAM auth", + expected_stderr=r'authentication method requirement "md5" failed: server requested SASL authentication', + ) + node.connect_fails( + "user=scram_role require_auth=none", + "all authentication forbidden, fails with SCRAM auth", + expected_stderr=r'authentication method requirement "none" failed: server requested SASL authentication', + ) + + # Authentication fails if SCRAM authentication is forbidden. + node.connect_fails( + "user=scram_role require_auth=!scram-sha-256", + "SCRAM authentication forbidden, fails with SCRAM auth", + expected_stderr=r"server requested SCRAM-SHA-256 authentication", + ) + node.connect_fails( + "user=scram_role require_auth=!password,!md5,!scram-sha-256", + "multiple authentication types forbidden, fails with SCRAM auth", + expected_stderr=r"server requested SCRAM-SHA-256 authentication", + ) + + # Test that bad passwords are rejected. + os.environ["PGPASSWORD"] = "badpass" + _test_conn( + node, + "user=scram_role", + "scram-sha-256", + 2, + log_unlike=[r"connection authenticated:"], + ) + os.environ["PGPASSWORD"] = "pass" + + # For "md5" method, all users should be able to connect (SCRAM + # authentication will be performed for the user with a SCRAM secret.) + reset_pg_hba(node, "all", "all", "md5") + _test_conn( + node, + "user=scram_role", + "md5", + 0, + log_like=[r'connection authenticated: identity="scram_role" method=md5'], + ) + if md5_works: + _test_conn( + node, + "user=md5_role", + "md5", + 0, + expected_stderr=r"authenticated with an MD5-encrypted password", + log_like=[r'connection authenticated: identity="md5_role" method=md5'], + ) + + # require_auth succeeds with SCRAM required. + node.connect_ok( + "user=scram_role require_auth=scram-sha-256", + "SCRAM authentication required, works with SCRAM auth", + ) + node.connect_ok( + "user=scram_role require_auth=!none", + "any authentication required, works with SCRAM auth", + ) + node.connect_ok( + "user=scram_role require_auth=md5,scram-sha-256,password", + "multiple authentication types required, works with SCRAM auth", + ) + + # Authentication fails if other types are required. + node.connect_fails( + "user=scram_role require_auth=password", + "password authentication required, fails with SCRAM auth", + expected_stderr=r'authentication method requirement "password" failed: server requested SASL authentication', + ) + node.connect_fails( + "user=scram_role require_auth=md5", + "MD5 authentication required, fails with SCRAM auth", + expected_stderr=r'authentication method requirement "md5" failed: server requested SASL authentication', + ) + node.connect_fails( + "user=scram_role require_auth=none", + "all authentication types forbidden, fails with SCRAM auth", + expected_stderr=r'authentication method requirement "none" failed: server requested SASL authentication', + ) + + # Authentication fails if SCRAM is forbidden. + node.connect_fails( + "user=scram_role require_auth=!scram-sha-256", + "password authentication forbidden, fails with SCRAM auth", + expected_stderr=r'authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication', + ) + node.connect_fails( + "user=scram_role require_auth=!password,!md5,!scram-sha-256", + "multiple authentication types forbidden, fails with SCRAM auth", + expected_stderr=r'authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication', + ) + + # Test password_expiration_warning_threshold + node.connect_fails( + "user=expired dbname=postgres", + "connection fails due to expired password", + expected_stderr=r'password authentication failed for user "expired"', + ) + node.connect_ok( + "user=expiration_warnings dbname=postgres", + "connection succeeds with password expiration warning", + expected_stderr=r"role password will expire soon", + ) + node.connect_ok( + "user=no_warnings dbname=postgres", + "connection succeeds with no password expiration warning", + ) + + # Test SYSTEM_USER <> NULL with parallel workers. Pass the password + # explicitly rather than via PGPASSWORD: this is an in-process libpq + # connection, and the in-process library does not portably read the + # environment (which is why connect_ok/connect_fails shell out to psql). + sess = node.connect(user="scram_role", password="pass") + try: + sess.do( + "TRUNCATE sysuser_data;", + "INSERT INTO sysuser_data SELECT 'md5:scram_role'" + " FROM generate_series(1, 10);", + ) + res = sess.query_oneval( + "SET min_parallel_table_scan_size TO 0;" + "SET parallel_setup_cost TO 0;" + "SET parallel_tuple_cost TO 0;" + "SET max_parallel_workers_per_gather TO 2;" + "SELECT bool_and(SYSTEM_USER IS NOT DISTINCT FROM n) FROM sysuser_data;" + ) + finally: + sess.close() + assert ( + res == "t" + ), "users with md5 authentication use SYSTEM_USER = md5:role in parallel workers" + + # Tests for channel binding without SSL. + # Using the password authentication method; channel binding can't work + reset_pg_hba(node, "all", "all", "password") + os.environ["PGCHANNELBINDING"] = "require" + _test_conn(node, "user=scram_role", "scram-sha-256", 2) + # SSL not in use; channel binding still can't work + reset_pg_hba(node, "all", "all", "scram-sha-256") + os.environ["PGCHANNELBINDING"] = "require" + _test_conn(node, "user=scram_role", "scram-sha-256", 2) + + # Test .pgpass processing; but use a temp file, don't overwrite the real one! + pgpassfile = os.path.join(node.basedir, "pgpass") + + os.environ.pop("PGPASSWORD", None) + os.environ.pop("PGCHANNELBINDING", None) + os.environ["PGPASSFILE"] = pgpassfile + + if os.path.exists(pgpassfile): + os.unlink(pgpassfile) + with open(pgpassfile, "a", encoding="utf-8") as fh: + fh.write( + "\n" + "# This very long comment is just here to exercise handling of long lines in the file. " + "This very long comment is just here to exercise handling of long lines in the file. " + "This very long comment is just here to exercise handling of long lines in the file. " + "This very long comment is just here to exercise handling of long lines in the file. " + "This very long comment is just here to exercise handling of long lines in the file.\n" + "*:*:postgres:scram_role:pass:this is not part of the password.\n" + ) + os.chmod(pgpassfile, 0o600) + + reset_pg_hba(node, "all", "all", "password") + _test_conn(node, "user=scram_role", "password from pgpass", 0) + _test_conn(node, "user=md5_role", "password from pgpass", 2) + + with open(pgpassfile, "a", encoding="utf-8") as fh: + fh.write("\n*:*:*:scram_role:p\\ass\n*:*:*:scram,role:p\\ass\n") + + _test_conn(node, "user=scram_role", "password from pgpass", 0) + + # Testing with regular expression for username. The third regexp matches. + reset_pg_hba(node, "all", "/^.*nomatch.*$, baduser, /^scr.*$", "password") + _test_conn( + node, + "user=scram_role", + "password, matching regexp for username", + 0, + log_like=[r'connection authenticated: identity="scram_role" method=password'], + ) + + # The third regex does not match anymore. + reset_pg_hba(node, "all", "/^.*nomatch.*$, baduser, /^sc_r.*$", "password") + _test_conn( + node, + "user=scram_role", + "password, non matching regexp for username", + 2, + log_unlike=[r"connection authenticated:"], + ) + + # Test with a comma in the regular expression. In this case, the use of + # double quotes is mandatory so as this is not considered as two elements + # of the user name list when parsing pg_hba.conf. + reset_pg_hba(node, "all", '"/^.*m,.*e$"', "password") + _test_conn( + node, + "user=scram,role", + "password, matching regexp for username", + 0, + log_like=[r'connection authenticated: identity="scram,role" method=password'], + ) + + # Testing with regular expression for dbname. The third regex matches. + reset_pg_hba(node, "/^.*nomatch.*$, baddb, /^regex_t.*b$", "all", "password") + _test_conn( + node, + "user=scram_role dbname=regex_testdb", + "password, matching regexp for dbname", + 0, + log_like=[r'connection authenticated: identity="scram_role" method=password'], + ) + + # The third regexp does not match anymore. + reset_pg_hba(node, "/^.*nomatch.*$, baddb, /^regex_t.*ba$", "all", "password") + _test_conn( + node, + "user=scram_role dbname=regex_testdb", + "password, non matching regexp for dbname", + 2, + log_unlike=[r"connection authenticated:"], + ) + + os.unlink(pgpassfile) + os.environ.pop("PGPASSFILE", None) + + print("# Authentication tests with specific HBA policies on roles") + + # Create database and roles for membership tests + reset_pg_hba(node, "all", "all", "trust") + # Database and root role names match for "samerole" and "samegroup". + node.safe_sql("CREATE DATABASE regress_regression_group;") + node.safe_sql( + "CREATE ROLE regress_regression_group LOGIN PASSWORD 'pass';" + "CREATE ROLE regress_member LOGIN SUPERUSER IN ROLE regress_regression_group PASSWORD 'pass';" + "CREATE ROLE regress_not_member LOGIN SUPERUSER PASSWORD 'pass';" + ) + + # Test role with exact matching, no members allowed. + os.environ["PGPASSWORD"] = "pass" + reset_pg_hba(node, "all", "regress_regression_group", "scram-sha-256") + _test_conn( + node, + "user=regress_regression_group", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="regress_regression_group" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=regress_member", + "scram-sha-256", + 2, + log_unlike=[ + r'connection authenticated: identity="regress_member" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=regress_not_member", + "scram-sha-256", + 2, + log_unlike=[ + r'connection authenticated: identity="regress_not_member" method=scram-sha-256' + ], + ) + + # Test role membership with '+', where all the members are allowed + # to connect. + reset_pg_hba(node, "all", "+regress_regression_group", "scram-sha-256") + _test_conn( + node, + "user=regress_regression_group", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="regress_regression_group" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=regress_member", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="regress_member" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=regress_not_member", + "scram-sha-256", + 2, + log_unlike=[ + r'connection authenticated: identity="regress_not_member" method=scram-sha-256' + ], + ) + + # Test role membership is respected for samerole. + # connect_ok forces dbname=postgres unless the connstr overrides it, so + # pass the database explicitly via PGDATABASE. + os.environ["PGDATABASE"] = "regress_regression_group" + reset_pg_hba(node, "samerole", "all", "scram-sha-256") + _test_conn( + node, + "user=regress_regression_group dbname=regress_regression_group", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="regress_regression_group" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=regress_member dbname=regress_regression_group", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="regress_member" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=regress_not_member dbname=regress_regression_group", + "scram-sha-256", + 2, + log_unlike=[ + r'connection authenticated: identity="regress_not_member" method=scram-sha-256' + ], + ) + + # Test role membership is respected for samegroup + reset_pg_hba(node, "samegroup", "all", "scram-sha-256") + _test_conn( + node, + "user=regress_regression_group dbname=regress_regression_group", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="regress_regression_group" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=regress_member dbname=regress_regression_group", + "scram-sha-256", + 0, + log_like=[ + r'connection authenticated: identity="regress_member" method=scram-sha-256' + ], + ) + _test_conn( + node, + "user=regress_not_member dbname=regress_regression_group", + "scram-sha-256", + 2, + log_unlike=[ + r'connection authenticated: identity="regress_not_member" method=scram-sha-256' + ], + ) diff --git a/src/test/authentication/pyt/test_002_saslprep.py b/src/test/authentication/pyt/test_002_saslprep.py new file mode 100644 index 00000000000..f2173ce1e3f --- /dev/null +++ b/src/test/authentication/pyt/test_002_saslprep.py @@ -0,0 +1,142 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test password normalization in SCRAM. + +These tests can only run with Unix-domain sockets, so the module is skipped +when the framework is running over TCP (Windows). + +The passwords below contain non-ASCII characters, taken from the example +strings of RFC4013.txt, Section "3. Examples". They are byte-exact UTF-8 +strings. The cluster is initialised with ``--locale=C --encoding=UTF8`` (the +framework default). +""" + +import os + +import pytest + +from pypg.util import USE_UNIX_SOCKETS + +pytestmark = pytest.mark.skipif( + not USE_UNIX_SOCKETS, reason="test requires Unix-domain sockets" +) + + +def _write_pgpass(path, password): + """Write a password file (matching any connection) holding the exact + *password* bytes. + + The password is delivered through a password file rather than PGPASSWORD + because libpq reads the file content verbatim, with no character-set + conversion, so the exact bytes reach the SCRAM exchange on every platform. + An environment variable would instead be re-encoded through the process + code page on Windows, corrupting non-ASCII bytes. + """ + escaped = password.replace(b"\\", b"\\\\").replace(b":", b"\\:") + with open(path, "wb") as fh: + fh.write(b"*:*:*:*:" + escaped + b"\n") + os.chmod(path, 0o600) + + +# Delete pg_hba.conf from the given node, add a new entry to it +# and then execute a reload to refresh it. +def reset_pg_hba(node, hba_method): + os.unlink(os.path.join(node.data_dir, "pg_hba.conf")) + node.append_conf(f"local all all {hba_method}", filename="pg_hba.conf") + node.reload() + + +# Test access for a single role, useful to wrap all tests into one. +# (Named with a leading underscore so pytest does not collect it as a test.) +# *password* is a bytes object, written to *pgpass* so libpq sees the exact +# bytes. *expected_res* is 0 for a successful login, non-zero otherwise. +def _test_login(node, pgpass, role, password, expected_res): + status_string = "success" if expected_res == 0 else "failed" + + connstr = f"user={role}" + testname = ( + f"authentication {status_string} for role {role} " f"with password {password!r}" + ) + + _write_pgpass(pgpass, password) + if expected_res == 0: + node.connect_ok(connstr, testname) + else: + # No checks of the error message, only the status code. + node.connect_fails(connstr, testname) + + +def test_002_saslprep(create_pg, tmp_path): + # Initialize primary node. Force UTF-8 encoding, so that we can use + # non-ASCII characters in the passwords below (the framework's init + # already uses --locale=C --encoding=UTF8). + node = create_pg("primary") + + pgpass = os.path.join(str(tmp_path), "saslprep_pgpass.conf") + + # Point libpq at our password file and make sure no PGPASSWORD overrides + # it; restore both so the rest of the suite is unaffected. + saved_file = os.environ.get("PGPASSFILE") + saved_pw = os.environ.get("PGPASSWORD") + os.environ["PGPASSFILE"] = pgpass + os.environ.pop("PGPASSWORD", None) + try: + _run_body(node, pgpass) + finally: + if saved_file is None: + os.environ.pop("PGPASSFILE", None) + else: + os.environ["PGPASSFILE"] = saved_file + if saved_pw is not None: + os.environ["PGPASSWORD"] = saved_pw + + +def _run_body(node, pgpass): + # These tests are based on the example strings from RFC4013.txt, + # Section "3. Examples": + # + # # Input Output Comments + # - ----- ------ -------- + # 1 IX IX SOFT HYPHEN mapped to nothing + # 2 user user no transformation + # 3 USER USER case preserved, will not match #2 + # 4 a output is NFKC, input in ISO 8859-1 + # 5 IX output is NFKC, will match #1 + # 6 Error - prohibited character + # 7 Error - bidirectional check + + # Create test roles. + node.safe_sql( + "SET password_encryption='scram-sha-256';\n" + "SET client_encoding='utf8';\n" + "CREATE ROLE saslpreptest1_role LOGIN PASSWORD 'IX';\n" + "CREATE ROLE saslpreptest4a_role LOGIN PASSWORD 'a';\n" + "CREATE ROLE saslpreptest4b_role LOGIN PASSWORD E'\\xc2\\xaa';\n" + "CREATE ROLE saslpreptest6_role LOGIN PASSWORD E'foo\\x07bar';\n" + "CREATE ROLE saslpreptest7_role LOGIN PASSWORD E'foo\\u0627\\u0031bar';\n" + ) + + # Require password from now on. + reset_pg_hba(node, "scram-sha-256") + + # Check that #1 and #5 are treated the same as just 'IX' + _test_login(node, pgpass, "saslpreptest1_role", b"I\xc2\xadX", 0) + _test_login(node, pgpass, "saslpreptest1_role", b"\xe2\x85\xa8", 0) + + # but different from lower case 'ix' + _test_login(node, pgpass, "saslpreptest1_role", b"ix", 2) + + # Check #4 + _test_login(node, pgpass, "saslpreptest4a_role", b"a", 0) + _test_login(node, pgpass, "saslpreptest4a_role", b"\xc2\xaa", 0) + _test_login(node, pgpass, "saslpreptest4b_role", b"a", 0) + _test_login(node, pgpass, "saslpreptest4b_role", b"\xc2\xaa", 0) + + # Check #6 and #7 - In PostgreSQL, contrary to the spec, if the password + # contains prohibited characters, we use it as is, without normalization. + _test_login(node, pgpass, "saslpreptest6_role", b"foo\x07bar", 0) + _test_login(node, pgpass, "saslpreptest6_role", b"foobar", 2) + + _test_login(node, pgpass, "saslpreptest7_role", b"foo\xd8\xa71bar", 0) + _test_login(node, pgpass, "saslpreptest7_role", b"foo1\xd8\xa7bar", 2) + _test_login(node, pgpass, "saslpreptest7_role", b"foobar", 2) diff --git a/src/test/authentication/pyt/test_003_peer.py b/src/test/authentication/pyt/test_003_peer.py new file mode 100644 index 00000000000..7713b1776bd --- /dev/null +++ b/src/test/authentication/pyt/test_003_peer.py @@ -0,0 +1,359 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for peer authentication and user name map. + +Peer authentication only works over Unix-domain sockets, so the module is +skipped when the framework is running over TCP (Windows); it is also skipped +if the platform does not support peer authentication (checked at run time). +""" + +import getpass +import os +import re + +import pytest + +from libpq.errors import PqConnectionError +from pypg.util import USE_UNIX_SOCKETS + +pytestmark = pytest.mark.skipif( + not USE_UNIX_SOCKETS, reason="test requires Unix-domain sockets" +) + + +# Delete pg_hba.conf from the given node, add a new entry to it +# and then execute a reload to refresh it. +def reset_pg_hba(node, hba_method): + os.unlink(os.path.join(node.data_dir, "pg_hba.conf")) + node.append_conf(f"local all all {hba_method}", filename="pg_hba.conf") + node.reload() + + +# Delete pg_ident.conf from the given node, add a new entry to it +# and then execute a reload to refresh it. +def reset_pg_ident(node, map_name, system_user, pg_user): + os.unlink(os.path.join(node.data_dir, "pg_ident.conf")) + node.append_conf(f"{map_name} {system_user} {pg_user}", filename="pg_ident.conf") + node.reload() + + +# Test access for a single role, useful to wrap all tests into one. +# (Named with a leading underscore so pytest does not collect it as a test.) +def _test_role(node, role, method, expected_res, test_details, **params): + status_string = "success" if expected_res == 0 else "failed" + + connstr = f"user={role}" + testname = ( + f"authentication {status_string} for method {method}, role {role} " + f"{test_details}" + ) + + if expected_res == 0: + node.connect_ok(connstr, testname, **params) + else: + # No checks of the error message, only the status code. + node.connect_fails(connstr, testname, **params) + + +def test_003_peer(create_pg): + node = create_pg("node", start=False) + node.append_conf("log_connections = authentication\n") + # Needed to allow connect_fails to inspect postmaster log: + node.append_conf("log_min_messages = debug2") + node.start() + + # Set pg_hba.conf with the peer authentication. + reset_pg_hba(node, "peer") + + # Check if peer authentication is supported on this platform. + log_offset = node.log_position() + # Attempt a connection (as the current OS user) to make the server emit + # the "not supported" message if peer auth is unavailable. Where peer auth + # is unsupported (e.g. Windows) the attempt itself fails, so tolerate the + # connection error and decide from the server log. + try: + node.sql("SELECT 1") + except PqConnectionError: + pass + if node.log_contains( + r"peer authentication is not supported on this platform", log_offset + ): + pytest.skip("peer authentication is not supported on this platform") + + # Add a database role and a group, to use for the user name map. + node.safe_sql("CREATE ROLE testmapuser LOGIN") + node.safe_sql("CREATE ROLE testmapgroup NOLOGIN") + node.safe_sql("GRANT testmapgroup TO testmapuser") + # Note the double quotes here. + node.safe_sql(r'CREATE ROLE "testmapgroupliteral\1" LOGIN') + node.safe_sql(r'GRANT "testmapgroupliteral\1" TO testmapuser') + + # Extract as well the system user for the user name map. + system_user = node.safe_sql("select (string_to_array(SYSTEM_USER, ':'))[2]") + assert system_user == getpass.getuser() + + # While on it, check the status of huge pages, that can be either on + # or off, but never unknown. + huge_pages_status = node.safe_sql("SHOW huge_pages_status;") + assert huge_pages_status != "unknown", "check huge_pages_status" + + # system_user is embedded into regex patterns below; escape it so any + # metacharacters in the OS user name are treated literally (the + # username is trusted to be regex-safe on the pg_ident.conf side, but + # escaped on the pattern side). + su_re = re.escape(system_user) + + # Tests without the user name map. + # Failure as connection is attempted with a database role not mapping + # to an authorized system user. + _test_role( + node, + "testmapuser", + "peer", + 2, + "without user name map", + log_like=[r'Peer authentication failed for user "testmapuser"'], + ) + + # Tests with a user name map. + reset_pg_ident(node, "mypeermap", system_user, "testmapuser") + reset_pg_hba(node, "peer map=mypeermap") + + # Success as the database role matches with the system user in the map. + _test_role( + node, + "testmapuser", + "peer", + 0, + "with user name map", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Tests with the "all" keyword. + reset_pg_ident(node, "mypeermap", system_user, "all") + _test_role( + node, + "testmapuser", + "peer", + 0, + 'with keyword "all" as database user in user name map', + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Tests with the "all" keyword, but quoted (no effect here). + reset_pg_ident(node, "mypeermap", system_user, '"all"') + _test_role( + node, + "testmapuser", + "peer", + 2, + 'with quoted keyword "all" as database user in user name map', + log_like=[r'no match in usermap "mypeermap" for user "testmapuser"'], + ) + + # Success as the regexp of the database user matches + reset_pg_ident(node, "mypeermap", system_user, r"/^testm.*$") + _test_role( + node, + "testmapuser", + "peer", + 0, + "with regexp of database user in user name map", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Failure as the regexp of the database user does not match. + reset_pg_ident(node, "mypeermap", system_user, r"/^doesnotmatch.*$") + _test_role( + node, + "testmapuser", + "peer", + 2, + "with bad regexp of database user in user name map", + log_like=[r'no match in usermap "mypeermap" for user "testmapuser"'], + ) + + # Test with regular expression in user name map. + # Extract the last 3 characters from the system_user + # or the entire system_user name (if its length is <= 3). + # We trust this will not include any regex metacharacters. + regex_test_string = system_user[-3:] + + # Success as the system user regular expression matches. + reset_pg_ident(node, "mypeermap", rf"/^.*{regex_test_string}$", "testmapuser") + _test_role( + node, + "testmapuser", + "peer", + 0, + "with regexp of system user in user name map", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Success as both regular expressions match. + reset_pg_ident(node, "mypeermap", rf"/^.*{regex_test_string}$", r"/^testm.*$") + _test_role( + node, + "testmapuser", + "peer", + 0, + "with regexps for both system and database user in user name map", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Success as the regular expression matches and database role is the "all" + # keyword. + reset_pg_ident(node, "mypeermap", rf"/^.*{regex_test_string}$", "all") + _test_role( + node, + "testmapuser", + "peer", + 0, + 'with regexp of system user and keyword "all" in user name map', + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Create target role for \1 tests. + mapped_name = f"test{regex_test_string}map{regex_test_string}user" + node.safe_sql(f"CREATE ROLE {mapped_name} LOGIN") + + # Success as the regular expression matches and \1 is replaced in the given + # subexpression. + reset_pg_ident( + node, "mypeermap", rf"/^.*({regex_test_string})$", r"test\1map\1user" + ) + _test_role( + node, + mapped_name, + "peer", + 0, + r"with regular expression in user name map with \1 replaced", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Success as the regular expression matches and \1 is replaced in the given + # subexpression, even if quoted. + reset_pg_ident( + node, "mypeermap", rf"/^.*({regex_test_string})$", r'"test\1map\1user"' + ) + _test_role( + node, + mapped_name, + "peer", + 0, + r"with regular expression in user name map with quoted \1 replaced", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Failure as the regular expression does not include a subexpression, but + # the database user contains \1, requesting a replacement. + reset_pg_ident(node, "mypeermap", rf"/^{system_user}$", r"\1testmapuser") + _test_role( + node, + "testmapuser", + "peer", + 2, + r"with regular expression in user name map with \1 not replaced", + log_like=[ + rf'regular expression "\^{su_re}\$" has no subexpressions as ' + r'requested by backreference in "\\1testmapuser"' + ], + ) + + # Concatenate system_user to system_user. + bad_regex_test_string = system_user + system_user + + # Failure as the regexp of system user does not match. + reset_pg_ident(node, "mypeermap", rf"/^.*{bad_regex_test_string}$", "testmapuser") + _test_role( + node, + "testmapuser", + "peer", + 2, + "with regexp of system user in user name map", + log_like=[r'no match in usermap "mypeermap" for user "testmapuser"'], + ) + + # Test using a group role match for the database user. + reset_pg_ident(node, "mypeermap", system_user, "+testmapgroup") + _test_role( + node, + "testmapuser", + "peer", + 0, + "plain user with group", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + _test_role( + node, + "testmapgroup", + "peer", + 2, + "group user with group", + log_like=[r'role "testmapgroup" is not permitted to log in'], + ) + + # Now apply quotes to the group match, nullifying its effect. + reset_pg_ident(node, "mypeermap", system_user, '"+testmapgroup"') + _test_role( + node, + "testmapuser", + "peer", + 2, + "plain user with quoted group name", + log_like=[r'no match in usermap "mypeermap" for user "testmapuser"'], + ) + + # Test using a regexp for the system user, with a group membership + # check for the database user. + reset_pg_ident(node, "mypeermap", rf"/^.*{regex_test_string}$", "+testmapgroup") + _test_role( + node, + "testmapuser", + "peer", + 0, + "regexp of system user as group member", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + _test_role( + node, + "testmapgroup", + "peer", + 2, + "regexp of system user as non-member of group", + log_like=[r'role "testmapgroup" is not permitted to log in'], + ) + + # Test that membership checks and regexes will use literal \1 instead of + # replacing it, as subexpression replacement is not allowed in this case. + reset_pg_ident( + node, + "mypeermap", + rf"/^.*{regex_test_string}(.*)$", + r"+testmapgroupliteral\1", + ) + _test_role( + node, + "testmapuser", + "peer", + 0, + r"membership check with literal \1", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) + + # Do the same with a quoted regular expression for the database user this + # time. No replacement of \1 is done. + reset_pg_ident( + node, + "mypeermap", + rf"/^.*{regex_test_string}(.*)$", + r'"/^testmapgroupliteral\\1$"', + ) + _test_role( + node, + r"testmapgroupliteral\\1", + "peer", + 0, + r"regexp of database user with literal \1", + log_like=[rf'connection authenticated: identity="{su_re}" method=peer'], + ) diff --git a/src/test/authentication/pyt/test_004_file_inclusion.py b/src/test/authentication/pyt/test_004_file_inclusion.py new file mode 100644 index 00000000000..2af8c14b1ff --- /dev/null +++ b/src/test/authentication/pyt/test_004_file_inclusion.py @@ -0,0 +1,279 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for include directives in HBA and ident files. + +This test can only run with Unix-domain sockets, so the module is skipped when +the framework is running over TCP (Windows). + +It is largely a data-driven test: include files and trees are written into the +data directory, then the pg_hba_file_rules() and pg_ident_file_mappings() +system views are inspected. add_hba_line()/add_ident_line() build the expected +view output as each entry is written. +""" + +import os + +import pytest + +from pypg.util import USE_UNIX_SOCKETS + +pytestmark = pytest.mark.skipif( + not USE_UNIX_SOCKETS, reason="test requires Unix-domain sockets" +) + + +# Stores the number of lines created for each file. "hba_rule" and +# "ident_rule" track pg_hba_file_rules.rule_number and +# pg_ident_file_mappings.map_number, the global counters tracking the priority +# of each entry processed. +line_counters = {"hba_rule": 0, "ident_rule": 0} + + +def _basename(path): + return os.path.basename(path) + + +# Add some data to the given HBA configuration file, generating the contents +# expected to match pg_hba_file_rules. +# +# Maintains line_counters, used to generate the catalog output for file lines +# and rule numbers. +# +# If the entry starts with "include", the function does not increase the +# general hba rule number as an include directive generates no data in +# pg_hba_file_rules. +# +# Returns the entry of pg_hba_file_rules expected when this is loaded by the +# backend. +def add_hba_line(node, filename, entry): + # Append the entry to the given file + node.append_conf(entry, filename=filename) + + base_filename = _basename(filename) + + # Get the current line_counters for the file. + line_counters[filename] = line_counters.get(filename, 0) + 1 + fileline = line_counters[filename] + + # Include directive, that does not generate a view entry. + if entry.startswith("include"): + return "" + + # Increment pg_hba_file_rules.rule_number and save it. + line_counters["hba_rule"] += 1 + globline = line_counters["hba_rule"] + + # Generate the expected pg_hba_file_rules line + tokens = entry.split(" ") + tokens[1] = "{" + tokens[1] + "}" # database + tokens[2] = "{" + tokens[2] + "}" # user_name + + # Append empty options and error + tokens.append("") + tokens.append("") + + # Final line expected, output of the SQL query. + line = "" + if globline > 1: + line += "\n" + line += f"{globline}|{base_filename}|{fileline}|" + line += "|".join(tokens) + + return line + + +# Add some data to the given ident configuration file, generating the contents +# expected to match pg_ident_file_mappings. +# +# Works pretty much the same as add_hba_line() above, except that it returns an +# entry to match pg_ident_file_mappings. +def add_ident_line(node, filename, entry): + base_filename = _basename(filename) + + # Append the entry to the given file + node.append_conf(entry, filename=filename) + + # Get the current line_counters counter for the file + line_counters[filename] = line_counters.get(filename, 0) + 1 + fileline = line_counters[filename] + + # Include directive, that does not generate a view entry. + if entry.startswith("include"): + return "" + + # Increment pg_ident_file_mappings.map_number and get it. + line_counters["ident_rule"] += 1 + globline = line_counters["ident_rule"] + + # Generate the expected pg_ident_file_mappings line + tokens = entry.split(" ") + # Append empty error + tokens.append("") + + # Final line expected, output of the SQL query. + line = "" + if globline > 1: + line += "\n" + line += f"{globline}|{base_filename}|{fileline}|" + line += "|".join(tokens) + + return line + + +def test_004_file_inclusion(create_pg): + # Locations for the entry points of the HBA and ident files. + hba_file = "subdir1/pg_hba_custom.conf" + ident_file = "subdir2/pg_ident_custom.conf" + + node = create_pg("primary") + + data_dir = node.data_dir + + # Generating HBA structure with include directives + + hba_expected = "" + ident_expected = "" + + # customise main auth file names + node.safe_sql(f"ALTER SYSTEM SET hba_file = '{data_dir}/{hba_file}'") + node.safe_sql(f"ALTER SYSTEM SET ident_file = '{data_dir}/{ident_file}'") + + # Remove the original ones, this node links to non-default ones now. + os.unlink(os.path.join(data_dir, "pg_hba.conf")) + os.unlink(os.path.join(data_dir, "pg_ident.conf")) + + # Generate HBA contents with include directives. + os.mkdir(os.path.join(data_dir, "subdir1")) + os.mkdir(os.path.join(data_dir, "hba_inc")) + os.mkdir(os.path.join(data_dir, "hba_inc_if")) + os.mkdir(os.path.join(data_dir, "hba_pos")) + + # First, make sure that we will always be able to connect. + hba_expected += add_hba_line(node, hba_file, "local all all trust") + + # "include". Note that as hba_file is located in data_dir/subdir1, + # pg_hba_pre.conf is located at the root of the data directory. + hba_expected += add_hba_line(node, hba_file, "include ../pg_hba_pre.conf") + hba_expected += add_hba_line(node, "pg_hba_pre.conf", "local pre all reject") + hba_expected += add_hba_line(node, hba_file, "local all all reject") + add_hba_line(node, hba_file, "include ../hba_pos/pg_hba_pos.conf") + hba_expected += add_hba_line( + node, "hba_pos/pg_hba_pos.conf", "local pos all reject" + ) + # When an include directive refers to a relative path, it is compiled from + # the base location of the file loaded from. + hba_expected += add_hba_line( + node, "hba_pos/pg_hba_pos.conf", "include pg_hba_pos2.conf" + ) + hba_expected += add_hba_line( + node, "hba_pos/pg_hba_pos2.conf", "local pos2 all reject" + ) + hba_expected += add_hba_line( + node, "hba_pos/pg_hba_pos2.conf", "local pos3 all reject" + ) + + # include_if_exists data, nothing generated for the catalog. + # Missing file, no catalog entries. + hba_expected += add_hba_line(node, hba_file, "include_if_exists ../hba_inc_if/none") + # File with some contents loaded. + hba_expected += add_hba_line(node, hba_file, "include_if_exists ../hba_inc_if/some") + hba_expected += add_hba_line(node, "hba_inc_if/some", "local if_some all reject") + + # include_dir + hba_expected += add_hba_line(node, hba_file, "include_dir ../hba_inc") + hba_expected += add_hba_line(node, "hba_inc/01_z.conf", "local dir_z all reject") + hba_expected += add_hba_line(node, "hba_inc/02_a.conf", "local dir_a all reject") + # Garbage file not suffixed by .conf, so it will be ignored. + node.append_conf("should not be included", filename="hba_inc/garbageconf") + + # Authentication file expanded in an existing entry for database names. + # As it is expanded, ignore the output generated. + add_hba_line(node, hba_file, "local @../dbnames.conf all reject") + node.append_conf("db1", filename="dbnames.conf") + node.append_conf("db3", filename="dbnames.conf") + hba_expected += ( + "\n" + + str(line_counters["hba_rule"]) + + "|" + + _basename(hba_file) + + "|" + + str(line_counters[hba_file]) + + "|local|{db1,db3}|{all}|reject||" + ) + + # Generating ident structure with include directives + + os.mkdir(os.path.join(data_dir, "subdir2")) + os.mkdir(os.path.join(data_dir, "ident_inc")) + os.mkdir(os.path.join(data_dir, "ident_inc_if")) + os.mkdir(os.path.join(data_dir, "ident_pos")) + + # include. Note that pg_ident_pre.conf is located at the root of the data + # directory. + ident_expected += add_ident_line(node, ident_file, "include ../pg_ident_pre.conf") + ident_expected += add_ident_line(node, "pg_ident_pre.conf", "pre foo bar") + ident_expected += add_ident_line(node, ident_file, "test a b") + ident_expected += add_ident_line( + node, ident_file, "include ../ident_pos/pg_ident_pos.conf" + ) + ident_expected += add_ident_line(node, "ident_pos/pg_ident_pos.conf", "pos foo bar") + # When an include directive refers to a relative path, it is compiled from + # the base location of the file loaded from. + ident_expected += add_ident_line( + node, "ident_pos/pg_ident_pos.conf", "include pg_ident_pos2.conf" + ) + ident_expected += add_ident_line( + node, "ident_pos/pg_ident_pos2.conf", "pos2 foo bar" + ) + ident_expected += add_ident_line( + node, "ident_pos/pg_ident_pos2.conf", "pos3 foo bar" + ) + + # include_if_exists + # Missing file, no catalog entries. + ident_expected += add_ident_line( + node, ident_file, "include_if_exists ../ident_inc_if/none" + ) + # File with some contents loaded. + ident_expected += add_ident_line( + node, ident_file, "include_if_exists ../ident_inc_if/some" + ) + ident_expected += add_ident_line(node, "ident_inc_if/some", "if_some foo bar") + + # include_dir + ident_expected += add_ident_line(node, ident_file, "include_dir ../ident_inc") + ident_expected += add_ident_line(node, "ident_inc/01_z.conf", "dir_z foo bar") + ident_expected += add_ident_line(node, "ident_inc/02_a.conf", "dir_a foo bar") + # Garbage file not suffixed by .conf, so it will be ignored. + node.append_conf("should not be included", filename="ident_inc/garbageconf") + + node.restart() + + # Note that the base path is filtered out, keeping only the file name to + # bypass portability issues. The configuration files had better have + # unique names. + contents = node.safe_sql( + "SELECT rule_number,\n" + " regexp_replace(file_name, '.*/', ''),\n" + " line_number,\n" + " type,\n" + " database,\n" + " user_name,\n" + " auth_method,\n" + " options,\n" + " error\n" + " FROM pg_hba_file_rules ORDER BY rule_number;" + ) + assert contents == hba_expected, "check contents of pg_hba_file_rules" + + contents = node.safe_sql( + "SELECT map_number,\n" + " regexp_replace(file_name, '.*/', ''),\n" + " line_number,\n" + " map_name,\n" + " sys_name,\n" + " pg_username,\n" + " error\n" + " FROM pg_ident_file_mappings ORDER BY map_number" + ) + assert contents == ident_expected, "check contents of pg_ident_file_mappings" diff --git a/src/test/authentication/pyt/test_005_sspi.py b/src/test/authentication/pyt/test_005_sspi.py new file mode 100644 index 00000000000..051adac6c47 --- /dev/null +++ b/src/test/authentication/pyt/test_005_sspi.py @@ -0,0 +1,45 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests targeting SSPI on Windows. + +These tests require Windows with TCP, so the module is skipped whenever the +framework uses Unix-domain sockets -- i.e. on every non-Windows host, and on +Windows when PG_TEST_USE_UNIX_SOCKETS is set. That leaves it running only on +Windows over TCP. +""" + +import pytest + +from pypg.util import USE_UNIX_SOCKETS + +pytestmark = pytest.mark.skipif( + USE_UNIX_SOCKETS, + reason="SSPI tests require Windows (without PG_TEST_USE_UNIX_SOCKETS)", +) + + +def test_005_sspi(create_pg): + # Initialize primary node + node = create_pg("primary", start=False) + node.append_conf("log_connections = authentication\n") + node.start() + + huge_pages_status = node.safe_sql("SHOW huge_pages_status;") + assert huge_pages_status != "unknown", "check huge_pages_status" + + # SSPI is set up by default. Make sure it interacts correctly with + # require_auth. + node.connect_ok( + "require_auth=sspi", + "SSPI authentication required, works with SSPI auth", + ) + node.connect_fails( + "require_auth=!sspi", + "SSPI authentication forbidden, fails with SSPI auth", + expected_stderr=r'authentication method requirement "!sspi" failed: server requested SSPI authentication', + ) + node.connect_fails( + "require_auth=scram-sha-256", + "SCRAM authentication required, fails with SSPI auth", + expected_stderr=r'authentication method requirement "scram-sha-256" failed: server requested SSPI authentication', + ) diff --git a/src/test/authentication/pyt/test_006_login_trigger.py b/src/test/authentication/pyt/test_006_login_trigger.py new file mode 100644 index 00000000000..65d7ce6b85c --- /dev/null +++ b/src/test/authentication/pyt/test_006_login_trigger.py @@ -0,0 +1,134 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests of authentication via login event trigger. + +Mostly for rejection via exception, because this scenario cannot be covered +with *.sql/*.out regress tests. + +Setup statements run before the login trigger is created, so they fire +nothing; every post-enable check uses connect_ok, which opens a fresh libpq +connection (firing the login trigger each time) and captures the +connection-time NOTICE on stderr. + +These tests require Unix-domain sockets, so the module is skipped when the +framework is running over TCP (Windows). +""" + +import pytest + +from pypg.util import USE_UNIX_SOCKETS + +pytestmark = pytest.mark.skipif( + not USE_UNIX_SOCKETS, reason="test requires Unix-domain sockets" +) + + +def test_006_login_trigger(create_pg): + node = create_pg("main", start=False) + node.append_conf( + "wal_level = 'logical'\nmax_replication_slots = 4\nmax_wal_senders = 4\n" + ) + node.start() + + # Create temporary roles and log table (trigger not yet created, so these + # fire nothing). + node.safe_sql( + "CREATE ROLE regress_alice WITH LOGIN;" + "CREATE ROLE regress_mallory WITH LOGIN;" + "CREATE TABLE user_logins(id serial, who text);" + "GRANT SELECT ON user_logins TO public;" + ) + + # Create login event function and trigger. + node.safe_sql( + """CREATE FUNCTION on_login_proc() RETURNS event_trigger AS $$ +BEGIN + INSERT INTO user_logins (who) VALUES (SESSION_USER); + IF SESSION_USER = 'regress_mallory' THEN + RAISE EXCEPTION 'Hello %! You are NOT welcome here!', SESSION_USER; + END IF; + RAISE NOTICE 'Hello %! You are welcome!', SESSION_USER; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER;""" + ) + + # CREATE EVENT TRIGGER: this connection logged in before the trigger + # existed, so nothing fires here. + node.safe_sql( + "CREATE EVENT TRIGGER on_login_trigger " + "ON login EXECUTE PROCEDURE on_login_proc();" + ) + + # From here on every command opens a fresh connection that fires the login + # trigger (inserting one row and raising "You are welcome"). + + # ALTER EVENT TRIGGER ... ENABLE ALWAYS -- insert #1 (postgres). + node.connect_ok( + "", + "alter event trigger", + sql="ALTER EVENT TRIGGER on_login_trigger ENABLE ALWAYS;", + expected_stderr=r"You are welcome", + ) + + # Check the two requests were logged via login trigger -- insert #2. + node.connect_ok( + "", + "select count", + sql="SELECT COUNT(*) FROM user_logins;", + expected_stdout=r"^2$", + expected_stderr=r"You are welcome", + ) + + # Try to login as allowed Alice. We don't check the Mallory login, because + # a FATAL error could cause a timing-dependent panic. Insert #3 (alice). + node.connect_ok( + "user=regress_alice", + "try regress_alice", + sql="SELECT 1;", + expected_stdout=r"^1$", + expected_stderr=r"You are welcome", + ) + + # We also need Alice's stderr to NOT match "You are NOT welcome"; + # connect_ok already required stderr to be exactly the welcome notice (no + # extra stderr is permitted unless it matches expected_stderr), so the + # rejection path is excluded. The NOT-welcome branch is only reachable for + # mallory, who is intentionally never connected (a FATAL there could cause a + # timing-dependent panic). + + # Check that Alice's login record is here, and that mallory never appears + # (mallory's login is rejected, so no row is inserted) -- insert #4 + # (postgres). + node.connect_ok( + "", + "select *", + sql="SELECT * FROM user_logins ORDER BY id;", + expected_stdout=r"3\|regress_alice", + stdout_unlike=r"regress_mallory", + expected_stderr=r"You are welcome", + ) + + # Check total number of successful logins so far -- insert #5. + node.connect_ok( + "", + "select count", + sql="SELECT COUNT(*) FROM user_logins;", + expected_stdout=r"^5$", + expected_stderr=r"You are welcome", + ) + + # Cleanup the temporary stuff -- DROP fires the trigger one last time. + node.connect_ok( + "", + "drop event trigger", + sql="DROP EVENT TRIGGER on_login_trigger;", + expected_stderr=r"You are welcome", + ) + + # With the trigger gone, a fresh connection fires nothing. + node.safe_sql( + "DROP TABLE user_logins;" + "DROP FUNCTION on_login_proc;" + "DROP ROLE regress_mallory;" + "DROP ROLE regress_alice;" + ) diff --git a/src/test/authentication/pyt/test_007_pre_auth.py b/src/test/authentication/pyt/test_007_pre_auth.py new file mode 100644 index 00000000000..eaf6d2774e9 --- /dev/null +++ b/src/test/authentication/pyt/test_007_pre_auth.py @@ -0,0 +1,100 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for connection behavior prior to authentication. An injection point +is attached at 'init-pre-auth' so that a new connection hangs during startup, +just before authentication. While it is stuck, a separate session inspects +pg_stat_activity to confirm the pre-auth backend is recorded, then the +waitpoint is released and the backend is observed reaching the idle state. +""" + +import os +import time + +import pytest + +from libpq import Session + + +def test_007_pre_auth(create_pg): + if os.environ.get("enable_injection_points") != "yes": + pytest.skip("Injection points not supported by this build") + + node = create_pg("primary", start=False) + node.append_conf("log_connections = 'receipt,authentication'\n") + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + if ( + node.safe_sql( + "SELECT count(*) FROM pg_available_extensions WHERE name = 'injection_points'" + ) + == "0" + ): + pytest.skip("Extension injection_points not installed") + + node.safe_sql("CREATE EXTENSION injection_points") + + # Connect to the server and inject a waitpoint. + session = Session(connstr=node.connstr("postgres"), libdir=node.libdir) + conn = None + try: + session.do("SELECT injection_points_attach('init-pre-auth', 'wait')") + + # From this point on, all new connections will hang during startup, + # just before authentication. Use the session connection handle for + # server interaction. + conn = Session(connstr=node.connstr("postgres"), libdir=node.libdir, wait=False) + + # Wait for the connection to show up in pg_stat_activity, with the + # wait_event of the injection point. We need to poll the async + # connection to drive it forward. + pid = None + while True: + # Drive the async connection forward - it won't progress without + # polling. + conn.poll_connect() + + pid = session.query_oneval( + "SELECT pid FROM pg_stat_activity " + "WHERE backend_type = 'client backend' " + "AND state = 'starting' " + "AND wait_event = 'init-pre-auth';", + missing_ok=True, + ) + if pid is not None and pid != "": + break + + time.sleep(0.1) + + print(f"# backend {pid} is authenticating") + # authenticating connections are recorded in pg_stat_activity + + # Detach the waitpoint and wait for the connection to complete. + session.do("SELECT injection_points_wakeup('init-pre-auth')") + conn.wait_connect() + + # Make sure the pgstat entry is updated eventually. + while True: + state = session.query_oneval( + f"SELECT state FROM pg_stat_activity WHERE pid = {pid}", + missing_ok=True, + ) + if state is not None and state == "idle": + break + + print( + f"# state for backend {pid} is " + f"'{state if state is not None else 'undef'}'; " + "waiting for 'idle'..." + ) + time.sleep(0.1) + + # authenticated connections reach idle state in pg_stat_activity + + session.do("SELECT injection_points_detach('init-pre-auth')") + finally: + if conn is not None: + conn.close() + session.close() diff --git a/src/test/kerberos/meson.build b/src/test/kerberos/meson.build index 11aa732e69b..92c3a1a1c5f 100644 --- a/src/test/kerberos/meson.build +++ b/src/test/kerberos/meson.build @@ -14,4 +14,14 @@ tests += { 'with_krb_srvnam': 'postgres', }, }, + 'pytest': { + 'test_kwargs': {'priority': 40}, # kerberos tests are slow, start early + 'tests': [ + 'pyt/test_001_auth.py', + ], + 'env': { + 'with_gssapi': gssapi.found() ? 'yes' : 'no', + 'with_krb_srvnam': 'postgres', + }, + }, } diff --git a/src/test/kerberos/pyt/test_001_auth.py b/src/test/kerberos/pyt/test_001_auth.py new file mode 100644 index 00000000000..f8ef357febf --- /dev/null +++ b/src/test/kerberos/pyt/test_001_auth.py @@ -0,0 +1,693 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for GSSAPI/Kerberos authentication and encryption. + +Sets up a KDC and then runs a variety of tests to make sure that the +GSSAPI/Kerberos authentication and encryption are working properly, that the +options in pg_hba.conf and pg_ident.conf are handled correctly, that the +server-side pg_stat_gssapi view reports what we expect to see for each test and +that SYSTEM_USER returns what we expect to see. + +Also tests that GSSAPI delegation is working properly and that those +credentials can be used to make dblink / postgres_fdw connections. +""" + +import os +import re + +import pytest + +from libpq.session import Session, PqConnectionError + + +def test_001_auth(create_pg, kerberos, tmp_path): + # Faithful skip ordering. + if os.environ.get("with_gssapi") != "yes": + pytest.skip("GSSAPI/Kerberos not supported by this build") + if "kerberos" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip( + "Potentially unsafe test GSSAPI/Kerberos not enabled in PG_TEST_EXTRA" + ) + # The kerberos fixture covers the krb5-binaries skip. + + pgpass = str(tmp_path / ".pgpass") + + dbname = "postgres" + username = "test1" + application = "001_auth" + + # Construct a pgpass file to make sure we don't use it + with open(pgpass, "w", encoding="utf-8") as fh: + fh.write("*:*:*:*:abc123") + os.chmod(pgpass, 0o600) + + # setting up Kerberos + host = "auth-test-localhost.postgresql.example.com" + hostaddr = "127.0.0.1" + realm = "EXAMPLE.COM" + srvnam = os.environ.get("with_krb_srvnam", "postgres") + + krb = kerberos(host, hostaddr, realm, srvnam=srvnam) + + test1_password = "secret1" + krb.create_principal("test1", test1_password) + + # setting up PostgreSQL instance. Must listen on TCP for Kerberos, and the + # KDC (kerberos fixture) was created first so the postmaster inherits the + # KRB5_* environment. + node = create_pg("node", start=False) + node.append_conf( + f""" +listen_addresses = '{hostaddr}' +krb_server_keyfile = '{krb.keytab}' +log_connections = all +log_min_messages = debug2 +lc_messages = 'C' +""" + ) + # The dblink / postgres_fdw cases below open server-side connections with a + # bare "port=" (no host), which libpq resolves via the PGHOST environment + # variable. Export PGHOST to the node's socket dir so the backend's + # outgoing connections reach this node's + # unix socket (and so they are rejected for lack of delegated credentials, + # not for a missing socket). + os.environ["PGHOST"] = node.host + node.start() + + port = node.port + + node.safe_sql("CREATE USER test1;") + node.safe_sql("CREATE USER test2 WITH ENCRYPTED PASSWORD 'abc123';") + node.safe_sql("CREATE EXTENSION postgres_fdw;") + node.safe_sql("CREATE EXTENSION dblink;") + node.safe_sql( + f"CREATE SERVER s1 FOREIGN DATA WRAPPER postgres_fdw OPTIONS " + f"(host '{host}', hostaddr '{hostaddr}', port '{port}', dbname 'postgres');" + ) + node.safe_sql( + f"CREATE SERVER s2 FOREIGN DATA WRAPPER postgres_fdw OPTIONS " + f"(port '{port}', dbname 'postgres', passfile '{pgpass}');" + ) + + node.safe_sql("GRANT USAGE ON FOREIGN SERVER s1 TO test1;") + + node.safe_sql("CREATE USER MAPPING FOR test1 SERVER s1 OPTIONS (user 'test1');") + node.safe_sql("CREATE USER MAPPING FOR test1 SERVER s2 OPTIONS (user 'test2');") + + node.safe_sql("CREATE TABLE t1 (c1 int);") + node.safe_sql("INSERT INTO t1 VALUES (1);") + node.safe_sql( + "CREATE FOREIGN TABLE tf1 (c1 int) SERVER s1 OPTIONS " + "(schema_name 'public', table_name 't1');" + ) + node.safe_sql("GRANT SELECT ON t1 TO test1;") + node.safe_sql("GRANT SELECT ON tf1 TO test1;") + + node.safe_sql( + "CREATE FOREIGN TABLE tf2 (c1 int) SERVER s2 OPTIONS " + "(schema_name 'public', table_name 't1');" + ) + node.safe_sql("GRANT SELECT ON tf2 TO test1;") + + # Set up a table for SYSTEM_USER parallel worker testing. + node.safe_sql( + f"CREATE TABLE ids (id) AS SELECT 'gss:test1@{realm}' " + f"FROM generate_series(1, 10);" + ) + node.safe_sql("GRANT SELECT ON ids TO public;") + + # running tests + + def _connstr(role, gssencmode): + # need to connect over TCP/IP for Kerberos; host/hostaddr override the + # unix-socket host that connect_ok/connect_fails prepend. The expected + # "connection authorized" log line carries the application name, so set + # it explicitly here. + cs = ( + f"user={role} host={host} hostaddr={hostaddr} " + f"application_name={application}" + ) + if gssencmode: + cs += f" {gssencmode}" + return cs + + def test_access(role, query, expected_res, gssencmode, test_name, *expect_log_msgs): + """Test connection success or failure, and if success, that query + returns true. + """ + connstr = _connstr(role, gssencmode) + + # Match every expected message literally. + log_like = [re.escape(m) for m in expect_log_msgs] or None + + if expected_res == 0: + # The result is assumed to match "true", or "t", here. + node.connect_ok( + connstr, test_name, sql=query, expected_stdout=r"^t$", log_like=log_like + ) + else: + # connect_fails does not run a query; the connection itself fails + # (or, with an authenticated-but-unmapped user, the auth log lines + # are still emitted and checked via log_like). + node.connect_fails(connstr, test_name, log_like=log_like) + + def test_query(role, query, expected, gssencmode, test_name): + """As above, but test for an arbitrary query result.""" + connstr = _connstr(role, gssencmode) + node.connect_ok(connstr, test_name, sql=query, expected_stdout=expected) + + def gss_connstr(gssencmode): + """Full conninfo (incl. node host/port/dbname) for a GSS connection. + + Used for the dblink / postgres_fdw cases. + """ + return ( + f"{node.connstr('postgres')} user=test1 " + f"host={host} hostaddr={hostaddr} {gssencmode}" + ) + + def gss_psql(query, gssencmode): + """Run *query* over a fresh GSS connection; return (rc, out, stderr). + + Emulates ``$node->psql``: rc 3 means an error was raised running the + query (psql's ON_ERROR_STOP exit code), rc 0 means success. + """ + sess = None + try: + sess = Session(connstr=gss_connstr(gssencmode), libdir=node.libdir) + res = sess.query(query) + stderr = sess.get_notices_str() + (res.error_message or "") + if res.error_message is not None: + return 3, "", stderr + return 0, res.psqlout, stderr + except PqConnectionError as exc: + return 2, "", str(exc) + finally: + if sess is not None: + sess.close() + + def set_hba(*lines): + """Replace pg_hba.conf with the given line(s).""" + os.unlink(os.path.join(node.data_dir, "pg_hba.conf")) + node.append_conf("\n".join(lines) + "\n", filename="pg_hba.conf") + + set_hba( + "local all test2 scram-sha-256", + f"host all all {hostaddr}/32 gss map=mymap", + ) + node.restart() + + test_access("test1", "SELECT true", 2, "", "fails without ticket") + + krb.create_ticket("test1", test1_password) + + test_access( + "test1", + "SELECT true", + 2, + "", + "fails without mapping", + f'connection authenticated: identity="test1@{realm}" method=gss', + 'no match in usermap "mymap" for user "test1"', + ) + + node.append_conf(f"mymap /^(.*)@{realm}$ \\1", filename="pg_ident.conf") + node.restart() + + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "", + "succeeds with mapping with default gssencmode and host hba, " + "ticket not forwardable", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssencmode=prefer", + "succeeds with GSS-encrypted access preferred with host hba, " + "ticket not forwardable", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssencmode=require", + "succeeds with GSS-encrypted access required with host hba, " + "ticket not forwardable", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssencmode=prefer gssdelegation=1", + "succeeds with GSS-encrypted access preferred with host hba and " + "credentials not delegated even though asked for (ticket not " + "forwardable)", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssencmode=require gssdelegation=1", + "succeeds with GSS-encrypted access required with host hba and " + "credentials not delegated even though asked for (ticket not " + "forwardable)", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + + # Test that we can transport a reasonable amount of data. + test_query( + "test1", + "SELECT * FROM generate_series(1, 100000);", + r"(?s)^1\n.*\n1024\n.*\n9999\n.*\n100000$", + "gssencmode=require", + "receiving 100K lines works", + ) + + # Sending 100K lines: the in-process libpq layer has no COPY-in support + # and does not interpret psql backslash meta-commands, so send the same + # number of rows in a single large query string over the encrypted channel + # (exercising the client->server send path) and count them. + big_send = ( + "CREATE TEMP TABLE mytab (f1 int primary key); " + "INSERT INTO mytab SELECT generate_series(1, 100000); " + "SELECT COUNT(*) FROM mytab;" + ) + test_query( + "test1", + big_send, + r"(?s)^100000$", + "gssencmode=require", + "sending 100K lines works", + ) + + # require_auth=gss succeeds if required. + node.connect_ok( + f"user=test1 host={host} hostaddr={hostaddr} " + f"gssencmode=disable require_auth=gss", + "GSS authentication requested, works with non-encrypted GSS", + ) + node.connect_ok( + f"user=test1 host={host} hostaddr={hostaddr} " + f"gssencmode=require require_auth=gss", + "GSS authentication requested, works with encrypted GSS auth", + ) + + # require_auth=sspi fails if required. + node.connect_fails( + f"user=test1 host={host} hostaddr={hostaddr} " + f"gssencmode=disable require_auth=sspi", + "SSPI authentication requested, fails with non-encrypted GSS", + expected_stderr=r'authentication method requirement "sspi" failed: ' + r"server requested GSSAPI authentication", + ) + node.connect_fails( + f"user=test1 host={host} hostaddr={hostaddr} " + f"gssencmode=require require_auth=sspi", + "SSPI authentication requested, fails with encrypted GSS", + expected_stderr=r'authentication method requirement "sspi" failed: ' + r"server did not complete authentication", + ) + + # Test that SYSTEM_USER works. + test_query( + "test1", + "SELECT SYSTEM_USER;", + rf"(?s)^gss:test1@{realm}$", + "gssencmode=require", + "testing system_user", + ) + + # Test that SYSTEM_USER works with parallel workers. + test_query( + "test1", + """ + SET min_parallel_table_scan_size TO 0; + SET parallel_setup_cost TO 0; + SET parallel_tuple_cost TO 0; + SET max_parallel_workers_per_gather TO 2; + SELECT bool_and(SYSTEM_USER = id) FROM ids;""", + r"(?s)^t$", + "gssencmode=require", + "testing system_user with parallel workers", + ) + + set_hba( + " local all test2 scram-sha-256", + f"\thostgssenc all all {hostaddr}/32 gss map=mymap", + ) + + # Re-create the ticket, with the forwardable flag set + krb.create_ticket("test1", test1_password, forwardable=True) + + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "from pg_stat_gssapi where pid = pg_backend_pid();", + 0, + "gssencmode=prefer gssdelegation=1", + "succeeds with GSS-encrypted access preferred and hostgssenc hba and " + "credentials not forwarded (server does not accept them, default)", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "from pg_stat_gssapi where pid = pg_backend_pid();", + 0, + "gssencmode=require gssdelegation=1", + "succeeds with GSS-encrypted access required and hostgssenc hba and " + "credentials not forwarded (server does not accept them, default)", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + + node.append_conf("gss_accept_delegation=off") + node.restart() + + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "from pg_stat_gssapi where pid = pg_backend_pid();", + 0, + "gssencmode=prefer gssdelegation=1", + "succeeds with GSS-encrypted access preferred and hostgssenc hba and " + "credentials not forwarded (server does not accept them, explicitly " + "disabled)", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "from pg_stat_gssapi where pid = pg_backend_pid();", + 0, + "gssencmode=require gssdelegation=1", + "succeeds with GSS-encrypted access required and hostgssenc hba and " + "credentials not forwarded (server does not accept them, explicitly " + "disabled)", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + + node.append_conf("gss_accept_delegation=on") + node.restart() + + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND credentials_delegated " + "from pg_stat_gssapi where pid = pg_backend_pid();", + 0, + "gssencmode=prefer gssdelegation=1", + "succeeds with GSS-encrypted access preferred and hostgssenc hba and " + "credentials forwarded", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=yes, principal=test1@{realm})", + ) + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND credentials_delegated " + "from pg_stat_gssapi where pid = pg_backend_pid();", + 0, + "gssencmode=require gssdelegation=1", + "succeeds with GSS-encrypted access required and hostgssenc hba and " + "credentials forwarded", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=yes, principal=test1@{realm})", + ) + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssencmode=prefer", + "succeeds with GSS-encrypted access preferred and hostgssenc hba and " + "credentials not forwarded", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND NOT credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssencmode=require gssdelegation=0", + "succeeds with GSS-encrypted access required and hostgssenc hba and " + "credentials explicitly not forwarded", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=no, principal=test1@{realm})", + ) + + no_deleg_re = r"password or GSSAPI delegated credentials required" + + rc, out, stderr = gss_psql( + f"SELECT * FROM dblink('user=test1 dbname={dbname} host={host} " + f"hostaddr={hostaddr} port={port}','select 1') as t1(c1 int);", + "gssencmode=require gssdelegation=0", + ) + assert rc == 3, "dblink attempt fails without delegated credentials" + assert re.search( + no_deleg_re, stderr + ), "dblink does not work without delegated credentials" + assert re.search(r"^$", out), "dblink does not work without delegated credentials" + + rc, out, stderr = gss_psql( + f"SELECT * FROM dblink('user=test2 dbname={dbname} port={port} " + f"passfile={pgpass}','select 1') as t1(c1 int);", + "gssencmode=require gssdelegation=0", + ) + assert ( + rc == 3 + ), "dblink does not work without delegated credentials and with passfile" + assert re.search( + no_deleg_re, stderr + ), "dblink does not work without delegated credentials and with passfile" + assert re.search( + r"^$", out + ), "dblink does not work without delegated credentials and with passfile" + + rc, out, stderr = gss_psql("TABLE tf1;", "gssencmode=require gssdelegation=0") + assert rc == 3, "postgres_fdw does not work without delegated credentials" + assert re.search( + no_deleg_re, stderr + ), "postgres_fdw does not work without delegated credentials" + assert re.search( + r"^$", out + ), "postgres_fdw does not work without delegated credentials" + + rc, out, stderr = gss_psql("TABLE tf2;", "gssencmode=require gssdelegation=0") + assert ( + rc == 3 + ), "postgres_fdw does not work without delegated credentials and with passfile" + assert re.search( + no_deleg_re, stderr + ), "postgres_fdw does not work without delegated credentials and with passfile" + assert re.search( + r"^$", out + ), "postgres_fdw does not work without delegated credentials and with passfile" + + test_access( + "test1", + "SELECT true", + 2, + "gssencmode=disable", + "fails with GSS encryption disabled and hostgssenc hba", + ) + + # require_auth=gss succeeds if required. + node.connect_ok( + f"user=test1 host={host} hostaddr={hostaddr} " + f"gssencmode=require require_auth=gss", + "GSS authentication requested, works with GSS encryption", + ) + node.connect_ok( + f"user=test1 host={host} hostaddr={hostaddr} " + f"gssencmode=require require_auth=gss,scram-sha-256", + "multiple authentication types requested, works with GSS encryption", + ) + + set_hba( + " local all test2 scram-sha-256", + f"\thostnogssenc all all {hostaddr}/32 gss map=mymap", + ) + node.restart() + + test_access( + "test1", + "SELECT gss_authenticated AND NOT encrypted AND credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssencmode=prefer gssdelegation=1", + "succeeds with GSS-encrypted access preferred and hostnogssenc hba, " + "but no encryption", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=no, " + f"delegated_credentials=yes, principal=test1@{realm})", + ) + test_access( + "test1", + "SELECT true", + 2, + "gssencmode=require", + "fails with GSS-encrypted access required and hostnogssenc hba", + ) + test_access( + "test1", + "SELECT gss_authenticated AND NOT encrypted AND credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssencmode=disable gssdelegation=1", + "succeeds with GSS encryption disabled and hostnogssenc hba", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=no, " + f"delegated_credentials=yes, principal=test1@{realm})", + ) + + test_query( + "test1", + f"SELECT * FROM dblink('user=test1 dbname={dbname} host={host} " + f"hostaddr={hostaddr} port={port}','select 1') as t1(c1 int);", + r"(?s)^1$", + "gssencmode=prefer gssdelegation=1", + "dblink works not-encrypted (server not configured to accept " + "encrypted GSSAPI connections)", + ) + + test_query( + "test1", + "TABLE tf1;", + r"(?s)^1$", + "gssencmode=prefer gssdelegation=1", + "postgres_fdw works not-encrypted (server not configured to accept " + "encrypted GSSAPI connections)", + ) + + rc, out, stderr = gss_psql( + f"SELECT * FROM dblink('user=test2 dbname={dbname} port={port} " + f"passfile={pgpass}','select 1') as t1(c1 int);", + "gssencmode=prefer gssdelegation=1", + ) + assert rc == 3, "dblink does not work with delegated credentials and with passfile" + assert re.search( + no_deleg_re, stderr + ), "dblink does not work with delegated credentials and with passfile" + assert re.search( + r"^$", out + ), "dblink does not work with delegated credentials and with passfile" + + rc, out, stderr = gss_psql("TABLE tf2;", "gssencmode=prefer gssdelegation=1") + assert ( + rc == 3 + ), "postgres_fdw does not work with delegated credentials and with passfile" + assert re.search( + no_deleg_re, stderr + ), "postgres_fdw does not work with delegated credentials and with passfile" + assert re.search( + r"^$", out + ), "postgres_fdw does not work with delegated credentials and with passfile" + + # Truncate pg_ident.conf and reset pg_hba.conf for include_realm=0. + with open(os.path.join(node.data_dir, "pg_ident.conf"), "w", encoding="utf-8"): + pass + set_hba( + " local all test2 scram-sha-256", + f"\thost all all {hostaddr}/32 gss include_realm=0", + ) + node.restart() + + test_access( + "test1", + "SELECT gss_authenticated AND encrypted AND credentials_delegated " + "FROM pg_stat_gssapi WHERE pid = pg_backend_pid();", + 0, + "gssdelegation=1", + "succeeds with include_realm=0 and defaults", + f'connection authenticated: identity="test1@{realm}" method=gss', + f"connection authorized: user={username} database={dbname} " + f"application_name={application} GSS (authenticated=yes, encrypted=yes, " + f"delegated_credentials=yes, principal=test1@{realm})", + ) + + test_query( + "test1", + f"SELECT * FROM dblink('user=test1 dbname={dbname} host={host} " + f"hostaddr={hostaddr} port={port} password=1234','select 1') " + f"as t1(c1 int);", + r"(?s)^1$", + "gssencmode=require gssdelegation=1", + "dblink works encrypted", + ) + + test_query( + "test1", + "TABLE tf1;", + r"(?s)^1$", + "gssencmode=require gssdelegation=1", + "postgres_fdw works encrypted", + ) + + # Reset pg_hba.conf, and cause a usermap failure with an authentication + # that has passed. + set_hba( + " local all test2 scram-sha-256", + f"\thost all all {hostaddr}/32 gss include_realm=0 krb_realm=EXAMPLE.ORG", + ) + node.restart() + + test_access( + "test1", + "SELECT true", + 2, + "", + "fails with wrong krb_realm, but still authenticates", + f'connection authenticated: identity="test1@{realm}" method=gss', + ) diff --git a/src/test/ldap/meson.build b/src/test/ldap/meson.build index d8961e6c8d7..a62996cff43 100644 --- a/src/test/ldap/meson.build +++ b/src/test/ldap/meson.build @@ -14,4 +14,14 @@ tests += { 'with_ldap': ldap.found() ? 'yes' : 'no', }, }, + 'pytest': { + 'tests': [ + 'pyt/test_001_auth.py', + 'pyt/test_002_bindpasswd.py', + 'pyt/test_003_ldap_connection_param_lookup.py', + ], + 'env': { + 'with_ldap': ldap.found() ? 'yes' : 'no', + }, + }, } diff --git a/src/test/ldap/pyt/test_001_auth.py b/src/test/ldap/pyt/test_001_auth.py new file mode 100644 index 00000000000..470eeb5af12 --- /dev/null +++ b/src/test/ldap/pyt/test_001_auth.py @@ -0,0 +1,286 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for LDAP authentication.""" + +import os + +import pytest + + +def _access(node, role, expected_res, test_name, **params): + """Attempt a connection and check the expected outcome. + + *expected_res* of 0 means the connection should succeed; anything else + means it should fail (only the status code is checked on failure). + """ + connstr = f"user={role}" + if expected_res == 0: + node.connect_ok(connstr, test_name, **params) + else: + # No checks of the error message, only the status code. + node.connect_fails(connstr, test_name, **params) + + +def test_001_auth(create_pg, ldap_server): + # Faithful skip ordering. + if os.environ.get("with_ldap") != "yes": + pytest.skip("LDAP not supported by this build") + if "ldap" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip("Potentially unsafe test LDAP not enabled in PG_TEST_EXTRA") + # The ldap_server fixture covers the slapd-availability skip. + + # setting up LDAP server + ldap_rootpw = "secret" + ldap = ldap_server(ldap_rootpw, "anonymous") # use anonymous auth + authdata = os.path.join(os.path.dirname(__file__), "..", "authdata.ldif") + ldap.ldapadd_file(authdata) + ldap.ldapsetpw("uid=test1,dc=example,dc=net", "secret1") + ldap.ldapsetpw("uid=test2,dc=example,dc=net", "secret2") + + (ldap_server_name, ldap_port, ldaps_port, ldap_url, ldaps_url, ldap_basedn) = ( + ldap.prop("server", "port", "s_port", "url", "s_url", "basedn") + ) + + # don't bother to check the server's cert (though perhaps we should) + os.environ["LDAPTLS_REQCERT"] = "never" + + # setting up PostgreSQL instance + node = create_pg("node", start=False) + node.append_conf("log_connections = all\n") + # Needed to allow connect_fails to inspect postmaster log: + node.append_conf("log_min_messages = debug2") + node.start() + + node.safe_sql("CREATE USER test0;") + node.safe_sql("CREATE USER test1;") + node.safe_sql('CREATE USER "test2@example.net";') + + def set_hba(line): + """Replace pg_hba.conf with a single ldap line and restart.""" + hba = os.path.join(node.data_dir, "pg_hba.conf") + os.unlink(hba) + node.append_conf(line, filename="pg_hba.conf") + node.restart() + + # running tests + + # simple bind + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapprefix="uid=" ' + f'ldapsuffix=",dc=example,dc=net"' + ) + + os.environ["PGPASSWORD"] = "wrong" + _access( + node, + "test0", + 2, + "simple bind authentication fails if user not found in LDAP", + log_unlike=[r"connection authenticated:"], + ) + _access( + node, + "test1", + 2, + "simple bind authentication fails with wrong password", + log_unlike=[r"connection authenticated:"], + ) + + os.environ["PGPASSWORD"] = "secret1" + _access( + node, + "test1", + 0, + "simple bind authentication succeeds", + log_like=[ + r'connection authenticated: identity="uid=test1,dc=example,dc=net" method=ldap' + ], + ) + + # require_auth=password should complete successfully; other methods should fail. + node.connect_ok( + "user=test1 require_auth=password", + "password authentication required, works with ldap auth", + ) + node.connect_fails( + "user=test1 require_auth=scram-sha-256", + "SCRAM authentication required, fails with ldap auth", + ) + + # search+bind + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}"' + ) + + os.environ["PGPASSWORD"] = "wrong" + _access( + node, "test0", 2, "search+bind authentication fails if user not found in LDAP" + ) + _access(node, "test1", 2, "search+bind authentication fails with wrong password") + os.environ["PGPASSWORD"] = "secret1" + _access( + node, + "test1", + 0, + "search+bind authentication succeeds", + log_like=[ + r'connection authenticated: identity="uid=test1,dc=example,dc=net" method=ldap' + ], + ) + + # multiple servers + set_hba( + f'local all all ldap ldapserver="{ldap_server_name} {ldap_server_name}" ' + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}"' + ) + + os.environ["PGPASSWORD"] = "wrong" + _access( + node, "test0", 2, "search+bind authentication fails if user not found in LDAP" + ) + _access(node, "test1", 2, "search+bind authentication fails with wrong password") + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 0, "search+bind authentication succeeds") + + # LDAP URLs + set_hba( + f'local all all ldap ldapurl="{ldap_url}" ldapprefix="uid=" ' + f'ldapsuffix=",dc=example,dc=net"' + ) + + os.environ["PGPASSWORD"] = "wrong" + _access( + node, + "test0", + 2, + "simple bind with LDAP URL authentication fails if user not found in LDAP", + ) + _access( + node, + "test1", + 2, + "simple bind with LDAP URL authentication fails with wrong password", + ) + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 0, "simple bind with LDAP URL authentication succeeds") + + set_hba(f'local all all ldap ldapurl="{ldap_url}/{ldap_basedn}?uid?sub"') + + os.environ["PGPASSWORD"] = "wrong" + _access( + node, + "test0", + 2, + "search+bind with LDAP URL authentication fails if user not found in LDAP", + ) + _access( + node, + "test1", + 2, + "search+bind with LDAP URL authentication fails with wrong password", + ) + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 0, "search+bind with LDAP URL authentication succeeds") + + # search filters + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}" ' + f'ldapsearchfilter="(|(uid=$username)(mail=$username))"' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access( + node, + "test1", + 0, + "search filter finds by uid", + log_like=[ + r'connection authenticated: identity="uid=test1,dc=example,dc=net" method=ldap' + ], + ) + os.environ["PGPASSWORD"] = "secret2" + _access( + node, + "test2@example.net", + 0, + "search filter finds by mail", + log_like=[ + r'connection authenticated: identity="uid=test2,dc=example,dc=net" method=ldap' + ], + ) + + # search filters in LDAP URLs + set_hba( + f"local all all ldap " + f'ldapurl="{ldap_url}/{ldap_basedn}??sub?(|(uid=$username)(mail=$username))"' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 0, "search filter finds by uid") + os.environ["PGPASSWORD"] = "secret2" + _access(node, "test2@example.net", 0, "search filter finds by mail") + + # This is not documented: You can combine ldapurl and other ldap* + # settings. ldapurl is always parsed first, then the other settings + # override. It might be useful in a case like this. + set_hba( + f'local all all ldap ldapurl="{ldap_url}/{ldap_basedn}??sub" ' + f'ldapsearchfilter="(|(uid=$username)(mail=$username))"' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 0, "combined LDAP URL and search filter") + + # diagnostic message + + # note bad ldapprefix with a question mark that triggers a diagnostic message + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapprefix="?uid=" ldapsuffix=""' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 2, "any attempt fails due to bad search pattern") + + # TLS + + # request StartTLS with ldaptls=1 + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}" ' + f'ldapsearchfilter="(uid=$username)" ldaptls=1' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 0, "StartTLS") + + # request LDAPS with ldapscheme=ldaps + set_hba( + f"local all all ldap ldapserver={ldap_server_name} ldapscheme=ldaps " + f'ldapport={ldaps_port} ldapbasedn="{ldap_basedn}" ' + f'ldapsearchfilter="(uid=$username)"' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 0, "LDAPS") + + # request LDAPS with ldapurl=ldaps://... + set_hba( + f"local all all ldap " + f'ldapurl="{ldaps_url}/{ldap_basedn}??sub?(uid=$username)"' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 0, "LDAPS with URL") + + # bad combination of LDAPS and StartTLS + set_hba( + f"local all all ldap " + f'ldapurl="{ldaps_url}/{ldap_basedn}??sub?(uid=$username)" ldaptls=1' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access(node, "test1", 2, "bad combination of LDAPS and StartTLS") diff --git a/src/test/ldap/pyt/test_002_bindpasswd.py b/src/test/ldap/pyt/test_002_bindpasswd.py new file mode 100644 index 00000000000..4566fb88305 --- /dev/null +++ b/src/test/ldap/pyt/test_002_bindpasswd.py @@ -0,0 +1,83 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Tests for LDAP authentication using ldapbindpasswd.""" + +import os + +import pytest + + +def _access(node, role, expected_res, test_name, **params): + """Attempt a connection and check the expected outcome. + + *expected_res* of 0 means the connection should succeed; anything else + means it should fail (only the status code is checked on failure). + """ + connstr = f"user={role}" + if expected_res == 0: + node.connect_ok(connstr, test_name, **params) + else: + # No checks of the error message, only the status code. + node.connect_fails(connstr, test_name, **params) + + +def test_002_bindpasswd(create_pg, ldap_server): + # Faithful skip ordering. + if os.environ.get("with_ldap") != "yes": + pytest.skip("LDAP not supported by this build") + if "ldap" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip("Potentially unsafe test LDAP not enabled in PG_TEST_EXTRA") + # The ldap_server fixture covers the slapd-availability skip. + + # setting up LDAP server + ldap_rootpw = "secret" + ldap = ldap_server(ldap_rootpw, "users") # no anonymous auth + authdata = os.path.join(os.path.dirname(__file__), "..", "authdata.ldif") + ldap.ldapadd_file(authdata) + ldap.ldapsetpw("uid=test1,dc=example,dc=net", "secret1") + ldap.ldapsetpw("uid=test2,dc=example,dc=net", "secret2") + + (ldap_server_name, ldap_port, ldap_basedn, ldap_rootdn) = ldap.prop( + "server", "port", "basedn", "rootdn" + ) + + # setting up PostgreSQL instance + node = create_pg("node", start=False) + node.append_conf("log_connections = all\n") + node.start() + + node.safe_sql("CREATE USER test0;") + node.safe_sql("CREATE USER test1;") + node.safe_sql('CREATE USER "test2@example.net";') + + def set_hba(line): + """Replace pg_hba.conf with a single ldap line and restart.""" + hba = os.path.join(node.data_dir, "pg_hba.conf") + os.unlink(hba) + node.append_conf(line, filename="pg_hba.conf") + node.restart() + + # running tests + + # use ldapbindpasswd + + # Note: the malformed ldapbinddn (unbalanced quote) is intentional and + # exercises the failure path. + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}" ' + f'ldapbinddn="{ldap_rootdn} ldapbindpasswd=wrong' + ) + + os.environ["PGPASSWORD"] = "secret1" + _access( + node, "test1", 2, "search+bind authentication fails with wrong ldapbindpasswd" + ) + + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}" ' + f'ldapbinddn="{ldap_rootdn}" ldapbindpasswd="{ldap_rootpw}"' + ) + + _access(node, "test1", 0, "search+bind authentication succeeds with ldapbindpasswd") diff --git a/src/test/ldap/pyt/test_003_ldap_connection_param_lookup.py b/src/test/ldap/pyt/test_003_ldap_connection_param_lookup.py new file mode 100644 index 00000000000..0a16980e3fa --- /dev/null +++ b/src/test/ldap/pyt/test_003_ldap_connection_param_lookup.py @@ -0,0 +1,307 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Tests for libpq's client-side LDAP lookup of connection parameters. + +This exercises libpq's *client*-side LDAP lookup of connection parameters +(an ldap:// URL in a service file that libpq resolves to obtain host/port), +together with the various service-name / service-file selection rules +(``service=``, ``postgres://?service=``, PGSERVICE, PGSERVICEFILE, +PGSYSCONFDIR, and the default ``pg_service.conf``). This is unrelated to +server-side ldap *authentication* (see test_001_auth.py). +""" + +import os +import shutil + +import pytest + +from libpq import Session +from libpq.errors import PqConnectionError + + +def _attempt(libdir, connstr): + """Make one raw libpq connection attempt and run a trivial query. + + Returns a (ok, output, error) tuple. We deliberately build the Session + from the bare *connstr* WITHOUT prepending the node's host/port (which is + what node.connect_ok/connect_fails do via _full_connstr). The whole point + of this test is that libpq must resolve host/port itself from the LDAP + lookup driven by the service file, so injecting an explicit host/port would + defeat the lookup (an explicit host/port in the conninfo overrides the + service-provided one). + """ + sess = None + try: + # user="" suppresses Session's automatic ` user=` append, + # which would otherwise be glued onto a URI connstr and corrupt it. + # The OS user is the initdb superuser, so no explicit user is needed. + sess = Session(connstr=connstr, libdir=libdir, user="") + res = sess.query("SELECT 1") + if res.error_message is not None: + return False, "", res.error_message + return True, res.psqlout, "" + except PqConnectionError as exc: + return False, "", str(exc) + finally: + if sess is not None: + sess.close() + + +def _connect_ok(libdir, connstr, test_name, *, sql=None, expected_stdout=None): + """Assert that a bare-connstr connection succeeds.""" + sess = None + try: + # See _attempt(): user="" avoids corrupting URI connstrings. + sess = Session(connstr=connstr, libdir=libdir, user="") + res = sess.query(sql if sql is not None else "SELECT 1") + err = res.error_message + out = res.psqlout + except PqConnectionError as exc: + err = str(exc) + out = "" + finally: + if sess is not None: + sess.close() + + assert err is None or err == "", f"{test_name}: connection should succeed\n{err}" + if expected_stdout is not None: + import re + + assert re.search( + expected_stdout, out + ), f"{test_name}: stdout matches {expected_stdout!r}, got {out!r}" + + +def _connect_fails(libdir, connstr, test_name, *, expected_stderr=None): + """Assert that a bare-connstr connection fails.""" + ok, _out, err = _attempt(libdir, connstr) + assert not ok, f"{test_name}: connection should fail" + if expected_stderr is not None: + import re + + assert re.search( + expected_stderr, err + ), f"{test_name}: stderr matches {expected_stderr!r}, got {err!r}" + + +def test_003_ldap_connection_param_lookup(create_pg, ldap_server, tmp_path): + # Faithful skip ordering. + if os.environ.get("with_ldap") != "yes": + pytest.skip("LDAP not supported by this build") + if "ldap" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip("Potentially unsafe test LDAP not enabled in PG_TEST_EXTRA") + # The ldap_server fixture covers the slapd-availability skip. + + # This tests scenarios related to the service name and the service file, + # for the connection options and their environment variables. + # + # dummy_node is created only to drive client connection attempts (it gives + # us a libdir for libpq); the real server we connect to is "node". + dummy_node = create_pg("dummy_node", start=False) + libdir = dummy_node.libdir + + node = create_pg("node") + + # setting up LDAP server + ldap_rootpw = "secret" + ldap = ldap_server(ldap_rootpw, "anonymous") # use anonymous auth + authdata = os.path.join(os.path.dirname(__file__), "..", "authdata.ldif") + ldap.ldapadd_file(authdata) + ldap.ldapsetpw("uid=test1,dc=example,dc=net", "secret1") + ldap.ldapsetpw("uid=test2,dc=example,dc=net", "secret2") + + td = str(tmp_path) + + # create ldap file based on postgres connection info + ldif_valid = os.path.join(td, "connection_params.ldif") + with open(ldif_valid, "w", encoding="utf-8") as fh: + fh.write( + "\nversion:1\n" + "dn:cn=mydatabase,dc=example,dc=net\n" + "changetype:add\n" + "objectclass:top\n" + "objectclass:device\n" + "cn:mydatabase\n" + f"description:host={node.host}\n" + f"description:port={node.port}\n" + ) + + ldap.ldapadd_file(ldif_valid) + + (ldap_port,) = ldap.prop("port") + + # don't bother to check the server's cert (though perhaps we should) + os.environ["LDAPTLS_REQCERT"] = "never" + + # setting up PostgreSQL instance + + # Create the set of service files used in the tests. + + # File that includes a valid service name, that uses a decomposed + # connection string for its contents, split on spaces. + srvfile_valid = os.path.join(td, "pg_service_valid.conf") + with open(srvfile_valid, "w", encoding="utf-8") as fh: + fh.write( + "\n[my_srv]\n" + f"ldap://localhost:{ldap_port}/dc=example,dc=net" + "?description?one?(cn=mydatabase)\n" + ) + + # File defined with no contents, used as default value for + # PGSERVICEFILE, so that no lookup is attempted in the user's home + # directory. + srvfile_empty = os.path.join(td, "pg_service_empty.conf") + with open(srvfile_empty, "w", encoding="utf-8") as fh: + fh.write("") + + # Missing service file. + srvfile_missing = os.path.join(td, "pg_service_missing.conf") + + # Snapshot the env vars we mutate so the test does not leak state into + # other tests in the same process. + saved_env = { + k: os.environ.get(k) + for k in ("PGSYSCONFDIR", "PGSERVICEFILE", "PGSERVICE", "PGDATABASE") + } + + def _restore_env(): + for k, v in saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + try: + # Set the fallback directory lookup of the service file to the + # temporary directory of this test. PGSYSCONFDIR is used if the + # service file defined in PGSERVICEFILE cannot be found, or when a + # service file is found but not the service name. + os.environ["PGSYSCONFDIR"] = td + + # The LDAP lookup supplies only host/port, not a database name; set + # PGDATABASE=postgres so libpq does not fall back to a database named + # after the OS user. + os.environ["PGDATABASE"] = "postgres" + + # Force PGSERVICEFILE to a default location, so as this test never + # tries to look at a home directory. This value needs to remain at + # the top before running any tests, and should never be changed. + os.environ["PGSERVICEFILE"] = srvfile_empty + + # Checks combinations of service name and a valid service file. + os.environ["PGSERVICEFILE"] = srvfile_valid + os.environ.pop("PGSERVICE", None) + + _connect_ok( + libdir, + "service=my_srv", + 'connection with correct "service" string and PGSERVICEFILE', + sql="SELECT 'connect1_1'", + expected_stdout=r"connect1_1", + ) + + _connect_ok( + libdir, + "postgres://?service=my_srv", + 'connection with correct "service" URI and PGSERVICEFILE', + sql="SELECT 'connect1_2'", + expected_stdout=r"connect1_2", + ) + + _connect_fails( + libdir, + "service=undefined-service", + 'connection with incorrect "service" string and PGSERVICEFILE', + expected_stderr=r'definition of service "undefined-service" not found', + ) + + os.environ["PGSERVICE"] = "my_srv" + + _connect_ok( + libdir, + "", + "connection with correct PGSERVICE and PGSERVICEFILE", + sql="SELECT 'connect1_3'", + expected_stdout=r"connect1_3", + ) + + os.environ["PGSERVICE"] = "undefined-service" + + _connect_fails( + libdir, + "", + "connection with incorrect PGSERVICE and PGSERVICEFILE", + expected_stderr=r'definition of service "undefined-service" not found', + ) + + # Restore the service-related env to the block's outer state. + os.environ["PGSERVICEFILE"] = srvfile_empty + os.environ.pop("PGSERVICE", None) + + # Checks case of incorrect service file. + os.environ["PGSERVICEFILE"] = srvfile_missing + + _connect_fails( + libdir, + "service=my_srv", + 'connection with correct "service" string and incorrect PGSERVICEFILE', + expected_stderr=r'service file ".*pg_service_missing.conf" not found', + ) + + os.environ["PGSERVICEFILE"] = srvfile_empty + + # Checks case of service file named "pg_service.conf" in PGSYSCONFDIR. + # Create copy of valid file. + srvfile_default = os.path.join(td, "pg_service.conf") + shutil.copy(srvfile_valid, srvfile_default) + + _connect_ok( + libdir, + "service=my_srv", + 'connection with correct "service" string and pg_service.conf', + sql="SELECT 'connect2_1'", + expected_stdout=r"connect2_1", + ) + + _connect_ok( + libdir, + "postgres://?service=my_srv", + 'connection with correct "service" URI and default pg_service.conf', + sql="SELECT 'connect2_2'", + expected_stdout=r"connect2_2", + ) + + _connect_fails( + libdir, + "service=undefined-service", + 'connection with incorrect "service" string and default pg_service.conf', + expected_stderr=r'definition of service "undefined-service" not found', + ) + + os.environ["PGSERVICE"] = "my_srv" + + _connect_ok( + libdir, + "", + "connection with correct PGSERVICE and default pg_service.conf", + sql="SELECT 'connect2_3'", + expected_stdout=r"connect2_3", + ) + + os.environ["PGSERVICE"] = "undefined-service" + + _connect_fails( + libdir, + "", + "connection with incorrect PGSERVICE and default pg_service.conf", + expected_stderr=r'definition of service "undefined-service" not found', + ) + + os.environ.pop("PGSERVICE", None) + + # Remove default pg_service.conf. + os.unlink(srvfile_default) + finally: + _restore_env() + + node.teardown() diff --git a/src/test/modules/ldap_password_func/meson.build b/src/test/modules/ldap_password_func/meson.build index 209b6683373..c745368cd70 100644 --- a/src/test/modules/ldap_password_func/meson.build +++ b/src/test/modules/ldap_password_func/meson.build @@ -30,4 +30,10 @@ tests += { ], 'env': {'with_ldap': 'yes'} }, + 'pytest': { + 'tests': [ + 'pyt/test_001_mutated_bindpasswd.py', + ], + 'env': {'with_ldap': 'yes'}, + }, } diff --git a/src/test/modules/ldap_password_func/pyt/test_001_mutated_bindpasswd.py b/src/test/modules/ldap_password_func/pyt/test_001_mutated_bindpasswd.py new file mode 100644 index 00000000000..8d21718e56d --- /dev/null +++ b/src/test/modules/ldap_password_func/pyt/test_001_mutated_bindpasswd.py @@ -0,0 +1,93 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Test that a custom hook can mutate the LDAP bind password. + +Verify that LDAP authentication succeeds when the ldap_password_func module +rewrites the configured bind password before it is used. +""" + +import os + +import pytest + + +def _access(node, role, expected_res, test_name, **params): + """Attempt a connection as *role* and assert success or failure. + + *expected_res* of 0 means the connection should succeed; anything else + means it should fail (only the status code is checked on failure). + """ + connstr = f"user={role}" + if expected_res == 0: + node.connect_ok(connstr, test_name, **params) + else: + # No checks of the error message, only the status code. + node.connect_fails(connstr, test_name, **params) + + +def test_001_mutated_bindpasswd(create_pg, ldap_server): + # Skip ordering: check build support, then PG_TEST_EXTRA opt-in. + if os.environ.get("with_ldap") != "yes": + pytest.skip("LDAP not supported by this build") + if "ldap" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip("Potentially unsafe test LDAP not enabled in PG_TEST_EXTRA") + # The ldap_server fixture covers the slapd-availability skip. + + clear_ldap_rootpw = "FooBaR1" + rot13_ldap_rootpw = "SbbOnE1" + + ldap = ldap_server(clear_ldap_rootpw, "users") # no anonymous auth + authdata = os.path.join( + os.path.dirname(__file__), "..", "..", "..", "ldap", "authdata.ldif" + ) + ldap.ldapadd_file(authdata) + ldap.ldapsetpw("uid=test1,dc=example,dc=net", "secret1") + + (ldap_server_name, ldap_port, ldap_basedn, ldap_rootdn) = ldap.prop( + "server", "port", "basedn", "rootdn" + ) + + # setting up PostgreSQL instance + node = create_pg("node", start=False) + node.append_conf("log_connections = 'receipt,authentication,authorization'\n") + node.append_conf("shared_preload_libraries = 'ldap_password_func'") + node.start() + + node.safe_sql("CREATE USER test1;") + + # running tests + + # use ldapbindpasswd + os.environ["PGPASSWORD"] = "secret1" + + def set_hba(line): + """Replace pg_hba.conf with a single ldap line and restart.""" + hba = os.path.join(node.data_dir, "pg_hba.conf") + os.unlink(hba) + node.append_conf(line, filename="pg_hba.conf") + node.restart() + + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}" ' + f'ldapbinddn="{ldap_rootdn}" ldapbindpasswd=wrong' + ) + _access( + node, "test1", 2, "search+bind authentication fails with wrong ldapbindpasswd" + ) + + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}" ' + f'ldapbinddn="{ldap_rootdn}" ldapbindpasswd="{clear_ldap_rootpw}"' + ) + _access(node, "test1", 2, "search+bind authentication fails with clear password") + + set_hba( + f"local all all ldap ldapserver={ldap_server_name} " + f'ldapport={ldap_port} ldapbasedn="{ldap_basedn}" ' + f'ldapbinddn="{ldap_rootdn}" ldapbindpasswd="{rot13_ldap_rootpw}"' + ) + _access( + node, "test1", 0, "search+bind authentication succeeds with rot13ed password" + ) diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build index 506a9894b8d..24fbf981863 100644 --- a/src/test/modules/oauth_validator/meson.build +++ b/src/test/modules/oauth_validator/meson.build @@ -84,4 +84,17 @@ tests += { }, 'deps': [oauth_hook_client], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_server.py', + 'pyt/test_002_client.py', + ], + 'env': { + 'PYTHON': python.full_path(), + 'with_libcurl': oauth_flow_supported ? 'yes' : 'no', + 'with_python': 'yes', + 'cert_dir': meson.project_source_root() / 'src/test/ssl/ssl', + }, + 'deps': [oauth_hook_client], + }, } diff --git a/src/test/modules/oauth_validator/pyt/test_001_server.py b/src/test/modules/oauth_validator/pyt/test_001_server.py new file mode 100644 index 00000000000..da07ebbcb14 --- /dev/null +++ b/src/test/modules/oauth_validator/pyt/test_001_server.py @@ -0,0 +1,937 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests the libpq builtin OAuth flow, plus server-side HBA and validator setup. + +Connections run in-process through the libpq Session helpers +(node.connect_ok / node.connect_fails). The builtin OAuth device flow writes +its "Visit ... and enter the code" prompt and its "[libpq] total number of +polls" line directly to the process's real stderr (fd 2) rather than through a +libpq notice. connect_ok/connect_fails capture fd 2 during the connection +attempt and fold it into the stderr they match against expected_stderr, so +those device prompts / WARNINGs are passed as expected_stderr regexes. +""" + +import base64 +import contextlib +import json +import os +import re +import shutil +import subprocess + +import pytest + +# --------------------------------------------------------------------------- +# Gating +# --------------------------------------------------------------------------- + +if "oauth" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip( + "Potentially unsafe test oauth not enabled in PG_TEST_EXTRA", + allow_module_level=True, + ) + +if os.environ.get("with_libcurl") != "yes": + pytest.skip( + "client-side OAuth not supported by this build", + allow_module_level=True, + ) + +if os.environ.get("with_python") != "yes": + pytest.skip( + "OAuth tests require --with-python to run", + allow_module_level=True, + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# The device-flow prompt printed by the builtin libpq flow. connect_ok +# captures fd 2, so these are passed as expected_stderr. +PROMPT_EXAMPLE_COM = r"Visit https://example\.com/ and enter the code: postgresuser" +PROMPT_EXAMPLE_ORG = r"Visit https://example\.org/ and enter the code: postgresuser" + + +def _set_hba(node, contents): + """Replace pg_hba.conf with *contents* (unlink then append).""" + path = os.path.join(node.data_dir, "pg_hba.conf") + if os.path.exists(path): + os.unlink(path) + node.append_conf(contents, filename="pg_hba.conf") + + +def _set_ident(node, contents): + path = os.path.join(node.data_dir, "pg_ident.conf") + if os.path.exists(path): + os.unlink(path) + node.append_conf(contents, filename="pg_ident.conf") + + +@contextlib.contextmanager +def _env(**kwargs): + """Temporarily set environment variables (None deletes), restoring after.""" + saved = {k: os.environ.get(k) for k in kwargs} + try: + for k, v in kwargs.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + yield + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def _restart_failok(node): + """Restart allowing the start to fail; return True on success, else False. + + The framework's restart() raises on a failed start, so we stop then + start(fail_ok=True) to observe failure. + """ + node.stop(fail_ok=True) + return node.start(fail_ok=True) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def node(create_pg, oauth_server): + """A started node configured for the OAuth validator, plus the mock server. + + Yields a tuple ``(node, issuer)`` where *issuer* is the HTTPS issuer URL of + the running mock OAuth provider. + """ + n = create_pg("primary", start=False) + n.append_conf("log_connections = all\n") + n.append_conf("oauth_validator_libraries = 'validator'\n") + # Needed to allow connect_fails to inspect postmaster log: + n.append_conf("log_min_messages = debug2") + n.start() + + n.safe_sql("CREATE USER test;") + n.safe_sql("CREATE USER testalt;") + n.safe_sql("CREATE USER testparam;") + + script = os.path.join(os.path.dirname(__file__), "..", "t", "oauth_server.py") + srv = oauth_server(script) + issuer = f"https://127.0.0.1:{srv.port}" + + yield n, issuer, srv + + +# --------------------------------------------------------------------------- +# The test +# --------------------------------------------------------------------------- + + +def test_oauth_server(node): + n, issuer, srv = node + cert_dir = os.environ.get("cert_dir") + alternative_ca = f"{cert_dir}/root+server_ca.crt" + + # A background session for later configuration changes (ALTER SYSTEM, etc.). + bgconn = n.session() + + # ---------------------------------------------------------------------- + # Check the client refuses HTTP and untrusted HTTPS by default. + # ---------------------------------------------------------------------- + http_issuer = f"http://127.0.0.1:{srv.port}" + + _set_hba( + n, + f'\nlocal all test oauth issuer="{http_issuer}" scope="openid postgres"\n', + ) + n.reload() + log_start = n.wait_for_log(r"reloading configuration files") + + n.connect_fails( + f"user=test dbname=postgres oauth_issuer={http_issuer} oauth_client_id=f02c6361-0635", + "HTTPS is required without debug mode", + expected_stderr=( + r'OAuth discovery URI "' + + re.escape(http_issuer) + + r'/.well-known/openid-configuration" must use HTTPS' + ), + ) + + # PGOAUTHDEBUG=http should have no effect (it needs an UNSAFE: marker). + # The "option ... is unsafe" WARNING is printed by the builtin flow to the + # real stderr (fd 2); connect_fails captures it together with the libpq + # "must use HTTPS" error. Match both with a single multiline/dotall regex. + with _env(PGOAUTHDEBUG="http"): + n.connect_fails( + f"user=test dbname=postgres oauth_issuer={http_issuer} oauth_client_id=f02c6361-0635", + "HTTPS is required without debug mode (bad PGOAUTHDEBUG value)", + expected_stderr=( + r'(?ms)^WARNING: .* option "http" is unsafe' + r".*" + r'OAuth discovery URI "' + + re.escape(http_issuer) + + r'/.well-known/openid-configuration" must use HTTPS' + ), + ) + + # ---------------------------------------------------------------------- + # Switch to HTTPS. + # ---------------------------------------------------------------------- + _set_hba( + n, + f""" +local all test oauth issuer="{issuer}" scope="openid postgres" +local all testalt oauth issuer="{issuer}/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt" +local all testparam oauth issuer="{issuer}/param" scope="openid postgres" +""", + ) + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + # Check pg_hba_file_rules() support. + contents = bgconn.query_safe( + "SELECT rule_number, auth_method, options " + "FROM pg_hba_file_rules ORDER BY rule_number;" + ) + expected = ( + f'1|oauth|{{issuer={issuer},"scope=openid postgres",validator=validator}}\n' + f'2|oauth|{{issuer={issuer}/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator}}\n' + f'3|oauth|{{issuer={issuer}/param,"scope=openid postgres",validator=validator}}' + ) + assert contents == expected, ( + "pg_hba_file_rules recreates OAuth HBA settings\n" + f"got: {contents!r}\nexpected: {expected!r}" + ) + + # Make sure PGOAUTHDEBUG=UNSAFE doesn't disable certificate verification. + with _env(PGOAUTHDEBUG="UNSAFE"): + n.connect_fails( + f"user=test dbname=postgres oauth_issuer={issuer} oauth_client_id=f02c6361-0635", + "HTTPS trusts only system CA roots by default", + expected_stderr=( + r"(?i)could not fetch OpenID discovery document:.*peer certificate" + ), + ) + + user = "test" + + # Use the oauth_ca_file option to specify the alternative CA path. + n.connect_ok( + f"user={user} dbname=postgres oauth_issuer={issuer} oauth_client_id=f02c6361-0635 oauth_ca_file={alternative_ca}", + "connect as test (oauth_ca_file)", + expected_stderr=PROMPT_EXAMPLE_COM, + log_like=[ + rf'oauth_validator: token="9243959234", role="{user}"', + r'oauth_validator: issuer="' + + re.escape(issuer) + + r'", scope="openid postgres"', + r'connection authenticated: identity="test" method=oauth', + r"connection authorized", + ], + ) + + # Make sure we can use the environment variable without PGOAUTHDEBUG, and + # then use it for the rest of the tests. + with _env(PGOAUTHCAFILE=alternative_ca): + n.connect_ok( + f"user={user} dbname=postgres oauth_issuer={issuer} oauth_client_id=f02c6361-0635", + "connect as test", + expected_stderr=PROMPT_EXAMPLE_COM, + log_like=[ + rf'oauth_validator: token="9243959234", role="{user}"', + r'oauth_validator: issuer="' + + re.escape(issuer) + + r'", scope="openid postgres"', + r'connection authenticated: identity="test" method=oauth', + r"connection authorized", + ], + log_unlike=[r"FATAL.*OAuth bearer authentication failed"], + ) + + # Enable some debugging features for all remaining tests: + # - trace, for detailed Curl logs on failure + # - dos-endpoint, to speed up the three-way handshake + # - call-count, for our later sanity check + with _env(PGOAUTHDEBUG="UNSAFE:trace,dos-endpoint,call-count"): + + # The /alternate issuer uses slightly different parameters, along + # with an OAuth-style discovery document. + user = "testalt" + n.connect_ok( + f"user={user} dbname=postgres oauth_issuer={issuer}/alternate oauth_client_id=f02c6361-0636", + "connect as testalt", + expected_stderr=PROMPT_EXAMPLE_ORG, + log_like=[ + rf'oauth_validator: token="9243959234-alt", role="{user}"', + r'oauth_validator: issuer="' + + re.escape( + f"{issuer}/.well-known/oauth-authorization-server/alternate" + ) + + r'", scope="openid postgres alt"', + r'connection authenticated: identity="testalt" method=oauth', + r"connection authorized", + ], + log_unlike=[r"FATAL.*OAuth bearer authentication failed"], + ) + + # The issuer linked by the server must match the client's + # oauth_issuer setting. + n.connect_fails( + f"user={user} dbname=postgres oauth_issuer={issuer} oauth_client_id=f02c6361-0636", + "oauth_issuer must match discovery", + expected_stderr=( + r"server's discovery document at " + + re.escape( + f"{issuer}/.well-known/oauth-authorization-server/alternate" + ) + + r' \(issuer "' + + re.escape(f"{issuer}/alternate") + + r'"\) is incompatible with oauth_issuer \(' + + re.escape(issuer) + + r"\)" + ), + ) + + # Test require_auth settings against OAUTHBEARER. + cases = [ + {"require_auth": "oauth"}, + {"require_auth": "oauth,scram-sha-256"}, + {"require_auth": "password,oauth"}, + {"require_auth": "none,oauth"}, + {"require_auth": "!scram-sha-256"}, + {"require_auth": "!none"}, + { + "require_auth": "!oauth", + "failure": r"server requested OAUTHBEARER authentication", + }, + { + "require_auth": "scram-sha-256", + "failure": r"server requested OAUTHBEARER authentication", + }, + { + "require_auth": "!password,!oauth", + "failure": r"server requested OAUTHBEARER authentication", + }, + { + "require_auth": "none", + "failure": r"server requested SASL authentication", + }, + { + "require_auth": "!oauth,!scram-sha-256", + "failure": r"server requested SASL authentication", + }, + ] + + user = "test" + for c in cases: + case_connstr = ( + f"user={user} dbname=postgres oauth_issuer={issuer} " + f"oauth_client_id=f02c6361-0635 require_auth={c['require_auth']}" + ) + if "failure" in c: + n.connect_fails( + case_connstr, + f"require_auth={c['require_auth']} fails", + expected_stderr=c["failure"], + ) + else: + n.connect_ok( + case_connstr, + f"require_auth={c['require_auth']} succeeds", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + + # Make sure the client_id and secret are correctly encoded. + # $vschars contains every allowed character for a client_id/_secret + # (the "VSCHAR" class). In a connection string a single quote and + # backslash must be backslash-escaped. + vschars = ( + " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`" + "abcdefghijklmnopqrstuvwxyz{|}~" + ) + vschars_esc = vschars.replace("\\", "\\\\").replace("'", "\\'") + + n.connect_ok( + f"user={user} dbname=postgres oauth_issuer={issuer} oauth_client_id='{vschars_esc}'", + "escapable characters: client_id", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + n.connect_ok( + f"user={user} dbname=postgres oauth_issuer={issuer} oauth_client_id='{vschars_esc}' oauth_client_secret='{vschars_esc}'", + "escapable characters: client_id and secret", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + + # ------------------------------------------------------------------ + # Tests relying on oauth_server.py behaviors triggered via the + # special .../param issuer (set up in HBA for testparam) by encoding + # magic instructions into the oauth_client_id. + # ------------------------------------------------------------------ + common_connstr = ( + f"user=testparam dbname=postgres oauth_issuer={issuer}/param " + ) + base = {"value": common_connstr} + + def connstr(**params): + js = json.dumps(params, separators=(",", ":")) + # encode_base64($json, "") -> no line breaks + encoded = base64.b64encode(js.encode("utf-8")).decode("ascii") + return f"{base['value']} oauth_client_id={encoded}" + + # Make sure the param system works end-to-end first. + n.connect_ok( + connstr(), "connect to /param", expected_stderr=PROMPT_EXAMPLE_COM + ) + + n.connect_ok( + connstr(stage="token", retries=1), + "token retry", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + n.connect_ok( + connstr(stage="token", retries=2), + "token retry (twice)", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + n.connect_ok( + connstr(stage="all", retries=1, interval=2), + "token retry (two second interval)", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + n.connect_ok( + connstr(stage="all", retries=1, interval=None), + "token retry (default interval)", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + + n.connect_ok( + connstr(stage="all", content_type="application/json;charset=utf-8"), + "content type with charset", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + n.connect_ok( + connstr( + stage="all", content_type="application/json \t;\t charset=utf-8" + ), + "content type with charset (whitespace)", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + n.connect_ok( + connstr(stage="device", uri_spelling="verification_url"), + "alternative spelling of verification_uri", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + + n.connect_fails( + connstr(stage="device", huge_response=True), + "bad device authz response: overlarge JSON", + expected_stderr=r"could not obtain device authorization: response is too large", + ) + n.connect_fails( + connstr(stage="token", huge_response=True), + "bad token response: overlarge JSON", + expected_stderr=r"could not obtain access token: response is too large", + ) + + nesting_limit = 16 + n.connect_ok( + connstr( + stage="device", + nested_array=nesting_limit, + nested_object=nesting_limit, + ), + "nested arrays and objects, up to parse limit", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + n.connect_fails( + connstr(stage="device", nested_array=nesting_limit + 1), + "bad discovery response: overly nested JSON array", + expected_stderr=r"could not parse device authorization: JSON is too deeply nested", + ) + n.connect_fails( + connstr(stage="device", nested_object=nesting_limit + 1), + "bad discovery response: overly nested JSON object", + expected_stderr=r"could not parse device authorization: JSON is too deeply nested", + ) + + n.connect_fails( + connstr(stage="device", content_type="text/plain"), + "bad device authz response: wrong content type", + expected_stderr=r"could not parse device authorization: unexpected content type", + ) + n.connect_fails( + connstr(stage="token", content_type="text/plain"), + "bad token response: wrong content type", + expected_stderr=r"could not parse access token response: unexpected content type", + ) + n.connect_fails( + connstr(stage="token", content_type="application/jsonx"), + "bad token response: wrong content type (correct prefix)", + expected_stderr=r"could not parse access token response: unexpected content type", + ) + + n.connect_fails( + # 2**64-1 is the all-ones 64-bit unsigned integer. This huge + # interval makes the client overflow when it adds the + # device-authz interval. + connstr( + stage="all", + interval=2**64 - 1, + retries=1, + retry_code="slow_down", + ), + "bad token response: server overflows the device authz interval", + expected_stderr=r"could not obtain access token: slow_down interval overflow", + ) + + n.connect_fails( + connstr(stage="token", error_code="invalid_grant"), + "bad token response: invalid_grant, no description", + expected_stderr=r"could not obtain access token: \(invalid_grant\)", + ) + n.connect_fails( + connstr( + stage="token", + error_code="invalid_grant", + error_desc="grant expired", + ), + "bad token response: expired grant", + expected_stderr=r"could not obtain access token: grant expired \(invalid_grant\)", + ) + n.connect_fails( + connstr( + stage="token", + error_code="invalid_client", + error_status=401, + ), + "bad token response: client authentication failure, default description", + expected_stderr=( + r"could not obtain access token: provider requires client authentication, " + r"and no oauth_client_secret is set \(invalid_client\)" + ), + ) + n.connect_fails( + connstr( + stage="token", + error_code="invalid_client", + error_status=401, + error_desc="authn failure", + ), + "bad token response: client authentication failure, provided description", + expected_stderr=r"could not obtain access token: authn failure \(invalid_client\)", + ) + + n.connect_fails( + connstr(stage="token", token=""), + "server rejects access: empty token", + expected_stderr=r"bearer authentication failed", + ) + n.connect_fails( + connstr(stage="token", token="****"), + "server rejects access: invalid token contents", + expected_stderr=r"bearer authentication failed", + ) + + # Test behavior of the oauth_client_secret. + base["value"] = f"{common_connstr} oauth_client_secret=''" + + n.connect_ok( + connstr(stage="all", expected_secret=""), + "empty oauth_client_secret", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + + base["value"] = f"{common_connstr} oauth_client_secret='{vschars_esc}'" + + n.connect_ok( + connstr(stage="all", expected_secret=vschars), + "nonempty oauth_client_secret", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + + n.connect_fails( + connstr( + stage="token", + error_code="invalid_client", + error_status=401, + ), + "bad token response: client authentication failure, default description with oauth_client_secret", + expected_stderr=( + r"could not obtain access token: provider rejected the oauth_client_secret " + r"\(invalid_client\)" + ), + ) + n.connect_fails( + connstr( + stage="token", + error_code="invalid_client", + error_status=401, + error_desc="mutual TLS required for client", + ), + "bad token response: client authentication failure, provided description with oauth_client_secret", + expected_stderr=( + r"could not obtain access token: mutual TLS required for client \(invalid_client\)" + ), + ) + + # ------------------------------------------------------------------ + # Count the number of calls to the internal flow when multiple + # retries are triggered. The poll count is printed to fd 2 by the + # builtin flow (call-count debug feature). We grab stderr and run + # several checks against it via attempt_connection, which folds + # fd 2 into the stderr it returns. + # ------------------------------------------------------------------ + base["value"] = common_connstr + ok, _stdout, captured = n.attempt_connection( + connstr(stage="token", retries=2), + "SELECT 'connected for call count'", + ) + assert ok, f"call count connection succeeds, got {captured!r}" + assert re.search( + PROMPT_EXAMPLE_COM, captured + ), f"call count: stderr matches, got {captured!r}" + m = re.search(r"\[libpq\] total number of polls: (\d+)", captured) + assert m is not None, f"call count: count is printed, got {captured!r}" + # A typical two-retry flow takes 5-15 calls; hundreds/thousands would + # indicate the multiplexer isn't clearing stale events. + assert ( + int(m.group(1)) < 100 + ), f"call count is reasonably small: {m.group(1)}" + + # ------------------------------------------------------------------ + # Stress test: make sure the builtin flow operates correctly even if + # the client application isn't respecting PGRES_POLLING_*. This uses + # the oauth_hook_client test program (a separate C binary). + # ------------------------------------------------------------------ + base["value"] = f"{common_connstr} port={n.port} host={n.host}" + cmd = [ + "oauth_hook_client", + "--no-hook", + "--stress-async", + connstr(stage="all", retries=1, interval=1), + ] + print("# running '" + "' '".join(cmd) + "'") + exe = shutil.which("oauth_hook_client") or "oauth_hook_client" + cmd[0] = exe + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + assert re.search( + r"connection succeeded", proc.stdout + ), f"stress-async: stdout matches, got {proc.stdout!r}" + assert not re.search( + r"connection to database failed", proc.stderr + ), f"stress-async: stderr matches, got {proc.stderr!r}" + + # End of PGOAUTHDEBUG=UNSAFE:trace,... block. + + # ------------------------------------------------------------------ + # This section reconfigures the validator module itself, rather than + # the OAuth server. Hardcode the discovery URI (and empty scope) so + # OAuth parameter discovery doesn't clutter the logs. + # ------------------------------------------------------------------ + common_connstr = ( + f"dbname=postgres oauth_issuer={issuer}/.well-known/openid-configuration " + f"oauth_scope='' oauth_client_id=f02c6361-0635" + ) + + # Misbehaving validators must fail shut. + bgconn.do("ALTER SYSTEM SET oauth_validator.authn_id TO ''") + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + n.connect_fails( + f"{common_connstr} user=test", + "validator must set authn_id", + expected_stderr=r"OAuth bearer authentication failed", + log_like=[ + r'connection authenticated: identity=""', + r"FATAL: ( [A-Z0-9]+:)? OAuth bearer authentication failed", + r"DETAIL:\s+Validator provided no identity", + ], + ) + + # Even if a validator authenticates the user, if the token isn't valid + # the connection fails. + bgconn.do("ALTER SYSTEM SET oauth_validator.authn_id TO 'test@example.org'") + bgconn.do("ALTER SYSTEM SET oauth_validator.authorize_tokens TO false") + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + n.connect_fails( + f"{common_connstr} user=test", + "validator must authorize token explicitly", + expected_stderr=r"OAuth bearer authentication failed", + log_like=[ + r'connection authenticated: identity="test@example\.org"', + r"FATAL: ( [A-Z0-9]+:)? OAuth bearer authentication failed", + r"DETAIL:\s+Validator failed to authorize the provided token", + ], + ) + + # Validators can provide their own explanations. + bgconn.query_safe( + "ALTER SYSTEM SET oauth_validator.error_detail TO 'something failed'" + ) + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + n.connect_fails( + f"{common_connstr} user=test", + "validator must authorize token explicitly (custom logdetail)", + expected_stderr=r"OAuth bearer authentication failed", + log_like=[ + r'connection authenticated: identity="test@example\.org"', + r"FATAL:\s+OAuth bearer authentication failed", + r"DETAIL:\s+something failed", + ], + ) + + bgconn.query_safe("ALTER SYSTEM SET oauth_validator.internal_error TO true") + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + n.connect_fails( + f"{common_connstr} user=test", + "validator internal error (custom logdetail)", + expected_stderr=r"OAuth bearer authentication failed", + log_like=[ + r"WARNING:\s+internal error in OAuth validator module", + r"DETAIL:\s+something failed", + ], + ) + + bgconn.query_safe("ALTER SYSTEM RESET oauth_validator.error_detail") + bgconn.query_safe("ALTER SYSTEM RESET oauth_validator.internal_error") + + # We complain when bad option names are registered, but connections may + # proceed (users can't set those options in the HBA anyway). + bgconn.query_safe("ALTER SYSTEM RESET oauth_validator.authn_id") + bgconn.query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens") + bgconn.query_safe("ALTER SYSTEM SET oauth_validator.invalid_hba TO true") + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + n.connect_ok( + f"{common_connstr} user=test", + "bad registered HBA option", + expected_stderr=PROMPT_EXAMPLE_COM, + log_like=[ + r'WARNING:\s+HBA option name "bad option name" is invalid and will be ignored', + r'CONTEXT:\s+validator module "validator", in call to RegisterOAuthHBAOptions', + ], + ) + + bgconn.query_safe("ALTER SYSTEM RESET oauth_validator.invalid_hba") + + # ------------------------------------------------------------------ + # Test user mapping. + # ------------------------------------------------------------------ + # Allow "user@example.com" to log in under the test role. + _set_ident(n, "\noauthmap\tuser@example.com\ttest\n") + + # test and testalt use the map; testparam uses ident delegation. + _set_hba( + n, + f""" +local all test oauth issuer="{issuer}" scope="" map=oauthmap +local all testalt oauth issuer="{issuer}" scope="" map=oauthmap +local all testparam oauth issuer="{issuer}" scope="" delegate_ident_mapping=1 +""", + ) + + # To start, have the validator use the role names as authn IDs. + bgconn.do("ALTER SYSTEM RESET oauth_validator.authn_id") + bgconn.do("ALTER SYSTEM RESET oauth_validator.authorize_tokens") + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + # The test and testalt roles should no longer map correctly. + n.connect_fails( + f"{common_connstr} user=test", + "mismatched username map (test)", + expected_stderr=r"OAuth bearer authentication failed", + ) + n.connect_fails( + f"{common_connstr} user=testalt", + "mismatched username map (testalt)", + expected_stderr=r"OAuth bearer authentication failed", + ) + + # Have the validator identify the end user as user@example.com. + bgconn.do("ALTER SYSTEM SET oauth_validator.authn_id TO 'user@example.com'") + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + # Now the test role can be logged into. (testalt still can't be mapped.) + n.connect_ok( + f"{common_connstr} user=test", + "matched username map (test)", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + n.connect_fails( + f"{common_connstr} user=testalt", + "mismatched username map (testalt)", + expected_stderr=r"OAuth bearer authentication failed", + ) + + # testparam ignores the map entirely. + n.connect_ok( + f"{common_connstr} user=testparam", + "delegated ident (testparam)", + expected_stderr=PROMPT_EXAMPLE_COM, + ) + + bgconn.do("ALTER SYSTEM RESET oauth_validator.authn_id") + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + bgconn.quit() # the tests below restart the server + + # ------------------------------------------------------------------ + # Test validator-specific HBA options. + # ------------------------------------------------------------------ + _set_hba( + n, + f""" +local all test oauth issuer="{issuer}" scope="openid postgres" delegate_ident_mapping=1 \\ + validator.authn_id="ignored" validator.authn_id="other-identity" +local all testalt oauth issuer="{issuer}" scope="openid postgres" validator.log="testalt message" +""", + ) + n.reload() + log_start = n.wait_for_log(r"reloading configuration files", log_start) + + n.connect_ok( + f"{common_connstr} user=test", + "custom HBA setting (test)", + expected_stderr=PROMPT_EXAMPLE_COM, + log_like=[r'connection authenticated: identity="other-identity"'], + ) + n.connect_ok( + f"{common_connstr} user=testalt", + "custom HBA setting (testalt)", + expected_stderr=PROMPT_EXAMPLE_COM, + log_like=[ + r"LOG:\s+testalt message", + r'connection authenticated: identity="testalt"', + ], + ) + + # bad syntax + _set_hba( + n, + f'\nlocal all testalt oauth issuer="{issuer}" scope="openid postgres" validator.=1\n', + ) + log_start = n.log_position() + _restart_failok(n) + n.log_check( + "empty HBA option name", + log_start, + log_like=[r'invalid OAuth validator option name: "validator\."'], + ) + + _set_hba( + n, + f'\nlocal all testalt oauth issuer="{issuer}" scope="openid postgres" validator.@@=1\n', + ) + log_start = n.log_position() + _restart_failok(n) + n.log_check( + "invalid HBA option name", + log_start, + log_like=[r'invalid OAuth validator option name: "validator\.@@"'], + ) + + # unknown settings (validation is deferred to connect time) + _set_hba( + n, + f""" +local all testalt oauth issuer="{issuer}" scope="openid postgres" \\ + validator.log=ignored validator.bad=1 +""", + ) + n.restart() + + n.connect_fails( + f"{common_connstr} user=testalt", + "bad HBA setting", + expected_stderr=r"OAuth bearer authentication failed", + log_like=[ + r'WARNING:\s+unrecognized authentication option name: "validator\.bad"', + r"FATAL:\s+OAuth bearer authentication failed", + r'DETAIL:\s+unrecognized authentication option name: "validator\.bad"', + ], + ) + + # ------------------------------------------------------------------ + # Test multiple validators. + # ------------------------------------------------------------------ + n.append_conf("oauth_validator_libraries = 'validator, fail_validator'\n") + + # With multiple validators, every HBA line must explicitly declare one. + result = _restart_failok(n) + assert ( + result is False + ), "restart fails without explicit validators in oauth HBA entries" + log_start = n.wait_for_log( + r'authentication method "oauth" requires option "validator" to be set', + log_start, + ) + + _set_hba( + n, + f""" +local all test oauth validator=validator issuer="{issuer}" scope="openid postgres" +local all testalt oauth validator=fail_validator issuer="{issuer}/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt" +""", + ) + n.restart() + log_start = n.wait_for_log(r"ready to accept connections", log_start) + + # The test user should work as before. + user = "test" + n.connect_ok( + f"user={user} dbname=postgres oauth_issuer={issuer} oauth_client_id=f02c6361-0635", + f"validator is used for {user}", + expected_stderr=PROMPT_EXAMPLE_COM, + log_like=[r"connection authorized"], + ) + + # testalt should be routed through the fail_validator. + user = "testalt" + n.connect_fails( + f"user={user} dbname=postgres oauth_issuer={issuer}/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636", + f"fail_validator is used for {user}", + expected_stderr=r"FATAL: ( [A-Z0-9]+:)? fail_validator: sentinel error", + ) + + # ------------------------------------------------------------------ + # Test ABI compatibility magic marker. + # ------------------------------------------------------------------ + n.append_conf("oauth_validator_libraries = 'magic_validator'\n") + _set_hba( + n, + f'\nlocal all test oauth validator=magic_validator issuer="{issuer}" scope="openid postgres"\n', + ) + n.restart() + log_start = n.wait_for_log(r"ready to accept connections", log_start) + + n.connect_fails( + f"user=test dbname=postgres oauth_issuer={issuer}/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636", + f"magic_validator is used for {user}", + expected_stderr=( + r'FATAL: ( [A-Z0-9]+:)? OAuth validator module "magic_validator": magic number mismatch' + ), + ) + n.stop() diff --git a/src/test/modules/oauth_validator/pyt/test_002_client.py b/src/test/modules/oauth_validator/pyt/test_002_client.py new file mode 100644 index 00000000000..2d1feb5f9f5 --- /dev/null +++ b/src/test/modules/oauth_validator/pyt/test_002_client.py @@ -0,0 +1,283 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Exercises the API for custom OAuth client flows. + +The program under test is the ``oauth_hook_client`` test driver (a C binary +built in the module's build dir), which exercises libpq's OAuth client hook +callbacks/flows. It is never psql: each case runs the binary via the node's +``pg_bin`` helper and asserts on its exit/stdout/stderr plus the postmaster +log. These tests don't use the builtin flow and there's no authorization +server running, so the issuer is a deliberately invalid IP address -- if some +cascade of errors causes the client to actually attempt a connection to it, +we'll fail noisily. +""" + +import os +import re + +import pytest + +# --------------------------------------------------------------------------- +# Gating +# --------------------------------------------------------------------------- + +if "oauth" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip( + "Potentially unsafe test oauth not enabled in PG_TEST_EXTRA", + allow_module_level=True, + ) + + +# --------------------------------------------------------------------------- +# Cluster setup +# --------------------------------------------------------------------------- + +ISSUER = "https://256.256.256.256" +SCOPE = "openid postgres" +USER = "test" + + +@pytest.fixture +def node(create_pg): + """A started node configured for the OAuth validator. + + Yields a tuple ``(node, common_connstr)`` where *common_connstr* is the + base connection string used by every case (it is updated per-case in the + test body, so a fresh copy is rebuilt as needed there). + """ + n = create_pg("primary", start=False) + n.append_conf("log_connections = all\n") + n.append_conf("oauth_validator_libraries = 'validator'\n") + # Needed to inspect postmaster log after connection failure: + n.append_conf("log_min_messages = debug2") + n.start() + + n.safe_sql("CREATE USER test;") + + path = os.path.join(n.data_dir, "pg_hba.conf") + if os.path.exists(path): + os.unlink(path) + n.append_conf( + f'\nlocal all test oauth issuer="{ISSUER}" scope="{SCOPE}"\n', + filename="pg_hba.conf", + ) + n.reload() + n.wait_for_log(r"reloading configuration files") + + yield n + + +# --------------------------------------------------------------------------- +# Test driver +# --------------------------------------------------------------------------- + + +def _run_case( + n, + common_connstr, + test_name, + *, + flags=None, + expect_success=False, + expected_stderr=None, + log_like=None, +): + """Run oauth_hook_client for one case and assert on its output. + + *common_connstr* is the connection string appended after the flags, so the + binary is invoked as ``oauth_hook_client ``. + """ + flags = flags or [] + cmd = ["oauth_hook_client", *flags, common_connstr] + + log_start = n.log_position() + res = n.pg_bin.result(cmd) + + if expect_success: + assert re.search( + r"connection succeeded", res.stdout + ), f"{test_name}: stdout matches, got {res.stdout!r}" + + if expected_stderr is not None: + assert re.search( + expected_stderr, res.stderr + ), f"{test_name}: stderr matches {expected_stderr!r}, got {res.stderr!r}" + else: + assert res.stderr == "", f"{test_name}: no stderr, got {res.stderr!r}" + + if log_like is not None: + # See connect_fails(): to avoid races, wait for the postmaster to flush + # the log for the finished connection. + # Use (?s) so ".*" spans the newline between the two DEBUG lines, since + # wait_for_log compiles the regex without the DOTALL flag. + n.wait_for_log( + r"(?s)DEBUG: (?:00000: )?forked new client backend, pid=(\d+) " + r"socket.*DEBUG: (?:00000: )?client backend \(PID \1\) exited " + r"with exit code \d", + log_start, + ) + n.log_check(f"{test_name}: log matches", log_start, log_like=log_like) + + +# --------------------------------------------------------------------------- +# The test +# --------------------------------------------------------------------------- + + +def test_oauth_client(node): + n = node + + os.environ["PGOAUTHDEBUG"] = "UNSAFE" + try: + base_connstr = f"{n.connstr()} user={USER}" + common_connstr = f"{base_connstr} oauth_issuer={ISSUER} oauth_client_id=myID" + + _run_case( + n, + common_connstr, + "basic synchronous hook can provide a token", + flags=[ + "--token", + "my-token", + "--expected-uri", + f"{ISSUER}/.well-known/openid-configuration", + "--expected-issuer", + ISSUER, + "--expected-scope", + SCOPE, + ], + expect_success=True, + log_like=[rf'oauth_validator: token="my-token", role="{USER}"'], + ) + + # The issuer ID provided to the hook is based on, but not equal to, + # oauth_issuer. Make sure the correct string is passed. + common_connstr = ( + f"{base_connstr} " + f"oauth_issuer={ISSUER}/.well-known/openid-configuration " + f"oauth_client_id=myID oauth_scope='{SCOPE}'" + ) + _run_case( + n, + common_connstr, + "derived issuer ID is correctly provided", + flags=[ + "--token", + "my-token", + "--expected-uri", + f"{ISSUER}/.well-known/openid-configuration", + "--expected-issuer", + ISSUER, + "--expected-scope", + SCOPE, + ], + expect_success=True, + log_like=[rf'oauth_validator: token="my-token", role="{USER}"'], + ) + + common_connstr = f"{base_connstr} oauth_issuer={ISSUER} oauth_client_id=myID" + + # Make sure the v1 hook continues to work. + _run_case( + n, + common_connstr, + "v1 synchronous hook can provide a token", + flags=[ + "-v1", + "--token", + "my-token-v1", + "--expected-uri", + f"{ISSUER}/.well-known/openid-configuration", + "--expected-scope", + SCOPE, + ], + expect_success=True, + log_like=[rf'oauth_validator: token="my-token-v1", role="{USER}"'], + ) + + if os.environ.get("with_libcurl") != "yes": + # libpq should help users out if no OAuth support is built in. + _run_case( + n, + common_connstr, + "fails without custom hook installed", + flags=["--no-hook"], + expected_stderr=( + r"no OAuth flows are available " + r"\(try installing the libpq-oauth package\)" + ), + ) + + # v2 synchronous flows should be able to set custom error messages. + _run_case( + n, + common_connstr, + "basic synchronous hook can set error messages", + flags=["--error", "a custom error message"], + expected_stderr=r"user-defined OAuth flow failed: a custom error message", + ) + + # connect_timeout should work if the flow doesn't respond. + common_connstr_timeout = f"{common_connstr} connect_timeout=1" + _run_case( + n, + common_connstr_timeout, + "connect_timeout interrupts hung client flow", + flags=["--hang-forever"], + expected_stderr=r"failed: timeout expired", + ) + + # Remove the timeout for later tests. + common_connstr = f"{base_connstr} oauth_issuer={ISSUER} oauth_client_id=myID" + + # Test various misbehaviors of the client hook. + cases = [ + { + "flag": "--misbehave=no-hook", + "expected_error": ( + r"user-defined OAuth flow provided neither a token " + r"nor an async callback" + ), + }, + { + "flag": "--misbehave=fail-async", + "expected_error": r"user-defined OAuth flow failed", + }, + { + "flag": "--misbehave=no-token", + "expected_error": r"user-defined OAuth flow did not provide a token", + }, + { + "flag": "--misbehave=no-socket", + "expected_error": ( + r"user-defined OAuth flow did not provide a socket for polling" + ), + }, + ] + + for c in cases: + _run_case( + n, + common_connstr, + f"hook misbehavior: {c['flag']}", + flags=[c["flag"]], + expected_stderr=c["expected_error"], + ) + _run_case( + n, + common_connstr, + f"hook misbehavior: {c['flag']} (v1)", + flags=["-v1", c["flag"]], + expected_stderr=c["expected_error"], + ) + + # v2 async flows should be able to set error messages, too. + _run_case( + n, + common_connstr, + "asynchronous hook can set error messages", + flags=["--misbehave", "fail-async", "--error", "async error message"], + expected_stderr=(r"user-defined OAuth flow failed: async error message"), + ) + finally: + os.environ.pop("PGOAUTHDEBUG", None) diff --git a/src/test/modules/ssl_passphrase_callback/meson.build b/src/test/modules/ssl_passphrase_callback/meson.build index 1b4078c037e..35ca9f0890f 100644 --- a/src/test/modules/ssl_passphrase_callback/meson.build +++ b/src/test/modules/ssl_passphrase_callback/meson.build @@ -54,4 +54,10 @@ tests += { ], 'env': {'with_ssl': 'openssl'}, }, + 'pytest': { + 'tests': [ + 'pyt/test_001_testfunc.py', + ], + 'env': {'with_ssl': 'openssl'}, + }, } diff --git a/src/test/modules/ssl_passphrase_callback/pyt/test_001_testfunc.py b/src/test/modules/ssl_passphrase_callback/pyt/test_001_testfunc.py new file mode 100644 index 00000000000..99404b9ac0a --- /dev/null +++ b/src/test/modules/ssl_passphrase_callback/pyt/test_001_testfunc.py @@ -0,0 +1,114 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Exercises the ssl_passphrase_func contrib module. + +Tests an encrypted server key +whose passphrase is supplied by the module's TLS init hook callback. Checks +that the server starts with the correct passphrase, warns when +ssl_passphrase_command is also set, fails to start with the wrong passphrase, +and (with SNI) bypasses the hook. +""" + +import os +import re +import shutil + +# Directory holding the module's own server.crt / server.key (the source dir, +# one level up from this pyt/ directory). We anchor them to the module +# directory so the test works regardless of where pytest is invoked from. +_MODULE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + + +def _install_cert(node): + """Copy the encrypted server key + cert into the data dir.""" + ddir = node.data_dir + shutil.copy(os.path.join(_MODULE_DIR, "server.crt"), ddir) + shutil.copy(os.path.join(_MODULE_DIR, "server.key"), ddir) + os.chmod(os.path.join(ddir, "server.key"), 0o600) + + +def test_001_testfunc(create_pg, ssl_server): + # The ssl_server fixture skips the test unless with_ssl=openssl. It also + # provides the LibreSSL check. + libressl = ssl_server.is_libressl() + + rot13pass = "SbbOnE1" + + # see the Makefile/meson.build for how the certificate and key were + # generated (the clear passphrase is "FooBaR1", rot13 of "SbbOnE1"). + node = create_pg("main", start=False) + node.append_conf(f"ssl_passphrase.passphrase = '{rot13pass}'") + node.append_conf("shared_preload_libraries = 'ssl_passphrase_func'") + node.append_conf("ssl = 'on'") + + ddir = node.data_dir + + # install certificate and protected key + _install_cert(node) + + node.start() + + # if the server is running we must have successfully transformed the + # passphrase + assert os.path.exists(node.pidfile), "postgres started" + + node.stop("fast") + + # should get a warning if ssl_passphrase_command is set + node.rotate_logfile() + + node.append_conf("ssl_passphrase_command = 'echo spl0tz'") + + node.start() + + node.stop("fast") + + log_contents = node.log_content() + + assert re.search( + r'WARNING.*"ssl_passphrase_command" setting ignored by ' + r"ssl_passphrase_func module", + log_contents, + ), "ssl_passphrase_command set warning" + + # set the wrong passphrase + node.append_conf("ssl_passphrase.passphrase = 'blurfl'") + + # try to start the server again -- with a bad passphrase the server should + # not start. start(fail_ok=True) returns False instead of raising. + started = node.start(fail_ok=True) + + assert not started, "pg_ctl fails with bad passphrase" + assert not os.path.exists(node.pidfile), "postgres not started with bad passphrase" + + # just in case + node.stop("fast") + + # Make sure the hook is bypassed when SNI is enabled. + if libressl: + # SNI not supported with LibreSSL. + return + + node.append_conf("ssl_passphrase_command = 'echo FooBaR1'\nssl_sni = on\n") + node.append_conf( + f'example.org "{ddir}/server.crt" "{ddir}/server.key" "" ' + '"echo FooBaR1" on\n' + f'example.com "{ddir}/server.crt" "{ddir}/server.key" "" ' + '"echo FooBaR1" on\n', + filename="pg_hosts.conf", + ) + + # If the server starts and runs, the bad ssl_passphrase.passphrase was + # correctly ignored. + node.start() + assert os.path.exists(node.pidfile), "postgres started after SNI" + + node.stop("fast") + log_contents = node.log_content() + assert re.search( + r"WARNING.*SNI is enabled; installed TLS init hook will be ignored", + log_contents, + ), "server warns that init hook and SNI are incompatible" + # Ensure that the warning was printed once and not once per host line + count = len(re.findall(r"installed TLS init hook will be ignored", log_contents)) + assert count == 1, "Only one WARNING" diff --git a/src/test/ssl/meson.build b/src/test/ssl/meson.build index d7e7ce23433..f576591e1ff 100644 --- a/src/test/ssl/meson.build +++ b/src/test/ssl/meson.build @@ -16,4 +16,16 @@ tests += { 't/004_sni.pl', ], }, + 'pytest': { + 'env': { + 'with_ssl': ssl_library, + 'OPENSSL': openssl.found() ? openssl.full_path() : '', + }, + 'tests': [ + 'pyt/test_001_ssltests.py', + 'pyt/test_002_scram.py', + 'pyt/test_003_sslinfo.py', + 'pyt/test_004_sni.py', + ], + }, } diff --git a/src/test/ssl/pyt/test_001_ssltests.py b/src/test/ssl/pyt/test_001_ssltests.py new file mode 100644 index 00000000000..ef6fb092676 --- /dev/null +++ b/src/test/ssl/pyt/test_001_ssltests.py @@ -0,0 +1,1162 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""The main SSL test. + +Exercises server cert/key/CRL variations (via switch_server_cert), client +certificate authentication, and the libpq +sslmode/sslnegotiation/sslrootcert/sslcert/sslkey options, CRL directory vs +file, verify-ca/verify-full host name matching, and channel binding. + +Connections are made in-process through libpq (node.connect_ok / +connect_fails); psql is forked only for the two pg_stat_ssl command_like +checks. + +The SSL helper (pypg.ssl_server.SSLServer, exposed by the ``ssl_server`` +fixture) installs the server certs into the data directory and copies the +client keys to a private-perms temp dir. Client cert files referenced in +connection strings are given as absolute paths under src/test/ssl/ssl; the key +fragment comes from ssl_server.sslkey() so libpq sees the right permissions. +""" + +import os +import subprocess +import sys + +import pytest + +# Path to src/test/ssl/ssl, where the cert/key/CRL files live. +_SSL_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "ssl")) + + +def _ssl(relpath): + """Absolute path to a file under src/test/ssl/ssl. + + We expand to an absolute path so the working directory does not matter. + """ + # Forward slashes: these paths go into conninfo, where libpq treats a + # backslash as an escape character. + return os.path.join(_SSL_DIR, relpath).replace("\\", "/") + + +def test_001_ssltests(create_pg, ssl_server): + # The ssl_server fixture already skips unless with_ssl=openssl. + if "ssl" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip("Potentially unsafe test SSL not enabled in PG_TEST_EXTRA") + + windows_os = sys.platform == "win32" + + # Determine whether this build uses OpenSSL or LibreSSL. + libressl = ssl_server.is_libressl() + + # This is the hostname used to connect to the server. This cannot be a + # hostname, because the server certificate is always for the domain + # postgresql-ssl-regression.test. + SERVERHOSTADDR = "127.0.0.1" + # This is the pattern to use in pg_hba.conf to match incoming connections. + SERVERHOSTCIDR = "127.0.0.1/32" + + # Determine whether build supports sslcertmode=require. + supports_sslcertmode_require = ssl_server.check_pg_config( + "#define HAVE_SSL_CTX_SET_CERT_CB 1" + ) + # Determine whether build supports IPv6 in certificates. + have_inet_pton = ssl_server.check_pg_config("#define HAVE_INET_PTON 1") + + # Set of default settings for SSL parameters in connection string. This + # makes the tests protected against any defaults the environment may have + # in ~/.postgresql/. + default_ssl_connstr = ( + "sslkey=invalid sslcert=invalid sslrootcert=invalid " + "sslcrl=invalid sslcrldir=invalid" + ) + + # Allocation of base connection string shared among multiple tests. + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"hostaddr={SERVERHOSTADDR} host=common-name.pg-ssltest.test" + ) + + #### Set up the server. + + node = create_pg("primary", start=False) + # Needed to allow connect_fails to inspect postmaster log: + node.append_conf("log_min_messages = debug2") + node.start() + + # Run this before we lock down access below. + result = node.safe_sql("SHOW ssl_library") + assert result == ssl_server.ssl_library(), "ssl_library parameter" + + exec_backend = node.safe_sql("SHOW debug_exec_backend").strip() + + ssl_server.configure_test_server_for_ssl( + node, SERVERHOSTADDR, SERVERHOSTCIDR, "trust" + ) + + def switch_server_cert(*args, **kwargs): + ssl_server.switch_server_cert(node, *args, **kwargs) + + def sslkey(keyfile): + return ssl_server.sslkey(keyfile) + + def restart_check(): + """Emulate $node->restart(fail_ok => 1): stop then start. + + Returns True if the server came back up, False otherwise. pg_ctl + restart cannot tolerate a server that fails to start, so this performs + a fail-tolerant stop followed by a fail-tolerant start. + """ + node.stop("fast", fail_ok=True) + return node.start(fail_ok=True) + + # ---- testing password-protected keys ---------------------------------- + + # Test a passphrase command which fails to unlock the private key, the + # server should not start at all. + switch_server_cert( + certfile="server-cn-only", + cafile="root+client_ca", + keyfile="server-password", + passphrase_cmd="echo wrongpassword", + restart=False, + ) + + # restart should fail (wrong password). + node.stop("fast", fail_ok=True) + log_pos = node.log_position() + started = node.start(fail_ok=True) + assert ( + not started + ), "restart fails with password-protected key file with wrong password" + assert node.log_contains(r"could not load private key file", log_pos) + # The failed start above leaves the server down; the next switch+restart + # picks it up from there. + + # Test a passphrase command which successfully unlocks the private key but + # which doesn't support reloading. Unlocking the private key will fail + # when reloading and the already existing SSL context will remain in place, + # with connections still accepted. EXEC_BACKEND builds will reload the SSL + # context on each backend startup, so command reloading must be enabled or + # else connections will fail. + switch_server_cert( + certfile="server-cn-only", + cafile="root+client_ca", + keyfile="server-password", + passphrase_cmd="echo secret1", + passphrase_cmd_reload="off", + restart=False, + ) + + log_pos = node.log_position() + started = restart_check() + assert started, "restart succeeds with password-protected key file" + assert not node.log_contains(r"could not load private key file", log_pos) + + if "on" in exec_backend: + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require", + "connect with correct server CA cert file sslmode=require", + expected_stderr=r"server does not support SSL", + ) + else: + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require", + "connect with correct server CA cert file sslmode=require", + ) + + # Reloading should fail since we cannot execute the passphrase command + node.reload() + log_start = node.wait_for_log( + r"cannot be reloaded because it requires a passphrase" + ) + + # Test a passphrase command which successfully unlocks the private key, and + # which can be reloaded. The server should start and connections be + # accepted. + switch_server_cert( + certfile="server-cn-only", + cafile="root+client_ca", + keyfile="server-password", + passphrase_cmd="echo secret1", + passphrase_cmd_reload="on", + restart=False, + ) + + log_pos = node.log_position() + started = restart_check() + assert started, "restart succeeds with password-protected key file" + assert not node.log_contains(r"could not load private key file", log_pos) + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require", + "connect with correct server CA cert file sslmode=require", + ) + + # Reloading the config should execute the passphrase reload command and + # successfully reload the private key. + node.reload() + log_start = node.wait_for_log(r"reloading configuration files", log_start) + node.log_check( + "passphrase could reload private key", + log_start, + log_unlike=[r"cannot be reloaded because it requires a passphrase"], + ) + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require", + "connect with correct server CA cert file sslmode=require", + ) + + # Test compatibility of SSL protocols. + # TLSv1.1 is lower than TLSv1.2, so it won't work. + node.append_conf( + "ssl_min_protocol_version='TLSv1.2'\nssl_max_protocol_version='TLSv1.1'\n" + ) + started = restart_check() + assert not started, "restart fails with incorrect SSL protocol bounds" + + # Go back to the defaults, this works. + node.append_conf( + "ssl_min_protocol_version='TLSv1.2'\nssl_max_protocol_version=''\n" + ) + started = restart_check() + assert started, "restart succeeds with correct SSL protocol bounds" + + # Test parsing colon-separated groups. Resetting to a default value to + # clear the error is fine since the call to switch_server_cert in the + # client side tests will overwrite ssl_groups with a known set of groups. + node.append_conf("ssl_groups='bad:value'", filename="sslconfig.conf") + log_pos = node.log_position() + started = restart_check() + assert not started, "restart fails with incorrect groups" + assert not node.log_contains( + r"no SSL error reported", log_pos + ), "error message translated" + node.append_conf("ssl_groups='prime256v1'", filename="ssl_config.conf") + restart_check() + + # ---- Run client-side tests -------------------------------------------- + # + # Test that libpq accepts/rejects the connection correctly, depending on + # sslmode and whether the server's certificate looks correct. No client + # certificate is used in these tests. + + switch_server_cert(certfile="server-cn-only") + + if not libressl: + # Keylogging is not supported with LibreSSL. + # Forward slashes: these paths go into connection strings as a + # sslkeylogfile= value, where libpq would mangle Windows backslashes. + tempdir = str(node.basedir).replace("\\", "/") + + # Connect should work with a given sslkeylogfile + keytxt = tempdir + "/key.txt" + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} " + f"sslkeylogfile={keytxt} sslmode=require", + f"connect with server root cert and sslkeylogfile={keytxt}", + ) + + # Verify the key file exists + assert os.path.isfile(keytxt), f"keylog file exists at: {keytxt}" + + # Skip permission checks on Windows/Cygwin + if not windows_os: + status = os.stat(keytxt) + assert not (status.st_mode & 0o006), "keylog file is not world readable" + + # Connect should work with an incorrect sslkeylogfile, with the error + # to open the logfile printed to stderr + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} " + f"sslkeylogfile={tempdir}/invalid/key.txt sslmode=require", + "connect with server root cert and incorrect sslkeylogfile path", + expected_stderr=r"could not open", + ) + + # The server should not accept non-SSL connections. + node.connect_fails( + f"{common_connstr} sslmode=disable", + "server doesn't accept non-SSL connections", + expected_stderr=r"no pg_hba\.conf entry", + ) + + # Try without a root cert. In sslmode=require, this should work. In + # verify-ca or verify-full mode it should fail. + node.connect_ok( + f"{common_connstr} sslrootcert=invalid sslmode=require", + "connect without server root cert sslmode=require", + ) + node.connect_fails( + f"{common_connstr} sslrootcert=invalid sslmode=verify-ca", + "connect without server root cert sslmode=verify-ca", + expected_stderr=r'root certificate file "invalid" does not exist', + ) + node.connect_fails( + f"{common_connstr} sslrootcert=invalid sslmode=verify-full", + "connect without server root cert sslmode=verify-full", + expected_stderr=r'root certificate file "invalid" does not exist', + ) + + # Try with wrong root cert, should fail. (We're using the client CA as the + # root, but the server's key is signed by the server CA.) + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('client_ca.crt')} sslmode=require", + "connect with wrong server root cert sslmode=require", + expected_stderr=r"SSL error: certificate verify failed", + ) + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('client_ca.crt')} sslmode=verify-ca", + "connect with wrong server root cert sslmode=verify-ca", + expected_stderr=r"SSL error: certificate verify failed", + ) + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('client_ca.crt')} sslmode=verify-full", + "connect with wrong server root cert sslmode=verify-full", + expected_stderr=r"SSL error: certificate verify failed", + ) + + # Try with just the server CA's cert. This fails because the root file + # must contain the whole chain up to the root CA. + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('server_ca.crt')} sslmode=verify-ca", + "connect with server CA cert, without root CA", + expected_stderr=r"SSL error: certificate verify failed", + ) + + # And finally, with the correct root cert. + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require", + "connect with correct server CA cert file sslmode=require", + ) + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca", + "connect with correct server CA cert file sslmode=verify-ca", + ) + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-full", + "connect with correct server CA cert file sslmode=verify-full", + ) + + # Test with cert root file that contains two certificates. The client + # should be able to pick the right one, regardless of the order in the file. + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('both-cas-1.crt')} sslmode=verify-ca", + "cert root file that contains two certificates, order 1", + ) + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('both-cas-2.crt')} sslmode=verify-ca", + "cert root file that contains two certificates, order 2", + ) + + # sslcertmode=allow and disable should both work without a client cert. + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require sslcertmode=disable", + "connect with sslcertmode=disable", + ) + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require sslcertmode=allow", + "connect with sslcertmode=allow", + ) + + # sslcertmode=require, however, should fail. + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require sslcertmode=require", + "connect with sslcertmode=require fails without a client certificate", + expected_stderr=( + r"server accepted connection without a valid SSL certificate" + if supports_sslcertmode_require + else r'sslcertmode value "require" is not supported' + ), + ) + + # CRL tests + + # Invalid CRL filename is the same as no CRL, succeeds + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca sslcrl=invalid", + "sslcrl option with invalid file name", + ) + + # A CRL belonging to a different CA is not accepted, fails + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca sslcrl={_ssl('client.crl')}", + "CRL belonging to a different CA", + expected_stderr=r"SSL error: certificate verify failed", + ) + + # The same for CRL directory. sslcrl='' is added here to override the + # invalid default, so as this does not interfere with this case. + node.connect_fails( + f"{common_connstr} sslcrl='' sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca sslcrldir={_ssl('client-crldir')}", + "directory CRL belonging to a different CA", + expected_stderr=r"SSL error: certificate verify failed", + ) + + # With the correct CRL, succeeds (this cert is not revoked) + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca sslcrl={_ssl('root+server.crl')}", + "CRL with a non-revoked cert", + ) + + # The same for CRL directory + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca sslcrldir={_ssl('root+server-crldir')}", + "directory CRL with a non-revoked cert", + ) + + # Check that connecting with verify-full fails, when the hostname doesn't + # match the hostname in the server's certificate. + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"sslrootcert={_ssl('root+server_ca.crt')} hostaddr={SERVERHOSTADDR}" + ) + + node.connect_ok( + f"{common_connstr} sslmode=require host=wronghost.test", + "mismatch between host name and server certificate sslmode=require", + ) + node.connect_ok( + f"{common_connstr} sslmode=verify-ca host=wronghost.test", + "mismatch between host name and server certificate sslmode=verify-ca", + ) + node.connect_fails( + f"{common_connstr} sslmode=verify-full host=wronghost.test", + "mismatch between host name and server certificate sslmode=verify-full", + expected_stderr=( + r'server certificate for "common-name\.pg-ssltest\.test" does ' + r'not match host name "wronghost\.test"' + ), + ) + + # Test with an IP address in the Common Name. This is a strange corner + # case that nevertheless is supported, as long as the address string + # matches exactly. + switch_server_cert(certfile="server-ip-cn-only") + + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"sslrootcert={_ssl('root+server_ca.crt')} hostaddr={SERVERHOSTADDR} " + "sslmode=verify-full" + ) + + node.connect_ok( + f"{common_connstr} host=192.0.2.1 sslsni=0", "IP address in the Common Name" + ) + + node.connect_fails( + f"{common_connstr} host=192.000.002.001 sslsni=0", + "mismatch between host name and server certificate IP address", + expected_stderr=( + r'server certificate for "192\.0\.2\.1" does not match host name ' + r'"192\.000\.002\.001"' + ), + ) + + # Similarly, we'll also match an IP address in a dNSName SAN. (This is + # long-standing behavior.) + switch_server_cert(certfile="server-ip-in-dnsname") + + node.connect_ok( + f"{common_connstr} host=192.0.2.1 sslsni=0", "IP address in a dNSName" + ) + + # Test Subject Alternative Names. + switch_server_cert(certfile="server-multiple-alt-names") + + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"sslrootcert={_ssl('root+server_ca.crt')} hostaddr={SERVERHOSTADDR} " + "sslmode=verify-full" + ) + + node.connect_ok( + f"{common_connstr} host=dns1.alt-name.pg-ssltest.test", + "host name matching with X.509 Subject Alternative Names 1", + ) + node.connect_ok( + f"{common_connstr} host=dns2.alt-name.pg-ssltest.test", + "host name matching with X.509 Subject Alternative Names 2", + ) + node.connect_ok( + f"{common_connstr} host=foo.wildcard.pg-ssltest.test", + "host name matching with X.509 Subject Alternative Names wildcard", + ) + + node.connect_fails( + f"{common_connstr} host=wronghost.alt-name.pg-ssltest.test", + "host name not matching with X.509 Subject Alternative Names", + expected_stderr=( + r'server certificate for "dns1\.alt-name\.pg-ssltest\.test" ' + r"\(and 2 other names\) does not match host name " + r'"wronghost\.alt-name\.pg-ssltest\.test"' + ), + ) + node.connect_fails( + f"{common_connstr} host=deep.subdomain.wildcard.pg-ssltest.test", + "host name not matching with X.509 Subject Alternative Names wildcard", + expected_stderr=( + r'server certificate for "dns1\.alt-name\.pg-ssltest\.test" ' + r"\(and 2 other names\) does not match host name " + r'"deep\.subdomain\.wildcard\.pg-ssltest\.test"' + ), + ) + + # Test certificate with a single Subject Alternative Name. (this gives a + # slightly different error message, that's all) + switch_server_cert(certfile="server-single-alt-name") + + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"sslrootcert={_ssl('root+server_ca.crt')} hostaddr={SERVERHOSTADDR} " + "sslmode=verify-full" + ) + + node.connect_ok( + f"{common_connstr} host=single.alt-name.pg-ssltest.test", + "host name matching with a single X.509 Subject Alternative Name", + ) + + node.connect_fails( + f"{common_connstr} host=wronghost.alt-name.pg-ssltest.test", + "host name not matching with a single X.509 Subject Alternative Name", + expected_stderr=( + r'server certificate for "single\.alt-name\.pg-ssltest\.test" ' + r'does not match host name "wronghost\.alt-name\.pg-ssltest\.test"' + ), + ) + node.connect_fails( + f"{common_connstr} host=deep.subdomain.wildcard.pg-ssltest.test", + "host name not matching with a single X.509 Subject Alternative Name wildcard", + expected_stderr=( + r'server certificate for "single\.alt-name\.pg-ssltest\.test" ' + r"does not match host name " + r'"deep\.subdomain\.wildcard\.pg-ssltest\.test"' + ), + ) + + if have_inet_pton: + # Test certificate with IP addresses in the SANs. + switch_server_cert(certfile="server-ip-alt-names") + + node.connect_ok( + f"{common_connstr} host=192.0.2.1", + "host matching an IPv4 address (Subject Alternative Name 1)", + ) + + node.connect_ok( + f"{common_connstr} host=192.000.002.001", + "host matching an IPv4 address in alternate form (Subject Alternative Name 1)", + ) + + node.connect_fails( + f"{common_connstr} host=192.0.2.2", + "host not matching an IPv4 address (Subject Alternative Name 1)", + expected_stderr=( + r'server certificate for "192\.0\.2\.1" \(and 1 other name\) ' + r'does not match host name "192\.0\.2\.2"' + ), + ) + + node.connect_ok( + f"{common_connstr} host=2001:DB8::1", + "host matching an IPv6 address (Subject Alternative Name 2)", + ) + + node.connect_ok( + f"{common_connstr} host=2001:db8:0:0:0:0:0:1", + "host matching an IPv6 address in alternate form (Subject Alternative Name 2)", + ) + + node.connect_ok( + f"{common_connstr} host=2001:db8::0.0.0.1", + "host matching an IPv6 address in mixed form (Subject Alternative Name 2)", + ) + + node.connect_fails( + f"{common_connstr} host=::1", + "host not matching an IPv6 address (Subject Alternative Name 2)", + expected_stderr=( + r'server certificate for "192\.0\.2\.1" \(and 1 other name\) ' + r'does not match host name "::1"' + ), + ) + + node.connect_fails( + f"{common_connstr} host=2001:DB8::1/128", + "IPv6 host with CIDR mask does not match", + expected_stderr=( + r'server certificate for "192\.0\.2\.1" \(and 1 other name\) ' + r'does not match host name "2001:DB8::1/128"' + ), + ) + + # Test server certificate with a CN and DNS SANs. Per RFCs 2818 and 6125, + # the CN should be ignored when the certificate has both. + switch_server_cert(certfile="server-cn-and-alt-names") + + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"sslrootcert={_ssl('root+server_ca.crt')} hostaddr={SERVERHOSTADDR} " + "sslmode=verify-full" + ) + + node.connect_ok( + f"{common_connstr} host=dns1.alt-name.pg-ssltest.test", + "certificate with both a CN and SANs 1", + ) + node.connect_ok( + f"{common_connstr} host=dns2.alt-name.pg-ssltest.test", + "certificate with both a CN and SANs 2", + ) + node.connect_fails( + f"{common_connstr} host=common-name.pg-ssltest.test", + "certificate with both a CN and SANs ignores CN", + expected_stderr=( + r'server certificate for "dns1\.alt-name\.pg-ssltest\.test" ' + r"\(and 1 other name\) does not match host name " + r'"common-name\.pg-ssltest\.test"' + ), + ) + + if have_inet_pton: + # But we will fall back to check the CN if the SANs contain only IP + # addresses. + switch_server_cert(certfile="server-cn-and-ip-alt-names") + + node.connect_ok( + f"{common_connstr} host=common-name.pg-ssltest.test", + "certificate with both a CN and IP SANs matches CN", + ) + node.connect_ok( + f"{common_connstr} host=192.0.2.1", + "certificate with both a CN and IP SANs matches SAN 1", + ) + node.connect_ok( + f"{common_connstr} host=2001:db8::1", + "certificate with both a CN and IP SANs matches SAN 2", + ) + + # And now the same tests, but with IP addresses and DNS names swapped. + switch_server_cert(certfile="server-ip-cn-and-alt-names") + + node.connect_ok( + f"{common_connstr} host=192.0.2.2", + "certificate with both an IP CN and IP SANs 1", + ) + node.connect_ok( + f"{common_connstr} host=2001:db8::1", + "certificate with both an IP CN and IP SANs 2", + ) + node.connect_fails( + f"{common_connstr} host=192.0.2.1", + "certificate with both an IP CN and IP SANs ignores CN", + expected_stderr=( + r'server certificate for "192\.0\.2\.2" \(and 1 other name\) ' + r'does not match host name "192\.0\.2\.1"' + ), + ) + + switch_server_cert(certfile="server-ip-cn-and-dns-alt-names") + + node.connect_ok( + f"{common_connstr} host=192.0.2.1", + "certificate with both an IP CN and DNS SANs matches CN", + ) + node.connect_ok( + f"{common_connstr} host=dns1.alt-name.pg-ssltest.test", + "certificate with both an IP CN and DNS SANs matches SAN 1", + ) + node.connect_ok( + f"{common_connstr} host=dns2.alt-name.pg-ssltest.test", + "certificate with both an IP CN and DNS SANs matches SAN 2", + ) + + # Finally, test a server certificate that has no CN or SANs. Of course, + # that's not a very sensible certificate, but libpq should handle it + # gracefully. + switch_server_cert(certfile="server-no-names") + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"sslrootcert={_ssl('root+server_ca.crt')} hostaddr={SERVERHOSTADDR}" + ) + + node.connect_ok( + f"{common_connstr} sslmode=verify-ca host=common-name.pg-ssltest.test", + "server certificate without CN or SANs sslmode=verify-ca", + ) + node.connect_fails( + f"{common_connstr} sslmode=verify-full host=common-name.pg-ssltest.test", + "server certificate without CN or SANs sslmode=verify-full", + expected_stderr=r"could not get server's host name from server certificate", + ) + + # Test system trusted roots. + switch_server_cert( + certfile="server-cn-only+server_ca", keyfile="server-cn-only", cafile="root_ca" + ) + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"sslrootcert=system hostaddr={SERVERHOSTADDR}" + ) + + # By default our custom-CA-signed certificate should not be trusted. + # OpenSSL 3.0 reports a missing/invalid system CA as "unregistered schema" + # instead of a failed certificate verification. + node.connect_fails( + f"{common_connstr} sslmode=verify-full host=common-name.pg-ssltest.test", + "sslrootcert=system does not connect with private CA", + expected_stderr=r"SSL error: (certificate verify failed|unregistered scheme)", + ) + + # Modes other than verify-full cannot be mixed with sslrootcert=system. + node.connect_fails( + f"{common_connstr} sslmode=verify-ca host=common-name.pg-ssltest.test", + "sslrootcert=system only accepts sslmode=verify-full", + expected_stderr=r'weak sslmode "verify-ca" may not be used with sslrootcert=system', + ) + + if not libressl: + # SSL_CERT_FILE is not supported with LibreSSL. + # We can modify the definition of "system" to get it trusted again. + saved_cert_file = os.environ.get("SSL_CERT_FILE") + os.environ["SSL_CERT_FILE"] = os.path.join(node.data_dir, "root_ca.crt") + try: + node.connect_ok( + f"{common_connstr} sslmode=verify-full host=common-name.pg-ssltest.test", + "sslrootcert=system connects with overridden SSL_CERT_FILE", + ) + + # verify-full mode should be the default for system CAs. + node.connect_fails( + f"{common_connstr} host=common-name.pg-ssltest.test.bad", + "sslrootcert=system defaults to sslmode=verify-full", + expected_stderr=( + r'server certificate for "common-name\.pg-ssltest\.test" ' + r"does not match host name " + r'"common-name\.pg-ssltest\.test\.bad"' + ), + ) + finally: + if saved_cert_file is None: + os.environ.pop("SSL_CERT_FILE", None) + else: + os.environ["SSL_CERT_FILE"] = saved_cert_file + + # Test that the CRL works + switch_server_cert(certfile="server-revoked") + + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=trustdb " + f"hostaddr={SERVERHOSTADDR} host=common-name.pg-ssltest.test" + ) + + # Without the CRL, succeeds. With it, fails. + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca", + "connects without client-side CRL", + ) + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca sslcrl={_ssl('root+server.crl')}", + "does not connect with client-side CRL file", + expected_stderr=r"SSL error: certificate verify failed", + ) + # sslcrl='' is added here to override the invalid default, so as this does + # not interfere with this case. + node.connect_fails( + f"{common_connstr} sslcrl='' sslrootcert={_ssl('root+server_ca.crt')} sslmode=verify-ca sslcrldir={_ssl('root+server-crldir')}", + "does not connect with client-side CRL directory", + expected_stderr=r"SSL error: certificate verify failed", + ) + + # pg_stat_ssl + node.command_like( + [ + "psql", + "--no-psqlrc", + "--no-align", + "--field-separator", + ",", + "--pset", + "null=_null_", + "--dbname", + f"{common_connstr} sslrootcert=invalid", + "--command", + "SELECT * FROM pg_stat_ssl WHERE pid = pg_backend_pid()", + ], + r"(?mx)^pid,ssl,version,cipher,bits,client_dn,client_serial,issuer_dn\r?\n" + r"^\d+,t,TLSv[\d.]+,[\w-]+,\d+,_null_,_null_,_null_\r?$", + "pg_stat_ssl view without client certificate", + ) + + # Test min/max SSL protocol versions. + node.connect_ok( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require ssl_min_protocol_version=TLSv1.2 ssl_max_protocol_version=TLSv1.2", + "connection success with correct range of TLS protocol versions", + ) + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require ssl_min_protocol_version=TLSv1.2 ssl_max_protocol_version=TLSv1.1", + "connection failure with incorrect range of TLS protocol versions", + expected_stderr=r"invalid SSL protocol version range", + ) + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require ssl_min_protocol_version=incorrect_tls", + "connection failure with an incorrect SSL protocol minimum bound", + expected_stderr=r'invalid "ssl_min_protocol_version" value', + ) + node.connect_fails( + f"{common_connstr} sslrootcert={_ssl('root+server_ca.crt')} sslmode=require ssl_max_protocol_version=incorrect_tls", + "connection failure with an incorrect SSL protocol maximum bound", + expected_stderr=r'invalid "ssl_max_protocol_version" value', + ) + + # ---- Server-side tests ------------------------------------------------ + # + # Test certificate authorization. + + common_connstr = ( + f"{default_ssl_connstr} sslrootcert={_ssl('root+server_ca.crt')} " + f"sslmode=require dbname=certdb hostaddr={SERVERHOSTADDR} host=localhost" + ) + + # no client cert + node.connect_fails( + f"{common_connstr} user=ssltestuser sslcert=invalid", + "certificate authorization fails without client cert", + expected_stderr=r"connection requires a valid client certificate", + ) + + # correct client cert in unencrypted PEM + node.connect_ok( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "certificate authorization succeeds with correct client cert in PEM format", + ) + + # correct client cert in unencrypted DER + node.connect_ok( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client.crt')}" + + sslkey("client-der.key"), + "certificate authorization succeeds with correct client cert in DER format", + ) + + # correct client cert in encrypted PEM + node.connect_ok( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client.crt')}" + + sslkey("client-encrypted-pem.key") + + " sslpassword='dUmmyP^#+'", + "certificate authorization succeeds with correct client cert in encrypted PEM format", + ) + + # correct client cert in encrypted DER + node.connect_ok( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client.crt')}" + + sslkey("client-encrypted-der.key") + + " sslpassword='dUmmyP^#+'", + "certificate authorization succeeds with correct client cert in encrypted DER format", + ) + + # correct client cert with sslcertmode=allow or require + if supports_sslcertmode_require: + node.connect_ok( + f"{common_connstr} user=ssltestuser sslcertmode=require sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "certificate authorization succeeds with correct client cert and sslcertmode=require", + ) + node.connect_ok( + f"{common_connstr} user=ssltestuser sslcertmode=allow sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "certificate authorization succeeds with correct client cert and sslcertmode=allow", + ) + + # client cert is not sent if sslcertmode=disable. + node.connect_fails( + f"{common_connstr} user=ssltestuser sslcertmode=disable sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "certificate authorization fails with correct client cert and sslcertmode=disable", + expected_stderr=r"connection requires a valid client certificate", + ) + + # correct client cert in encrypted PEM with wrong password + node.connect_fails( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client.crt')}" + + sslkey("client-encrypted-pem.key") + + " sslpassword='wrong'", + "certificate authorization fails with correct client cert and wrong password in encrypted PEM format", + expected_stderr=r'private key file ".*client-encrypted-pem\.key": bad decrypt', + ) + + # correct client cert using whole DN + dn_connstr = f"{common_connstr} dbname=certdb_dn" + + node.connect_ok( + f"{dn_connstr} user=ssltestuser sslcert={_ssl('client-dn.crt')}" + + sslkey("client-dn.key"), + "certificate authorization succeeds with DN mapping", + log_like=[ + r'connection authenticated: identity="CN=ssltestuser-dn,OU=Testing,OU=Engineering,O=PGDG" method=cert' + ], + ) + + # same thing but with a regex + dn_connstr = f"{common_connstr} dbname=certdb_dn_re" + + node.connect_ok( + f"{dn_connstr} user=ssltestuser sslcert={_ssl('client-dn.crt')}" + + sslkey("client-dn.key"), + "certificate authorization succeeds with DN regex mapping", + ) + + # same thing but using explicit CN + dn_connstr = f"{common_connstr} dbname=certdb_cn" + + node.connect_ok( + f"{dn_connstr} user=ssltestuser sslcert={_ssl('client-dn.crt')}" + + sslkey("client-dn.key"), + "certificate authorization succeeds with CN mapping", + # the full DN should still be used as the authenticated identity + log_like=[ + r'connection authenticated: identity="CN=ssltestuser-dn,OU=Testing,OU=Engineering,O=PGDG" method=cert' + ], + ) + + # The two encrypted-PEM-with-empty/no-password cases need Pty support and + # are skipped here. + + # pg_stat_ssl + # + # If the openssl program isn't available, or fails to run, fall back to a + # generic integer match rather than skipping the test. + serialno = r"\d+" + openssl = os.environ.get("OPENSSL", "") + if openssl != "": + try: + serialstr = subprocess.run( + [openssl, "x509", "-serial", "-noout", "-in", _ssl("client.crt")], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout + # OpenSSL prints serial numbers in hexadecimal. + serialstr = serialstr.replace("serial=", "") + serialstr = "".join(serialstr.split()) + serialno = str(int(serialstr, 16)) + except (subprocess.CalledProcessError, ValueError, OSError): + serialno = r"\d+" + + node.command_like( + [ + "psql", + "--no-psqlrc", + "--no-align", + "--field-separator", + ",", + "--pset", + "null=_null_", + "--dbname", + f"{common_connstr} user=ssltestuser sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "--command", + "SELECT * FROM pg_stat_ssl WHERE pid = pg_backend_pid()", + ], + r"(?mx)^pid,ssl,version,cipher,bits,client_dn,client_serial,issuer_dn\r?\n" + r"^\d+,t,TLSv[\d.]+,[\w-]+,\d+,/?CN=ssltestuser," + + serialno + + r",/?CN=Test\ CA\ for\ PostgreSQL\ SSL\ regression\ test\ client\ certs\r?$", + "pg_stat_ssl with client certificate", + ) + + # client key with wrong permissions + if not windows_os: + node.connect_fails( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client.crt')}" + + sslkey("client_wrongperms.key"), + "certificate authorization fails because of file permissions", + expected_stderr=r'private key file ".*client_wrongperms\.key" has group or world access', + ) + + # client cert belonging to another user + node.connect_fails( + f"{common_connstr} user=anotheruser sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "certificate authorization fails with client cert belonging to another user", + expected_stderr=r'certificate authentication failed for user "anotheruser"', + # certificate authentication should be logged even on failure + log_like=[r'connection authenticated: identity="CN=ssltestuser" method=cert'], + ) + + # revoked client cert + node.connect_fails( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client-revoked.crt')}" + + sslkey("client-revoked.key"), + "certificate authorization fails with revoked client cert", + expected_stderr=r"SSL error: (ssl[a-z0-9/]*|tls) alert certificate revoked", + log_like=[ + r"Client certificate verification failed at depth 0: certificate revoked", + r'Failed certificate data \(unverified\): subject "/CN=ssltestuser", serial number \d+, issuer "/CN=Test CA for PostgreSQL SSL regression test client certs"', + ], + # revoked certificates should not authenticate the user + log_unlike=[r"connection authenticated:"], + ) + + # Check that connecting with auth-option verify-full in pg_hba: + # works, iff username matches Common Name + # fails, iff username doesn't match Common Name. + common_connstr = ( + f"{default_ssl_connstr} sslrootcert={_ssl('root+server_ca.crt')} " + f"sslmode=require dbname=verifydb hostaddr={SERVERHOSTADDR} host=localhost" + ) + + node.connect_ok( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "auth_option clientcert=verify-full succeeds with matching username and Common Name", + log_like=[r'connection authenticated: user="ssltestuser" method=trust'], + ) + + node.connect_fails( + f"{common_connstr} user=anotheruser sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "auth_option clientcert=verify-full fails with mismatching username and Common Name", + expected_stderr=r'FATAL: .* "trust" authentication failed for user "anotheruser"', + # verify-full does not provide authentication + log_unlike=[r"connection authenticated:"], + ) + + # Check that connecting with auth-option verify-ca in pg_hba: + # works, when username doesn't match Common Name + node.connect_ok( + f"{common_connstr} user=yetanotheruser sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "auth_option clientcert=verify-ca succeeds with mismatching username and Common Name", + log_like=[r'connection authenticated: user="yetanotheruser" method=trust'], + ) + + # intermediate client_ca.crt is provided by client, and isn't in server's + # ssl_ca_file + switch_server_cert(certfile="server-cn-only", cafile="root_ca") + common_connstr = ( + f"{default_ssl_connstr} user=ssltestuser dbname=certdb" + + sslkey("client.key") + + f" sslrootcert={_ssl('root+server_ca.crt')} hostaddr={SERVERHOSTADDR} host=localhost" + ) + + node.connect_ok( + f"{common_connstr} sslmode=require sslcert={_ssl('client+client_ca.crt')}", + "intermediate client certificate is provided by client", + ) + + node.connect_fails( + f"{common_connstr} sslmode=require sslcert={_ssl('client.crt')}", + "intermediate client certificate is missing", + expected_stderr=r"SSL error: tlsv1 alert unknown ca", + log_like=[ + r"Client certificate verification failed at depth 0: unable to get local issuer certificate", + r'Failed certificate data \(unverified\): subject "/CN=ssltestuser", serial number \d+, issuer "/CN=Test CA for PostgreSQL SSL regression test client certs"', + ], + ) + + node.connect_fails( + f"{common_connstr} sslmode=require sslcert={_ssl('client-long.crt')}" + + sslkey("client-long.key"), + "logged client certificate Subjects are truncated if they're too long", + expected_stderr=r"SSL error: tlsv1 alert unknown ca", + log_like=[ + r"Client certificate verification failed at depth 0: unable to get local issuer certificate", + r'Failed certificate data \(unverified\): subject "\.\.\./CN=ssl-123456789012345678901234567890123456789012345678901234567890", serial number \d+, issuer "/CN=Test CA for PostgreSQL SSL regression test client certs"', + ], + ) + + # Use an invalid cafile here so that the next test won't be able to verify + # the client CA. + switch_server_cert(certfile="server-cn-only", cafile="server-cn-only") + + # intermediate CA is provided but doesn't have a trusted root (checks error + # logging for cert chain depths > 0) + node.connect_fails( + f"{common_connstr} sslmode=require sslcert={_ssl('client+client_ca.crt')}", + "intermediate client certificate is untrusted", + expected_stderr=r"SSL error: tlsv1 alert unknown ca", + log_like=[ + r"Client certificate verification failed at depth 1: unable to get local issuer certificate", + # As of 5/2025, LibreSSL reports a different cert as being at fault; + # it's wrong, but seems to be their bug not ours + ( + r'Failed certificate data \(unverified\): subject "/CN=Test CA for PostgreSQL SSL regression test client certs", serial number \d+, issuer "/CN=Test root CA for PostgreSQL SSL regression test suite"' + if not libressl + else r'Failed certificate data \(unverified\): subject "/CN=ssltestuser", serial number \d+, issuer "/CN=Test CA for PostgreSQL SSL regression test client certs"' + ), + ], + ) + + # test server-side CRL directory + switch_server_cert(certfile="server-cn-only", crldir="root+client-crldir") + + # revoked client cert + node.connect_fails( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client-revoked.crt')}" + + sslkey("client-revoked.key"), + "certificate authorization fails with revoked client cert with server-side CRL directory", + expected_stderr=r"SSL error: (ssl[a-z0-9/]*|tls) alert certificate revoked", + log_like=[ + r"Client certificate verification failed at depth 0: certificate revoked", + r'Failed certificate data \(unverified\): subject "/CN=ssltestuser", serial number \d+, issuer "/CN=Test CA for PostgreSQL SSL regression test client certs"', + ], + ) + + # revoked client cert, non-ASCII subject + node.connect_fails( + f"{common_connstr} user=ssltestuser sslcert={_ssl('client-revoked-utf8.crt')}" + + sslkey("client-revoked-utf8.key"), + "certificate authorization fails with revoked UTF-8 client cert with server-side CRL directory", + expected_stderr=r"SSL error: (ssl[a-z0-9/]*|tls) alert certificate revoked", + log_like=[ + r"Client certificate verification failed at depth 0: certificate revoked", + r'Failed certificate data \(unverified\): subject "/CN=\\xce\\x9f\\xce\\xb4\\xcf\\x85\\xcf\\x83\\xcf\\x83\\xce\\xad\\xce\\xb1\\xcf\\x82", serial number \d+, issuer "/CN=Test CA for PostgreSQL SSL regression test client certs"', + ], + ) + + if supports_sslcertmode_require: + # Test client CAs + connstr = ( + f"user=ssltestuser dbname=certdb hostaddr={SERVERHOSTADDR} " + "sslmode=require sslsni=1" + ) + + switch_server_cert(certfile="server-cn-only", cafile="") + # example.org is unconfigured and should fail. + node.connect_fails( + f"{connstr} host=example.org sslcertmode=require sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "host: 'example.org', ca: '': connect with sslcert, no client CA configured", + expected_stderr=r"client certificates can only be checked if a root certificate store is available", + ) + + # example.com uses the client CA. + switch_server_cert(certfile="server-cn-only", cafile="root+client_ca") + # example.com is configured and should require a valid client cert. + node.connect_fails( + f"{connstr} host=example.com sslcertmode=disable", + "host: 'example.com', ca: 'root+client_ca.crt': connect fails if no client certificate sent", + expected_stderr=r"connection requires a valid client certificate", + ) + node.connect_ok( + f"{connstr} host=example.com sslcertmode=require sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "host: 'example.com', ca: 'root+client_ca.crt': connect with sslcert, client certificate sent", + ) + + # example.net uses the server CA (which is wrong). + switch_server_cert(certfile="server-cn-only", cafile="root+server_ca") + # example.net is configured and should require a client cert, but will + # always fail verification. + node.connect_fails( + f"{connstr} host=example.net sslcertmode=disable", + "host: 'example.net', ca: 'root+server_ca.crt': connect fails if no client certificate sent", + expected_stderr=r"connection requires a valid client certificate", + ) + + node.connect_fails( + f"{connstr} host=example.net sslcertmode=require sslcert={_ssl('client.crt')}" + + sslkey("client.key"), + "host: 'example.net', ca: 'root+server_ca.crt': connect with sslcert, client certificate sent", + expected_stderr=r"unknown ca", + ) diff --git a/src/test/ssl/pyt/test_002_scram.py b/src/test/ssl/pyt/test_002_scram.py new file mode 100644 index 00000000000..fd5076167ca --- /dev/null +++ b/src/test/ssl/pyt/test_002_scram.py @@ -0,0 +1,173 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test SCRAM authentication and TLS channel binding types over SSL.""" + +import os + +import pytest + +# This is the hostname used to connect to the server. +SERVERHOSTADDR = "127.0.0.1" +# This is the pattern to use in pg_hba.conf to match incoming connections. +SERVERHOSTCIDR = "127.0.0.1/32" + + +def test_002_scram(create_pg, ssl_server): + # Faithful skip ordering. + # The ssl_server fixture covers the with_ssl=openssl skip. + if os.environ.get("with_ssl") != "openssl": + pytest.skip("OpenSSL not supported by this build") + if "ssl" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip("Potentially unsafe test SSL not enabled in PG_TEST_EXTRA") + + # Snapshot/restore env vars we mutate (PGPASSWORD) so the rest of the + # suite is unaffected. + saved_env = {k: os.environ.get(k) for k in ("PGPASSWORD",)} + try: + _run_body(create_pg, ssl_server) + finally: + for k, v in saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def _run_body(create_pg, ssl_server): + # Determine whether this build uses OpenSSL or LibreSSL. + libressl = ssl_server.is_libressl() + + # Determine whether build supports detection of hash algorithms for + # RSA-PSS certificates. + supports_rsapss_certs = ssl_server.check_pg_config( + "#define HAVE_X509_GET_SIGNATURE_INFO 1" + ) + # As of 5/2025, LibreSSL doesn't actually work for RSA-PSS certificates. + if libressl: + supports_rsapss_certs = False + + # Set up the server. + # + # Connections go through the in-process libpq layer; the connstr carries + # host=/hostaddr= explicitly, and later keywords win over the node defaults + # prepended by _full_connstr. + + # setting up data directory + node = create_pg("primary") + + # could fail in FIPS mode + md5_works = node.sql("select md5('')").error_message is None + + # Configure server for SSL connections, with password handling. + ssl_server.configure_test_server_for_ssl( + node, + SERVERHOSTADDR, + SERVERHOSTCIDR, + "scram-sha-256", + password="pass", + password_enc="scram-sha-256", + ) + ssl_server.switch_server_cert(node, certfile="server-cn-only") + os.environ["PGPASSWORD"] = "pass" + common_connstr = ( + "dbname=trustdb sslmode=require sslcert=invalid sslrootcert=invalid " + f"hostaddr={SERVERHOSTADDR} host=localhost" + ) + + # Default settings + node.connect_ok( + f"{common_connstr} user=ssltestuser", + "Basic SCRAM authentication with SSL", + ) + + # Test channel_binding + node.connect_fails( + f"{common_connstr} user=ssltestuser channel_binding=invalid_value", + "SCRAM with SSL and channel_binding=invalid_value", + expected_stderr=r'invalid channel_binding value: "invalid_value"', + ) + node.connect_ok( + f"{common_connstr} user=ssltestuser channel_binding=disable", + "SCRAM with SSL and channel_binding=disable", + ) + node.connect_ok( + f"{common_connstr} user=ssltestuser channel_binding=require", + "SCRAM with SSL and channel_binding=require", + ) + + # Now test when the user has an MD5-encrypted password; should fail + if md5_works: + node.connect_fails( + f"{common_connstr} user=md5testuser channel_binding=require", + "MD5 with SSL and channel_binding=require", + expected_stderr=( + r"channel binding required but not supported by server's " + r"authentication request" + ), + ) + + # Now test with auth method 'cert' by connecting to 'certdb'. Should fail, + # because channel binding is not performed. The ssl_server fixture has + # already copied ssl/client.key to a private-perms temp copy that libpq + # will accept. + ssl_dir = os.path.join(os.path.dirname(__file__), "..", "ssl") + client_crt = os.path.join(ssl_dir, "client.crt").replace("\\", "/") + client_tmp_key = ssl_server.key["client.key"] + node.connect_fails( + f"sslcert={client_crt} sslkey={client_tmp_key} sslrootcert=invalid " + f"hostaddr={SERVERHOSTADDR} host=localhost dbname=certdb " + "user=ssltestuser channel_binding=require", + "Cert authentication and channel_binding=require", + expected_stderr=( + r"channel binding required, but server authenticated client " + r"without channel binding" + ), + ) + + # Certificate verification at the connection level should still work fine. + node.connect_ok( + f"sslcert={client_crt} sslkey={client_tmp_key} sslrootcert=invalid " + f"hostaddr={SERVERHOSTADDR} host=localhost dbname=verifydb " + "user=ssltestuser", + "SCRAM with clientcert=verify-full", + log_like=[ + 'connection authenticated: identity="ssltestuser" method=scram-sha-256' + ], + ) + + # channel_binding should continue to work independently of require_auth. + node.connect_ok( + f"{common_connstr} user=ssltestuser channel_binding=disable " + "require_auth=scram-sha-256", + "SCRAM with SSL, channel_binding=disable, and require_auth=scram-sha-256", + ) + if md5_works: + node.connect_fails( + f"{common_connstr} user=md5testuser require_auth=md5 " + "channel_binding=require", + "channel_binding can fail even when require_auth succeeds", + expected_stderr=( + r"channel binding required but not supported by server's " + r"authentication request" + ), + ) + node.connect_ok( + f"{common_connstr} user=ssltestuser channel_binding=require " + "require_auth=scram-sha-256", + "SCRAM with SSL, channel_binding=require, and require_auth=scram-sha-256", + ) + + # Now test with a server certificate that uses the RSA-PSS algorithm. + # This checks that the certificate can be loaded and that channel binding + # works. (see bug #17760) + if supports_rsapss_certs: + ssl_server.switch_server_cert(node, certfile="server-rsapss") + node.connect_ok( + f"{common_connstr} user=ssltestuser channel_binding=require", + "SCRAM with SSL and channel_binding=require, server certificate " + "uses 'rsassaPss'", + log_like=[ + r'connection authenticated: identity="ssltestuser" ' + r"method=scram-sha-256" + ], + ) diff --git a/src/test/ssl/pyt/test_003_sslinfo.py b/src/test/ssl/pyt/test_003_sslinfo.py new file mode 100644 index 00000000000..1a6f50826ca --- /dev/null +++ b/src/test/ssl/pyt/test_003_sslinfo.py @@ -0,0 +1,195 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Tests for the sslinfo extension. + +Exercises the sslinfo extension functions (ssl_is_used, ssl_version, +ssl_cipher, ssl_client_cert_present, ssl_client_serial, ssl_client_dn_field, +ssl_issuer_dn, ssl_issuer_field, ssl_extension_info, ...) over a real SSL +connection that presents a client certificate. + +All SSL queries run in-process via the libpq Session (no psql fork); the +``ssl_server`` fixture handles the with_ssl=openssl skip and copies the +client keys with private permissions. +""" + +import os + +import libpq +import pytest + +# This is the hostname used to connect to the server. This cannot be a +# hostname, because the server certificate is always for the domain +# postgresql-ssl-regression.test. +SERVERHOSTADDR = "127.0.0.1" +# This is the pattern to use in pg_hba.conf to match incoming connections. +SERVERHOSTCIDR = "127.0.0.1/32" + +# Path to the directory holding the test certificates/keys. +SSL_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "ssl")) + + +def _cert(name): + """Absolute path to a cert/key file in the ssl directory.""" + # Forward slashes: these paths go into conninfo, where libpq treats a + # backslash as an escape character. + return os.path.join(SSL_DIR, name).replace("\\", "/") + + +# Set of default settings for SSL parameters in connection string. This +# makes the tests protected against any defaults the environment may have +# in ~/.postgresql/. +DEFAULT_SSL_CONNSTR = ( + "sslkey=invalid sslcert=invalid sslrootcert=invalid " + "sslcrl=invalid sslcrldir=invalid" +) + + +def _query(node, connstr, sql): + """Run *sql* over an SSL connection described by *connstr*, trimmed text.""" + sess = libpq.Session(connstr=connstr, libdir=node.libdir) + try: + return sess.query_safe(sql) + finally: + sess.close() + + +def test_sslinfo(ssl_server, create_pg): + # Faithful skip ordering. + # The ssl_server fixture covers the with_ssl=openssl skip. + if "ssl" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip("Potentially unsafe test SSL not enabled in PG_TEST_EXTRA") + + #### Set up the server. + node = create_pg("primary") + + ssl_server.configure_test_server_for_ssl( + node, SERVERHOSTADDR, SERVERHOSTCIDR, "trust", extensions=["sslinfo"] + ) + + # We aren't using any CRL's in this suite so we can keep using + # server-revoked as server certificate for simple client.crt connection + # much like how the 001 test does. + ssl_server.switch_server_cert(node, certfile="server-revoked") + + # Determine whether build supports sslcertmode=require. This corresponds + # to checking for "#define HAVE_SSL_CTX_SET_CERT_CB 1" in pg_config. + supports_sslcertmode_require = not ssl_server.is_libressl() + + # Name the port explicitly in the connection string. + common_connstr = ( + f"{DEFAULT_SSL_CONNSTR} sslrootcert={_cert('root+server_ca.crt')} " + f"sslmode=require dbname=certdb hostaddr={SERVERHOSTADDR} host=localhost " + f"port={node.port} " + f"user=ssltestuser sslcert={_cert('client_ext.crt')}" + f"{ssl_server.sslkey('client_ext.key')}" + ) + + # Connection string for a connection without a client cert (trustdb). + nocert_connstr = ( + f"{DEFAULT_SSL_CONNSTR} sslrootcert={_cert('root+server_ca.crt')} " + f"sslmode=require dbname=trustdb hostaddr={SERVERHOSTADDR} " + f"port={node.port} " + "user=ssltestuser host=localhost" + ) + + # Make sure we can connect even though previous test suites have + # established this. + node.connect_ok( + common_connstr, + "certificate authorization succeeds with correct client cert in PEM format", + ) + + result = _query(node, common_connstr, "SELECT ssl_is_used();") + assert result == "t", "ssl_is_used() for TLS connection" + + result = _query( + node, + common_connstr + + " ssl_min_protocol_version=TLSv1.2 ssl_max_protocol_version=TLSv1.2", + "SELECT ssl_version();", + ) + assert result == "TLSv1.2", "ssl_version() correctly returning TLS protocol" + + result = _query( + node, + common_connstr, + "SELECT ssl_cipher() = cipher FROM pg_stat_ssl WHERE pid = pg_backend_pid();", + ) + assert result == "t", "ssl_cipher() compared with pg_stat_ssl" + + result = _query(node, common_connstr, "SELECT ssl_client_cert_present();") + assert result == "t", "ssl_client_cert_present() for connection with cert" + + result = _query(node, nocert_connstr, "SELECT ssl_client_cert_present();") + assert result == "f", "ssl_client_cert_present() for connection without cert" + + result = _query( + node, + common_connstr, + "SELECT ssl_client_serial() = client_serial " + "FROM pg_stat_ssl WHERE pid = pg_backend_pid();", + ) + assert result == "t", "ssl_client_serial() compared with pg_stat_ssl" + + # Must not use query_safe since we expect an error here; query() returns + # the error in the result rather than raising. + sess = libpq.Session(connstr=common_connstr, libdir=node.libdir) + try: + res = sess.query("SELECT ssl_client_dn_field('invalid');") + assert ( + res.error_message is not None + ), "ssl_client_dn_field() for an invalid field should error" + finally: + sess.close() + + result = _query(node, nocert_connstr, "SELECT ssl_client_dn_field('commonName');") + assert result == "", "ssl_client_dn_field() for connection without cert" + + result = _query( + node, + common_connstr, + "SELECT '/CN=' || ssl_client_dn_field('commonName') = client_dn " + "FROM pg_stat_ssl WHERE pid = pg_backend_pid();", + ) + assert result == "t", "ssl_client_dn_field() for commonName" + + result = _query( + node, + common_connstr, + "SELECT ssl_issuer_dn() = issuer_dn " + "FROM pg_stat_ssl WHERE pid = pg_backend_pid();", + ) + assert result == "t", "ssl_issuer_dn() for connection with cert" + + result = _query( + node, + common_connstr, + "SELECT '/CN=' || ssl_issuer_field('commonName') = issuer_dn " + "FROM pg_stat_ssl WHERE pid = pg_backend_pid();", + ) + assert result == "t", "ssl_issuer_field() for commonName" + + result = _query( + node, + common_connstr, + "SELECT value, critical FROM ssl_extension_info() " + "WHERE name = 'basicConstraints';", + ) + assert result == "CA:FALSE|t", "extract extension from cert" + + # Sanity tests for sslcertmode, using ssl_client_cert_present(). + cases = [ + {"opts": "sslcertmode=allow", "present": "t"}, + {"opts": "sslcertmode=allow sslcert=invalid", "present": "f"}, + {"opts": "sslcertmode=disable", "present": "f"}, + ] + if supports_sslcertmode_require: + cases.append({"opts": "sslcertmode=require", "present": "t"}) + + for c in cases: + result = _query( + node, + f"{common_connstr} dbname=trustdb {c['opts']}", + "SELECT ssl_client_cert_present();", + ) + assert result == c["present"], f"ssl_client_cert_present() for {c['opts']}" diff --git a/src/test/ssl/pyt/test_004_sni.py b/src/test/ssl/pyt/test_004_sni.py new file mode 100644 index 00000000000..87c83e6ef65 --- /dev/null +++ b/src/test/ssl/pyt/test_004_sni.py @@ -0,0 +1,549 @@ +# Copyright (c) 2024-2026, PostgreSQL Global Development Group + +"""Server Name Indication (SNI) tests. The server is taught to select a +certificate/key/CA per requested SNI hostname via pg_hosts.conf, and the +client sends the connection's ``host`` value as the SNI hostname. The tests +exercise: + - falling back to the postgresql.conf cert/key when ssl_sni is off or + pg_hosts.conf is missing/empty, + - per-host certificate/CA selection (including hostname lists and + @-file inclusion, case-insensitive matching), + - rejection of malformed/duplicate pg_hosts.conf entries (server fails to + start), + - password-protected per-host keys and passphrase-command reload behaviour, + - non-SNI-only (/no_sni/) host entries, and + - per-host client-CA selection. + +Connections are made over TCP to 127.0.0.1 (via hostaddr) while the libpq +``host`` keyword carries the SNI hostname, so SNI is sent to the server while +the actual connection always lands on 127.0.0.1. +""" + +import os + +import pytest + + +# This is the hostaddr used to connect to the server. This cannot be a +# hostname, because the server certificate is always for the domain +# postgresql-ssl-regression.test. +SERVERHOSTADDR = "127.0.0.1" +# This is the pattern to use in pg_hba.conf to match incoming connections. +SERVERHOSTCIDR = "127.0.0.1/32" + +# The directory holding the test certificates and keys. +SSL_DIR = os.path.join(os.path.dirname(__file__), "..", "ssl").replace("\\", "/") + + +def _restart_fails(node): + """Restart the node, expecting failure; return True if it came back up. + + The framework's restart() raises on failure, so do a stop followed by a + fail-tolerant start and report whether it started. + """ + node.stop(mode="fast", fail_ok=True) + return node.start(fail_ok=True) + + +def test_004_sni(create_pg, ssl_server): + # ssl_server fixture skips unless this build uses OpenSSL. + if "ssl" not in os.environ.get("PG_TEST_EXTRA", "").split(): + pytest.skip("Potentially unsafe test SSL not enabled in PG_TEST_EXTRA") + + if ssl_server.is_libressl(): + pytest.skip("SNI not supported when building with LibreSSL") + + node = create_pg("primary") + + exec_backend = node.safe_sql("SHOW debug_exec_backend") + + ssl_server.configure_test_server_for_ssl( + node, SERVERHOSTADDR, SERVERHOSTCIDR, "trust" + ) + + ssl_server.switch_server_cert(node, certfile="server-cn-only") + + connstr = f"user=ssltestuser dbname=trustdb hostaddr={SERVERHOSTADDR} sslsni=1" + + ########################################################################## + # postgresql.conf + ########################################################################## + + # Connect without any hosts configured in pg_hosts.conf, thus using the + # cert and key in postgresql.conf. pg_hosts.conf exists at this point but + # is empty apart from the comments stemming from the sample. + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require", + "pg.conf: connect with correct server CA cert file sslmode=require", + ) + + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root_ca.crt sslmode=verify-ca", + "pg.conf: connect fails without intermediate for sslmode=verify-ca", + expected_stderr=r"certificate verify failed", + ) + + # Add an entry in pg_hosts.conf with no default, and reload. Since + # ssl_sni is still 'off' we should still be able to connect using the + # certificates in postgresql.conf + node.append_conf( + "example.org server-cn-only.crt server-cn-only.key", + filename="pg_hosts.conf", + ) + node.reload() + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require", + "pg.conf: connect with correct server CA cert file sslmode=require", + ) + + # Turn on SNI support and remove pg_hosts.conf and reload to make sure a + # missing file is treated like an empty file. + node.append_conf("ssl_sni = on") + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.reload() + + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require", + "pg.conf: connect after deleting pg_hosts.conf", + ) + + ########################################################################## + # pg_hosts.conf + ########################################################################## + + # Replicate the postgresql.conf configuration into pg_hosts.conf and retry + # the same tests as above. + node.append_conf( + "* server-cn-only.crt server-cn-only.key", + filename="pg_hosts.conf", + ) + node.reload() + + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require", + "pg_hosts.conf: connect to default, with correct server CA cert file " + "sslmode=require", + ) + + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root_ca.crt sslmode=verify-ca", + "pg_hosts.conf: connect to default, fail without intermediate for " + "sslmode=verify-ca", + expected_stderr=r"certificate verify failed", + ) + + # Add host entry for example.org which serves the server cert and its + # intermediate CA. The previously existing default host still exists + # without a CA. + node.append_conf( + "example.org server-cn-only+server_ca.crt server-cn-only.key root_ca.crt", + filename="pg_hosts.conf", + ) + node.reload() + + node.connect_ok( + f"{connstr} host=example.org sslrootcert={SSL_DIR}/root_ca.crt " + "sslmode=verify-ca", + "pg_hosts.conf: connect to example.org and verify server CA", + ) + + node.connect_ok( + f"{connstr} host=Example.ORG sslrootcert={SSL_DIR}/root_ca.crt " + "sslmode=verify-ca", + "pg_hosts.conf: connect to Example.ORG and verify server CA", + ) + + node.connect_fails( + f"{connstr} host=example.org sslrootcert=invalid sslmode=verify-ca", + "pg_hosts.conf: connect to example.org but without server root cert, " + "sslmode=verify-ca", + expected_stderr=r'root certificate file "invalid" does not exist', + ) + + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root_ca.crt sslmode=verify-ca", + "pg_hosts.conf: connect to default and fail to verify CA", + expected_stderr=r"certificate verify failed", + ) + + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require", + "pg_hosts.conf: connect to default with sslmode=require", + ) + + # Use multiple hostnames for a single configuration + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "example.org,example.com,example.net server-cn-only+server_ca.crt " + "server-cn-only.key root_ca.crt", + filename="pg_hosts.conf", + ) + node.reload() + + node.connect_ok( + f"{connstr} host=example.org sslrootcert={SSL_DIR}/root_ca.crt " + "sslmode=verify-ca", + "pg_hosts.conf: connect to example.org and verify server CA", + ) + node.connect_ok( + f"{connstr} host=example.com sslrootcert={SSL_DIR}/root_ca.crt " + "sslmode=verify-ca", + "pg_hosts.conf: connect to example.com and verify server CA", + ) + node.connect_ok( + f"{connstr} host=example.net sslrootcert={SSL_DIR}/root_ca.crt " + "sslmode=verify-ca", + "pg_hosts.conf: connect to example.net and verify server CA", + ) + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "host=example.se", + "pg_hosts.conf: connect to default with sslmode=require", + expected_stderr=r"unrecognized name", + ) + + # Test @-inclusion of hostnames. + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "example.org,@hostnames.txt server-cn-only+server_ca.crt " + "server-cn-only.key root_ca.crt", + filename="pg_hosts.conf", + ) + node.append_conf( + "\nexample.com\nexample.net\n", + filename="hostnames.txt", + ) + node.reload() + + node.connect_ok( + f"{connstr} host=example.org sslrootcert={SSL_DIR}/root_ca.crt " + "sslmode=verify-ca", + "@hostnames.txt: connect to example.org and verify server CA", + ) + node.connect_ok( + f"{connstr} host=example.com sslrootcert={SSL_DIR}/root_ca.crt " + "sslmode=verify-ca", + "@hostnames.txt: connect to example.com and verify server CA", + ) + node.connect_ok( + f"{connstr} host=example.net sslrootcert={SSL_DIR}/root_ca.crt " + "sslmode=verify-ca", + "@hostnames.txt: connect to example.net and verify server CA", + ) + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "host=example.se", + "@hostnames.txt: connect to default with sslmode=require", + expected_stderr=r"unrecognized name", + ) + + # Add an incorrect entry specifying a default entry combined with hostnames + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "example.org,*,example.net server-cn-only+server_ca.crt " + "server-cn-only.key root_ca.crt", + filename="pg_hosts.conf", + ) + assert not _restart_fails( + node + ), "pg_hosts.conf: restart fails with default entry combined with hostnames" + + # Add incorrect duplicate entries. + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "\n* server-cn-only.crt server-cn-only.key\n" + "* server-cn-only.crt server-cn-only.key\n", + filename="pg_hosts.conf", + ) + assert not _restart_fails( + node + ), "pg_hosts.conf: restart fails with two default entries" + + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "\n/no_sni/ server-cn-only.crt server-cn-only.key\n" + "/no_sni/ server-cn-only.crt server-cn-only.key\n", + filename="pg_hosts.conf", + ) + assert not _restart_fails( + node + ), "pg_hosts.conf: restart fails with two no_sni entries" + + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "\nexample.org server-cn-only.crt server-cn-only.key\n" + "example.net server-cn-only.crt server-cn-only.key\n" + "example.org server-cn-only.crt server-cn-only.key\n", + filename="pg_hosts.conf", + ) + assert not _restart_fails( + node + ), "pg_hosts.conf: restart fails with two identical hostname entries" + + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "\nexample.org server-cn-only.crt server-cn-only.key\n" + "example.net,example.com,Example.org server-cn-only.crt " + "server-cn-only.key\n", + filename="pg_hosts.conf", + ) + assert not _restart_fails( + node + ), "pg_hosts.conf: restart fails with two identical hostname entries in lists" + + # Modify pg_hosts.conf to no longer have the default host entry. + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "example.org server-cn-only+server_ca.crt server-cn-only.key root_ca.crt", + filename="pg_hosts.conf", + ) + node.restart() + + # Connecting without a hostname as well as with a hostname which isn't in + # the pg_hosts configuration should fail. + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "sslsni=0", + "pg_hosts.conf: connect to default with sslmode=require", + expected_stderr=r"handshake failure", + ) + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "host=example.com", + "pg_hosts.conf: connect to default with sslmode=require", + expected_stderr=r"unrecognized name", + ) + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "host=example", + "pg_hosts.conf: connect to 'example' with sslmode=require", + expected_stderr=r"unrecognized name", + ) + + # Reconfigure with broken configuration for the key passphrase, the server + # should not start up + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "localhost server-cn-only.crt server-password.key " + 'root+client_ca.crt "echo wrongpassword" on', + filename="pg_hosts.conf", + ) + assert not _restart_fails(node), ( + "pg_hosts.conf: restart fails with password-protected key when using " + "the wrong passphrase command" + ) + + # Reconfigure again but with the correct passphrase set + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "localhost server-cn-only.crt server-password.key " + 'root+client_ca.crt "echo secret1" on', + filename="pg_hosts.conf", + ) + assert _restart_fails(node), ( + "pg_hosts.conf: restart succeeds with password-protected key when " + "using the correct passphrase command" + ) + + # Make sure connecting works, and try to stress the reload logic by issuing + # subsequent reloads + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "host=localhost", + "pg_hosts.conf: connect with correct server CA cert file sslmode=require", + ) + node.reload() + node.reload() + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "host=localhost", + "pg_hosts.conf: connect with correct server CA cert file after reloads", + ) + node.reload() + node.reload() + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "host=localhost", + "pg_hosts.conf: connect with correct server CA cert file after more reloads", + ) + + # Test reloading a passphrase protected key without reloading support in + # the passphrase hook. Restarting should not give any errors in the log, + # but the subsequent reload should fail with an error regarding reloading. + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "localhost server-cn-only.crt server-password.key " + 'root+client_ca.crt "echo secret1" off', + filename="pg_hosts.conf", + ) + node_loglocation = node.log_position() + assert _restart_fails(node), ( + "pg_hosts.conf: restart succeeds with password-protected key when " + "using the correct passphrase command" + ) + log = node.log_content()[node_loglocation:] + assert ( + "cannot be reloaded because it requires a passphrase" not in log + ), "log reload failure due to passphrase command reloading" + + # Passphrase reloads must be enabled on Windows (and EXEC_BACKEND) to + # succeed even without a restart + if exec_backend != "on": + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt " + "sslmode=require host=localhost", + "pg_hosts.conf: connect with correct server CA cert file " + "sslmode=require", + ) + # Reloading should fail since the passphrase cannot be reloaded, with + # an error recorded in the log. Since we keep existing contexts around + # it should still work. + node_loglocation = node.log_position() + node.reload() + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt " + "sslmode=require host=localhost", + "pg_hosts.conf: connect with correct server CA cert file " + "sslmode=require", + ) + node.wait_for_log( + r"cannot be reloaded because it requires a passphrase", + node_loglocation, + ) + + # Configure with only non-SNI connections allowed + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + node.append_conf( + "/no_sni/ server-cn-only.crt server-cn-only.key", + filename="pg_hosts.conf", + ) + node.restart() + + node.connect_ok( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "sslsni=0", + "pg_hosts.conf: only non-SNI connections allowed", + ) + + node.connect_fails( + f"{connstr} sslrootcert={SSL_DIR}/root+server_ca.crt sslmode=require " + "host=example.org", + "pg_hosts.conf: only non-SNI connections allowed, connecting with SNI", + expected_stderr=r"unrecognized name", + ) + + # Test client CAs + + # pg_hosts configuration + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + + # Neither ssl_ca_file nor the default host should have any effect + # whatsoever on the following tests. + node.append_conf("ssl_ca_file = 'root+client_ca.crt'") + node.append_conf( + "* server-cn-only.crt server-cn-only.key root+client_ca.crt", + filename="pg_hosts.conf", + ) + + # example.org has an unconfigured CA. + node.append_conf( + "example.org server-cn-only.crt server-cn-only.key", + filename="pg_hosts.conf", + ) + # example.com uses the client CA. + node.append_conf( + "example.com server-cn-only.crt server-cn-only.key root+client_ca.crt", + filename="pg_hosts.conf", + ) + # example.net uses the server CA (which is wrong). + node.append_conf( + "example.net server-cn-only.crt server-cn-only.key root+server_ca.crt", + filename="pg_hosts.conf", + ) + + node.restart() + + connstr = ( + f"user=ssltestuser dbname=certdb hostaddr={SERVERHOSTADDR} " + "sslmode=require sslsni=1" + ) + + # example.org is unconfigured and should fail. + node.connect_fails( + f"{connstr} host=example.org sslcertmode=require " + f"sslcert={SSL_DIR}/client.crt" + ssl_server.sslkey("client.key"), + "host: 'example.org', ca: '': connect with sslcert, no client CA configured", + expected_stderr=r"client certificates can only be checked if a root " + r"certificate store is available", + ) + + # example.com is configured and should require a valid client cert. + node.connect_fails( + f"{connstr} host=example.com sslcertmode=disable", + "host: 'example.com', ca: 'root+client_ca.crt': connect fails if no " + "client certificate sent", + expected_stderr=r"connection requires a valid client certificate", + ) + + node.connect_ok( + f"{connstr} host=example.com sslcertmode=require " + f"sslcert={SSL_DIR}/client.crt" + ssl_server.sslkey("client.key"), + "host: 'example.com', ca: 'root+client_ca.crt': connect with sslcert, " + "client certificate sent", + ) + + # example.net is configured and should require a client cert, but will + # always fail verification. + node.connect_fails( + f"{connstr} host=example.net sslcertmode=disable", + "host: 'example.net', ca: 'root+server_ca.crt': connect fails if no " + "client certificate sent", + expected_stderr=r"connection requires a valid client certificate", + ) + + node.connect_fails( + f"{connstr} host=example.net sslcertmode=require " + f"sslcert={SSL_DIR}/client.crt" + ssl_server.sslkey("client.key"), + "host: 'example.net', ca: 'root+server_ca.crt': connect with sslcert, " + "client certificate sent", + expected_stderr=r"unknown ca", + ) + + # Make sure the global CRL dir interacts properly with per-host trust. + ssl_server.switch_server_cert( + node, + certfile="server-cn-only", + crldir="client-crldir", + ) + + node.connect_fails( + f"{connstr} host=example.com sslcertmode=require " + f"sslcert={SSL_DIR}/client-revoked.crt" + + ssl_server.sslkey("client-revoked.key"), + "host: 'example.com', ca: 'root+client_ca.crt': connect fails with " + "revoked client cert", + expected_stderr=r"certificate revoked", + ) + + # pg_hosts configuration with useless data at EOL + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + # example.org has an unconfigured CA. + node.append_conf( + "example.org server-cn-only.crt server-cn-only.key " + 'root+client_ca.crt "cmd" on TRAILING_TEXT MORE_TEXT', + filename="pg_hosts.conf", + ) + assert not _restart_fails( + node + ), "pg_hosts.conf: restart fails with extra data at EOL" + # pg_hosts configuration with useless data at EOL + os.unlink(os.path.join(node.data_dir, "pg_hosts.conf")) + # example.org has an unconfigured CA. + node.append_conf( + "example.org server-cn-only.crt server-cn-only.key " + 'root+client_ca.crt "cmd" notabooleanvalue', + filename="pg_hosts.conf", + ) + assert not _restart_fails( + node + ), "pg_hosts.conf: restart fails with non-boolean value in boolean field" From 0ade47c192b6411389b54f4d050a8be95fbc2f2c Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Sat, 6 Jun 2026 08:13:08 -0400 Subject: [PATCH 14/18] python tests: pytest suites for libpq, ecpg, psql, pgbench and others interfaces/libpq, interfaces/ecpg/preproc, bin/psql (tab-completion and pager driven via pexpect), bin/pgbench, test/icu and tools/pg_bsd_indent. --- src/bin/pgbench/meson.build | 6 + .../pyt/test_001_pgbench_with_server.py | 2096 +++++++++++++++++ .../pgbench/pyt/test_002_pgbench_no_server.py | 307 +++ src/bin/psql/meson.build | 12 + src/bin/psql/pyt/conftest.py | 137 ++ src/bin/psql/pyt/test_001_basic.py | 750 ++++++ src/bin/psql/pyt/test_010_tab_completion.py | 600 +++++ src/bin/psql/pyt/test_020_cancel.py | 72 + src/bin/psql/pyt/test_030_pager.py | 119 + src/interfaces/ecpg/preproc/meson.build | 7 + .../preproc/pyt/test_001_ecpg_err_warn_msg.py | 89 + .../test_002_ecpg_err_warn_msg_informix.py | 44 + src/interfaces/libpq/meson.build | 16 + src/interfaces/libpq/pyt/test_001_uri.py | 299 +++ src/interfaces/libpq/pyt/test_002_api.py | 19 + .../pyt/test_003_load_balance_host_list.py | 127 + .../libpq/pyt/test_004_load_balance_dns.py | 188 ++ .../pyt/test_005_negotiate_encryption.py | 791 +++++++ src/interfaces/libpq/pyt/test_006_service.py | 335 +++ src/test/icu/meson.build | 6 + src/test/icu/pyt/test_010_database.py | 85 + src/tools/pg_bsd_indent/meson.build | 5 + .../pyt/test_001_pg_bsd_indent.py | 67 + 23 files changed, 6177 insertions(+) create mode 100644 src/bin/pgbench/pyt/test_001_pgbench_with_server.py create mode 100644 src/bin/pgbench/pyt/test_002_pgbench_no_server.py create mode 100644 src/bin/psql/pyt/conftest.py create mode 100644 src/bin/psql/pyt/test_001_basic.py create mode 100644 src/bin/psql/pyt/test_010_tab_completion.py create mode 100644 src/bin/psql/pyt/test_020_cancel.py create mode 100644 src/bin/psql/pyt/test_030_pager.py create mode 100644 src/interfaces/ecpg/preproc/pyt/test_001_ecpg_err_warn_msg.py create mode 100644 src/interfaces/ecpg/preproc/pyt/test_002_ecpg_err_warn_msg_informix.py create mode 100644 src/interfaces/libpq/pyt/test_001_uri.py create mode 100644 src/interfaces/libpq/pyt/test_002_api.py create mode 100644 src/interfaces/libpq/pyt/test_003_load_balance_host_list.py create mode 100644 src/interfaces/libpq/pyt/test_004_load_balance_dns.py create mode 100644 src/interfaces/libpq/pyt/test_005_negotiate_encryption.py create mode 100644 src/interfaces/libpq/pyt/test_006_service.py create mode 100644 src/test/icu/pyt/test_010_database.py create mode 100644 src/tools/pg_bsd_indent/pyt/test_001_pg_bsd_indent.py diff --git a/src/bin/pgbench/meson.build b/src/bin/pgbench/meson.build index 12e895796c1..95d1b410eef 100644 --- a/src/bin/pgbench/meson.build +++ b/src/bin/pgbench/meson.build @@ -45,4 +45,10 @@ tests += { 't/002_pgbench_no_server.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_pgbench_with_server.py', + 'pyt/test_002_pgbench_no_server.py', + ], + }, } diff --git a/src/bin/pgbench/pyt/test_001_pgbench_with_server.py b/src/bin/pgbench/pyt/test_001_pgbench_with_server.py new file mode 100644 index 00000000000..62654932e64 --- /dev/null +++ b/src/bin/pgbench/pyt/test_001_pgbench_with_server.py @@ -0,0 +1,2096 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""pgbench tests that need a running server. + +Covers built-in scripts, custom scripts (via +``\\set``/``\\if``/``\\gset``/``\\aset``/pipelines), expression and error +handling, ``--init-steps``, tablespaces, zipfian/permute, serialization and +deadlock retries, logging/aggregation, late evaluation, and so on. + +The installed ``pgbench`` binary is the program under test and is run as a +subprocess (via the ``pg_bin`` helper). Any SQL that the test itself needs to +run (setup, verification) goes through the in-process libpq Session +(``pg.safe_sql``/``pg.sql``) rather than forking psql. + +One shared server is used for the whole module. Temporary script and log files +are written under a per-module temporary directory. +""" + +import os +import random +import re +import shutil +import socket +from typing import Any, List + +import pytest + +from pypg.server import PostgresServer +from pypg.util import short_tempdir + +EMPTY = [r"^$"] + + +# --------------------------------------------------------------------------- +# Module-scoped server (one node for the whole file) +# --------------------------------------------------------------------------- + + +def _free_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +@pytest.fixture(scope="module") +def basedir(): + """A per-module scratch directory for data dir, scripts, and logs.""" + d = short_tempdir(prefix="pgbench_001_") + yield d + shutil.rmtree(d, ignore_errors=True) + + +@pytest.fixture(scope="module") +def pg(bindir, libdir, basedir): + """A single started PostgresServer shared across the module. + + Initialized with ``--locale C`` so program output can be matched against + untranslated message strings. + """ + sockdir = short_tempdir() + server = PostgresServer( + "main", + bindir, + libdir, + os.path.join(basedir, "main"), + _free_port(), + sockdir, + ) + server.init(extra=["--locale", "C"]) + server.start() + yield server + server.teardown() + shutil.rmtree(sockdir, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# The pgbench() helper +# --------------------------------------------------------------------------- + + +def _make_files(basedir, files): + """Write *files* (dict name->contents) and return the --file option list. + + The ``--file`` argument keeps any ``@weight`` suffix, while the file written + to disk has the suffix stripped. + Filenames must be unique within a test; existing files are removed first. + """ + file_opts = [] + if files: + for fn in sorted(files): + # Forward slashes: pgbench echoes the script path back ("script N: + # " / "type: "), and the expectations match it with + # ".*/", so a Windows backslash path would not match. + arg = os.path.join(basedir, fn).replace("\\", "/") + file_opts += ["--file", arg] + filename = re.sub(r"@\d+$", "", arg) + if os.path.exists(filename): + os.unlink(filename) + with open(filename, "a", encoding="utf-8") as fh: + fh.write(files[fn]) + return file_opts + + +def pgbench(pg, opts, stat, out, err, name, files=None, args=None, extra_env=None): + """Invoke pgbench against *pg* and check exit code / stdout / stderr. + + *opts* is a string split on whitespace; *files* is an optional dict of + script files written under the server's basedir; *args* is an optional + list of extra trailing arguments (e.g. ``--log-prefix=...``). + """ + cmd = ["pgbench", *opts.split()] + cmd += _make_files(pg.basedir, files) + if args: + cmd += list(args) + pg.pg_bin.command_checks_all(cmd, stat, out, err, name, extra_env=extra_env) + + +def check_data_state(pg, kind): + """Verify the pgbench tables' initial filler/history state.""" + assert ( + pg.safe_sql( + "SELECT count(*) AS null_count FROM pgbench_accounts " + "WHERE filler IS NULL LIMIT 10;" + ) + == "0" + ), f"{kind}: filler column of pgbench_accounts has no NULL data" + assert ( + pg.safe_sql( + "SELECT count(*) AS null_count FROM pgbench_branches WHERE filler IS NULL;" + ) + == "1" + ), f"{kind}: filler column of pgbench_branches has only NULL data" + assert ( + pg.safe_sql( + "SELECT count(*) AS null_count FROM pgbench_tellers WHERE filler IS NULL;" + ) + == "10" + ), f"{kind}: filler column of pgbench_tellers has only NULL data" + assert ( + pg.safe_sql("SELECT count(*) AS data_count FROM pgbench_history;") == "0" + ), f"{kind}: pgbench_history has no data" + + +# --------------------------------------------------------------------------- +# Setup: tablespace + initialization, then data-state checks. +# +# These run in declared order (the module server carries state forward), so we +# keep the ordering: tablespace, concurrency, connection errors, init. +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def tablespace(pg): + """Create a tablespace for partitioned-table init testing. + + Partitioned tables cannot use pg_default explicitly; this exercises table + creation with a tablespace for partitioned tables. + """ + ts = os.path.join(pg.basedir, "regress_pgbench_tap_1_ts_dir") + os.mkdir(ts) + pg.safe_sql(f"CREATE TABLESPACE regress_pgbench_tap_1_ts LOCATION '{ts}';") + yield "regress_pgbench_tap_1_ts" + pg.safe_sql("DROP TABLESPACE regress_pgbench_tap_1_ts") + + +def test_concurrent_oid_generation(pg): + """Test concurrent OID generation via pg_enum_oid_index. + + Indirectly exercises LWLock and spinlock concurrency. + """ + labels = ",".join(f"'l{i}'" for i in range(1, 1001)) + pgbench( + pg, + "--no-vacuum --client=5 --protocol=prepared --transactions=25", + 0, + [r"processed: 125/125"], + EMPTY, + "concurrent OID generation", + { + "001_pgbench_concurrent_insert": f"CREATE TYPE pg_temp.e AS ENUM ({labels}); DROP TYPE pg_temp.e;" + }, + ) + + +def test_concurrent_grant_vacuum(pg): + """Inplace updates from VACUUM concurrent with heap_update from GRANT. + + This is a known-flaky case ('PROC_IN_VACUUM scan breakage') that fails + rarely. We mark it as an xfail so a spurious failure does not break the + suite. + """ + pg.safe_sql("CREATE TABLE ddl_target ()") + try: + pgbench( + pg, + "--no-vacuum --client=5 --protocol=prepared --transactions=50", + 0, + [r"processed: 250/250"], + EMPTY, + "concurrent GRANT/VACUUM", + { + "001_pgbench_grant@9": ( + "\n" + "\t\t\tDO $$\n" + "\t\t\tBEGIN\n" + "\t\t\t\tPERFORM pg_advisory_xact_lock(42);\n" + "\t\t\t\tFOR i IN 1 .. 10 LOOP\n" + "\t\t\t\t\tGRANT SELECT ON ddl_target TO PUBLIC;\n" + "\t\t\t\t\tREVOKE SELECT ON ddl_target FROM PUBLIC;\n" + "\t\t\t\tEND LOOP;\n" + "\t\t\tEND\n" + "\t\t\t$$;\n" + ), + "001_pgbench_vacuum_ddl_target@1": "VACUUM ddl_target;", + }, + ) + except AssertionError: + pytest.xfail("PROC_IN_VACUUM scan breakage (known flaky)") + + +def test_no_such_database(pg): + pgbench( + pg, + "no-such-database", + 1, + EMPTY, + [ + r"connection to server .* failed", + r'FATAL: database "no-such-database" does not exist', + ], + "no such database", + ) + + +def test_run_without_init(pg): + pgbench( + pg, + "-S -t 1", + 1, + [], + [r"Perhaps you need to do initialization"], + "run without init", + ) + + +def test_initialization_scale_1(pg): + """Initialize pgbench tables scale 1 (client-side data generation).""" + pgbench( + pg, + "-i", + 0, + EMPTY, + [ + r"creating tables", + r"vacuuming", + r"creating primary keys", + r"done in \d+\.\d\d s ", + ], + "pgbench scale 1 initialization", + ) + check_data_state(pg, "client-side") + + +def test_initialization_all_options(pg, tablespace): + """Initialize again with all possible options and a tablespace.""" + pgbench( + pg, + "--initialize --init-steps=dtpvg --scale=1 --unlogged-tables " + "--fillfactor=98 --foreign-keys --quiet " + f"--tablespace={tablespace} --index-tablespace={tablespace} " + "--partitions=2 --partition-method=hash", + 0, + [r"(?i)^$"], + [ + r"dropping old tables", + r"creating tables", + r"creating 2 partitions", + r"vacuuming", + r"creating primary keys", + r"creating foreign keys", + r"(?!vacuuming)", # no vacuum + r"done in \d+\.\d\d s ", + ], + "pgbench scale 1 initialization", + ) + + +def test_init_steps(pg): + """Interaction of --init-steps with legacy step-selection options.""" + pgbench( + pg, + "--initialize --init-steps=dtpvGvv --no-vacuum --foreign-keys " + "--unlogged-tables --partitions=3", + 0, + EMPTY, + [ + r"dropping old tables", + r"creating tables", + r"creating 3 partitions", + r"creating primary keys", + r"generating data \(server-side\)", + r"creating foreign keys", + r"(?!vacuuming)", # no vacuum + r"done in \d+\.\d\d s ", + ], + "pgbench --init-steps", + ) + check_data_state(pg, "server-side") + + +# --------------------------------------------------------------------------- +# Built-in scripts +# --------------------------------------------------------------------------- + + +def test_builtin_tpcb_like(pg): + pgbench( + pg, + "--transactions=5 -Dfoo=bla --client=2 --protocol=simple --builtin=t" + " --connect -n -v -n", + 0, + [ + r"builtin: TPC-B", + r"clients: 2\b", + r"processed: 10/10", + r"mode: simple", + r"maximum number of tries: 1", + ], + EMPTY, + "pgbench tpcb-like", + ) + + +def test_builtin_simple_update(pg): + pgbench( + pg, + "--transactions=20 --client=5 -M extended --builtin=si -C --no-vacuum -s 1", + 0, + [ + r"builtin: simple update", + r"clients: 5\b", + r"threads: 1\b", + r"processed: 100/100", + r"mode: extended", + ], + [r"scale option ignored"], + "pgbench simple update", + ) + + +def test_builtin_select_only(pg): + pgbench( + pg, + "-t 100 -c 7 -M prepared -b se --debug", + 0, + [ + r"builtin: select only", + r"clients: 7\b", + r"threads: 1\b", + r"processed: 700/700", + r"mode: prepared", + ], + [ + r"vacuum", + r"client 0", + r"client 1", + r"sending", + r"receiving", + r"executing", + ], + "pgbench select only", + ) + + +# --------------------------------------------------------------------------- +# Thread support detection (used by custom-script test below) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def nthreads(pg): + """Number of jobs to use: 2 if threads are supported, else 1.""" + res = pg.pg_bin.result(["pgbench", "--jobs", "2", "--bad-option"]) + if re.search(r"threads are not supported on this platform", res.stderr): + return 1 + return 2 + + +def test_custom_scripts_weighted(pg, nthreads): + pgbench( + pg, + f"-t 100 -c 1 -j {nthreads} -M prepared -n", + 0, + [ + r"type: multiple scripts", + r"mode: prepared", + r"script 1: .*/001_pgbench_custom_script_1", + r"weight: 2", + r"script 2: .*/001_pgbench_custom_script_2", + r"weight: 1", + r"processed: 100/100", + ], + EMPTY, + "pgbench custom scripts", + { + "001_pgbench_custom_script_1@1": ( + "-- select only\n" + "\\set aid random(1, :scale * 100000)\n" + "SELECT abalance::INTEGER AS balance\n" + " FROM pgbench_accounts\n" + " WHERE aid=:aid;\n" + ), + "001_pgbench_custom_script_2@2": ( + "-- special variables\n" + "BEGIN;\n" + "\\set foo 1\n" + "-- cast are needed for typing under -M prepared\n" + "SELECT :foo::INT + :scale::INT * :client_id::INT AS bla;\n" + "COMMIT;\n" + ), + }, + ) + + +def test_custom_script_simple(pg): + pgbench( + pg, + "-n -t 10 -c 1 -M simple", + 0, + [ + r"type: .*/001_pgbench_custom_script_3", + r"processed: 10/10", + r"mode: simple", + ], + EMPTY, + "pgbench custom script", + { + "001_pgbench_custom_script_3": ( + "-- select only variant\n" + "\\set aid random(1, :scale * 100000)\n" + "BEGIN;\n" + "SELECT abalance::INTEGER AS balance\n" + " FROM pgbench_accounts\n" + " WHERE aid=:aid;\n" + "COMMIT;\n" + ), + }, + ) + + +def test_custom_script_extended(pg): + pgbench( + pg, + "-n -t 10 -c 2 -M extended", + 0, + [ + r"type: .*/001_pgbench_custom_script_4", + r"processed: 20/20", + r"mode: extended", + ], + EMPTY, + "pgbench custom script", + { + "001_pgbench_custom_script_4": ( + "-- select only variant\n" + "\\set aid random(1, :scale * 100000)\n" + "BEGIN;\n" + "SELECT abalance::INTEGER AS balance\n" + " FROM pgbench_accounts\n" + " WHERE aid=:aid;\n" + "COMMIT;\n" + ), + }, + ) + + +# --------------------------------------------------------------------------- +# Server logging of query parameters +# +# (This doesn't really belong here, but pgbench is a convenient way to issue +# commands using extended query mode with parameters.) +# --------------------------------------------------------------------------- + + +def test_server_parameter_logging(pg): + # 1. Logging neither with errors nor with statements + pg.append_conf( + "log_min_duration_statement = 0\n" + "log_parameter_max_length = 0\n" + "log_parameter_max_length_on_error = 0" + ) + pg.reload() + offset = pg.log_position() + pgbench( + pg, + "-n -t1 -c1 -M prepared", + 2, + [], + [ + r"ERROR: invalid input syntax for type json", + r"(?!unnamed portal with parameters)", + ], + "server parameter logging", + { + "001_param_1": ( + "select '{ invalid ' as value \\gset\n" + "select $$'Valame Dios!' dijo Sancho; 'no le dije yo a vuestra " + "merced que mirase bien lo que hacia?'$$ as long \\gset\n" + "select column1::jsonb from (values (:value), (:long)) as q;\n" + ) + }, + ) + log = pg.log_content()[offset:] + assert not re.search( + r"DETAIL: Parameters: \$1 = '\{ invalid ',", log + ), "no parameters logged" + + # 2. Logging truncated parameters on error, full with statements + pg.append_conf( + "log_parameter_max_length = -1\nlog_parameter_max_length_on_error = 64" + ) + pg.reload() + pgbench( + pg, + "-n -t1 -c1 -M prepared", + 2, + [], + [ + r"ERROR: division by zero", + r"CONTEXT: unnamed portal with parameters: \$1 = '1', \$2 = NULL", + ], + "server parameter logging", + { + "001_param_2": ( + "select '1' as one \\gset\n" + "SELECT 1 / (random() / 2)::int, :one::int, :two::int;\n" + ) + }, + ) + offset = pg.log_position() + pgbench( + pg, + "-n -t1 -c1 -M prepared", + 2, + [], + [ + r"ERROR: invalid input syntax for type json", + r"(?m)CONTEXT: JSON data, line 1: \{ invalid\.\.\.[\r\n]+" + r"unnamed portal with parameters: \$1 = '\{ invalid ', " + r"\$2 = '''Valame Dios!'' dijo Sancho; ''no le dije yo a vuestra " + r"merced que \.\.\.'", + ], + "server parameter logging", + { + "001_param_3": ( + "select '{ invalid ' as value \\gset\n" + "select $$'Valame Dios!' dijo Sancho; 'no le dije yo a vuestra " + "merced que mirase bien lo que hacia?'$$ as long \\gset\n" + "select column1::jsonb from (values (:value), (:long)) as q;\n" + ) + }, + ) + log = pg.log_content()[offset:] + assert re.search( + r"DETAIL: Parameters: \$1 = '\{ invalid ', \$2 = '''Valame Dios!'' " + r"dijo Sancho; ''no le dije yo a vuestra merced que mirase bien lo que " + r"hacia\?'''", + log, + ), "parameter report does not truncate" + + # 3. Logging full parameters on error, truncated with statements + pg.append_conf( + "log_min_duration_statement = -1\n" + "log_parameter_max_length = 7\n" + "log_parameter_max_length_on_error = -1" + ) + pg.reload() + pgbench( + pg, + "-n -t1 -c1 -M prepared", + 2, + [], + [ + r"ERROR: division by zero", + r"CONTEXT: unnamed portal with parameters: \$1 = '1', \$2 = NULL", + ], + "server parameter logging", + { + "001_param_4": ( + "select '1' as one \\gset\n" + "SELECT 1 / (random() / 2)::int, :one::int, :two::int;\n" + ) + }, + ) + + pg.append_conf("log_min_duration_statement = 0") + pg.reload() + offset = pg.log_position() + pgbench( + pg, + "-n -t1 -c1 -M prepared", + 2, + [], + [ + r"ERROR: invalid input syntax for type json", + r"(?m)CONTEXT: JSON data, line 1: \{ invalid\.\.\.[\r\n]+" + r"unnamed portal with parameters: \$1 = '\{ invalid ', " + r"\$2 = '''Valame Dios!'' dijo Sancho; ''no le dije yo a vuestra " + r"merced que mirase bien lo que hacia\?'", + ], + "server parameter logging", + { + "001_param_5": ( + "select '{ invalid ' as value \\gset\n" + "select $$'Valame Dios!' dijo Sancho; 'no le dije yo a vuestra " + "merced que mirase bien lo que hacia?'$$ as long \\gset\n" + "select column1::jsonb from (values (:value), (:long)) as q;\n" + ) + }, + ) + log = pg.log_content()[offset:] + assert re.search( + r"DETAIL: Parameters: \$1 = '\{ inval\.\.\.', \$2 = '''Valame\.\.\.'", + log, + ), "parameter report truncates" + + # Check that bad parameters are reported during typinput phase of BIND + pgbench( + pg, + "-n -t1 -c1 -M prepared", + 2, + [], + [ + r'ERROR: invalid input syntax for type smallint: "1a"', + r"CONTEXT: unnamed portal parameter \$2 = '1a'", + ], + "server parameter logging", + { + "001_param_6": ( + "select 42 as value1, '1a' as value2 \\gset\n" + "select :value1::smallint, :value2::smallint;\n" + ) + }, + ) + + # Restore default logging config + pg.append_conf( + "log_min_duration_statement = -1\n" + "log_parameter_max_length_on_error = 0\n" + "log_parameter_max_length = -1" + ) + pg.reload() + + +# --------------------------------------------------------------------------- +# Expressions +# --------------------------------------------------------------------------- + +_EXPRESSIONS_SCRIPT = r"""-- integer functions +\set i1 debug(random(10, 19)) +\set i2 debug(random_exponential(100, 199, 10.0)) +\set i3 debug(random_gaussian(1000, 1999, 10.0)) +\set i4 debug(abs(-4)) +\set i5 debug(greatest(5, 4, 3, 2)) +\set i6 debug(11 + least(-5, -4, -3, -2)) +\set i7 debug(int(7.3)) +-- integer arithmetic and bit-wise operators +\set i8 debug(17 / (4|1) + ( 4 + (7 >> 2))) +\set i9 debug(- (3 * 4 - (-(~ 1) + -(~ 0))) / -1 + 3 % -1) +\set ia debug(10 + (0 + 0 * 0 - 0 / 1)) +\set ib debug(:ia + :scale) +\set ic debug(64 % (((2 + 1 * 2 + (1 # 2) | 4 * (2 & 11)) - (1 << 2)) + 2)) +-- double functions and operators +\set d1 debug(sqrt(+1.5 * 2.0) * abs(-0.8E1)) +\set d2 debug(double(1 + 1) * (-75.0 / :foo)) +\set pi debug(pi() * 4.9) +\set d4 debug(greatest(4, 2, -1.17) * 4.0 * Ln(Exp(1.0))) +\set d5 debug(least(-5.18, .0E0, 1.0/0) * -3.3) +-- reset variables +\set i1 0 +\set d1 false +-- yet another integer function +\set id debug(random_zipfian(1, 9, 1.3)) +--- pow and power +\set poweri debug(pow(-3,3)) +\set powerd debug(pow(2.0,10)) +\set poweriz debug(pow(0,0)) +\set powerdz debug(pow(0.0,0.0)) +\set powernegi debug(pow(-2,-3)) +\set powernegd debug(pow(-2.0,-3.0)) +\set powernegd2 debug(power(-5.0,-5.0)) +\set powerov debug(pow(9223372036854775807, 2)) +\set powerov2 debug(pow(10,30)) +-- comparisons and logical operations +\set c0 debug(1.0 = 0.0 and 1.0 != 0.0) +\set c1 debug(0 = 1 Or 1.0 = 1) +\set c4 debug(case when 0 < 1 then 32 else 0 end) +\set c5 debug(case when true then 33 else 0 end) +\set c6 debug(case when false THEN -1 when 1 = 1 then 13 + 19 + 2.0 end ) +\set c7 debug(case when (1 > 0) and (1 >= 0) and (0 < 1) and (0 <= 1) and (0 != 1) and (0 = 0) and (0 <> 1) then 35 else 0 end) +\set c8 debug(CASE \ + WHEN (1.0 > 0.0) AND (1.0 >= 0.0) AND (0.0 < 1.0) AND (0.0 <= 1.0) AND \ + (0.0 != 1.0) AND (0.0 = 0.0) AND (0.0 <> 1.0) AND (0.0 = 0.0) \ + THEN 36 \ + ELSE 0 \ + END) +\set c9 debug(CASE WHEN NOT FALSE THEN 3 * 12.3333334 END) +\set ca debug(case when false then 0 when 1-1 <> 0 then 1 else 38 end) +\set cb debug(10 + mod(13 * 7 + 12, 13) - mod(-19 * 11 - 17, 19)) +\set cc debug(NOT (0 > 1) AND (1 <= 1) AND NOT (0 >= 1) AND (0 < 1) AND \ + NOT (false and true) AND (false OR TRUE) AND (NOT :f) AND (NOT FALSE) AND \ + NOT (NOT TRUE)) +-- NULL value and associated operators +\set n0 debug(NULL + NULL * exp(NULL)) +\set n1 debug(:n0) +\set n2 debug(NOT (:n0 IS NOT NULL OR :d1 IS NULL)) +\set n3 debug(:n0 IS NULL AND :d1 IS NOT NULL AND :d1 NOTNULL) +\set n4 debug(:n0 ISNULL AND NOT :n0 IS TRUE AND :n0 IS NOT FALSE) +\set n5 debug(CASE WHEN :n IS NULL THEN 46 ELSE NULL END) +-- use a variables of all types +\set n6 debug(:n IS NULL AND NOT :f AND :t) +-- conditional truth +\set cs debug(CASE WHEN 1 THEN TRUE END AND CASE WHEN 1.0 THEN TRUE END AND CASE WHEN :n THEN NULL ELSE TRUE END) +-- hash functions +\set h0 debug(hash(10, 5432)) +\set h1 debug(:h0 = hash_murmur2(10, 5432)) +\set h3 debug(hash_fnv1a(10, 5432)) +\set h4 debug(hash(10)) +\set h5 debug(hash(10) = hash(10, :default_seed)) +-- lazy evaluation +\set zy 0 +\set yz debug(case when :zy = 0 then -1 else (1 / :zy) end) +\set yz debug(case when :zy = 0 or (1 / :zy) < 0 then -1 else (1 / :zy) end) +\set yz debug(case when :zy > 0 and (1 / :zy) < 0 then (1 / :zy) else 1 end) +-- substitute variables of all possible types +\set v0 NULL +\set v1 TRUE +\set v2 5432 +\set v3 -54.21E-2 +SELECT :v0, :v1, :v2, :v3; +-- if tests +\set nope 0 +\if 1 > 0 +\set id debug(65) +\elif 0 +\set nope 1 +\else +\set nope 1 +\endif +\if 1 < 0 +\set nope 1 +\elif 1 > 0 +\set ie debug(74) +\else +\set nope 1 +\endif +\if 1 < 0 +\set nope 1 +\elif 1 < 0 +\set nope 1 +\else +\set if debug(83) +\endif +\if 1 = 1 +\set ig debug(86) +\elif 0 +\set nope 1 +\endif +\if 1 = 0 +\set nope 1 +\elif 1 <> 0 +\set ih debug(93) +\endif +-- must be zero if false branches where skipped +\set nope debug(:nope) +-- check automatic variables +\set sc debug(:scale) +\set ci debug(:client_id) +\set rs debug(:random_seed) +-- minint constant parsing +\set min debug(-9223372036854775808) +\set max debug(-(:min + 1)) +-- parametric pseudorandom permutation function +\set t debug(permute(0, 2) + permute(1, 2) = 1) +\set t debug(permute(0, 3) + permute(1, 3) + permute(2, 3) = 3) +\set t debug(permute(0, 4) + permute(1, 4) + permute(2, 4) + permute(3, 4) = 6) +\set t debug(permute(0, 5) + permute(1, 5) + permute(2, 5) + permute(3, 5) + permute(4, 5) = 10) +\set t debug(permute(0, 16) + permute(1, 16) + permute(2, 16) + permute(3, 16) + \ + permute(4, 16) + permute(5, 16) + permute(6, 16) + permute(7, 16) + \ + permute(8, 16) + permute(9, 16) + permute(10, 16) + permute(11, 16) + \ + permute(12, 16) + permute(13, 16) + permute(14, 16) + permute(15, 16) = 120) +-- random sanity checks +\set size random(2, 1000) +\set v random(0, :size - 1) +\set p permute(:v, :size) +\set t debug(0 <= :p and :p < :size and :p = permute(:v + :size, :size) and :p <> permute(:v + 1, :size)) +-- actual values +\set t debug(permute(:v, 1) = 0) +\set t debug(permute(0, 2, 5431) = 0 and permute(1, 2, 5431) = 1 and \ + permute(0, 2, 5433) = 1 and permute(1, 2, 5433) = 0) +-- check permute's portability across architectures +\set size debug(:max - 10) +\set t debug(permute(:size-1, :size, 5432) = 520382784483822430 and \ + permute(:size-2, :size, 5432) = 1143715004660802862 and \ + permute(:size-3, :size, 5432) = 447293596416496998 and \ + permute(:size-4, :size, 5432) = 916527772266572956 and \ + permute(:size-5, :size, 5432) = 2763809008686028849 and \ + permute(:size-6, :size, 5432) = 8648551549198294572 and \ + permute(:size-7, :size, 5432) = 4542876852200565125) +""" + + +def test_expressions(pg): + pgbench( + pg, + "--random-seed=5432 -t 1 -Dfoo=-10.1 -Dbla=false -Di=+3 -Dn=null " + "-Dt=t -Df=of -Dd=1.0", + 0, + [r"type: .*/001_pgbench_expressions", r"processed: 1/1"], + [ + r"setting random seed to 5432\b", + # After explicit seeding, the four random checks (1-3,20) are + # deterministic; but see also magic values in checks 111,113. + r"command=1.: int 17\b", # uniform random + r"command=2.: int 104\b", # exponential random + r"command=3.: int 1498\b", # gaussian random + r"command=4.: int 4\b", + r"command=5.: int 5\b", + r"command=6.: int 6\b", + r"command=7.: int 7\b", + r"command=8.: int 8\b", + r"command=9.: int 9\b", + r"command=10.: int 10\b", + r"command=11.: int 11\b", + r"command=12.: int 12\b", + r"command=15.: double 15\b", + r"command=16.: double 16\b", + r"command=17.: double 17\b", + r"command=20.: int 3\b", # zipfian random + r"command=21.: double -27\b", + r"command=22.: double 1024\b", + r"command=23.: double 1\b", + r"command=24.: double 1\b", + r"command=25.: double -0.125\b", + r"command=26.: double -0.125\b", + r"command=27.: double -0.00032\b", + r"command=28.: double 8.50705917302346e\+0?37\b", + r"command=29.: double 1e\+0?30\b", + r"command=30.: boolean false\b", + r"command=31.: boolean true\b", + r"command=32.: int 32\b", + r"command=33.: int 33\b", + r"command=34.: double 34\b", + r"command=35.: int 35\b", + r"command=36.: int 36\b", + r"command=37.: double 37\b", + r"command=38.: int 38\b", + r"command=39.: int 39\b", + r"command=40.: boolean true\b", + r"command=41.: null\b", + r"command=42.: null\b", + r"command=43.: boolean true\b", + r"command=44.: boolean true\b", + r"command=45.: boolean true\b", + r"command=46.: int 46\b", + r"command=47.: boolean true\b", + r"command=48.: boolean true\b", + r"command=49.: int -5817877081768721676\b", + r"command=50.: boolean true\b", + r"command=51.: int -7793829335365542153\b", + r"command=52.: int -?\d+\b", + r"command=53.: boolean true\b", + r"command=65.: int 65\b", + r"command=74.: int 74\b", + r"command=83.: int 83\b", + r"command=86.: int 86\b", + r"command=93.: int 93\b", + r"command=95.: int 0\b", + r"command=96.: int 1\b", # :scale + r"command=97.: int 0\b", # :client_id + r"command=98.: int 5432\b", # :random_seed + r"command=99.: int -9223372036854775808\b", # min int + r"command=100.: int 9223372036854775807\b", # max int + # pseudorandom permutation tests + r"command=101.: boolean true\b", + r"command=102.: boolean true\b", + r"command=103.: boolean true\b", + r"command=104.: boolean true\b", + r"command=105.: boolean true\b", + r"command=109.: boolean true\b", + r"command=110.: boolean true\b", + r"command=111.: boolean true\b", + r"command=113.: boolean true\b", + ], + "pgbench expressions", + {"001_pgbench_expressions": _EXPRESSIONS_SCRIPT}, + ) + + +def test_nested_ifs(pg): + pgbench( + pg, + "--no-vacuum --client=1 --exit-on-abort --transactions=1", + 0, + [r"actually processed"], + EMPTY, + "nested ifs", + { + "pgbench_nested_if": ( + "\n" + "\t\t\t\\if false\n" + "\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\if true\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\elif true\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\else\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\endif\n" + "\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\\elif false\n" + "\t\t\t\t\\if true\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\elif true\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\else\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\endif\n" + "\t\t\t\\else\n" + "\t\t\t\t\\if false\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\elif false\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\else\n" + "\t\t\t\t\tSELECT 'correct';\n" + "\t\t\t\t\\endif\n" + "\t\t\t\\endif\n" + "\t\t\t\\if true\n" + "\t\t\t\tSELECT 'correct';\n" + "\t\t\t\\else\n" + "\t\t\t\t\\if true\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\elif true\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\else\n" + "\t\t\t\t\tSELECT 1 / 0;\n" + "\t\t\t\t\\endif\n" + "\t\t\t\\endif\n" + "\t\t\t" + ) + }, + ) + + +def test_random_seeded_determinism(pg): + """Random determinism when seeded: same seed gives same 4 values twice.""" + pg.safe_sql( + "CREATE UNLOGGED TABLE seeded_random(" + "seed INT8 NOT NULL, rand TEXT NOT NULL, val INTEGER NOT NULL);" + ) + + seed = random.randint(0, 999999999) + for i in (1, 2): + pgbench( + pg, + f"--random-seed={seed} -t 1", + 0, + [r"processed: 1/1"], + [rf"setting random seed to {seed}\b"], + f"random seeded with {seed}", + { + f"001_pgbench_random_seed_{i}": ( + "-- test random functions\n" + "\\set ur random(1000, 1999)\n" + "\\set er random_exponential(2000, 2999, 2.0)\n" + "\\set gr random_gaussian(3000, 3999, 3.0)\n" + "\\set zr random_zipfian(4000, 4999, 1.5)\n" + "INSERT INTO seeded_random(seed, rand, val) VALUES\n" + " (:random_seed, 'uniform', :ur),\n" + " (:random_seed, 'exponential', :er),\n" + " (:random_seed, 'gaussian', :gr),\n" + " (:random_seed, 'zipfian', :zr);\n" + ) + }, + ) + + # check that all runs generated the same 4 values + out = pg.safe_sql( + "SELECT seed, rand, val, COUNT(*) FROM seeded_random " + "GROUP BY seed, rand, val" + ) + assert re.search( + rf"\b{seed}\|uniform\|1\d\d\d\|2", out + ), "psql seeded_random count uniform" + assert re.search( + rf"\b{seed}\|exponential\|2\d\d\d\|2", out + ), "psql seeded_random count exponential" + assert re.search( + rf"\b{seed}\|gaussian\|3\d\d\d\|2", out + ), "psql seeded_random count gaussian" + assert re.search( + rf"\b{seed}\|zipfian\|4\d\d\d\|2", out + ), "psql seeded_random count zipfian" + + pg.safe_sql("DROP TABLE seeded_random;") + + +def test_backslash_commands(pg): + pgbench( + pg, + "-t 1", + 0, + [ + r"type: .*/001_pgbench_backslash_commands", + r"processed: 1/1", + r"shell-echo-output", + ], + [r"command=8.: int 1\b"], + "pgbench backslash commands", + { + "001_pgbench_backslash_commands": ( + "-- run set\n" + "\\set zero 0\n" + "\\set one 1.0\n" + "-- sleep\n" + "\\sleep :one ms\n" + "\\sleep 100 us\n" + "\\sleep 0 s\n" + "\\sleep :zero\n" + "-- setshell and continuation\n" + "\\setshell another_one\\\n" + " echo \\\n" + " :one\n" + "\\set n debug(:another_one)\n" + "-- shell\n" + "\\shell echo shell-echo-output\n" + ) + }, + ) + + +def test_gset(pg): + pgbench( + pg, + "-t 1", + 0, + [r"type: .*/001_pgbench_gset", r"processed: 1/1"], + [ + r"command=3.: int 0\b", + r"command=5.: int 1\b", + r"command=6.: int 2\b", + r"command=8.: int 3\b", + r"command=10.: int 4\b", + r"command=12.: int 5\b", + ], + "pgbench gset command", + { + "001_pgbench_gset": ( + "-- test gset\n" + "-- no columns\n" + "SELECT \\gset\n" + "-- one value\n" + "SELECT 0 AS i0 \\gset\n" + "\\set i debug(:i0)\n" + "-- two values\n" + "SELECT 1 AS i1, 2 AS i2 \\gset\n" + "\\set i debug(:i1)\n" + "\\set i debug(:i2)\n" + "-- with prefix\n" + "SELECT 3 AS i3 \\gset x_\n" + "\\set i debug(:x_i3)\n" + "-- overwrite existing variable\n" + "SELECT 0 AS i4, 4 AS i4 \\gset\n" + "\\set i debug(:i4)\n" + "-- work on the last SQL command under \\;\n" + "\\; \\; SELECT 0 AS i5 \\; SELECT 5 AS i5 \\; \\; \\gset\n" + "\\set i debug(:i5)\n" + ) + }, + ) + + +def test_gset_two_rows(pg): + # \gset cannot accept more than one row, causing command to fail. + pgbench( + pg, + "-t 1", + 2, + [r"type: .*/001_pgbench_gset_two_rows", r"processed: 0/1"], + [r"expected one row, got 2\b"], + "pgbench gset command with two rows", + { + "001_pgbench_gset_two_rows": ( + "\nSELECT 5432 AS fail UNION SELECT 5433 ORDER BY 1 \\gset\n" + ) + }, + ) + + +def test_aset(pg): + # working \aset, valid cases. + pgbench( + pg, + "-t 1", + 0, + [r"type: .*/001_pgbench_aset", r"processed: 1/1"], + [r"command=3.: int 8\b", r"command=4.: int 7\b"], + "pgbench aset command", + { + "001_pgbench_aset": ( + "\n" + "-- test aset, which applies to a combined query\n" + "\\; SELECT 6 AS i6 \\; SELECT 7 AS i7 \\; \\aset\n" + "-- unless it returns more than one row, last is kept\n" + "SELECT 8 AS i6 UNION SELECT 9 ORDER BY 1 DESC \\aset\n" + "\\set i debug(:i6)\n" + "\\set i debug(:i7)\n" + ) + }, + ) + + +def test_aset_empty(pg): + # Empty result set with \aset, causing command to fail. + pgbench( + pg, + "-t 1", + 2, + [r"type: .*/001_pgbench_aset_empty", r"processed: 0/1"], + [ + r'undefined variable "i8"', + r"evaluation of meta-command failed\b", + ], + "pgbench aset command with empty result", + { + "001_pgbench_aset_empty": ( + "\n" + "-- empty result\n" + "\\; SELECT 5432 AS i8 WHERE FALSE \\; \\aset\n" + "\\set i debug(:i8)\n" + ) + }, + ) + + +# --------------------------------------------------------------------------- +# Pipeline tests +# --------------------------------------------------------------------------- + + +def test_pipeline_basic(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 0, + [r"type: .*/001_pgbench_pipeline", r"actually processed: 1/1"], + [], + "working \\startpipeline", + { + "001_pgbench_pipeline": ( + "\n-- test startpipeline\n\\startpipeline\n" + + "select 1;\n" * 10 + + "\n\\endpipeline\n" + ) + }, + ) + + +def test_pipeline_sync(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 0, + [r"type: .*/001_pgbench_pipeline_sync", r"actually processed: 1/1"], + [], + "working \\startpipeline with \\syncpipeline", + { + "001_pgbench_pipeline_sync": ( + "\n-- test startpipeline\n\\startpipeline\nselect 1;\n" + "\\syncpipeline\n\\syncpipeline\nselect 2;\n\\syncpipeline\n" + "select 3;\n\\endpipeline\n" + ) + }, + ) + + +def test_pipeline_prepared(pg): + pgbench( + pg, + "-t 1 -n -M prepared", + 0, + [r"type: .*/001_pgbench_pipeline_prep", r"actually processed: 1/1"], + [], + "working \\startpipeline", + { + "001_pgbench_pipeline_prep": ( + "\n-- test startpipeline\n\\startpipeline\n\\endpipeline\n" + "\\startpipeline\n" + "select 1;\n" * 10 + "\n\\endpipeline\n" + ) + }, + ) + + +def test_pipeline_twice_error(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 2, + [], + [r"already in pipeline mode"], + "error: call \\startpipeline twice", + { + "001_pgbench_pipeline_2": "\n-- startpipeline twice\n\\startpipeline\n\\startpipeline\n" + }, + ) + + +def test_pipeline_end_no_start_error(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 2, + [], + [r"not in pipeline mode"], + "error: \\endpipeline with no start", + {"001_pgbench_pipeline_3": "\n-- pipeline not started\n\\endpipeline\n"}, + ) + + +def test_pipeline_gset_error(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 2, + [], + [r"gset is not allowed in pipeline mode"], + "error: \\gset not allowed in pipeline mode", + { + "001_pgbench_pipeline_4": "\n\\startpipeline\nselect 1 \\gset f\n\\endpipeline\n" + }, + ) + + +def test_pipeline_no_end_single_txn_error(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 2, + [], + [r"end of script reached with pipeline open"], + "error: call \\startpipeline without \\endpipeline in a single transaction", + { + "001_pgbench_pipeline_5": "\n-- startpipeline only with single transaction\n\\startpipeline\n" + }, + ) + + +def test_pipeline_no_end_error(pg): + pgbench( + pg, + "-t 2 -n -M extended", + 2, + [], + [r"end of script reached with pipeline open"], + "error: call \\startpipeline without \\endpipeline", + {"001_pgbench_pipeline_6": "\n-- startpipeline only\n\\startpipeline\n"}, + ) + + +def test_pipeline_sync_no_end_error(pg): + pgbench( + pg, + "-t 2 -n -M extended", + 2, + [], + [r"end of script reached with pipeline open"], + "error: call \\startpipeline and \\syncpipeline without \\endpipeline", + { + "001_pgbench_pipeline_7": "\n-- startpipeline with \\syncpipeline only\n" + "\\startpipeline\n\\syncpipeline\n" + }, + ) + + +def test_pipeline_set_local_first(pg): + # SET LOCAL as first pipeline command: succeeds with a WARNING. + pgbench( + pg, + "-t 1 -n -M extended", + 0, + [], + [r"WARNING: SET LOCAL can only be used in transaction blocks"], + "SET LOCAL outside implicit transaction block of pipeline", + { + "001_pgbench_pipeline_set_local_1": "\n\\startpipeline\nSET LOCAL statement_timeout='1h';\n\\endpipeline\n" + }, + ) + + +def test_pipeline_set_local_second(pg): + # SET LOCAL as second pipeline command: succeeds, no WARNING. + pgbench( + pg, + "-t 1 -n -M extended", + 0, + [], + EMPTY, + "SET LOCAL inside implicit transaction block of pipeline", + { + "001_pgbench_pipeline_set_local_2": "\n\\startpipeline\nSELECT 1;\n" + "SET LOCAL statement_timeout='1h';\n\\endpipeline\n" + }, + ) + + +def test_pipeline_set_local_sync(pg): + # SET LOCAL with \syncpipeline: command after sync is outside the + # implicit transaction block, causing a WARNING. + pgbench( + pg, + "-t 1 -n -M extended", + 0, + [], + [r"WARNING: SET LOCAL can only be used in transaction blocks"], + "SET LOCAL and \\syncpipeline", + { + "001_pgbench_pipeline_set_local_3": "\n\\startpipeline\nSELECT 1;\n\\syncpipeline\n" + "SET LOCAL statement_timeout='1h';\n\\endpipeline\n" + }, + ) + + +def test_pipeline_reindex_first(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 0, + [], + [], + "REINDEX CONCURRENTLY outside implicit transaction block of pipeline", + { + "001_pgbench_pipeline_reindex_1": "\n\\startpipeline\n" + "REINDEX TABLE CONCURRENTLY pgbench_accounts;\n" + "SELECT 1;\n\\endpipeline\n" + }, + ) + + +def test_pipeline_reindex_second_error(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 2, + [], + [], + "error: REINDEX CONCURRENTLY inside implicit transaction block of pipeline", + { + "001_pgbench_pipeline_reindex_2": "\n\\startpipeline\nSELECT 1;\n" + "REINDEX TABLE CONCURRENTLY pgbench_accounts;\n\\endpipeline\n" + }, + ) + + +def test_pipeline_vacuum_first(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 0, + [], + [], + "VACUUM outside implicit transaction block of pipeline", + { + "001_pgbench_pipeline_vacuum_1": "\n\\startpipeline\nVACUUM pgbench_accounts;\n\\endpipeline\n" + }, + ) + + +def test_pipeline_vacuum_second_error(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 2, + [], + [], + "error: VACUUM inside implicit transaction block of pipeline", + { + "001_pgbench_pipeline_vacuum_2": "\n\\startpipeline\nSELECT 1;\n" + "VACUUM pgbench_accounts;\n\\endpipeline\n" + }, + ) + + +def test_pipeline_subtransactions_error(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 2, + [], + [], + "error: subtransactions not allowed in pipeline", + { + "001_pgbench_pipeline_subtrans": "\n\\startpipeline\nSAVEPOINT a;\nSELECT 1;\n" + "ROLLBACK TO SAVEPOINT a;\nSELECT 2;\n\\endpipeline\n" + }, + ) + + +def test_pipeline_lock_first_error(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 2, + [], + [], + "error: LOCK TABLE outside implicit transaction block of pipeline", + { + "001_pgbench_pipeline_lock_1": "\n\\startpipeline\nLOCK pgbench_accounts;\n" + "SELECT 1;\n\\endpipeline\n" + }, + ) + + +def test_pipeline_lock_second(pg): + pgbench( + pg, + "-t 1 -n -M extended", + 0, + [], + [], + "LOCK TABLE inside implicit transaction block of pipeline", + { + "001_pgbench_pipeline_lock_2": "\n\\startpipeline\nSELECT 1;\n" + "LOCK pgbench_accounts;\n\\endpipeline\n" + }, + ) + + +def test_pipeline_serializable(pg): + pgbench( + pg, + "-c4 -t 10 -n -M prepared", + 0, + [ + r"type: .*/001_pgbench_pipeline_serializable", + r"actually processed: (\d+)/\1", + ], + [], + "working \\startpipeline with serializable", + { + "001_pgbench_pipeline_serializable": ( + "\n-- test startpipeline with serializable\n\\startpipeline\n" + "BEGIN ISOLATION LEVEL SERIALIZABLE;\n" + + "select 1;\n" * 10 + + "\nEND;\n\\endpipeline\n" + ) + }, + ) + + +# --------------------------------------------------------------------------- +# Many expression / meta-command errors +# --------------------------------------------------------------------------- + +# [ test name, expected status, expected stderr regexes, script ] +# Each row is a heterogeneous [str, int, List[str], str] record. +_ERRORS: List[List[Any]] = [ + # SQL + [ + "sql syntax error", + 2, + [r"ERROR: syntax error", r"prepared statement .* does not exist"], + "-- SQL syntax error\n SELECT 1 + ;\n", + ], + [ + "sql too many args", + 1, + [r"statement has too many arguments.*\b255\b"], + "-- MAX_ARGS=256 for prepared\n\\set i 0\nSELECT LEAST(" + + ", ".join([":i"] * 256) + + ")", + ], + # SHELL + [ + "shell bad command", + 2, + [r"\(shell\) .* meta-command failed"], + "\\shell no-such-command", + ], + [ + "shell undefined variable", + 2, + [r'undefined variable ":nosuchvariable"'], + "-- undefined variable in shell\n\\shell echo ::foo :nosuchvariable\n", + ], + ["shell missing command", 1, [r"missing command "], "\\shell"], + [ + "shell too many args", + 1, + [r'too many arguments in command "shell"'], + "-- 256 arguments to \\shell\n\\shell echo " + " ".join(["arg"] * 255), + ], + # SET + ["set syntax error", 1, [r'syntax error in command "set"'], "\\set i 1 +"], + [ + "set no such function", + 1, + [r"unexpected function name"], + "\\set i noSuchFunction()", + ], + ["set invalid variable name", 2, [r"invalid variable name"], "\\set . 1"], + ["set division by zero", 2, [r"division by zero"], "\\set i 1/0"], + [ + "set undefined variable", + 2, + [r'undefined variable "nosuchvariable"'], + "\\set i :nosuchvariable", + ], + ["set unexpected char", 1, [r"unexpected character .;."], "\\set i ;"], + [ + "set too many args", + 2, + [r"too many function arguments"], + "\\set i least(0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16)", + ], + [ + "set empty random range", + 2, + [r"empty range given to random"], + "\\set i random(5,3)", + ], + [ + "set random range too large", + 2, + [r"random range is too large"], + "\\set i random(:minint, :maxint)", + ], + [ + "set gaussian param too small", + 2, + [r"gaussian param.* at least 2"], + "\\set i random_gaussian(0, 10, 1.0)", + ], + [ + "set exponential param greater 0", + 2, + [r"exponential parameter must be greater "], + "\\set i random_exponential(0, 10, 0.0)", + ], + [ + "set zipfian param to 1", + 2, + [r"zipfian parameter must be in range \[1\.001, 1000\]"], + "\\set i random_zipfian(0, 10, 1)", + ], + [ + "set zipfian param too large", + 2, + [r"zipfian parameter must be in range \[1\.001, 1000\]"], + "\\set i random_zipfian(0, 10, 1000000)", + ], + [ + "set non numeric value", + 2, + [r'malformed variable "foo" value: "bla"'], + "\\set i :foo + 1", + ], + ["set no expression", 1, [r"syntax error"], "\\set i"], + ["set missing argument", 1, [r"(?i)missing argument"], "\\set"], + ["set not a bool", 2, [r"cannot coerce double to boolean"], "\\set b NOT 0.0"], + ["set not an int", 2, [r"cannot coerce boolean to int"], "\\set i TRUE + 2"], + ["set not a double", 2, [r"cannot coerce boolean to double"], "\\set d ln(TRUE)"], + [ + "set case error", + 1, + [r'syntax error in command "set"'], + "\\set i CASE TRUE THEN 1 ELSE 0 END", + ], + [ + "set random error", + 2, + [r"cannot coerce boolean to int"], + "\\set b random(FALSE, TRUE)", + ], + [ + "set number of args mismatch", + 1, + [r"unexpected number of arguments"], + "\\set d ln(1.0, 2.0))", + ], + [ + "set at least one arg", + 1, + [r"at least one argument expected"], + "\\set i greatest())", + ], + # SET: ARITHMETIC OVERFLOW DETECTION + [ + "set double to int overflow", + 2, + [r"double to int overflow for 100"], + "\\set i int(1E32)", + ], + ["set bigint add overflow", 2, [r"int add out"], "\\set i (1<<62) + (1<<62)"], + [ + "set bigint sub overflow", + 2, + [r"int sub out"], + "\\set i 0 - (1<<62) - (1<<62) - (1<<62)", + ], + ["set bigint mul overflow", 2, [r"int mul out"], "\\set i 2 * (1<<62)"], + [ + "set bigint div out of range", + 2, + [r"bigint div out of range"], + "\\set i :minint / -1", + ], + # SETSHELL + [ + "setshell not an int", + 2, + [r"command must return an integer"], + "\\setshell i echo -n one", + ], + ["setshell missing arg", 1, [r"missing argument "], "\\setshell var"], + [ + "setshell no such command", + 2, + [r"could not read result "], + "\\setshell var no-such-command", + ], + # SLEEP + [ + "sleep undefined variable", + 2, + [r"sleep: undefined variable"], + "\\sleep :nosuchvariable", + ], + ["sleep too many args", 1, [r"too many arguments"], "\\sleep too many args"], + ["sleep missing arg", 1, [r"missing argument", r"\\sleep"], "\\sleep"], + ["sleep unknown unit", 1, [r"unrecognized time unit"], "\\sleep 1 week"], + # MISC + [ + "misc invalid backslash command", + 1, + [r'invalid command .* "nosuchcommand"'], + "\\nosuchcommand", + ], + ["misc empty script", 1, [r"empty command list for script"], ""], + ["bad boolean", 2, [r"malformed variable.*trueXXX"], "\\set b :badtrue or true"], + [ + "invalid permute size", + 2, + [r"permute size parameter must be greater than zero"], + "\\set i permute(0, 0)", + ], + # GSET + ["gset no row", 2, [r"expected one row, got 0\b"], "SELECT WHERE FALSE \\gset"], + ["gset alone", 1, [r"gset must follow an SQL command"], "\\gset"], + ["gset no SQL", 1, [r"gset must follow an SQL command"], "\\set i +1\n\\gset"], + ["gset too many arguments", 1, [r"too many arguments"], "SELECT 1 \\gset a b"], + [ + "gset after gset", + 1, + [r"gset must follow an SQL command"], + "SELECT 1 AS i \\gset\n\\gset", + ], + [ + "gset non SELECT", + 2, + [r"expected one row, got 0"], + "DROP TABLE IF EXISTS no_such_table \\gset", + ], + [ + "gset bad default name", + 2, + [r"error storing into variable \?column\?"], + "SELECT 1 \\gset", + ], + [ + "gset bad name", + 2, + [r"error storing into variable bad name!"], + 'SELECT 1 AS "bad name!" \\gset', + ], +] + + +@pytest.mark.parametrize( + "name,status,err,script", + _ERRORS, + ids=[e[0] for e in _ERRORS], +) +def test_script_errors(pg, name, status, err, script): + assert status != 0, f'invalid expected status for test "{name}"' + n = "001_pgbench_error_" + name.replace(" ", "_") + pgbench( + pg, + "-n -t 1 -Dfoo=bla -Dnull=null -Dtrue=true -Done=1 -Dzero=0.0 " + "-Dbadtrue=trueXXX -Dmaxint=9223372036854775807 " + "-Dminint=-9223372036854775808 -M prepared", + status, + [r"^$" if status == 1 else r"processed: 0/1"], + err, + "pgbench script error: " + name, + {n: script}, + ) + + +# --------------------------------------------------------------------------- +# Throttling +# --------------------------------------------------------------------------- + + +def test_throttling(pg): + pgbench( + pg, + "-t 100 -S --rate=100000 --latency-limit=1000000 -c 2 -n -r", + 0, + [r"processed: 200/200", r"builtin: select only"], + EMPTY, + "pgbench throttling", + ) + + +def test_late_throttling(pg): + pgbench( + pg, + # given the expected rate and the 2 ms tx duration, at most one is executed + "-t 10 --rate=100000 --latency-limit=1 -n -r", + 0, + [ + r"processed: [01]/10", + r"type: .*/001_pgbench_sleep", + r"above the 1.0 ms latency limit: [01]/", + ], + EMPTY, + "pgbench late throttling", + {"001_pgbench_sleep": "\\sleep 2ms"}, + ) + + +# --------------------------------------------------------------------------- +# Logging contents +# --------------------------------------------------------------------------- + + +def _list_files(directory, regex): + """Return files in *directory* whose names match *regex*.""" + pat = re.compile(regex) + return [os.path.join(directory, f) for f in os.listdir(directory) if pat.search(f)] + + +def check_pgbench_logs(directory, prefix, nb, min_lines, max_lines, line_re): + """Check per-thread log files and their contents.""" + logs = _list_files(directory, rf"^{prefix}\..*$") + assert len(logs) == nb, "number of log files" + name_re = re.compile(rf"[\\/]{prefix}\.\d+(\.\d+)?$") + assert len([log for log in logs if name_re.search(log)]) == nb, "file name format" + + body_re = re.compile(line_re) + for log in sorted(logs): + with open(log, "r", encoding="utf-8", errors="replace") as fh: + contents = fh.read().split("\n") + # split() on a trailing newline yields a final empty element; drop it + # so trailing empty lines are discarded. + while contents and contents[-1] == "": + contents.pop() + clen = len(contents) + assert clen >= min_lines, f"transaction count for {log} ({clen}) is above min" + assert clen <= max_lines, f"transaction count for {log} ({clen}) is below max" + clen_match = len([c for c in contents if body_re.search(c)]) + assert clen_match == clen, f"transaction format for {prefix}" + + +def test_logs_sampling(pg): + # Run with sampling rate, 2 clients with 50 transactions each. + bdir = pg.basedir + pgbench( + pg, + "-n -S -t 50 -c 2 --log --sampling-rate=0.5", + 0, + [r"select only", r"processed: 100/100"], + EMPTY, + "pgbench logs", + None, + [f"--log-prefix={bdir}/001_pgbench_log_2"], + ) + # The IDs of the clients (1st field) in the logs should be either 0 or 1. + check_pgbench_logs( + bdir, "001_pgbench_log_2", 1, 8, 92, r"^[01] \d{1,2} \d+ \d \d+ \d+$" + ) + + +def test_logs_contents(pg): + # Run with different read-only option pattern, 1 client with 10 transactions. + bdir = pg.basedir + pgbench( + pg, + "-n -b select-only -t 10 -l", + 0, + [r"select only", r"processed: 10/10"], + EMPTY, + "pgbench logs contents", + None, + [f"--log-prefix={bdir}/001_pgbench_log_3"], + ) + # The ID of a single client (1st field) should match 0. + check_pgbench_logs( + bdir, "001_pgbench_log_3", 1, 10, 10, r"^0 \d{1,2} \d+ \d \d+ \d+$" + ) + + +def test_incomplete_transaction_block(pg): + # abortion of the client if the script contains an incomplete transaction block + pgbench( + pg, + "--no-vacuum", + 2, + [r"processed: 1/10"], + [ + r"client 0 aborted: end of script reached without completing the " + r"last transaction" + ], + "incomplete transaction block", + {"001_pgbench_incomplete_transaction_block": "BEGIN;SELECT 1;"}, + ) + + +# --------------------------------------------------------------------------- +# Concurrent update / deadlock retries +# --------------------------------------------------------------------------- + +_SERIALIZATION_SCRIPT = r""" +-- What's happening: +-- The first client starts the transaction with the isolation level Repeatable +-- Read: +-- +-- BEGIN; +-- UPDATE xy SET y = ... WHERE x = 1; +-- +-- The second client starts a similar transaction with the same isolation level: +-- +-- BEGIN; +-- UPDATE xy SET y = ... WHERE x = 1; +-- +-- +-- The first client commits its transaction, and the second client gets a +-- serialization error. + +\set delta random(-5000, 5000) + +-- The second client will stop here +SELECT pg_advisory_lock(0); + +-- Start transaction with concurrent update +BEGIN; +UPDATE xy SET y = y + :delta WHERE x = 1 AND pg_advisory_lock(1) IS NOT NULL; + +-- Wait for the second client +DO $$ +DECLARE + exists boolean; + waiters integer; +BEGIN + -- The second client always comes in second, and the number of rows in the + -- table first_client_table reflect this. Here the first client inserts a row, + -- so the second client will see a non-empty table when repeating the + -- transaction after the serialization error. + SELECT EXISTS (SELECT * FROM first_client_table) INTO STRICT exists; + IF NOT exists THEN + -- Let the second client begin + PERFORM pg_advisory_unlock(0); + -- And wait until the second client tries to get the same lock + LOOP + SELECT COUNT(*) INTO STRICT waiters FROM pg_locks WHERE + locktype = 'advisory' AND objsubid = 1 AND + ((classid::bigint << 32) | objid::bigint = 1::bigint) AND NOT granted; + IF waiters = 1 THEN + INSERT INTO first_client_table VALUES (1); + + -- Exit loop + EXIT; + END IF; + END LOOP; + END IF; +END$$; + +COMMIT; +SELECT pg_advisory_unlock_all(); +""" + +_DEADLOCK_SCRIPT = r""" +-- What's happening: +-- The first client gets the lock 2. +-- The second client gets the lock 3 and tries to get the lock 2. +-- The first client tries to get the lock 3 and one of them gets a deadlock +-- error. +-- +-- A client that does not get a deadlock error must hold a lock at the +-- transaction start. Thus in the end it releases all of its locks before the +-- client with the deadlock error starts a retry (we do not want any errors +-- again). + +-- Since the client with the deadlock error has not released the blocking locks, +-- let's do this here. +SELECT pg_advisory_unlock_all(); + +-- The second client and the client with the deadlock error stop here +SELECT pg_advisory_lock(0); +SELECT pg_advisory_lock(1); + +-- The second client and the client with the deadlock error always come after +-- the first and the number of rows in the table first_client_table reflects +-- this. Here the first client inserts a row, so in the future the table is +-- always non-empty. +DO $$ +DECLARE + exists boolean; +BEGIN + SELECT EXISTS (SELECT * FROM first_client_table) INTO STRICT exists; + IF exists THEN + -- We are the second client or the client with the deadlock error + + -- The first client will take care by itself of this lock (see below) + PERFORM pg_advisory_unlock(0); + + PERFORM pg_advisory_lock(3); + + -- The second client can get a deadlock here + PERFORM pg_advisory_lock(2); + ELSE + -- We are the first client + + -- This code should not be used in a new transaction after an error + INSERT INTO first_client_table VALUES (1); + + PERFORM pg_advisory_lock(2); + END IF; +END$$; + +DO $$ +DECLARE + num_rows integer; + waiters integer; +BEGIN + -- Check if we are the first client + SELECT COUNT(*) FROM first_client_table INTO STRICT num_rows; + IF num_rows = 1 THEN + -- This code should not be used in a new transaction after an error + INSERT INTO first_client_table VALUES (2); + + -- Let the second client begin + PERFORM pg_advisory_unlock(0); + PERFORM pg_advisory_unlock(1); + + -- Make sure the second client is ready for deadlock + LOOP + SELECT COUNT(*) INTO STRICT waiters FROM pg_locks WHERE + locktype = 'advisory' AND + objsubid = 1 AND + ((classid::bigint << 32) | objid::bigint = 2::bigint) AND + NOT granted; + + IF waiters = 1 THEN + -- Exit loop + EXIT; + END IF; + END LOOP; + + PERFORM pg_advisory_lock(0); + -- And the second client took care by itself of the lock 1 + END IF; +END$$; + +-- The first client can get a deadlock here +SELECT pg_advisory_lock(3); + +SELECT pg_advisory_unlock_all(); +""" + + +@pytest.fixture(scope="module") +def retry_tables(pg): + """Create tables used by the serialization/deadlock retry tests.""" + pg.safe_sql( + "CREATE UNLOGGED TABLE first_client_table (value integer); " + "CREATE UNLOGGED TABLE xy (x integer, y integer); " + "INSERT INTO xy VALUES (1, 2);" + ) + yield + pg.safe_sql("DROP TABLE first_client_table, xy;") + + +def test_serialization_retry(pg, retry_tables): + # Serialization error and retry (repeatable read). + err_pattern = ( + r"(?s)" + r"(client (0|1) sending UPDATE xy SET y = y \+ -?\d+\b).*" + r"client \2 got an error in command 3 \(SQL\) of script 0; " + r"ERROR: could not serialize access due to concurrent update\b.*" + r"\1" + ) + pgbench( + pg, + "-n -c 2 -t 1 --debug --verbose-errors --max-tries 2", + 0, + [ + r"processed: 2/2\b", + r"number of transactions retried: 1\b", + r"total number of retries: 1\b", + ], + [err_pattern], + "concurrent update with retrying", + {"001_pgbench_serialization": _SERIALIZATION_SCRIPT}, + extra_env={"PGOPTIONS": "-c default_transaction_isolation=repeatable\\ read"}, + ) + # Clean up + pg.safe_sql("DELETE FROM first_client_table;") + + +def test_deadlock_retry(pg, retry_tables): + # Deadlock error and retry (read committed). + err_pattern = ( + r"client (0|1) got an error in command (3|5) \(SQL\) of script 0; " + r"ERROR: deadlock detected\b" + ) + pgbench( + pg, + "-n -c 2 -t 1 --max-tries 2 --verbose-errors", + 0, + [ + r"processed: 2/2\b", + r"number of transactions retried: 1\b", + r"total number of retries: 1\b", + ], + [err_pattern], + "deadlock with retrying", + {"001_pgbench_deadlock": _DEADLOCK_SCRIPT}, + extra_env={"PGOPTIONS": "-c default_transaction_isolation=read\\ committed"}, + ) + + +# --------------------------------------------------------------------------- +# --exit-on-abort, COPY, --continue-on-error +# --------------------------------------------------------------------------- + + +def test_exit_on_abort(pg): + pg.safe_sql("CREATE TABLE counter(i int); INSERT INTO counter VALUES (0);") + try: + pgbench( + pg, + "-t 10 -c 2 -j 2 --exit-on-abort", + 2, + [], + [r"division by zero", r"Run was aborted due to an error in thread"], + "test --exit-on-abort", + { + "001_exit_on_abort": ( + "\nupdate counter set i = i+1 returning i \\gset\n" + "\\if :i = 5\n\\set y 1/0\n\\endif\n" + ) + }, + ) + finally: + pg.safe_sql("DROP TABLE counter;") + + +def test_copy_in_script(pg): + pgbench( + pg, + "-t 10", + 2, + [], + [r"COPY is not supported in pgbench, aborting"], + "Test copy in script", + {"001_copy": " COPY pgbench_accounts FROM stdin "}, + ) + + +def test_continue_on_error(pg): + pg.safe_sql("CREATE TABLE unique_table(i int unique);") + try: + pgbench( + pg, + "-n -t 10 --continue-on-error --failures-detailed", + 0, + [r"processed: 1/10\b", r"other failures: 9\b"], + [], + "test --continue-on-error", + { + "001_continue_on_error": "\n\t\t\tINSERT INTO unique_table VALUES(0);\n\t\t\t" + }, + ) + finally: + pg.safe_sql("DROP TABLE unique_table;") diff --git a/src/bin/pgbench/pyt/test_002_pgbench_no_server.py b/src/bin/pgbench/pyt/test_002_pgbench_no_server.py new file mode 100644 index 00000000000..e1ed8992837 --- /dev/null +++ b/src/bin/pgbench/pyt/test_002_pgbench_no_server.py @@ -0,0 +1,307 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""pgbench tests which do not need a server. + +Invoke the installed pgbench program with various invalid/option-handling +arguments and check exit codes and stderr. +""" + +import os +from typing import Any, List + +import pytest + +EMPTY = [r"^$"] + + +def _pgbench(pg_bin, opts, stat, out, err, name): + """Run pgbench with whitespace-split *opts* and check exit/stdout/stderr.""" + cmd = ["pgbench", *opts.split()] + pg_bin.command_checks_all(cmd, stat, out, err, name) + + +def _pgbench_scripts(pg_bin, testdir, opts, stat, out, err, name, files): + """Run pgbench with extra --file scripts written into *testdir*.""" + cmd = ["pgbench", *(opts.split() if opts else [])] + for fn in sorted(files): + # cleanup file weight if any + filename = os.path.join(testdir, fn) + if "@" in fn: + filename = filename.split("@")[0] + # cleanup from prior runs + if os.path.exists(filename): + os.unlink(filename) + with open(filename, "a", encoding="utf-8") as f: + f.write(files[fn]) + cmd += ["--file", filename] + pg_bin.command_checks_all(cmd, stat, out, err, name) + + +# name, options, stderr checks +# Each row is a heterogeneous [str, str, List[str]] record. +OPTIONS: List[List[Any]] = [ + [ + "bad option", + "-h home -p 5432 -U calvin ---debug --bad-option", + [r"--help.*more information"], + ], + ["no file", "-f no-such-file", [r'could not open file "no-such-file":']], + ["no builtin", "-b no-such-builtin", [r'no builtin script .* "no-such-builtin"']], + [ + "invalid weight", + "--builtin=select-only@one", + [r"invalid weight specification: \@one"], + ], + ["invalid weight", "-b select-only@-1", [r"weight spec.* out of range .*: -1"]], + ["too many scripts", "-S " * 129, [r"at most 128 SQL scripts"]], + ["bad #clients", "-c three", [r'invalid value "three" for option -c/--clients']], + ["bad #threads", "-j eleven", [r'invalid value "eleven" for option -j/--jobs']], + ["bad scale", "-i -s two", [r'invalid value "two" for option -s/--scale']], + [ + "invalid #transactions", + "-t zil", + [r'invalid value "zil" for option -t/--transactions'], + ], + ["invalid duration", "-T ten", [r'invalid value "ten" for option -T/--time']], + [ + "-t XOR -T", + "-N -l --aggregate-interval=5 --log-prefix=notused -t 1000 -T 1", + [r"specify either "], + ], + [ + "-T XOR -t", + "-P 1 --progress-timestamp -l --sampling-rate=0.001 -T 10 -t 1000", + [r"specify either "], + ], + ["bad variable", "--define foobla", [r"invalid variable definition"]], + ["invalid fillfactor", "-F 1", [r"-F/--fillfactor must be in range"]], + ["invalid query mode", "-M no-such-mode", [r"invalid query mode"]], + ["invalid progress", "--progress=0", [r"-P/--progress must be in range"]], + ["invalid rate", "--rate=0.0", [r"invalid rate limit"]], + ["invalid latency", "--latency-limit=0.0", [r"invalid latency limit"]], + ["invalid sampling rate", "--sampling-rate=0", [r"invalid sampling rate"]], + [ + "invalid aggregate interval", + "--aggregate-interval=-3", + [r"--aggregate-interval must be in range"], + ], + ["weight zero", "-b se@0 -b si@0 -b tpcb@0", [r"weight must not be zero"]], + ["init vs run", "-i -S", [r"cannot be used in initialization"]], + ["run vs init", "-S -F 90", [r"cannot be used in benchmarking"]], + ["ambiguous builtin", "-b s", [r"ambiguous"]], + [ + "--progress-timestamp => --progress", + "--progress-timestamp", + [r"allowed only under"], + ], + ["-I without init option", "-I dtg", [r"cannot be used in benchmarking mode"]], + [ + "invalid init step", + "-i -I dta", + [r"unrecognized initialization step", r"Allowed step characters are"], + ], + [ + "bad random seed", + "--random-seed=one", + [ + r'unrecognized random seed option "one"', + r'Expecting an unsigned integer, "time" or "rand"', + r"error while setting random seed from --random-seed option", + ], + ], + [ + "bad partition method", + "-i --partition-method=BAD", + [r'"range"', r'"hash"', r'"BAD"'], + ], + ["bad partition number", "-i --partitions -1", [r"--partitions must be in range"]], + [ + "partition method without partitioning", + "-i --partition-method=hash", + [r"partition-method requires greater than zero --partitions"], + ], + [ + "bad maximum number of tries", + "--max-tries -10", + [r'invalid number of maximum tries: "-10"'], + ], + [ + "an infinite number of tries", + "--max-tries 0", + [ + r"an unlimited number of transaction tries can only be used with " + r"--latency-limit or a duration" + ], + ], + # logging sub-options + ["sampling => log", "--sampling-rate=0.01", [r"log sampling .* only when"]], + [ + "sampling XOR aggregate", + "-l --sampling-rate=0.1 --aggregate-interval=3", + [r"sampling .* aggregation .* cannot be used at the same time"], + ], + ["aggregate => log", "--aggregate-interval=3", [r"aggregation .* only when"]], + ["log-prefix => log", "--log-prefix=x", [r"prefix .* only when"]], + [ + "duration & aggregation", + "-l -T 1 --aggregate-interval=3", + [r"aggr.* not be higher"], + ], + ["duration % aggregation", "-l -T 5 --aggregate-interval=3", [r"multiple"]], +] + + +@pytest.mark.parametrize( + "name,opts,err_checks", + OPTIONS, + ids=[o[0] for o in OPTIONS], +) +def test_option_errors(pg_bin, name, opts, err_checks): + _pgbench(pg_bin, opts, 1, EMPTY, err_checks, "pgbench option error: " + name) + + +def test_program_help_version_options(pg_bin): + pg_bin.program_help_ok("pgbench") + pg_bin.program_version_ok("pgbench") + pg_bin.program_options_handling_ok("pgbench") + + +def test_builtin_list(pg_bin): + # list of builtins + _pgbench( + pg_bin, + "-b list", + 0, + EMPTY, + [r"Available builtin scripts:", r"tpcb-like", r"simple-update", r"select-only"], + "pgbench builtin list", + ) + + +def test_builtin_listing(pg_bin): + # builtin listing + _pgbench( + pg_bin, + "--show-script se", + 0, + EMPTY, + [ + r"select-only: ", + r"SELECT abalance FROM pgbench_accounts WHERE", + r"(?!UPDATE)", + r"(?!INSERT)", + ], + "pgbench builtin listing", + ) + + +# name, err, { file => contents } +# Each row is a heterogeneous [str, List[str], Dict[str, str]] record. +SCRIPT_TESTS: List[List[Any]] = [ + ["missing endif", [r"\\if without matching \\endif"], {"if-noendif.sql": "\\if 1"}], + [ + "missing if on elif", + [r"\\elif without matching \\if"], + {"elif-noif.sql": "\\elif 1"}, + ], + [ + "missing if on else", + [r"\\else without matching \\if"], + {"else-noif.sql": "\\else"}, + ], + [ + "missing if on endif", + [r"\\endif without matching \\if"], + {"endif-noif.sql": "\\endif"}, + ], + [ + "elif after else", + [r"\\elif after \\else"], + {"else-elif.sql": "\\if 1\n\\else\n\\elif 0\n\\endif"}, + ], + [ + "else after else", + [r"\\else after \\else"], + {"else-else.sql": "\\if 1\n\\else\n\\else\n\\endif"}, + ], + [ + "if syntax error", + [r'syntax error in command "if"'], + {"if-bad.sql": "\\if\n\\endif\n"}, + ], + [ + "elif syntax error", + [r'syntax error in command "elif"'], + {"elif-bad.sql": "\\if 0\n\\elif +\n\\endif\n"}, + ], + [ + "else syntax error", + [r'unexpected argument in command "else"'], + {"else-bad.sql": "\\if 0\n\\else BAD\n\\endif\n"}, + ], + [ + "endif syntax error", + [r'unexpected argument in command "endif"'], + {"endif-bad.sql": "\\if 0\n\\endif BAD\n"}, + ], + [ + "not enough arguments for least", + [r"at least one argument expected \(least\)"], + {"bad-least.sql": "\\set i least()\n"}, + ], + [ + "not enough arguments for greatest", + [r"at least one argument expected \(greatest\)"], + {"bad-greatest.sql": "\\set i greatest()\n"}, + ], + [ + "not enough arguments for hash", + [r"unexpected number of arguments \(hash\)"], + {"bad-hash-1.sql": "\\set i hash()\n"}, + ], + [ + "too many arguments for hash", + [r"unexpected number of arguments \(hash\)"], + {"bad-hash-2.sql": "\\set i hash(1,2,3)\n"}, + ], + # overflow + [ + "bigint overflow 1", + [r"bigint constant overflow"], + {"overflow-1.sql": "\\set i 100000000000000000000\n"}, + ], + [ + "double overflow 2", + [r"double constant overflow"], + {"overflow-2.sql": "\\set d 1.0E309\n"}, + ], + [ + "double overflow 3", + [r"double constant overflow"], + {"overflow-3.sql": "\\set d .1E310\n"}, + ], + ["set i", [r"set i 1 ", r"\^ error found here"], {"set_i_op": "\\set i 1 +\n"}], + [ + "not enough arguments to permute", + [r"unexpected number of arguments \(permute\)"], + {"bad-permute-1.sql": "\\set i permute(1)\n"}, + ], + [ + "too many arguments to permute", + [r"unexpected number of arguments \(permute\)"], + {"bad-permute-2.sql": "\\set i permute(1, 2, 3, 4)\n"}, + ], +] + + +@pytest.mark.parametrize( + "name,err,files", + SCRIPT_TESTS, + ids=[t[0] for t in SCRIPT_TESTS], +) +def test_script_errors(pg_bin, tmp_path, name, err, files): + testdir = tmp_path / "scripts" + testdir.mkdir(exist_ok=True) + _pgbench_scripts( + pg_bin, str(testdir), "", 1, EMPTY, err, "pgbench option error: " + name, files + ) diff --git a/src/bin/psql/meson.build b/src/bin/psql/meson.build index 922b2845267..d2c2476e43c 100644 --- a/src/bin/psql/meson.build +++ b/src/bin/psql/meson.build @@ -80,6 +80,18 @@ tests += { 't/030_pager.pl', ], }, + 'pytest': { + 'env': {'with_readline': readline.found() ? 'yes' : 'no'}, + # 010_tab_completion and 030_pager drive psql through an interactive pty; + # they use pexpect via the interactive_psql fixture in pyt/conftest.py and + # skip when pexpect (or readline / a working "wc -l") is unavailable. + 'tests': [ + 'pyt/test_001_basic.py', + 'pyt/test_010_tab_completion.py', + 'pyt/test_020_cancel.py', + 'pyt/test_030_pager.py', + ], + }, } subdir('po', if_found: libintl) diff --git a/src/bin/psql/pyt/conftest.py b/src/bin/psql/pyt/conftest.py new file mode 100644 index 00000000000..2879afc2bd9 --- /dev/null +++ b/src/bin/psql/pyt/conftest.py @@ -0,0 +1,137 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Interactive (pty-driven) psql helper for the psql test suite. + +Drives a psql session through a pseudo-terminal so that psql believes it is +interactive (readline/libedit and the pager are active), using pexpect. The +helper exposes a ``query_until`` method that sends input and reads output up to +a given pattern, with timeout tracking. + +pexpect is an optional dependency: the ``interactive_psql`` fixture issues +``pytest.importorskip("pexpect")`` so tests that need a real terminal +(010_tab_completion, 030_pager) skip cleanly where it is not installed. +""" + +import os + +import pytest + +from pypg.util import TIMEOUT_DEFAULT + + +class InteractivePsql: + """A psql session driven through a pseudo-terminal. + + psql believes it is interactive (so readline/libedit and the pager are + active). Output includes psql's prompts and the echoed input. + """ + + def __init__( + self, + pexpect_mod, + psql_path, + node, + dbname="postgres", + history_file=None, + extra_params=None, + extra_env=None, + dimensions=(24, 80), + timeout=TIMEOUT_DEFAULT, + ): + self._pexpect = pexpect_mod + self.timeout = timeout + # True if the most recent query_until() timed out. + self.timed_out = False + + # Since the invoked psql will believe it's interactive, it will use + # readline/libedit if available. Adjust the environment to prevent + # unwanted side-effects: + env = dict(os.environ) + # Redirect readline history somewhere harmless (or the caller's file). + env["PSQL_HISTORY"] = history_file or "/dev/null" + # Ignore any ~/.inputrc that could change readline's behavior. + env["INPUTRC"] = "/dev/null" + # Unset TERM so readline/libedit won't emit terminal-dependent escapes. + env.pop("TERM", None) + # Some versions of readline inspect LS_COLORS; drop it for luck. + env.pop("LS_COLORS", None) + if extra_env: + env.update(extra_env) + + args = [ + "--no-psqlrc", + "--no-align", + "--tuples-only", + "--dbname", + node.connstr(dbname), + ] + if extra_params: + args += list(extra_params) + + self.child = pexpect_mod.spawn( + psql_path, + args, + env=env, + encoding="utf-8", + timeout=timeout, + dimensions=dimensions, + ) + # Wait until psql has connected and printed its first prompt. + self.child.expect(r"=[#>] ") + + def query_until(self, until, send): + """Send *send*, then read output until regex *until* appears. + + *until* may be a regex string or a compiled pattern (use re.MULTILINE + for ``$``-anchored patterns). Returns the output produced since the + previous match -- psql prompts and echoed input included. On timeout, + sets ``timed_out`` and returns whatever was captured so far instead of + raising. + """ + self.timed_out = False + self.child.send(send) + try: + self.child.expect(until, timeout=self.timeout) + except self._pexpect.TIMEOUT: + self.timed_out = True + return self.child.before + return self.child.before + self.child.after + + def quit(self): + """Send an explicit \\q so the pty closes cleanly.""" + if self.child.isalive(): + try: + self.child.send("\\q\n") + self.child.expect(self._pexpect.EOF, timeout=self.timeout) + except (self._pexpect.TIMEOUT, self._pexpect.EOF, OSError): + pass + if self.child.isalive(): + self.child.close(force=True) + + +@pytest.fixture +def interactive_psql(bindir): + """Factory yielding :class:`InteractivePsql` sessions. + + Skips the whole test if pexpect is not installed. All sessions created + through the factory are quit at teardown. + """ + pexpect_mod = pytest.importorskip( + "pexpect", reason="pexpect is needed to drive an interactive psql" + ) + psql_path = os.path.join(bindir, "psql") + sessions = [] + + def _factory(node, dbname="postgres", **kwargs): + sess = InteractivePsql(pexpect_mod, psql_path, node, dbname, **kwargs) + sessions.append(sess) + return sess + + yield _factory + + for sess in sessions: + try: + sess.quit() + except Exception: # pylint: disable=broad-exception-caught + # Best-effort teardown; a session may already have exited. + pass diff --git a/src/bin/psql/pyt/test_001_basic.py b/src/bin/psql/pyt/test_001_basic.py new file mode 100644 index 00000000000..e82bb1c601f --- /dev/null +++ b/src/bin/psql/pyt/test_001_basic.py @@ -0,0 +1,750 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Exercise the psql binary itself. + +These tests cover psql's CLI flags, output formatting, error handling, +backslash commands and exit codes. Here psql is the program under test, so it +is run as a subprocess (via the installed binary), feeding scripts in on stdin +or via -c/-f. +""" + +import os +import re +import subprocess + +import pytest + + +# --------------------------------------------------------------------------- +# psql runner helpers backing the psql_like/psql_fails_like checks. +# --------------------------------------------------------------------------- + + +def _run_psql(pg, sql, *, replication=None, on_error_stop=True): + """Run psql, feeding *sql* on stdin, returning (ret, out, err). + + Uses --no-psqlrc --no-align --tuples-only --quiet, connecting via a connstr + (with optional ``replication=`` appended), reading the script from stdin + (-f -), with ON_ERROR_STOP on by default. stdout and stderr have a single + trailing newline removed. + """ + connstr = pg.connstr("postgres") + if replication is not None and replication != "": + connstr += f" replication={replication}" + argv = [ + os.path.join(pg.bindir, "psql"), + "--no-psqlrc", + "--no-align", + "--tuples-only", + "--quiet", + "--dbname", + connstr, + "--file", + "-", + ] + if on_error_stop: + argv += ["--variable", "ON_ERROR_STOP=1"] + print("# Running: " + " ".join(argv)) + proc = subprocess.run( + argv, + input=sql, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + out = proc.stdout + err = proc.stderr + # Strip a single trailing newline. + if out.endswith("\n"): + out = out[:-1] + if err.endswith("\n"): + err = err[:-1] + return proc.returncode, out, err + + +def _psql_like(pg, sql, expected_stdout, test_name): + """Run *sql* and check exit 0, empty stderr, stdout matching the regex.""" + ret, stdout, stderr = _run_psql(pg, sql) + assert ret == 0, f"{test_name}: exit code 0 (got {ret}, stderr: {stderr})" + assert stderr == "", f"{test_name}: no stderr (got: {stderr})" + assert re.search( + expected_stdout, stdout + ), f"{test_name}: stdout matches /{expected_stdout.pattern}/\n{stdout}" + + +def _psql_fails_like(pg, sql, expected_stderr, test_name, replication=None): + """Run *sql* and check nonzero exit and stderr matching the regex.""" + ret, stdout, stderr = _run_psql(pg, sql, replication=replication) + assert ret != 0, f"{test_name}: exit code not 0\n{stdout}\n{stderr}" + assert re.search( + expected_stderr, stderr + ), f"{test_name}: stderr matches /{expected_stderr.pattern}/\n{stderr}" + + +def _append_to_file(path, text): + with open(path, "a", encoding="utf-8") as fh: + fh.write(text) + + +def _slurp_file(path): + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read() + + +# --------------------------------------------------------------------------- +# Program-level checks that need no server. +# --------------------------------------------------------------------------- + + +def test_program_help_version_options(pg_bin): + pg_bin.program_help_ok("psql") + pg_bin.program_version_ok("psql") + pg_bin.program_options_handling_ok("psql") + + +@pytest.mark.parametrize("arg", ["commands", "variables"]) +def test_help_arg(pg_bin, arg): + # Test --help=foo, analogous to program_help_ok(). + res = pg_bin.result(["psql", f"--help={arg}"]) + assert res.returncode == 0, f"psql --help={arg} exit code 0" + assert res.stdout != "", f"psql --help={arg} goes to stdout" + assert res.stderr == "", f"psql --help={arg} nothing to stderr" + + +# --------------------------------------------------------------------------- +# Server fixture: --locale=C --encoding=UTF8 (from init defaults) plus the +# logical replication settings. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def node(create_pg): + return create_pg( + "main", + allows_streaming="logical", + initdb_extra=["--locale=C", "--encoding=UTF8"], + ) + + +# --------------------------------------------------------------------------- +# Basic backslash commands. +# --------------------------------------------------------------------------- + + +def test_copyright(node): + _psql_like(node, "\\copyright", re.compile(r"Copyright"), "\\copyright") + + +def test_help_no_args(node): + _psql_like(node, "\\help", re.compile(r"ALTER"), "\\help without arguments") + + +def test_help_with_arg(node): + _psql_like(node, "\\help SELECT", re.compile(r"SELECT"), "\\help with argument") + + +def test_unsupported_replication_command(node): + # Clean handling of unsupported replication command responses. + _psql_fails_like( + node, + "START_REPLICATION 0/0", + re.compile(r"unexpected PQresultStatus: 8$"), + "handling of unexpected PQresultStatus", + replication="database", + ) + + +def test_timing_successful_query(node): + _psql_like( + node, + "\\timing on\nSELECT 1", + re.compile(r"^1$\n^Time: \d+[.,]\d\d\d ms", re.M), + "\\timing with successful query", + ) + + +def test_timing_query_error(node): + ret, stdout, _stderr = _run_psql(node, "\\timing on\nSELECT error") + assert ret != 0, "\\timing with query error: query failed" + assert re.search(r"^Time: \d+[.,]\d\d\d ms", stdout, re.M), ( + "\\timing with query error: timing output appears\n" + stdout + ) + assert not re.search(r"^Time: 0[.,]000 ms", stdout, re.M), ( + "\\timing with query error: timing was updated\n" + stdout + ) + + +def test_encoding_variable(node): + # ENCODING variable is set and updated when client encoding changes. + _psql_like( + node, + "\\echo :ENCODING\nset client_encoding = LATIN1;\n\\echo :ENCODING", + re.compile(r"^UTF8$\n^LATIN1$", re.M), + "ENCODING variable is set and updated", + ) + + +def test_listen_notify(node): + _psql_like( + node, + "LISTEN foo;\nNOTIFY foo;", + re.compile( + r'^Asynchronous notification "foo" received from server ' + r"process with PID \d+\.$", + re.M, + ), + "notification", + ) + + +def test_listen_notify_payload(node): + _psql_like( + node, + "LISTEN foo;\nNOTIFY foo, 'bar';", + re.compile( + r'^Asynchronous notification "foo" with payload "bar" received ' + r"from server process with PID \d+\.$", + re.M, + ), + "notification with payload", + ) + + +def test_server_crash(node): + # Behavior and output on server crash. + ret, out, err = _run_psql( + node, + "SELECT 'before' AS running;\n" + "SELECT pg_terminate_backend(pg_backend_pid());\n" + "SELECT 'AFTER' AS not_running;\n", + ) + assert ret == 2, f"server crash: psql exit code (got {ret})" + assert re.search(r"before", out), "server crash: output before crash" + assert not re.search(r"AFTER", out), "server crash: no output after crash" + assert re.search( + r"psql::2: FATAL: terminating connection due to administrator command\n" + r"psql::2: server closed the connection unexpectedly\n" + r"\tThis probably means the server terminated abnormally\n" + r"\tbefore or while processing the request\.\n" + r"psql::2: error: connection to server was lost", + err, + ), ( + "server crash: error message\n" + err + ) + + +# --------------------------------------------------------------------------- +# \errverbose +# +# (Not in the regular regression tests because the output contains the source +# code location which we don't want to have to update.) +# --------------------------------------------------------------------------- + + +def test_errverbose_no_previous_error(node): + _psql_like( + node, + "SELECT 1;\n\\errverbose", + re.compile(r"^1\nThere is no previous error\.$"), + "\\errverbose with no previous error", + ) + + +# There are three main ways to run a query that might affect \errverbose: the +# normal way, piecemeal retrieval using FETCH_COUNT, and using \gdesc. + +ERRVERBOSE_CASES = [ + ( + "SELECT error;\n\\errverbose", + r"\A^psql::1: ERROR: .*$\n" + r"^LINE 1: SELECT error;$\n" + r"^ *\^.*$\n" + r"^psql::2: error: ERROR: [0-9A-Z]{5}: .*$\n" + r"^LINE 1: SELECT error;$\n" + r"^ *\^.*$\n" + r"^LOCATION: .*$", + "\\errverbose after normal query with error", + ), + ( + "\\set FETCH_COUNT 1\nSELECT error;\n\\errverbose", + r"\A^psql::2: ERROR: .*$\n" + r"^LINE 1: SELECT error;$\n" + r"^ *\^.*$\n" + r"^psql::3: error: ERROR: [0-9A-Z]{5}: .*$\n" + r"^LINE 1: SELECT error;$\n" + r"^ *\^.*$\n" + r"^LOCATION: .*$", + "\\errverbose after FETCH_COUNT query with error", + ), + ( + "SELECT error\\gdesc\n\\errverbose", + r"\A^psql::1: ERROR: .*$\n" + r"^LINE 1: SELECT error$\n" + r"^ *\^.*$\n" + r"^psql::2: error: ERROR: [0-9A-Z]{5}: .*$\n" + r"^LINE 1: SELECT error$\n" + r"^ *\^.*$\n" + r"^LOCATION: .*$", + "\\errverbose after \\gdesc with error", + ), +] + + +@pytest.mark.parametrize( + "sql,pattern,name", ERRVERBOSE_CASES, ids=[c[2] for c in ERRVERBOSE_CASES] +) +def test_errverbose(node, sql, pattern, name): + _ret, _stdout, stderr = _run_psql(node, sql, on_error_stop=False) + assert re.search(pattern, stderr, re.M), f"{name}\n{stderr}" + + +# --------------------------------------------------------------------------- +# Multiple -c and -f switches. +# +# Note that we cannot test backend-side errors as tests are unstable in this +# case. +# --------------------------------------------------------------------------- + + +def test_single_transaction_multiple_switches(node, tmp_path): + tempdir = str(tmp_path) + node.safe_sql("CREATE TABLE tab_psql_single (a int);") + + def row_count(): + return node.safe_sql("SELECT count(*) FROM tab_psql_single").strip() + + # Tests with ON_ERROR_STOP. + node.command_ok( + [ + "psql", + "--no-psqlrc", + "--single-transaction", + "--set", + "ON_ERROR_STOP=1", + "--command", + "INSERT INTO tab_psql_single VALUES (1)", + "--command", + "INSERT INTO tab_psql_single VALUES (2)", + ], + "ON_ERROR_STOP, --single-transaction and multiple -c switches", + ) + assert row_count() == "2", ( + "--single-transaction commits transaction, ON_ERROR_STOP and " + "multiple -c switches" + ) + + node.command_fails( + [ + "psql", + "--no-psqlrc", + "--single-transaction", + "--set", + "ON_ERROR_STOP=1", + "--command", + "INSERT INTO tab_psql_single VALUES (3)", + "--command", + f"\\copy tab_psql_single FROM '{tempdir}/nonexistent'", + ], + "ON_ERROR_STOP, --single-transaction and multiple -c switches, error", + ) + assert row_count() == "2", ( + "client-side error rolls back transaction, ON_ERROR_STOP and " + "multiple -c switches" + ) + + # Tests mixing files and commands. + copy_sql_file = os.path.join(tempdir, "tab_copy.sql") + insert_sql_file = os.path.join(tempdir, "tab_insert.sql") + _append_to_file( + copy_sql_file, f"\\copy tab_psql_single FROM '{tempdir}/nonexistent';" + ) + _append_to_file(insert_sql_file, "INSERT INTO tab_psql_single VALUES (4);") + + node.command_ok( + [ + "psql", + "--no-psqlrc", + "--single-transaction", + "--set", + "ON_ERROR_STOP=1", + "--file", + insert_sql_file, + "--file", + insert_sql_file, + ], + "ON_ERROR_STOP, --single-transaction and multiple -f switches", + ) + assert row_count() == "4", ( + "--single-transaction commits transaction, ON_ERROR_STOP and " + "multiple -f switches" + ) + + node.command_fails( + [ + "psql", + "--no-psqlrc", + "--single-transaction", + "--set", + "ON_ERROR_STOP=1", + "--file", + insert_sql_file, + "--file", + copy_sql_file, + ], + "ON_ERROR_STOP, --single-transaction and multiple -f switches, error", + ) + assert row_count() == "4", ( + "client-side error rolls back transaction, ON_ERROR_STOP and " + "multiple -f switches" + ) + + # Tests without ON_ERROR_STOP. + # The last switch fails on \copy. The command returns a failure and the + # transaction commits. + node.command_fails( + [ + "psql", + "--no-psqlrc", + "--single-transaction", + "--file", + insert_sql_file, + "--file", + insert_sql_file, + "--command", + f"\\copy tab_psql_single FROM '{tempdir}/nonexistent'", + ], + "no ON_ERROR_STOP, --single-transaction and multiple -f/-c switches", + ) + assert row_count() == "6", ( + "client-side error commits transaction, no ON_ERROR_STOP and " + "multiple -f/-c switches" + ) + + # The last switch fails on \copy coming from an input file. The command + # returns a success and the transaction commits. + node.command_ok( + [ + "psql", + "--no-psqlrc", + "--single-transaction", + "--file", + insert_sql_file, + "--file", + insert_sql_file, + "--file", + copy_sql_file, + ], + "no ON_ERROR_STOP, --single-transaction and multiple -f switches", + ) + assert row_count() == "8", ( + "client-side error commits transaction, no ON_ERROR_STOP and " + "multiple -f switches" + ) + + # The last switch makes the command return a success, and the contents of + # the transaction commit even if there is a failure in-between. + node.command_ok( + [ + "psql", + "--no-psqlrc", + "--single-transaction", + "--command", + "INSERT INTO tab_psql_single VALUES (5)", + "--file", + copy_sql_file, + "--command", + "INSERT INTO tab_psql_single VALUES (6)", + ], + "no ON_ERROR_STOP, --single-transaction and multiple -c switches", + ) + assert row_count() == "10", ( + "client-side error commits transaction, no ON_ERROR_STOP and " + "multiple -c switches" + ) + + +def test_copy_from_with_default(node, tmp_path): + # Test \copy from with DEFAULT option. + node.safe_sql( + "CREATE TABLE copy_default (" + "id integer PRIMARY KEY, " + "text_value text NOT NULL DEFAULT 'test', " + "ts_value timestamp without time zone NOT NULL DEFAULT '2022-07-05')" + ) + + copy_default_file = str(tmp_path / "copy_default.csv") + _append_to_file(copy_default_file, "1,value,2022-07-04\n") + _append_to_file(copy_default_file, "2,placeholder,2022-07-03\n") + _append_to_file(copy_default_file, "3,placeholder,placeholder\n") + + _psql_like( + node, + f"\\copy copy_default from {copy_default_file} with " + "(format 'csv', default 'placeholder');\n" + "SELECT * FROM copy_default", + re.compile( + r"1\|value\|2022-07-04 00:00:00\n" + r"2\|test\|2022-07-03 00:00:00\n" + r"3\|test\|2022-07-05 00:00:00" + ), + "\\copy from with DEFAULT", + ) + + +# --------------------------------------------------------------------------- +# \watch +# --------------------------------------------------------------------------- + + +def test_watch_three_iterations(node): + # Note: the interval value is parsed with locale-aware strtod(); in the C + # locale the formatting is the same as Python's %g. + _psql_like( + node, + "SELECT 1 \\watch c=3 i=%g" % 0.01, + re.compile(r"1\n1\n1"), + "\\watch with 3 iterations, interval of 0.01", + ) + + +def test_watch_submillisecond(node): + # Sub-millisecond wait works, equivalent to 0. + _psql_like( + node, + "SELECT 1 \\watch c=3 i=%g" % 0.0001, + re.compile(r"1\n1\n1"), + "\\watch with 3 iterations, interval of 0.0001", + ) + + +def test_watch_zero_interval(node): + _psql_like( + node, + "\\set WATCH_INTERVAL 0\nSELECT 1 \\watch c=3", + re.compile(r"1\n1\n1"), + "\\watch with 3 iterations, interval of 0", + ) + + +def test_watch_invalid_minimum_row(node): + _psql_fails_like( + node, + "SELECT 3 \\watch m=x", + re.compile(r"incorrect minimum row count"), + "\\watch, invalid minimum row setting", + ) + + +def test_watch_minimum_rows_twice(node): + _psql_fails_like( + node, + "SELECT 3 \\watch m=1 min_rows=2", + re.compile(r"minimum row count specified more than once"), + "\\watch, minimum rows is specified more than once", + ) + + +def test_watch_two_minimum_rows(node): + _psql_like( + node, + "with x as (\n" + "\t\tselect now()-backend_start AS howlong\n" + "\t\tfrom pg_stat_activity\n" + "\t\twhere pid = pg_backend_pid()\n" + "\t ) select 123 from x where howlong < '2 seconds' \\watch i=%g m=2" % 0.5, + re.compile(r"^123$", re.M), + "\\watch, 2 minimum rows", + ) + + +WATCH_ERROR_CASES = [ + ( + "SELECT 1 \\watch -10", + r'incorrect interval value "-10"', + "\\watch, negative interval", + ), + ( + "SELECT 1 \\watch 10ab", + r'incorrect interval value "10ab"', + "\\watch, incorrect interval", + ), + ( + "SELECT 1 \\watch 10e400", + r'incorrect interval value "10e400"', + "\\watch, out-of-range interval", + ), + ( + "SELECT 1 \\watch 1 1", + r"interval value is specified more than once", + "\\watch, interval value is specified more than once", + ), + ( + "SELECT 1 \\watch c=1 c=1", + r"iteration count is specified more than once", + "\\watch, iteration count is specified more than once", + ), +] + + +@pytest.mark.parametrize( + "sql,pattern,name", WATCH_ERROR_CASES, ids=[c[2] for c in WATCH_ERROR_CASES] +) +def test_watch_errors(node, sql, pattern, name): + _psql_fails_like(node, sql, re.compile(pattern), name) + + +def test_watch_interval_variable(node): + # Check WATCH_INTERVAL. + _psql_like( + node, + "\\echo :WATCH_INTERVAL\n" + "\\set WATCH_INTERVAL 10\n" + "\\echo :WATCH_INTERVAL\n" + "\\unset WATCH_INTERVAL\n" + "\\echo :WATCH_INTERVAL", + re.compile(r"^2$\n^10$\n^2$", re.M), + "WATCH_INTERVAL variable is set and updated", + ) + _psql_fails_like( + node, + "\\set WATCH_INTERVAL 1e500", + re.compile(r"is out of range"), + "WATCH_INTERVAL variable is out of range", + ) + _psql_like( + node, + "\\echo :WATCH_INTERVAL", + re.compile(r"^2$", re.M), + "WATCH_INTERVAL variable was not altered", + ) + + +# --------------------------------------------------------------------------- +# \g output piped into a program. +# +# The program is "perl -pe ''" to simply copy the input to the output. +# --------------------------------------------------------------------------- + + +def test_g_pipe(node, tmp_path): + import shutil + + perlbin = shutil.which("perl") + if perlbin is None: + pytest.skip("perl not available for \\g pipe test") + + g_file = str(tmp_path / "g_file_1.out") + pipe_cmd = f"{perlbin} -pe '' >{g_file}" + + _psql_like( + node, f"SELECT 'one' \\g | {pipe_cmd}", re.compile(r""), "one command \\g" + ) + c1 = _slurp_file(g_file) + assert re.search(r"one", c1) + + _psql_like( + node, + f"SELECT 'two' \\; SELECT 'three' \\g | {pipe_cmd}", + re.compile(r""), + "two commands \\g", + ) + c2 = _slurp_file(g_file) + assert re.search(r"two.*three", c2, re.S) + + _psql_like( + node, + f"\\set SHOW_ALL_RESULTS 0\nSELECT 'four' \\; SELECT 'five' \\g | {pipe_cmd}", + re.compile(r""), + "two commands \\g with only last result", + ) + c3 = _slurp_file(g_file) + assert re.search(r"five", c3) + assert not re.search(r"four", c3) + + _psql_like( + node, + f"copy (values ('foo'),('bar')) to stdout \\g | {pipe_cmd}", + re.compile(r""), + "copy output passed to \\g pipe", + ) + c4 = _slurp_file(g_file) + assert re.search(r"foo.*bar", c4, re.S) + + +# --------------------------------------------------------------------------- +# COPY within pipelines. These abort the connection from the frontend so they +# cannot be tested via SQL. +# --------------------------------------------------------------------------- + + +def test_copy_from_in_pipeline(node): + node.safe_sql("CREATE TABLE psql_pipeline()") + log_location = node.log_position() + _psql_fails_like( + node, + "\\startpipeline\n" + "COPY psql_pipeline FROM STDIN;\n" + "SELECT 'val1';\n" + "\\syncpipeline\n" + "\\endpipeline", + re.compile(r"COPY in a pipeline is not supported, aborting connection"), + "COPY FROM in pipeline: fails", + ) + node.wait_for_log( + r"FATAL: .*terminating connection because protocol synchronization was lost", + log_location, + ) + + +def test_copy_to_in_pipeline(node): + node.safe_sql("CREATE TABLE psql_pipeline()") + # Remove \syncpipeline here. + _psql_fails_like( + node, + "\\startpipeline\n" + "COPY psql_pipeline TO STDOUT;\n" + "SELECT 'val1';\n" + "\\endpipeline", + re.compile(r"COPY in a pipeline is not supported, aborting connection"), + "COPY TO in pipeline: fails", + ) + + +def test_copy_meta_from_in_pipeline(node): + node.safe_sql("CREATE TABLE psql_pipeline()") + _psql_fails_like( + node, + "\\startpipeline\n" + "\\copy psql_pipeline from stdin;\n" + "SELECT 'val1';\n" + "\\syncpipeline\n" + "\\endpipeline", + re.compile(r"COPY in a pipeline is not supported, aborting connection"), + "\\copy from in pipeline: fails", + ) + + +def test_copy_meta_to_in_pipeline(node): + node.safe_sql("CREATE TABLE psql_pipeline()") + # Sync attempt after a COPY TO/FROM. + _psql_fails_like( + node, + "\\startpipeline\n" + "\\copy psql_pipeline to stdout;\n" + "\\syncpipeline\n" + "\\endpipeline", + re.compile(r"COPY in a pipeline is not supported, aborting connection"), + "\\copy to in pipeline: fails", + ) + + +def test_meta_command_in_restrict_mode(node): + _psql_fails_like( + node, + "\\restrict test\n\\! should_fail", + re.compile(r"backslash commands are restricted; only \\unrestrict is allowed"), + "meta-command in restrict mode fails", + ) diff --git a/src/bin/psql/pyt/test_010_tab_completion.py b/src/bin/psql/pyt/test_010_tab_completion.py new file mode 100644 index 00000000000..62d7d04a89b --- /dev/null +++ b/src/bin/psql/pyt/test_010_tab_completion.py @@ -0,0 +1,600 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Exercise psql's readline/libedit tab-completion machinery. + +Drives an interactive psql session through a pty (via the ``interactive_psql`` +fixture in conftest.py) and checks, for a long list of inputs, that readline +completes the partial command/identifier/filename as expected. + +This test only runs when the build is --with-readline (the ``with_readline`` +environment variable is 'yes'), and is skipped when SKIP_READLINE_TESTS is set +or when pexpect is unavailable. +""" + +import os +import re + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers: check_completion / clear_query / clear_line. +# These are deliberately NOT named test_* -- they are called by the single +# test function below. +# --------------------------------------------------------------------------- + + +def check_completion(psql, send, pattern, annotation): + """Type *send* and check psql responds with output matching *pattern*. + + *pattern* is a compiled regex. Send the data, wait for its result, and + assert the captured output matches the pattern and that the query did not + time out. + """ + out = psql.query_until(pattern, send) + assert pattern.search(out) and not psql.timed_out, ( + f"{annotation}\nActual output was {out!r}\n" + f"Did not match {pattern.pattern!r}" + ) + + +def clear_query(psql): + """Clear the query buffer to start over. + + (won't work if we are inside a string literal!) + """ + check_completion( + psql, + "\\r\n", + re.compile(r"Query buffer reset.*postgres=# ", re.DOTALL), + "\\r works", + ) + + +def clear_line(psql): + """Clear the current line to start over. + + (this will work in an incomplete string literal, but it's less desirable + than clear_query because we lose evidence in the history file) + """ + check_completion(psql, "\025\n", re.compile(r"postgres=# "), "control-U works") + + +# --------------------------------------------------------------------------- +# The test. +# --------------------------------------------------------------------------- + + +def test_010_tab_completion(pg, interactive_psql, tmp_path): + # Do nothing unless the build is --with-readline. + if os.environ.get("with_readline") != "yes": + pytest.skip("readline is not supported by this build") + + # Also, skip if user has set environment variable to command that. This is + # mainly intended to allow working around some of the more broken versions + # of libedit --- some users might find them acceptable even if they won't + # pass these tests. + if "SKIP_READLINE_TESTS" in os.environ: + pytest.skip("SKIP_READLINE_TESTS is set") + + # (pexpect availability is handled by the interactive_psql fixture, which + # issues pytest.importorskip("pexpect").) + + node = pg + + # set up a few database objects + node.safe_sql( + "CREATE TABLE tab1 (c1 int primary key constraint foo not null, c2 text);\n" + "CREATE TABLE mytab123 (f1 int, f2 text);\n" + "CREATE TABLE mytab246 (f1 int, f2 text);\n" + 'CREATE TABLE "mixedName" (f1 int, f2 text);\n' + "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n" + "CREATE PUBLICATION some_publication;\n" + "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n" + ) + + # Use relative paths to access the tab_comp_dir subdirectory; otherwise the + # output from filename completion tests is too variable. We do this by + # creating tab_comp_dir under tmp_path and chdir'ing there for the duration + # of the test, restoring the cwd afterwards. + saved_cwd = os.getcwd() + os.chdir(str(tmp_path)) + try: + # Create some junk files for filename completion testing. + os.mkdir("tab_comp_dir") + with open("tab_comp_dir/somefile", "w", encoding="utf-8") as fh: + fh.write("some stuff\n") + with open("tab_comp_dir/afile123", "w", encoding="utf-8") as fh: + fh.write("more stuff\n") + with open("tab_comp_dir/afile456", "w", encoding="utf-8") as fh: + fh.write("other stuff\n") + + # Arrange to capture, not discard, the interactive session's history + # output. We put it under tmp_path so buildfarm runs can still capture + # it for debugging. + historyfile = str(tmp_path / "010_psql_history.txt") + + # fire up an interactive psql session + psql = interactive_psql(node, dbname="postgres", history_file=historyfile) + + _run_cases(psql) + + # send psql an explicit \q to shut it down, else pty won't close + # properly + psql.quit() + finally: + os.chdir(saved_cwd) + + +def _run_cases(psql): + # check basic command completion: SEL produces SELECT + check_completion( + psql, "SEL\t", re.compile(r"SELECT "), "complete SEL to SELECT" + ) + + clear_query(psql) + + # check case variation is honored + check_completion( + psql, "sel\t", re.compile(r"select "), "complete sel to select" + ) + + # check basic table name completion + check_completion( + psql, "* from t\t", re.compile(r"\* from tab1 "), "complete t to tab1" + ) + + clear_query(psql) + + # check table name completion with multiple alternatives + # note: readline might print a bell before the completion + check_completion( + psql, + "select * from my\t", + re.compile(r"select \* from my\a?tab"), + "complete my to mytab when there are multiple choices", + ) + + # some versions of readline/libedit require two tabs here, some only need + # one + check_completion( + psql, + "\t\t", + re.compile(r"mytab123 +mytab246"), + "offer multiple table choices", + ) + + check_completion( + psql, + "2\t", + re.compile(r"246 "), + "finish completion of one of multiple table choices", + ) + + clear_query(psql) + + # check handling of quoted names + check_completion( + psql, + 'select * from "my\t', + re.compile(r'select \* from "my\a?tab'), + 'complete "my to "mytab when there are multiple choices', + ) + + check_completion( + psql, + "\t\t", + re.compile(r'"mytab123" +"mytab246"'), + "offer multiple quoted table choices", + ) + + check_completion( + psql, + "2\t", + re.compile(r'246" '), + "finish completion of one of multiple quoted table choices", + ) + + clear_query(psql) + + # check handling of mixed-case names + check_completion( + psql, + 'select * from "mi\t', + re.compile(r'"mixedName" '), + "complete a mixed-case name", + ) + + clear_query(psql) + + # check case folding + check_completion( + psql, + "select * from TAB\t", + re.compile(r"tab1 "), + "automatically fold case", + ) + + clear_query(psql) + + # check case-sensitive keyword replacement + # note: various versions of readline/libedit handle backspacing + # differently, so just check that the replacement comes out correctly + check_completion( + psql, "\\DRD\t", re.compile(r"drds "), "complete \\DRD to \\drds" + ) + + clear_query(psql) + + # check completion of a schema-qualified name + check_completion( + psql, + "select * from pub\t", + re.compile(r"public\."), + "complete schema when relevant", + ) + + check_completion( + psql, "tab\t", re.compile(r"tab1 "), "complete schema-qualified name" + ) + + clear_query(psql) + + check_completion( + psql, + "select * from PUBLIC.t\t", + re.compile(r"public\.tab1 "), + "automatically fold case in schema-qualified name", + ) + + clear_query(psql) + + # check interpretation of referenced names + check_completion( + psql, + "alter table tab1 drop constraint t\t", + re.compile(r"tab1_pkey "), + "complete index name for referenced table", + ) + + clear_query(psql) + + check_completion( + psql, + "alter table TAB1 drop constraint t\t", + re.compile(r"tab1_pkey "), + "complete index name for referenced table, with downcasing", + ) + + clear_query(psql) + + check_completion( + psql, + 'alter table public."tab1" drop constraint t\t', + re.compile(r"tab1_pkey "), + "complete index name for referenced table, with schema and quoting", + ) + + clear_query(psql) + + # check variant where we're completing a qualified name from a refname + # (this one also checks successful completion in a multiline command) + check_completion( + psql, + "comment on constraint tab1_pkey \n on public.\t", + re.compile(r"public\.tab1"), + "complete qualified name from object reference", + ) + + clear_query(psql) + + # check filename completion + check_completion( + psql, + "\\lo_import tab_comp_dir/some\t", + re.compile(r"tab_comp_dir/somefile "), + "filename completion with one possibility", + ) + + clear_query(psql) + + # note: readline might print a bell before the completion + check_completion( + psql, + "\\lo_import tab_comp_dir/af\t", + re.compile(r"tab_comp_dir/af\a?ile"), + "filename completion with multiple possibilities", + ) + + # here we are inside a string literal 'afile*', so must use clear_line(). + clear_line(psql) + + # COPY requires quoting + check_completion( + psql, + "COPY foo FROM tab_comp_dir/some\t", + re.compile(r"'tab_comp_dir/somefile' "), + "quoted filename completion with one possibility", + ) + + clear_query(psql) + + check_completion( + psql, + "COPY foo FROM tab_comp_dir/af\t", + re.compile(r"'tab_comp_dir/afile"), + "quoted filename completion with multiple possibilities", + ) + + # some versions of readline/libedit require two tabs here, some only need + # one + # also, some will offer the whole path name and some just the file name + # the quotes might appear, too + check_completion( + psql, + "\t\t", + re.compile(r"afile123'? +'?(tab_comp_dir/)?afile456"), + "offer multiple file choices", + ) + + clear_line(psql) + + # check enum label completion + # some versions of readline/libedit require two tabs here, some only need + # one + # also, some versions will offer quotes, some will not + check_completion( + psql, + "ALTER TYPE enum1 RENAME VALUE 'ba\t\t", + re.compile(r"'?bar'? +'?baz'?"), + "offer multiple enum choices", + ) + + clear_line(psql) + + # enum labels are case sensitive, so this should complete BLACK immediately + check_completion( + psql, + "ALTER TYPE enum1 RENAME VALUE 'B\t", + re.compile(r"BLACK"), + "enum labels are case sensitive", + ) + + clear_line(psql) + + # check timezone name completion + check_completion( + psql, + "SET timezone TO am\t", + re.compile(r"'America/"), + "offer partial timezone name", + ) + + check_completion( + psql, "new_\t", re.compile(r"New_York"), "complete partial timezone name" + ) + + clear_line(psql) + + # check completion of a keyword offered in addition to object names; + # such a keyword should obey COMP_KEYWORD_CASE + for case, in_, out in ( + ("lower", "CO", "column"), + ("upper", "co", "COLUMN"), + ("preserve-lower", "co", "column"), + ("preserve-upper", "CO", "COLUMN"), + ): + check_completion( + psql, + f"\\set COMP_KEYWORD_CASE {case}\n", + re.compile(r"postgres=#"), + f"set completion case to '{case}'", + ) + check_completion( + psql, + f"alter table tab1 rename {in_}\t\t\t", + re.compile(out), + f"offer keyword {out} for input {in_}, " f"COMP_KEYWORD_CASE = {case}", + ) + clear_query(psql) + + # alternate path where keyword comes from SchemaQuery + check_completion( + psql, + "DROP TYPE big\t", + re.compile(r"DROP TYPE bigint "), + "offer keyword from SchemaQuery", + ) + + clear_query(psql) + + # check create_command_generator + check_completion( + psql, + "CREATE TY\t", + re.compile(r"CREATE TYPE "), + "check create_command_generator", + ) + + clear_query(psql) + + # check words_after_create infrastructure + check_completion( + psql, + "CREATE TABLE mytab\t\t", + re.compile(r"mytab123 +mytab246"), + "check words_after_create", + ) + + clear_query(psql) + + # check VersionedQuery infrastructure + check_completion( + psql, + "DROP PUBLIC\t \t\t", + re.compile(r"DROP PUBLICATION\s+some_publication "), + "check VersionedQuery", + ) + + clear_query(psql) + + # hits ends_with() and logic for completing in multi-line queries + check_completion( + psql, + "analyze (\n\t\t", + re.compile(r"VERBOSE"), + "check ANALYZE (VERBOSE ...", + ) + + clear_query(psql) + + # check completions for GUCs + check_completion( + psql, + "set interval\t\t", + re.compile(r"intervalstyle TO"), + "complete a GUC name", + ) + check_completion( + psql, " iso\t", re.compile(r"iso_8601 "), "complete a GUC enum value" + ) + + clear_query(psql) + + # same, for qualified GUC names + check_completion( + psql, + "DO $$begin end$$ LANGUAGE plpgsql;\n", + re.compile(r"postgres=# "), + "load plpgsql extension", + ) + + check_completion( + psql, + "set plpg\t", + re.compile(r"plpg\a?sql\."), + "complete prefix of a GUC name", + ) + check_completion( + psql, + "var\t\t", + re.compile(r"variable_conflict TO"), + "complete a qualified GUC name", + ) + check_completion( + psql, + " USE_C\t", + re.compile(r"use_column"), + "complete a qualified GUC enum value", + ) + + clear_query(psql) + + # check completions for psql variables + check_completion( + psql, + "\\set VERB\t", + re.compile(r"VERBOSITY "), + "complete a psql variable name", + ) + check_completion( + psql, "def\t", re.compile(r"default "), "complete a psql variable value" + ) + + clear_query(psql) + + check_completion( + psql, + "\\echo :VERB\t", + re.compile(r":VERBOSITY "), + "complete an interpolated psql variable name", + ) + + clear_query(psql) + + # check completion for psql variable test + check_completion( + psql, + "\\echo :{?VERB\t", + re.compile(r":\{\?VERBOSITY} "), + "complete a psql variable test", + ) + + clear_query(psql) + + # check no-completions code path + check_completion( + psql, "blarg \t\t", re.compile(r""), "check completion failure path" + ) + + clear_query(psql) + + # check COPY FROM with DEFAULT option + check_completion( + psql, + "COPY foo FROM stdin WITH ( DEF\t)", + re.compile(r"DEFAULT "), + "COPY FROM with DEFAULT completion", + ) + + clear_line(psql) + + # check tab completion for DELETE ... FOR PORTION OF + check_completion( + psql, + "DELETE FROM fpo_test F\t", + re.compile(r"FOR "), + "complete DELETE FROM F to FOR", + ) + + check_completion( + psql, "P\t", re.compile(r"PORTION "), "complete FOR P to PORTION" + ) + + check_completion(psql, "O\t", re.compile(r"OF "), "complete PORTION O to OF") + + check_completion( + psql, + "v\t", + re.compile(r"valid_at "), + "complete FOR PORTION OF offers column names", + ) + + check_completion( + psql, + "FR\t", + re.compile(r"FROM "), + "complete FOR PORTION OF FR to FROM", + ) + + clear_query(psql) + + # check tab completion for UPDATE ... FOR PORTION OF + check_completion( + psql, + "UPDATE fpo_test F\t", + re.compile(r"FOR "), + "complete UPDATE
F to FOR", + ) + + check_completion( + psql, "P\t", re.compile(r"PORTION "), "complete FOR P to PORTION" + ) + + check_completion(psql, "O\t", re.compile(r"OF "), "complete PORTION O to OF") + + check_completion( + psql, + "v\t", + re.compile(r"valid_at "), + "complete FOR PORTION OF offers column names", + ) + + check_completion( + psql, + "FR\t", + re.compile(r"FROM "), + "complete FOR PORTION OF FR to FROM", + ) + + clear_query(psql) diff --git a/src/bin/psql/pyt/test_020_cancel.py b/src/bin/psql/pyt/test_020_cancel.py new file mode 100644 index 00000000000..18ff735d3b6 --- /dev/null +++ b/src/bin/psql/pyt/test_020_cancel.py @@ -0,0 +1,72 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test query canceling by sending SIGINT to a running psql. A long-running +query is started in a background psql; once the server reports the backend +is executing it (observed in-process via pg_stat_activity), SIGINT is sent to +the psql process group and the resulting cancel error is checked. +""" + +import os +import signal +import subprocess +import sys + +import pytest + +from pypg.util import TIMEOUT_DEFAULT + + +# Sending SIGINT on Windows terminates the test itself, so skip there. +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="sending SIGINT on Windows terminates the test itself", +) + + +def test_020_cancel(pg): + node = pg + + with subprocess.Popen( + [ + os.path.join(node.bindir, "psql"), + "--no-psqlrc", + "--set", + "ON_ERROR_STOP=1", + "-h", + node.host, + "-p", + str(node.port), + "postgres", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + # Run in its own session/process group so we can deliver SIGINT to the + # psql process without it also hitting the test process. + start_new_session=True, + ) as psql: + try: + # Send sleep command and wait until the server has registered it. + psql.stdin.write(f"select pg_sleep({TIMEOUT_DEFAULT});\n") + psql.stdin.flush() + + assert node.poll_query_until( + "SELECT (SELECT count(*) FROM pg_stat_activity " + "WHERE query ~ '^select pg_sleep') > 0;" + ), "timed out waiting for the backend to start the sleep query" + + # Send cancel request (SIGINT to psql's process group). + psql.send_signal(signal.SIGINT) + + _stdout, stderr = psql.communicate(timeout=TIMEOUT_DEFAULT) + finally: + if psql.poll() is None: + psql.kill() + psql.communicate() + + # The query failed as expected (ON_ERROR_STOP=1 -> nonzero exit). + assert psql.returncode != 0, "query failed as expected" + assert ( + "canceling statement due to user request" in stderr + ), f"query was canceled\nstderr:\n{stderr}" diff --git a/src/bin/psql/pyt/test_030_pager.py b/src/bin/psql/pyt/test_030_pager.py new file mode 100644 index 00000000000..5cd96124d83 --- /dev/null +++ b/src/bin/psql/pyt/test_030_pager.py @@ -0,0 +1,119 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Set up "wc -l" as the pager (via PSQL_PAGER) so we can tell whether psql used +the pager, then drive an interactive psql session through a pty whose window is +sized 24x80 and check, for several queries/commands, that the pager was invoked +by matching the line-count number that "wc -l" prints. +""" + +import re +import subprocess + +import pytest + + +def _do_command(psql, send, pattern, annotation): + """Send *send*, wait for *pattern*, and assert it matched. + + *pattern* is a compiled regex. The match must be found in the captured + output and the query must not have timed out. + """ + out = psql.query_until(pattern, send) + assert pattern.search(out) and not psql.timed_out, annotation + + +def test_030_pager(pg, interactive_psql): + node = pg + + # Check that "wc -l" does what we expect, else forget it. + result = subprocess.run( + ["wc", "-l"], + input=b"foo bar\nbaz\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + wcstdout = result.stdout.decode().strip() + wcstderr = result.stderr.decode() + if not re.match(r"^ *2$", wcstdout) or wcstderr != "": + pytest.skip('"wc -l" is needed to run this test') + + # create a view we'll use below + node.safe_sql( + """create view public.view_030_pager as select +1 as a, +2 as b, +3 as c, +4 as d, +5 as e, +6 as f, +7 as g, +8 as h, +9 as i, +10 as j, +11 as k, +12 as l, +13 as m, +14 as n, +15 as o, +16 as p, +17 as q, +18 as r, +19 as s, +20 as t, +21 as u, +22 as v, +23 as w, +24 as x, +25 as y, +26 as z""", + ) + + # fire up an interactive psql session. We set up "wc -l" as the pager so + # we can tell whether psql used the pager, and size the pty's window to + # known values (24x80). + psql = interactive_psql( + node, + dbname="postgres", + extra_env={"PSQL_PAGER": "wc -l"}, + dimensions=(24, 80), + ) + + # Test invocation of the pager + # + # Note that interactive_psql starts psql with --no-align --tuples-only, + # and that the output string will include psql's prompts and command echo. + # So we have to test for patterns that can't match the command itself, + # and we can't assume the match will extend across a whole line (there + # might be a prompt ahead of it in the output). + + _do_command( + psql, + "SELECT 'test' AS t FROM generate_series(1,23);\n", + re.compile(r"test\r?$", re.MULTILINE), + "execute SELECT query that needs no pagination", + ) + + _do_command( + psql, + "SELECT 'test' AS t FROM generate_series(1,24);\n", + re.compile(r"24\r?$", re.MULTILINE), + "execute SELECT query that needs pagination", + ) + + _do_command( + psql, + "\\pset expanded\nSELECT generate_series(1,20) as g;\n", + re.compile(r"39\r?$", re.MULTILINE), + "execute SELECT query that needs pagination in expanded mode", + ) + + _do_command( + psql, + "\\pset tuples_only off\n\\d+ public.view_030_pager\n", + re.compile(r"55\r?$", re.MULTILINE), + "execute command with footer that needs pagination", + ) + + # send psql an explicit \q to shut it down, else pty won't close properly + psql.quit() diff --git a/src/interfaces/ecpg/preproc/meson.build b/src/interfaces/ecpg/preproc/meson.build index 3a56e2bb4ef..0562db711c9 100644 --- a/src/interfaces/ecpg/preproc/meson.build +++ b/src/interfaces/ecpg/preproc/meson.build @@ -98,4 +98,11 @@ tests += { ], 'deps': [ecpg_exe], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_ecpg_err_warn_msg.py', + 'pyt/test_002_ecpg_err_warn_msg_informix.py', + ], + 'deps': [ecpg_exe], + }, } diff --git a/src/interfaces/ecpg/preproc/pyt/test_001_ecpg_err_warn_msg.py b/src/interfaces/ecpg/preproc/pyt/test_001_ecpg_err_warn_msg.py new file mode 100644 index 00000000000..ec0f50602e6 --- /dev/null +++ b/src/interfaces/ecpg/preproc/pyt/test_001_ecpg_err_warn_msg.py @@ -0,0 +1,89 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Run the ecpg preprocessor on an input file and check that it detects +unsupported or disallowed statements and reports the appropriate error or +warning messages. +""" + +# Input file exercising the warning/error messages. +ERR_WARN_MSG_PGC = """\ +/* Test ECPG warning/error messages */ + +#include + +int +main(void) +{ +\tEXEC SQL BEGIN DECLARE SECTION; +\tchar *cursor_var = "mycursor"; +\tshort a; +\tEXEC SQL END DECLARE SECTION; + +\t/* For consistency with other tests */ +\tEXEC SQL CONNECT TO testdb AS con1; + +\t/* Test AT option errors */ +\tEXEC SQL AT con1 CONNECT TO testdb2; +\tEXEC SQL AT con1 DISCONNECT; +\tEXEC SQL AT con1 SET CONNECTION TO testdb2; +\tEXEC SQL AT con1 TYPE string IS char[11]; +\tEXEC SQL AT con1 WHENEVER NOT FOUND CONTINUE; +\tEXEC SQL AT con1 VAR a IS int; + +\t/* Test COPY FROM STDIN warning */ +\tEXEC SQL COPY test FROM stdin; + +\t/* Test same variable in multi declare statement */ +\tEXEC SQL DECLARE :cursor_var CURSOR FOR SELECT * FROM test; +\tEXEC SQL DECLARE :cursor_var CURSOR FOR SELECT * FROM test; + +\t/* Test duplicate cursor declarations */ +\tEXEC SQL DECLARE duplicate_cursor CURSOR FOR SELECT * FROM test; +\tEXEC SQL DECLARE duplicate_cursor CURSOR FOR SELECT * FROM test; + +\t/* Test SHOW ALL error */ +\tEXEC SQL SHOW ALL; + +\t/* Test deprecated LIMIT syntax warning */ +\tEXEC SQL SELECT * FROM test LIMIT 10, 5; + +\treturn 0; +} +""" + + +def test_001_ecpg_err_warn_msg(pg_bin, tmp_path): + pg_bin.program_help_ok("ecpg") + pg_bin.program_version_ok("ecpg") + pg_bin.program_options_handling_ok("ecpg") + pg_bin.command_fails(["ecpg"], "ecpg without arguments fails") + + # Test that the ecpg command correctly detects unsupported or disallowed + # statements in the input file and reports the appropriate error or + # warning messages. + pgc = tmp_path / "err_warn_msg.pgc" + pgc.write_text(ERR_WARN_MSG_PGC) + + pg_bin.command_checks_all( + ["ecpg", str(pgc)], + 3, + [r""], + [ + r"ERROR: AT option not allowed in CONNECT statement", + r"ERROR: AT option not allowed in DISCONNECT statement", + r"ERROR: AT option not allowed in SET CONNECTION statement", + r"ERROR: AT option not allowed in TYPE statement", + r"ERROR: AT option not allowed in WHENEVER statement", + r"ERROR: AT option not allowed in VAR statement", + r"WARNING: COPY FROM STDIN is not implemented", + r'ERROR: using variable "cursor_var" in different declare statements is not supported', + r'ERROR: cursor "duplicate_cursor" is already defined', + r"ERROR: SHOW ALL is not implemented", + r"WARNING: no longer supported LIMIT", + r'WARNING: cursor "duplicate_cursor" has been declared but not opened', + r'WARNING: cursor "duplicate_cursor" has been declared but not opened', + r'WARNING: cursor ":cursor_var" has been declared but not opened', + r'WARNING: cursor ":cursor_var" has been declared but not opened', + ], + "ecpg with errors and warnings", + ) diff --git a/src/interfaces/ecpg/preproc/pyt/test_002_ecpg_err_warn_msg_informix.py b/src/interfaces/ecpg/preproc/pyt/test_002_ecpg_err_warn_msg_informix.py new file mode 100644 index 00000000000..8149bb86a28 --- /dev/null +++ b/src/interfaces/ecpg/preproc/pyt/test_002_ecpg_err_warn_msg_informix.py @@ -0,0 +1,44 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test that the ecpg command in INFORMIX mode correctly detects unsupported or +disallowed statements in the input file and reports the appropriate error or +warning messages. +""" + +# Input file exercising the warning/error messages in INFORMIX mode. +PGC_SOURCE = """\ +/* Test ECPG warning/error messages in INFORMIX mode */ + +#include + +int +main(void) +{ + /* For consistency with other tests */ + $CONNECT TO testdb AS con1; + + /* Test AT option usage at CLOSE DATABASE statement in INFORMIX mode */ + $AT con1 CLOSE DATABASE; + + /* Test cursor name errors in INFORMIX mode */ + $DECLARE database CURSOR FOR SELECT * FROM test; + + return 0; +} +""" + + +def test_002_ecpg_err_warn_msg_informix(pg_bin, tmp_path): + pgc = tmp_path / "err_warn_msg_informix.pgc" + pgc.write_text(PGC_SOURCE) + + pg_bin.command_checks_all( + ["ecpg", "-C", "INFORMIX", str(pgc)], + 3, + [r""], + [ + r"ERROR: AT option not allowed in CLOSE DATABASE statement", + r'ERROR: "database" cannot be used as cursor name in INFORMIX mode', + ], + "ecpg in INFORMIX mode with errors and warnings", + ) diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build index b0ae72167a1..6df9fbf43e4 100644 --- a/src/interfaces/libpq/meson.build +++ b/src/interfaces/libpq/meson.build @@ -169,6 +169,22 @@ tests += { }, 'deps': libpq_test_deps, }, + 'pytest': { + 'tests': [ + 'pyt/test_001_uri.py', + 'pyt/test_002_api.py', + 'pyt/test_003_load_balance_host_list.py', + 'pyt/test_004_load_balance_dns.py', + 'pyt/test_005_negotiate_encryption.py', + 'pyt/test_006_service.py', + ], + 'env': { + 'with_ssl': ssl_library, + 'with_gssapi': gssapi.found() ? 'yes' : 'no', + 'with_krb_srvnam': 'postgres', + }, + 'deps': libpq_test_deps, + }, } subdir('po', if_found: libintl) diff --git a/src/interfaces/libpq/pyt/test_001_uri.py b/src/interfaces/libpq/pyt/test_001_uri.py new file mode 100644 index 00000000000..a9ec1ae6d4b --- /dev/null +++ b/src/interfaces/libpq/pyt/test_001_uri.py @@ -0,0 +1,299 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Table-driven test of libpq URI / connection-string parsing. Each case feeds an +input string to the build-dir ``libpq_uri_regress`` program (a single argv +argument) and checks the normalized stdout, the stderr, and the exit status. + +``libpq_uri_regress`` lives in builddir/src/interfaces/libpq/test/ and is found +via PATH (PgBin falls back to the bare name when it is not in bindir). No +server is needed. +""" + +from typing import Dict, List, Tuple, Union + +import pytest + +# List of URI tests. For each test the first element is the input string, the +# second the expected stdout and the third the expected stderr. Optionally, a +# fourth element is a dict of key/value pairs which override environment +# variables for the duration of the test. +TESTS: List[Union[Tuple[str, str, str], Tuple[str, str, str, Dict[str, str]]]] = [ + ( + r"postgresql://uri-user:secret@host:12345/db", + r"user='uri-user' password='secret' dbname='db' host='host' port='12345' (inet)", + r"", + ), + ( + r"postgresql://uri-user@host:12345/db", + r"user='uri-user' dbname='db' host='host' port='12345' (inet)", + r"", + ), + ( + r"postgresql://uri-user@host/db", + r"user='uri-user' dbname='db' host='host' (inet)", + r"", + ), + ( + r"postgresql://host:12345/db", + r"dbname='db' host='host' port='12345' (inet)", + r"", + ), + (r"postgresql://host/db", r"dbname='db' host='host' (inet)", r""), + ( + r"postgresql://uri-user@host:12345/", + r"user='uri-user' host='host' port='12345' (inet)", + r"", + ), + ( + r"postgresql://uri-user@host/", + r"user='uri-user' host='host' (inet)", + r"", + ), + (r"postgresql://uri-user@", r"user='uri-user' (local)", r""), + (r"postgresql://host:12345/", r"host='host' port='12345' (inet)", r""), + (r"postgresql://host:12345", r"host='host' port='12345' (inet)", r""), + (r"postgresql://host/db", r"dbname='db' host='host' (inet)", r""), + (r"postgresql://host/", r"host='host' (inet)", r""), + (r"postgresql://host", r"host='host' (inet)", r""), + (r"postgresql://", r"(local)", r""), + ( + r"postgresql://?hostaddr=127.0.0.1", + r"hostaddr='127.0.0.1' (inet)", + r"", + ), + ( + r"postgresql://example.com?hostaddr=63.1.2.4", + r"host='example.com' hostaddr='63.1.2.4' (inet)", + r"", + ), + (r"postgresql://%68ost/", r"host='host' (inet)", r""), + ( + r"postgresql://host/db?user=uri-user", + r"user='uri-user' dbname='db' host='host' (inet)", + r"", + ), + ( + r"postgresql://host/db?user=uri-user&port=12345", + r"user='uri-user' dbname='db' host='host' port='12345' (inet)", + r"", + ), + ( + r"postgresql://host/db?u%73er=someotheruser&port=12345", + r"user='someotheruser' dbname='db' host='host' port='12345' (inet)", + r"", + ), + ( + r"postgresql://host/db?u%7aer=someotheruser&port=12345", + r"", + r'libpq_uri_regress: invalid URI query parameter: "uzer"', + ), + ( + r"postgresql://host:12345?user=uri-user", + r"user='uri-user' host='host' port='12345' (inet)", + r"", + ), + ( + r"postgresql://host?user=uri-user", + r"user='uri-user' host='host' (inet)", + r"", + ), + ( + # Leading and trailing spaces, works. + r"postgresql://host? user = uri-user & port = 12345 ", + r"user='uri-user' host='host' port='12345' (inet)", + r"", + ), + ( + # Trailing data in parameter. + r"postgresql://host? user user = uri & port = 12345 12 ", + r"", + r'libpq_uri_regress: unexpected spaces found in " user user ", use percent-encoded spaces (%20) instead', + ), + ( + # Trailing data in value. + r"postgresql://host? user = uri-user & port = 12345 12 ", + r"", + r'libpq_uri_regress: unexpected spaces found in " 12345 12 ", use percent-encoded spaces (%20) instead', + ), + (r"postgresql://host?", r"host='host' (inet)", r""), + ( + r"postgresql://[::1]:12345/db", + r"dbname='db' host='::1' port='12345' (inet)", + r"", + ), + (r"postgresql://[::1]/db", r"dbname='db' host='::1' (inet)", r""), + ( + r"postgresql://[2001:db8::1234]/", + r"host='2001:db8::1234' (inet)", + r"", + ), + ( + r"postgresql://[200z:db8::1234]/", + r"host='200z:db8::1234' (inet)", + r"", + ), + (r"postgresql://[::1]", r"host='::1' (inet)", r""), + (r"postgres://", r"(local)", r""), + (r"postgres:///", r"(local)", r""), + (r"postgres:///db", r"dbname='db' (local)", r""), + ( + r"postgres://uri-user@/db", + r"user='uri-user' dbname='db' (local)", + r"", + ), + ( + r"postgres://?host=/path/to/socket/dir", + r"host='/path/to/socket/dir' (local)", + r"", + ), + ( + r"postgresql://host?uzer=", + r"", + r'libpq_uri_regress: invalid URI query parameter: "uzer"', + ), + ( + r"postgre://", + r"", + r'libpq_uri_regress: missing "=" after "postgre://" in connection info string', + ), + ( + r"postgres://[::1", + r"", + r'libpq_uri_regress: end of string reached when looking for matching "]" in IPv6 host address in URI: "postgres://[::1"', + ), + ( + r"postgres://[]", + r"", + r'libpq_uri_regress: IPv6 host address may not be empty in URI: "postgres://[]"', + ), + ( + r"postgres://[::1]z", + r"", + r'libpq_uri_regress: unexpected character "z" at position 17 in URI (expected ":" or "/"): "postgres://[::1]z"', + ), + ( + r"postgresql://host?zzz", + r"", + r'libpq_uri_regress: missing key/value separator "=" in URI query parameter: "zzz"', + ), + ( + r"postgresql://host?value1&value2", + r"", + r'libpq_uri_regress: missing key/value separator "=" in URI query parameter: "value1"', + ), + ( + r"postgresql://host?key=key=value", + r"", + r'libpq_uri_regress: extra key/value separator "=" in URI query parameter: "key"', + ), + ( + r"postgres://host?dbname=%XXfoo", + r"", + r'libpq_uri_regress: invalid percent-encoded token: "%XXfoo"', + ), + ( + r"postgresql://a%00b", + r"", + r'libpq_uri_regress: forbidden value %00 in percent-encoded value: "a%00b"', + ), + ( + r"postgresql://%zz", + r"", + r'libpq_uri_regress: invalid percent-encoded token: "%zz"', + ), + ( + r"postgresql://%1", + r"", + r'libpq_uri_regress: invalid percent-encoded token: "%1"', + ), + ( + r"postgresql://%", + r"", + r'libpq_uri_regress: invalid percent-encoded token: "%"', + ), + (r"postgres://@host", r"host='host' (inet)", r""), + (r"postgres://host:/", r"host='host' (inet)", r""), + (r"postgres://:12345/", r"port='12345' (local)", r""), + ( + r"postgres://otheruser@?host=/no/such/directory", + r"user='otheruser' host='/no/such/directory' (local)", + r"", + ), + ( + r"postgres://otheruser@/?host=/no/such/directory", + r"user='otheruser' host='/no/such/directory' (local)", + r"", + ), + ( + r"postgres://otheruser@:12345?host=/no/such/socket/path", + r"user='otheruser' host='/no/such/socket/path' port='12345' (local)", + r"", + ), + ( + r"postgres://otheruser@:12345/db?host=/path/to/socket", + r"user='otheruser' dbname='db' host='/path/to/socket' port='12345' (local)", + r"", + ), + ( + r"postgres://:12345/db?host=/path/to/socket", + r"dbname='db' host='/path/to/socket' port='12345' (local)", + r"", + ), + ( + r"postgres://:12345?host=/path/to/socket", + r"host='/path/to/socket' port='12345' (local)", + r"", + ), + ( + r"postgres://%2Fvar%2Flib%2Fpostgresql/dbname", + r"dbname='dbname' host='/var/lib/postgresql' (local)", + r"", + ), + # Usually the default sslmode is 'prefer' (for libraries with SSL) or + # 'disable' (for those without). This default changes to 'verify-full' if + # the system CA store is in use. + ( + r"postgresql://host?sslmode=disable", + r"host='host' sslmode='disable' (inet)", + r"", + {"PGSSLROOTCERT": "system"}, + ), + ( + r"postgresql://host?sslmode=prefer", + r"host='host' sslmode='prefer' (inet)", + r"", + {"PGSSLROOTCERT": "system"}, + ), + ( + r"postgresql://host?sslmode=verify-full", + r"host='host' (inet)", + r"", + {"PGSSLROOTCERT": "system"}, + ), +] + + +@pytest.mark.parametrize( + "case", + TESTS, + ids=[t[0] for t in TESTS], +) +def test_001_uri(pg_bin, case): + uri = case[0] + expect_stdout = case[1] + expect_stderr = case[2] + envvars = case[3] if len(case) > 3 else {} + + res = pg_bin.result(["libpq_uri_regress", uri], extra_env=envvars) + + # IPC::Run chomps trailing newlines off the captured streams. + got_stdout = res.stdout.rstrip("\n") + got_stderr = res.stderr.rstrip("\n") + + # The expected exit status is success exactly when the expected stderr is + # empty. + expect_success = expect_stderr == "" + + assert got_stdout == expect_stdout, f"stdout for {uri}" + assert got_stderr == expect_stderr, f"stderr for {uri}" + assert (res.returncode == 0) == expect_success, f"exit status for {uri}" diff --git a/src/interfaces/libpq/pyt/test_002_api.py b/src/interfaces/libpq/pyt/test_002_api.py new file mode 100644 index 00000000000..1ad28a11bd7 --- /dev/null +++ b/src/interfaces/libpq/pyt/test_002_api.py @@ -0,0 +1,19 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Test PQsslAttribute(NULL, "library") via the libpq_testclient helper.""" + +import os + + +def test_002_api(pg_bin): + # Test PQsslAttribute(NULL, "library") + res = pg_bin.result(["libpq_testclient", "--ssl"]) + + if os.environ.get("with_ssl") == "openssl": + assert ( + res.stdout.strip() == "OpenSSL" + ), 'PQsslAttribute(NULL, "library") returns "OpenSSL"' + else: + assert ( + res.stderr.strip() == "SSL is not enabled" + ), 'PQsslAttribute(NULL, "library") returns NULL' diff --git a/src/interfaces/libpq/pyt/test_003_load_balance_host_list.py b/src/interfaces/libpq/pyt/test_003_load_balance_host_list.py new file mode 100644 index 00000000000..7a976f6f27d --- /dev/null +++ b/src/interfaces/libpq/pyt/test_003_load_balance_host_list.py @@ -0,0 +1,127 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test load balancing across the list of different hosts in the host +parameter of the connection string. + +This framework uses the in-process libpq Session, so each node's "host" is its +own socket directory (or the loopback address on TCP) and the nodes are still +distinguished by listening on different ports. We observe which node answered +by checking the executed statement in each node's server log +(log_statement = all) and counting log occurrences. +""" + +import re + +import pytest + +from libpq import Session +from libpq.errors import PqConnectionError + + +def connect_with(node, connstr, sql=None): + """Open a libpq Session with *connstr*, optionally running *sql*. + + Returns the Session on success; the caller owns it and must close it. + Raises PqConnectionError on connection failure (mirrors connect_fails). + """ + sess = Session(connstr=connstr, libdir=node.libdir) + if sql is not None: + sess.query_safe(sql) + return sess + + +def count_statements(node, sql): + """Count occurrences of "statement: " in *node*'s server log.""" + pattern = "statement: " + re.escape(sql) + return len(re.findall(pattern, node.log_content())) + + +def test_003_load_balance_host_list(create_pg): + # Cluster setup which is shared for testing both load balancing methods. + # Each node listens on its own socket directory and port; logging every + # statement lets us tell which node served a given connection. + node1 = create_pg("node1", start=False) + node2 = create_pg("node2", start=False) + node3 = create_pg("node3", start=False) + + for node in (node1, node2, node3): + node.append_conf("log_statement = all\n") + node.start() + + # Build the shared host/port lists. In this unix-socket-only framework + # each node's host is its socket directory. + hostlist = ",".join(n.host for n in (node1, node2, node3)) + portlist = ",".join(str(n.port) for n in (node1, node2, node3)) + + # load_balance_hosts doesn't accept unknown values. + with pytest.raises(PqConnectionError) as excinfo: + connect_with( + node1, + f"host={hostlist} port={portlist} dbname=postgres load_balance_hosts=doesnotexist", + ) + assert re.search( + r'invalid load_balance_hosts value: "doesnotexist"', str(excinfo.value) + ), "load_balance_hosts doesn't accept unknown values" + + # load_balance_hosts=disable should always choose the first one. + sess = connect_with( + node1, + f"host={hostlist} port={portlist} dbname=postgres load_balance_hosts=disable", + sql="SELECT 'connect1'", + ) + sess.close() + assert ( + count_statements(node1, "SELECT 'connect1'") >= 1 + ), "load_balance_hosts=disable connects to the first node" + + # Statistically the following loop with load_balance_hosts=random will + # almost certainly connect at least once to each of the nodes. The chance + # of that not happening is so small that it's negligible: + # (2/3)^50 = 1.56832855e-9 + for _ in range(1, 51): + sess = connect_with( + node1, + f"host={hostlist} port={portlist} dbname=postgres load_balance_hosts=random", + sql="SELECT 'connect2'", + ) + sess.close() + + node1_occurrences = count_statements(node1, "SELECT 'connect2'") + node2_occurrences = count_statements(node2, "SELECT 'connect2'") + node3_occurrences = count_statements(node3, "SELECT 'connect2'") + + total_occurrences = node1_occurrences + node2_occurrences + node3_occurrences + + assert node1_occurrences > 1, "received at least one connection on node1" + assert node2_occurrences > 1, "received at least one connection on node2" + assert node3_occurrences > 1, "received at least one connection on node3" + assert total_occurrences == 50, "received 50 connections across all nodes" + + node1.stop() + node2.stop() + + # load_balance_hosts=disable should continue trying hosts until it finds a + # working one. + sess = connect_with( + node3, + f"host={hostlist} port={portlist} dbname=postgres load_balance_hosts=disable", + sql="SELECT 'connect3'", + ) + sess.close() + assert ( + count_statements(node3, "SELECT 'connect3'") >= 1 + ), "load_balance_hosts=disable continues until it connects to a working node" + + # Also with load_balance_hosts=random we continue to the next nodes if + # previous ones are down. Connect a few times to make sure it's not just + # lucky. + for _ in range(1, 6): + sess = connect_with( + node3, + f"host={hostlist} port={portlist} dbname=postgres load_balance_hosts=random", + sql="SELECT 'connect4'", + ) + sess.close() + assert ( + count_statements(node3, "SELECT 'connect4'") >= 5 + ), "load_balance_hosts=random continues until it connects to a working node" diff --git a/src/interfaces/libpq/pyt/test_004_load_balance_dns.py b/src/interfaces/libpq/pyt/test_004_load_balance_dns.py new file mode 100644 index 00000000000..4845399d1e7 --- /dev/null +++ b/src/interfaces/libpq/pyt/test_004_load_balance_dns.py @@ -0,0 +1,188 @@ +# Copyright (c) 2023-2026, PostgreSQL Global Development Group + +"""Test load balancing based on DNS records with multiple IPs. + +This tests load balancing based on a DNS entry that contains multiple records +for different IPs. Since setting up a DNS server is more effort than we +consider reasonable to run this test, this situation is instead imitated by +using a hosts file where a single hostname maps to multiple different IP +addresses. This test requires the administrator to add the following lines to +the hosts file (if we detect that this hasn't happened we skip the test): + + 127.0.0.1 pg-loadbalancetest + 127.0.0.2 pg-loadbalancetest + 127.0.0.3 pg-loadbalancetest + +Windows or Linux are required to run this test because these OSes allow binding +to 127.0.0.2 and 127.0.0.3 addresses by default, but other OSes don't. We need +to bind to different IP addresses, so that we can use these different IP +addresses in the hosts file. + +The hosts file needs to be prepared before running this test. We don't do it +on the fly, because it requires root permissions to change the hosts file. In +CI we set up the previously mentioned rules in the hosts file, so that this load +balancing method is tested. + +This test is gated behind PG_TEST_EXTRA=load_balance, the special /etc/hosts +entries above, and a Linux/Windows host; otherwise it skips. When those are +present it runs the three nodes with create_pg(own_host=True) and a shared +port, so each binds a distinct loopback address (127.0.0.1/2/3) on the same TCP +port -- the topology the single DNS name pg-loadbalancetest needs. +""" + +import os +import re +import sys + +import pytest + +from libpq import Session +from libpq.errors import PqConnectionError + +# The hostname that the prepared hosts file maps to 127.0.0.1/2/3. +LOADBALANCE_HOST = "pg-loadbalancetest" +HOSTS_PATTERN = re.compile(r"127\.0\.0\.[1-3] pg-loadbalancetest") + + +# -- skip gating ------------------------------------------------------------- + + +def _skip_reason(): + """Return a skip reason if this test cannot run here, else None.""" + # Potentially unsafe test load_balance not enabled in PG_TEST_EXTRA. + extra = os.environ.get("PG_TEST_EXTRA", "") + if not re.search(r"\bload_balance\b", extra): + return "Potentially unsafe test load_balance not enabled in PG_TEST_EXTRA" + + # Windows or Linux are required: these OSes allow binding to 127.0.0.2 and + # 127.0.0.3 by default, but other OSes don't. + can_bind_to_127_0_0_2 = sys.platform.startswith("linux") or sys.platform == "win32" + if not can_bind_to_127_0_0_2: + return "load_balance test only supported on Linux and Windows" + + # The hosts file must contain the three pg-loadbalancetest mappings. + if sys.platform == "win32": + hosts_path = r"c:\Windows\System32\Drivers\etc\hosts" + else: + hosts_path = "/etc/hosts" + try: + with open(hosts_path, "r", encoding="utf-8", errors="replace") as fh: + hosts_content = fh.read() + except OSError: + hosts_content = "" + if len(HOSTS_PATTERN.findall(hosts_content)) != 3: + return "hosts file was not prepared for DNS load balance test" + + return None + + +# Module-level skip: in the conversion environment load_balance is not in +# PG_TEST_EXTRA, so the whole module skips cleanly and never starts a server. +_skip = _skip_reason() +if _skip is not None: + pytest.skip(_skip, allow_module_level=True) + + +# -- helpers (NOT named test_*) ---------------------------------------------- + + +def _connect_ok(node, connstr, msg, *, sql=None, log_like=None): + """Connect with *connstr*, optionally run *sql*, assert success and logs. + + Opens a fresh libpq Session (no psql subprocess), runs *sql* if given, and + -- when *log_like* patterns are supplied -- asserts each appears in + *node*'s server log. + """ + offset = node.log_position() + sess = None + try: + sess = Session(connstr=connstr, libdir=node.libdir) + if sql is not None: + sess.query_safe(sql) + except PqConnectionError as exc: # pragma: no cover - only runs when enabled + raise AssertionError(f"{msg}: connection failed: {exc}") from exc + finally: + if sess is not None: + sess.close() + + for pattern in log_like or []: + log = node.log_content()[offset:] + assert re.search( + pattern, log + ), f"{msg}: pattern /{pattern}/ not found in server log\n{log}" + + +def _occurrences(node, pattern): + return len(re.findall(pattern, node.log_content())) + + +# -- the test ---------------------------------------------------------------- + + +def test_004_load_balance_dns(create_pg): + # All three nodes bind their own loopback address (127.0.0.1/2/3) on the + # *same* TCP port, so the single DNS name pg-loadbalancetest (which the + # prepared hosts file maps to those three IPs) selects between them. + node1 = create_pg("node1", start=False, own_host=True) + node2 = create_pg("node2", start=False, own_host=True, port=node1.port) + node3 = create_pg("node3", start=False, own_host=True, port=node1.port) + + for node in (node1, node2, node3): + # log_statement = all so connect_ok's log_like checks can see the SQL. + node.append_conf("log_statement = all\n") + node.start() + + # load_balance_hosts=disable should always choose the first one. + _connect_ok( + node1, + f"host={LOADBALANCE_HOST} port={node1.port} load_balance_hosts=disable", + "load_balance_hosts=disable connects to the first node", + sql="SELECT 'connect1'", + log_like=[r"statement: SELECT 'connect1'"], + ) + + # Statistically the following loop with load_balance_hosts=random will + # almost certainly connect at least once to each of the nodes. The chance + # of that not happening is negligible: (2/3)^50 = 1.56832855e-9. + for _ in range(50): + _connect_ok( + node1, + f"host={LOADBALANCE_HOST} port={node1.port} load_balance_hosts=random", + "repeated connections with random load balancing", + sql="SELECT 'connect2'", + ) + + node1_occurrences = _occurrences(node1, r"statement: SELECT 'connect2'") + node2_occurrences = _occurrences(node2, r"statement: SELECT 'connect2'") + node3_occurrences = _occurrences(node3, r"statement: SELECT 'connect2'") + + total_occurrences = node1_occurrences + node2_occurrences + node3_occurrences + + assert node1_occurrences > 1, "received at least one connection on node1" + assert node2_occurrences > 1, "received at least one connection on node2" + assert node3_occurrences > 1, "received at least one connection on node3" + assert total_occurrences == 50, "received 50 connections across all nodes" + + node1.stop() + node2.stop() + + # load_balance_hosts=disable should continue trying hosts until it finds a + # working one. + _connect_ok( + node3, + f"host={LOADBALANCE_HOST} port={node3.port} load_balance_hosts=disable", + "load_balance_hosts=disable continues until it connects to a working node", + sql="SELECT 'connect3'", + log_like=[r"statement: SELECT 'connect3'"], + ) + + # Also with load_balance_hosts=random we continue to the next nodes if + # previous ones are down. Connect a few times to make sure it's not luck. + for _ in range(5): + _connect_ok( + node3, + f"host={LOADBALANCE_HOST} port={node3.port} load_balance_hosts=random", + "load_balance_hosts=random continues until it connects to a working node", + sql="SELECT 'connect4'", + log_like=[r"statement: SELECT 'connect4'"], + ) diff --git a/src/interfaces/libpq/pyt/test_005_negotiate_encryption.py b/src/interfaces/libpq/pyt/test_005_negotiate_encryption.py new file mode 100644 index 00000000000..6a7cd09e941 --- /dev/null +++ b/src/interfaces/libpq/pyt/test_005_negotiate_encryption.py @@ -0,0 +1,791 @@ +# Copyright (c) 2021-2026, PostgreSQL Global Development Group + +"""Test negotiation of SSL and GSSAPI encryption. + +OVERVIEW +-------- + +Test negotiation of SSL and GSSAPI encryption. + +We test all combinations of: + +- all the libpq client options that affect the protocol negotiations + (gssencmode, sslmode, sslnegotiation) +- server accepting or rejecting the authentication due to pg_hba.conf entries +- SSL and GSS enabled/disabled in the server + +That's a lot of combinations, so we use a table-driven approach. Each +combination is represented by a line in a table. The line lists the options +specifying the test case, and an expected outcome. The expected outcome +includes whether the connection succeeds or fails, and whether it uses SSL, +GSS or no encryption. It also includes a condensed trace of what steps were +taken during the negotiation. + +See the docstring of :func:`_parse_log_events` for the EVENTS / OUTCOME table +format. + +NOTES +----- + +The whole combination table is checked as follows: + +* The connection is attempted in-process via libpq (a fresh ``Session``), not + by forking psql. The full conninfo string carries + host=/hostaddr=/user=/gssencmode=/sslmode=/sslnegotiation=, and the outcome + is the value returned by ``SELECT current_enc()`` on success, or ``fail`` if + the connection (or that query) fails. + +* The EVENTS trace is scraped from the *server* log, relying on the server's + ``trace_connection_negotiation``, ``log_connections`` and + ``log_disconnections`` output. The in-process Session drives the same wire + protocol negotiation, and the same server log lines are produced and parsed. + +Framework specifics: + +* TCP listening is configured with ``listen_addresses = '127.0.0.1'`` (the + PostgresServer fixture is unix-socket-only by default). + +* injection_points availability is probed via ``pg_available_extensions``. + +* SSL on/off is toggled by appending ``ssl = on`` / ``ssl = off`` to + postgresql.conf and reloading (a later setting wins). + +The kerberos and ssl_server fixtures are obtained lazily (via +``request.getfixturevalue``) only when the corresponding support is enabled, +so the GSS/SSL sub-blocks skip independently (and the whole module skips +cleanly when libpq_encryption is not enabled). +""" + +import os +import re + +import pytest + +from libpq import Session +from libpq.errors import PqConnectionError + +# -- module-level gating ----------------------------------------------------- + +if not re.search(r"\blibpq_encryption\b", os.environ.get("PG_TEST_EXTRA", "")): + pytest.skip( + "Potentially unsafe test libpq_encryption not enabled in PG_TEST_EXTRA", + allow_module_level=True, + ) + +# Only run the GSSAPI tests when compiled with GSSAPI support and PG_TEST_EXTRA +# includes 'kerberos'. +GSS_SUPPORTED = os.environ.get("with_gssapi") == "yes" +KERBEROS_ENABLED = bool(re.search(r"\bkerberos\b", os.environ.get("PG_TEST_EXTRA", ""))) +SSL_SUPPORTED = os.environ.get("with_ssl") == "openssl" + +HOST = "enc-test-localhost.postgresql.example.com" +HOSTADDR = "127.0.0.1" +SERVERCIDR = "127.0.0.1/32" + +DBNAME = "postgres" +GSSUSER_PASSWORD = "secret1" + +ALL_TEST_USERS = ["testuser", "ssluser", "nossluser", "gssuser", "nogssuser"] +ALL_GSSENCMODES = ["disable", "prefer", "require"] +ALL_SSLMODES = ["disable", "allow", "prefer", "require"] +ALL_SSLNEGOTIATIONS = ["postgres", "direct"] + + +# -- table parsing helpers (NOT named test_*) -------------------------------- + + +def _expand_expected_line(user, gssencmode, sslmode, sslnegotiation, expected): + """Expand '*' wildcards on a test table line into concrete keys.""" + result = {} + if user == "*": + for x in ALL_TEST_USERS: + result.update( + _expand_expected_line(x, gssencmode, sslmode, sslnegotiation, expected) + ) + elif gssencmode == "*": + for x in ALL_GSSENCMODES: + result.update( + _expand_expected_line(user, x, sslmode, sslnegotiation, expected) + ) + elif sslmode == "*": + for x in ALL_SSLMODES: + result.update( + _expand_expected_line(user, gssencmode, x, sslnegotiation, expected) + ) + elif sslnegotiation == "*": + for x in ALL_SSLNEGOTIATIONS: + result.update(_expand_expected_line(user, gssencmode, sslmode, x, expected)) + else: + result[f"{user} {gssencmode} {sslmode} {sslnegotiation}"] = expected + return result + + +_LINE_RE = re.compile(r"^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S.*)\s*->\s*(\S+)\s*$") + + +def _parse_table(table): + """Parse a test table. See the comment at the top of the file for format.""" + expected = {} + user = gssencmode = sslmode = sslnegotiation = None + + for raw_line in table.split("\n"): + # Trim comments and surrounding whitespace. + line = re.sub(r"#.*$", "", raw_line).strip() + if line == "": + continue + + m = _LINE_RE.match(line) + if not m: + raise AssertionError(f'could not parse line "{line}"') + + if m.group(1) != ".": + user = m.group(1) + if m.group(2) != ".": + gssencmode = m.group(2) + if m.group(3) != ".": + sslmode = m.group(3) + if m.group(4) != ".": + sslnegotiation = m.group(4) + + # Normalize the whitespace in the "EVENTS -> OUTCOME" part. + events = re.split(r",\s*", m.group(5)) + outcome = m.group(6) + events_str = ", ".join(events).rstrip() + events_and_outcome = f"{events_str} -> {outcome}" + + expected.update( + _expand_expected_line( + user, gssencmode, sslmode, sslnegotiation, events_and_outcome + ) + ) + return expected + + +def _parse_log_events(log_contents): + """Scrape the server log for the negotiation events. + + Each recognised log line emits one condensed event token; no events at all + is represented by ``-``. + """ + events = [] + for line in log_contents.split("\n"): + if "connection received" in line: + events.append("reconnect" if events else "connect") + if "SSLRequest accepted" in line: + events.append("sslaccept") + if "SSLRequest rejected" in line: + events.append("sslreject") + if "direct SSL connection accepted" in line: + events.append("directsslaccept") + if "direct SSL connection rejected" in line: + events.append("directsslreject") + if "GSSENCRequest accepted" in line: + events.append("gssaccept") + if "GSSENCRequest rejected" in line: + events.append("gssreject") + if "no pg_hba.conf entry" in line: + events.append("authfail") + if "connection authenticated" in line: + events.append("authok") + if "error triggered for injection point backend-" in line: + events.append("backenderror") + if "protocol version 2 error triggered" in line: + events.append("v2error") + + if not events: + events.append("-") + return events + + +# -- connection test driver (NOT named test_*) ------------------------------- + + +class _Harness: + """Holds the node and accumulates pass/fail results for the run.""" + + def __init__(self, node): + self.node = node + self.failures = [] + self.count = 0 + + def connect_test(self, connstr, expected_events_and_outcome): + """Attempt a connection and verify the events and outcome. + + The outcome is the value of ``SELECT current_enc()`` on success or + ``fail`` on any failure, and the EVENTS are scraped from the server log + lines produced since the attempt began. + """ + self.count += 1 + test_name = f" '{connstr}' -> {expected_events_and_outcome}" + + connstr_full = "" + if "dbname=" not in connstr: + connstr_full += "dbname=postgres " + if "host=" not in connstr: + connstr_full += f"host={HOST} hostaddr={HOSTADDR} " + # The framework gives each node its own port, so add it here (later + # keywords win in libpq, so an explicit port= in connstr -- there is + # none -- would still take precedence). + if "port=" not in connstr: + connstr_full += f"port={self.node.port} " + connstr_full += connstr + + # Record the current log size; afterwards we look only at new lines. + log_location = self.node.log_position() + + outcome = "fail" + stderr = "" + sess = None + try: + sess = Session(connstr=connstr_full, libdir=self.node.libdir) + res = sess.query("SELECT current_enc()") + if res.error_message is None: + outcome = res.psqlout.strip() + else: + stderr = res.error_message + except PqConnectionError as exc: + stderr = str(exc) + finally: + if sess is not None: + sess.close() + + # Parse the EVENTS from the new portion of the log file. Wait briefly + # for the disconnection record so the trailing events are present. + log_contents = self._slurp_log(log_location) + events = _parse_log_events(log_contents) + + events_and_outcome = ", ".join(events) + f" -> {outcome}" + if events_and_outcome != expected_events_and_outcome: + self.failures.append( + f"FAIL:{test_name}\n" + f" got: {events_and_outcome}\n" + f" expected: {expected_events_and_outcome}\n" + f" stderr: {stderr.strip()}" + ) + + def _slurp_log(self, offset): + """Return log text written since *offset*, allowing it to settle. + + The server writes its log records slightly asynchronously from the + client's point of view, so poll until two consecutive reads agree + (content stopped growing). An empty result is legitimate -- a purely + client-side failure (e.g. gssencmode=require with no ccache, or a + direct-SSL request rejected before connecting) never reaches the server + -- so empty-and-stable returns promptly rather than waiting out a long + timeout. + """ + import time + + deadline = time.monotonic() + 5.0 + prev = self.node.log_content()[offset:] + while True: + time.sleep(0.05) + content = self.node.log_content()[offset:] + if content == prev: + return content + prev = content + if time.monotonic() > deadline: + return content + + +def _test_matrix(harness, test_users, gssencmodes, sslmodes, sslnegotiations, expected): + """Test the cube of parameters: user, gssencmode, sslmode, sslnegotiation.""" + for test_user in test_users: + for gssencmode in gssencmodes: + for client_mode in sslmodes: + for negotiation in sslnegotiations: + key = f"{test_user} {gssencmode} {client_mode} {negotiation}" + expected_events = expected.get( + key, "" + ) + harness.connect_test( + f"user={test_user} gssencmode={gssencmode} " + f"sslmode={client_mode} sslnegotiation={negotiation}", + expected_events, + ) + + +# -- the test ---------------------------------------------------------------- + + +def test_005_negotiate_encryption(create_pg, request, tmp_path): + ### + ### Prepare test server for GSSAPI and SSL authentication, with a few + ### different test users and helper functions. We don't actually enable + ### SSL and kerberos in the server yet, we will do that later. + ### + node = create_pg("node", start=False) + node.append_conf( + f""" +listen_addresses = '{HOSTADDR}' + +# Capturing the EVENTS that occur during tests requires these settings +log_connections = 'receipt,authentication,authorization' +log_disconnections = on +trace_connection_negotiation = on +lc_messages = 'C' +""" + ) + pgdata = node.data_dir + + krb = None + if GSS_SUPPORTED and KERBEROS_ENABLED: + # note: setting up Kerberos + realm = "EXAMPLE.COM" + kerberos = request.getfixturevalue("kerberos") + krb = kerberos(HOST, HOSTADDR, realm) + node.append_conf(f"krb_server_keyfile = '{krb.keytab}'\n") + + ssl_server = None + if SSL_SUPPORTED: + ssl_server = request.getfixturevalue("ssl_server") + certdir = ssl_server.ssl_dir + import shutil + + shutil.copy( + os.path.join(certdir, "server-cn-only.crt"), + os.path.join(pgdata, "server.crt"), + ) + shutil.copy( + os.path.join(certdir, "server-cn-only.key"), + os.path.join(pgdata, "server.key"), + ) + os.chmod(os.path.join(pgdata, "server.key"), 0o600) + + # Start with SSL disabled. + node.append_conf("ssl = off\n") + + node.start() + + # Check if the extension injection_points is available, as it may be + # possible that this script is run with installcheck, where the module + # would not be installed by default. + injection_points_supported = ( + node.safe_sql( + "SELECT count(*) > 0 FROM pg_available_extensions " + "WHERE name = 'injection_points'" + ).strip() + == "t" + ) + + node.safe_sql("CREATE USER localuser;") + node.safe_sql("CREATE USER testuser;") + node.safe_sql("CREATE USER ssluser;") + node.safe_sql("CREATE USER nossluser;") + node.safe_sql("CREATE USER gssuser;") + node.safe_sql("CREATE USER nogssuser;") + if injection_points_supported: + node.safe_sql("CREATE EXTENSION injection_points;") + + unixdir = node.safe_sql("SHOW unix_socket_directories;").strip() + + # Helper function that returns the encryption method in use in the + # connection. + node.safe_sql( + r""" +CREATE FUNCTION current_enc() RETURNS text LANGUAGE plpgsql AS $$ +DECLARE + ssl_in_use bool; + gss_in_use bool; +BEGIN + ssl_in_use = (SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()); + gss_in_use = (SELECT encrypted FROM pg_stat_gssapi WHERE pid = pg_backend_pid()); + + raise log 'ssl % gss %', ssl_in_use, gss_in_use; + + IF ssl_in_use AND gss_in_use THEN + RETURN 'ssl+gss'; -- shouldn't happen + ELSIF ssl_in_use THEN + RETURN 'ssl'; + ELSIF gss_in_use THEN + RETURN 'gss'; + ELSE + RETURN 'plain'; + END IF; +END; +$$; +""" + ) + + # Only accept SSL connections from $servercidr. Our tests don't depend on + # this but seems best to keep it as narrow as possible for security reasons. + hba = ( + "# TYPE DATABASE USER ADDRESS METHOD OPTIONS\n" + "local postgres localuser trust\n" + f"host postgres testuser {SERVERCIDR} trust\n" + f"hostnossl postgres nossluser {SERVERCIDR} trust\n" + f"hostnogssenc postgres nogssuser {SERVERCIDR} trust\n" + ) + if SSL_SUPPORTED: + hba += f"hostssl postgres ssluser {SERVERCIDR} trust\n" + if GSS_SUPPORTED and KERBEROS_ENABLED: + hba += f"hostgssenc postgres gssuser {SERVERCIDR} trust\n" + with open(os.path.join(pgdata, "pg_hba.conf"), "w", encoding="utf-8") as fh: + fh.write(hba) + node.reload() + + # After the pg_hba.conf rewrite above, the only local-socket entry is for + # 'localuser', so the framework's own safe_sql (which connects as the OS + # user over the unix socket) no longer works. Run the later administrative + # statements via a dedicated localuser session. This is also what + # backend-side injection_points_attach needs. + def admin_psql(sql): + with Session( + connstr=f"host={unixdir} port={node.port} dbname=postgres " + f"user=localuser", + libdir=node.libdir, + ) as sess: + sess.query_safe(sql) + + # Ok, all prepared. Run the tests. + harness = _Harness(node) + + ### + ### Run tests with GSS and SSL disabled in the server + ### + if SSL_SUPPORTED: + test_table = r""" +# USER GSSENCMODE SSLMODE SSLNEGOTIATION EVENTS -> OUTCOME +testuser disable disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslreject, authok -> plain +. . require postgres connect, sslreject -> fail +. . . direct connect, directsslreject -> fail +. prefer disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslreject, authok -> plain +. . require postgres connect, sslreject -> fail +. . . direct connect, directsslreject -> fail + +# sslnegotiation=direct is not accepted unless sslmode=require or stronger +* * disable direct - -> fail +* * allow direct - -> fail +* * prefer direct - -> fail +""" + else: + # Compiled without SSL support + test_table = r""" +# USER GSSENCMODE SSLMODE SSLNEGOTIATION EVENTS -> OUTCOME +testuser disable disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, authok -> plain +. prefer disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, authok -> plain + +# Without SSL support, sslmode=require and sslnegotiation=direct are +# not accepted at all +* * require * - -> fail +* * * direct - -> fail +""" + + # All attempts with gssencmode=require fail without connecting because no + # credential cache has been configured in the client. (Or if GSS support + # is not compiled in, they will fail because of that.) + test_table += r""" +testuser require * * - -> fail +""" + + # note: Running tests with SSL and GSS disabled in the server + _test_matrix( + harness, + ["testuser"], + ALL_GSSENCMODES, + ALL_SSLMODES, + ALL_SSLNEGOTIATIONS, + _parse_table(test_table), + ) + + ### + ### Run tests with GSS disabled and SSL enabled in the server + ### + if SSL_SUPPORTED: + test_table = r""" +# USER GSSENCMODE SSLMODE SSLNEGOTIATION EVENTS -> OUTCOME +testuser disable disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslaccept, authok -> ssl +. . require postgres connect, sslaccept, authok -> ssl +. . . direct connect, directsslaccept, authok -> ssl +ssluser . disable postgres connect, authfail -> fail +. . allow postgres connect, authfail, reconnect, sslaccept, authok -> ssl +. . prefer postgres connect, sslaccept, authok -> ssl +. . require postgres connect, sslaccept, authok -> ssl +. . . direct connect, directsslaccept, authok -> ssl +nossluser . disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslaccept, authfail, reconnect, authok -> plain +. . require postgres connect, sslaccept, authfail -> fail +. . require direct connect, directsslaccept, authfail -> fail + +# sslnegotiation=direct is not accepted unless sslmode=require or stronger +* * disable direct - -> fail +* * allow direct - -> fail +* * prefer direct - -> fail +""" + + # Enable SSL in the server + node.append_conf("ssl = on\n") + node.reload() + + # note: Running tests with SSL enabled in server + _test_matrix( + harness, + ["testuser", "ssluser", "nossluser"], + ["disable"], + ALL_SSLMODES, + ALL_SSLNEGOTIATIONS, + _parse_table(test_table), + ) + + if injection_points_supported: + admin_psql("SELECT injection_points_attach('backend-initialize', 'error');") + harness.connect_test( + "user=testuser sslmode=prefer", "connect, backenderror -> fail" + ) + node.restart() + + admin_psql( + "SELECT injection_points_attach('backend-initialize-v2-error', 'error');" + ) + harness.connect_test( + "user=testuser sslmode=prefer", "connect, v2error -> fail" + ) + node.restart() + + admin_psql( + "SELECT injection_points_attach('backend-ssl-startup', 'error');" + ) + harness.connect_test( + "user=testuser sslmode=prefer", + "connect, sslaccept, backenderror, reconnect, authok -> plain", + ) + node.restart() + + # Disable SSL again + node.append_conf("ssl = off\n") + node.reload() + + ### + ### Run tests with GSS enabled, SSL disabled in the server + ### + if GSS_SUPPORTED and KERBEROS_ENABLED: + krb.create_principal("gssuser", GSSUSER_PASSWORD) + krb.create_ticket("gssuser", GSSUSER_PASSWORD) + + test_table = r""" +# USER GSSENCMODE SSLMODE SSLNEGOTIATION EVENTS -> OUTCOME +testuser disable disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslreject, authok -> plain +. . require postgres connect, sslreject -> fail +. . . direct connect, directsslreject -> fail +. prefer * postgres connect, gssaccept, authok -> gss +. prefer require direct connect, gssaccept, authok -> gss +. require * postgres connect, gssaccept, authok -> gss +. . require direct connect, gssaccept, authok -> gss + +gssuser disable disable postgres connect, authfail -> fail +. . allow postgres connect, authfail, reconnect, sslreject -> fail +. . prefer postgres connect, sslreject, authfail -> fail +. . require postgres connect, sslreject -> fail +. . . direct connect, directsslreject -> fail +. prefer * postgres connect, gssaccept, authok -> gss +. prefer require direct connect, gssaccept, authok -> gss +. require * postgres connect, gssaccept, authok -> gss +. . require direct connect, gssaccept, authok -> gss + +nogssuser disable disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslreject, authok -> plain +. . require postgres connect, sslreject -> fail +. . . direct connect, directsslreject -> fail +. prefer disable postgres connect, gssaccept, authfail, reconnect, authok -> plain +. . allow postgres connect, gssaccept, authfail, reconnect, authok -> plain +. . prefer postgres connect, gssaccept, authfail, reconnect, sslreject, authok -> plain +. . require postgres connect, gssaccept, authfail, reconnect, sslreject -> fail +. . . direct connect, gssaccept, authfail, reconnect, directsslreject -> fail +. require disable postgres connect, gssaccept, authfail -> fail +. . allow postgres connect, gssaccept, authfail -> fail +. . prefer postgres connect, gssaccept, authfail -> fail +. . require postgres connect, gssaccept, authfail -> fail # If both GSSAPI and sslmode are required, and GSS is not available -> fail +. . . direct connect, gssaccept, authfail -> fail # If both GSSAPI and sslmode are required, and GSS is not available -> fail + +# sslnegotiation=direct is not accepted unless sslmode=require or stronger +* * disable direct - -> fail +* * allow direct - -> fail +* * prefer direct - -> fail +""" + + # The expected events and outcomes above assume that SSL support is + # enabled. When libpq is compiled without SSL support, all attempts to + # connect with sslmode=require or sslnegotiation=direct would fail + # immediately without even connecting to the server. Skip those, + # because we tested them earlier already. + if SSL_SUPPORTED: + sslmodes, sslnegotiations = ALL_SSLMODES, ALL_SSLNEGOTIATIONS + else: + sslmodes, sslnegotiations = ["disable"], ["postgres"] + + # note: Running tests with GSS enabled in server + _test_matrix( + harness, + ["testuser", "gssuser", "nogssuser"], + ALL_GSSENCMODES, + sslmodes, + sslnegotiations, + _parse_table(test_table), + ) + + if injection_points_supported: + admin_psql("SELECT injection_points_attach('backend-initialize', 'error');") + harness.connect_test( + "user=testuser gssencmode=prefer sslmode=disable", + "connect, backenderror, reconnect, backenderror -> fail", + ) + node.restart() + + admin_psql( + "SELECT injection_points_attach('backend-initialize-v2-error', 'error');" + ) + harness.connect_test( + "user=testuser gssencmode=prefer sslmode=disable", + "connect, v2error, reconnect, v2error -> fail", + ) + node.restart() + + admin_psql( + "SELECT injection_points_attach('backend-gssapi-startup', 'error');" + ) + harness.connect_test( + "user=testuser gssencmode=prefer sslmode=disable", + "connect, gssaccept, backenderror, reconnect, authok -> plain", + ) + node.restart() + + ### + ### Tests with both GSS and SSL enabled in the server + ### + if SSL_SUPPORTED and GSS_SUPPORTED and KERBEROS_ENABLED: + # Sanity check that GSSAPI is still enabled from previous test. + harness.connect_test( + "user=testuser gssencmode=prefer sslmode=prefer", + "connect, gssaccept, authok -> gss", + ) + + # Enable SSL + node.append_conf("ssl = on\n") + node.reload() + + test_table = r""" +# USER GSSENCMODE SSLMODE SSLNEGOTIATION EVENTS -> OUTCOME +testuser disable disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslaccept, authok -> ssl +. . require postgres connect, sslaccept, authok -> ssl +. . . direct connect, directsslaccept, authok -> ssl +. prefer disable postgres connect, gssaccept, authok -> gss +. . allow postgres connect, gssaccept, authok -> gss +. . prefer postgres connect, gssaccept, authok -> gss +. . require postgres connect, gssaccept, authok -> gss # If both GSS and SSL is possible, GSS is chosen over SSL, even if sslmode=require +. . . direct connect, gssaccept, authok -> gss +. require disable postgres connect, gssaccept, authok -> gss +. . allow postgres connect, gssaccept, authok -> gss +. . prefer postgres connect, gssaccept, authok -> gss +. . require postgres connect, gssaccept, authok -> gss # If both GSS and SSL is possible, GSS is chosen over SSL, even if sslmode=require +. . . direct connect, gssaccept, authok -> gss + +gssuser disable disable postgres connect, authfail -> fail +. . allow postgres connect, authfail, reconnect, sslaccept, authfail -> fail +. . prefer postgres connect, sslaccept, authfail, reconnect, authfail -> fail +. . require postgres connect, sslaccept, authfail -> fail +. . . direct connect, directsslaccept, authfail -> fail +. prefer disable postgres connect, gssaccept, authok -> gss +. . allow postgres connect, gssaccept, authok -> gss +. . prefer postgres connect, gssaccept, authok -> gss +. . require postgres connect, gssaccept, authok -> gss # GSS is chosen over SSL, even though sslmode=require +. . . direct connect, gssaccept, authok -> gss +. require disable postgres connect, gssaccept, authok -> gss +. . allow postgres connect, gssaccept, authok -> gss +. . prefer postgres connect, gssaccept, authok -> gss +. . require postgres connect, gssaccept, authok -> gss # If both GSS and SSL is possible, GSS is chosen over SSL, even if sslmode=require +. . . direct connect, gssaccept, authok -> gss + +ssluser disable disable postgres connect, authfail -> fail +. . allow postgres connect, authfail, reconnect, sslaccept, authok -> ssl +. . prefer postgres connect, sslaccept, authok -> ssl +. . require postgres connect, sslaccept, authok -> ssl +. . . direct connect, directsslaccept, authok -> ssl +. prefer disable postgres connect, gssaccept, authfail, reconnect, authfail -> fail +. . allow postgres connect, gssaccept, authfail, reconnect, authfail, reconnect, sslaccept, authok -> ssl +. . prefer postgres connect, gssaccept, authfail, reconnect, sslaccept, authok -> ssl +. . require postgres connect, gssaccept, authfail, reconnect, sslaccept, authok -> ssl +. . . direct connect, gssaccept, authfail, reconnect, directsslaccept, authok -> ssl +. require disable postgres connect, gssaccept, authfail -> fail +. . allow postgres connect, gssaccept, authfail -> fail +. . prefer postgres connect, gssaccept, authfail -> fail +. . require postgres connect, gssaccept, authfail -> fail # If both GSS and SSL are required, the sslmode=require is effectively ignored and GSS is required +. . . direct connect, gssaccept, authfail -> fail + +nogssuser disable disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslaccept, authok -> ssl +. . require postgres connect, sslaccept, authok -> ssl +. . . direct connect, directsslaccept, authok -> ssl +. prefer disable postgres connect, gssaccept, authfail, reconnect, authok -> plain +. . allow postgres connect, gssaccept, authfail, reconnect, authok -> plain +. . prefer postgres connect, gssaccept, authfail, reconnect, sslaccept, authok -> ssl +. . require postgres connect, gssaccept, authfail, reconnect, sslaccept, authok -> ssl +. . . direct connect, gssaccept, authfail, reconnect, directsslaccept, authok -> ssl +. require disable postgres connect, gssaccept, authfail -> fail +. . allow postgres connect, gssaccept, authfail -> fail +. . prefer postgres connect, gssaccept, authfail -> fail +. . require postgres connect, gssaccept, authfail -> fail # If both GSS and SSL are required, the sslmode=require is effectively ignored and GSS is required +. . . direct connect, gssaccept, authfail -> fail + +nossluser disable disable postgres connect, authok -> plain +. . allow postgres connect, authok -> plain +. . prefer postgres connect, sslaccept, authfail, reconnect, authok -> plain +. . require postgres connect, sslaccept, authfail -> fail +. . . direct connect, directsslaccept, authfail -> fail +. prefer * postgres connect, gssaccept, authok -> gss +. . require direct connect, gssaccept, authok -> gss +. require * postgres connect, gssaccept, authok -> gss +. . require direct connect, gssaccept, authok -> gss + +# sslnegotiation=direct is not accepted unless sslmode=require or stronger +* * disable direct - -> fail +* * allow direct - -> fail +* * prefer direct - -> fail +""" + + # note: Running tests with both GSS and SSL enabled in server + _test_matrix( + harness, + ["testuser", "gssuser", "ssluser", "nogssuser", "nossluser"], + ALL_GSSENCMODES, + ALL_SSLMODES, + ALL_SSLNEGOTIATIONS, + _parse_table(test_table), + ) + + ### + ### Test negotiation over unix domain sockets. + ### + if unixdir != "": + # libpq doesn't attempt SSL or GSSAPI over Unix domain sockets. The + # server would reject them too. + harness.connect_test( + f"user=localuser gssencmode=prefer sslmode=prefer host={unixdir}", + "connect, authok -> plain", + ) + harness.connect_test( + f"user=localuser gssencmode=require sslmode=prefer host={unixdir}", + "- -> fail", + ) + + # Report all accumulated failures at once. + assert not harness.failures, ( + f"{len(harness.failures)} of {harness.count} negotiation cases failed:\n" + + "\n".join(harness.failures) + ) diff --git a/src/interfaces/libpq/pyt/test_006_service.py b/src/interfaces/libpq/pyt/test_006_service.py new file mode 100644 index 00000000000..0401c375064 --- /dev/null +++ b/src/interfaces/libpq/pyt/test_006_service.py @@ -0,0 +1,335 @@ +# Copyright (c) 2025-2026, PostgreSQL Global Development Group + +"""Tests scenarios related to the service name and the service file. + +Covers the connection options and their environment variables (PGSERVICE / +PGSERVICEFILE / PGSYSCONFDIR, and the "service" / "servicefile" connection +keywords). + +Connections are made with a psql subprocess so that the service environment +variables are inherited the way a real client sees them; a successful +connection runs the SELECT and checks its output, a failed connection exits +non-zero with an error message we match. (An in-process libpq connection is +not used here: the in-process library does not portably read the environment, +and on Windows it cannot connect to the server's socket from this process.) + +The service file points at the real, started ``pg`` server. The framework's +environment setup clears the PG* connection variables, so the only connection +information in play comes from the service file / service keywords under test. +""" + +import getpass +import os +import re +import shutil +from contextlib import contextmanager + +import pytest + +# The login role: the cluster uses trust auth, so any user connects. We pin it +# explicitly in every connection string so that nothing has to inject a +# "user=" keyword (which would corrupt the URI-form connection strings). +USER = getpass.getuser() + + +@contextmanager +def _env(**overrides): + """Temporarily set/unset environment variables, restoring on exit. + + A value of None unsets the variable for the duration of the block. + """ + saved = {} + try: + for key, val in overrides.items(): + saved[key] = os.environ.get(key) + if val is None: + os.environ.pop(key, None) + else: + os.environ[key] = val + yield + finally: + for key, val in saved.items(): + if val is None: + os.environ.pop(key, None) + else: + os.environ[key] = val + + +def _kw_user(connstr): + """Append a user= keyword to a (keyword-form) connection string.""" + return (connstr + f" user='{USER}'").strip() + + +def _uri_user(uri): + """Append a user query parameter to a URI-form connection string.""" + sep = "&" if "?" in uri else "?" + return f"{uri}{sep}user={USER}" + + +def _psql(node, connstr, sql): + """Run psql with *connstr* verbatim and return ``(ok, stdout, stderr)``. + + The connection string is passed as-is (no host/port prepended), so the + service / servicefile under test fully determines the connection target. + psql is a subprocess, so it inherits PGSERVICE / PGSERVICEFILE / + PGSYSCONFDIR from the environment. + """ + res = node.pg_bin.result( + ["psql", "-w", "-X", "-A", "-q", "-t", "-d", connstr, "-c", sql] + ) + return res.returncode == 0, res.stdout, res.stderr + + +def _connect_ok(node, connstr, expected, **env): + """Assert psql connects with *connstr* and its output matches *expected*.""" + with _env(**env): + ok, out, err = _psql(node, connstr, f"SELECT '{expected}'") + assert ok, f"connection should succeed for {connstr!r}: got {err!r}" + assert re.search(expected, out), f"stdout matches for {connstr!r}: got {out!r}" + + +def _connect_fails(node, connstr, pattern, **env): + """Assert psql fails to connect with *connstr* and *pattern* is in the error.""" + with _env(**env): + ok, _out, err = _psql(node, connstr, "SELECT 1") + assert not ok, f"connection should fail for {connstr!r}" + assert re.search( + pattern, err + ), f"error matches /{pattern}/ for {connstr!r}: got {err!r}" + + +def _connect_servicefile_is(node, connstr, expected_servicefile, **env): + """Assert psql connects and the service file it resolved matches. + + psql exposes the service file libpq actually used as the :SERVICEFILE + variable. + """ + with _env(**env): + ok, out, err = _psql(node, connstr, r"\echo :SERVICEFILE") + assert ok, f"connection should succeed for {connstr!r}: got {err!r}" + actual = out.strip() + assert actual == expected_servicefile, ( + f"resolved servicefile for {connstr!r}: expected " + f"{expected_servicefile!r}, got {actual!r}" + ) + + +@pytest.fixture +def service_setup(pg, tmp_path): + """Build the set of service files used by the tests, pointing at ``pg``. + + Returns a dict of the paths plus the base environment (PGSYSCONFDIR and a + default empty PGSERVICEFILE) that every test starts from. + """ + td = tmp_path + + # File that includes a valid service name, using a decomposed connection + # string for its contents (one parameter per line). The connection + # parameters are written unquoted, as a service file gives each value + # verbatim to the right of the "=". + srvfile_valid = td / "pg_service_valid.conf" + lines = [ + "[my_srv]", + f"host={pg.host}", + f"port={pg.port}", + "dbname=postgres", + ] + srvfile_valid.write_text("\n".join(lines) + "\n") + + # File defined with no contents, used as the default value for + # PGSERVICEFILE so that no lookup is attempted in the user's home dir. + srvfile_empty = td / "pg_service_empty.conf" + srvfile_empty.write_text("") + + # Default service file in PGSYSCONFDIR. + srvfile_default = td / "pg_service.conf" + + # Missing service file. + srvfile_missing = td / "pg_service_missing.conf" + + # Service file with a nested "service" defined. + srvfile_nested = td / "pg_service_nested.conf" + shutil.copy(srvfile_valid, srvfile_nested) + with open(srvfile_nested, "a", encoding="utf-8") as fh: + fh.write("service=invalid_srv\n") + + # Service file with a nested "servicefile" defined. + srvfile_nested_2 = td / "pg_service_nested_2.conf" + shutil.copy(srvfile_valid, srvfile_nested_2) + with open(srvfile_nested_2, "a", encoding="utf-8") as fh: + fh.write(f"servicefile={srvfile_default}\n") + + # Use forward slashes for every path. Several of these go into connection + # strings as a servicefile= value, where libpq treats backslash as an + # escape character and would mangle a Windows path; forward slashes are a + # valid path separator on Windows for files, environment values and + # libpq's own servicefile bookkeeping, so they work everywhere. + def fwd(path): + return str(path).replace("\\", "/") + + return { + "td": fwd(td), + "valid": fwd(srvfile_valid), + "empty": fwd(srvfile_empty), + "default": fwd(srvfile_default), + "missing": fwd(srvfile_missing), + "nested": fwd(srvfile_nested), + "nested_2": fwd(srvfile_nested_2), + # PGSYSCONFDIR is the fallback directory lookup of the service file. + # PGSERVICEFILE is forced to a default (empty) location so the test + # never looks at a home directory. + "base_env": {"PGSYSCONFDIR": fwd(td), "PGSERVICEFILE": fwd(srvfile_empty)}, + "node": pg, + } + + +def test_service_with_pgservicefile(service_setup): + """Combinations of service name and a valid service file via PGSERVICEFILE.""" + s = service_setup + node = s["node"] + env = dict(s["base_env"], PGSERVICEFILE=s["valid"]) + + _connect_ok(node, _kw_user("service=my_srv"), "connect1_1", **env) + _connect_ok(node, _uri_user("postgres://?service=my_srv"), "connect1_2", **env) + _connect_fails( + node, + _kw_user("service=undefined-service"), + r'definition of service "undefined-service" not found', + **env, + ) + + _connect_ok(node, _kw_user(""), "connect1_3", **dict(env, PGSERVICE="my_srv")) + _connect_fails( + node, + _kw_user(""), + r'definition of service "undefined-service" not found', + **dict(env, PGSERVICE="undefined-service"), + ) + + +def test_service_with_incorrect_pgservicefile(service_setup): + """Incorrect (missing) service file referenced by PGSERVICEFILE.""" + s = service_setup + env = dict(s["base_env"], PGSERVICEFILE=s["missing"]) + _connect_fails( + s["node"], + _kw_user("service=my_srv"), + r'service file ".*pg_service_missing\.conf" not found', + **env, + ) + + +def test_service_with_default_pg_service_conf(service_setup): + """Service file named "pg_service.conf" found in PGSYSCONFDIR.""" + s = service_setup + node = s["node"] + # Create copy of the valid file at the default PGSYSCONFDIR location. + shutil.copy(s["valid"], s["default"]) + try: + env = dict(s["base_env"]) # PGSERVICEFILE stays at the empty default + _connect_ok(node, _kw_user("service=my_srv"), "connect2_1", **env) + _connect_ok(node, _uri_user("postgres://?service=my_srv"), "connect2_2", **env) + _connect_fails( + node, + _kw_user("service=undefined-service"), + r'definition of service "undefined-service" not found', + **env, + ) + _connect_ok(node, _kw_user(""), "connect2_3", **dict(env, PGSERVICE="my_srv")) + # The given servicefile (empty) does not define the service, so it is + # found in the default pg_service.conf; libpq then reports the default + # file as the resolved servicefile. + _connect_servicefile_is( + node, + _kw_user(f"service=my_srv servicefile='{s['empty']}'"), + s["default"], + **env, + ) + _connect_fails( + node, + _kw_user(""), + r'definition of service "undefined-service" not found', + **dict(env, PGSERVICE="undefined-service"), + ) + finally: + os.unlink(s["default"]) + + +def test_service_nested(service_setup): + """Nested "service" / "servicefile" specifications are rejected.""" + s = service_setup + node = s["node"] + + _connect_fails( + node, + _kw_user("service=my_srv"), + r'nested "service" specifications not supported in service file', + **dict(s["base_env"], PGSERVICEFILE=s["nested"]), + ) + _connect_fails( + node, + _kw_user("service=my_srv"), + r'nested "servicefile" specifications not supported in service file', + **dict(s["base_env"], PGSERVICEFILE=s["nested_2"]), + ) + + +def test_servicefile_option(service_setup): + """The "servicefile" connection option works in keyword and URI forms.""" + s = service_setup + node = s["node"] + env = dict(s["base_env"]) # PGSERVICEFILE stays at the empty default + + # No backslash escaping needed on non-Windows (paths use forward slashes). + valid = s["valid"] + + _connect_ok( + node, + _kw_user(f"service=my_srv servicefile='{valid}'"), + "connect3_1", + **env, + ) + + # Encode slashes (and backslash, and colon) for the URI form. + encoded = valid.replace("\\", "%5C").replace("/", "%2F").replace(":", "%3A") + + _connect_ok( + node, + _uri_user(f"postgresql:///?service=my_srv&servicefile={encoded}"), + "connect3_2", + **env, + ) + + _connect_ok( + node, + _kw_user(f"servicefile='{valid}'"), + "connect3_3", + **dict(env, PGSERVICE="my_srv"), + ) + _connect_ok( + node, + _uri_user(f"postgresql://?servicefile={encoded}"), + "connect3_4", + **dict(env, PGSERVICE="my_srv"), + ) + + +def test_servicefile_option_priority(service_setup): + """The "servicefile" option takes priority over PGSERVICEFILE.""" + s = service_setup + node = s["node"] + valid = s["valid"] + env = dict(s["base_env"], PGSERVICEFILE="non-existent-file.conf") + + _connect_fails( + node, + _kw_user("service=my_srv"), + r'service file "non-existent-file\.conf" not found', + **env, + ) + _connect_ok( + node, + _kw_user(f"service=my_srv servicefile='{valid}'"), + "connect4_1", + **env, + ) diff --git a/src/test/icu/meson.build b/src/test/icu/meson.build index d2cff55220a..8356d3b3a1b 100644 --- a/src/test/icu/meson.build +++ b/src/test/icu/meson.build @@ -10,4 +10,10 @@ tests += { ], 'env': {'with_icu': icu.found() ? 'yes' : 'no'}, }, + 'pytest': { + 'tests': [ + 'pyt/test_010_database.py', + ], + 'env': {'with_icu': icu.found() ? 'yes' : 'no'}, + }, } diff --git a/src/test/icu/pyt/test_010_database.py b/src/test/icu/pyt/test_010_database.py new file mode 100644 index 00000000000..569c6a1b336 --- /dev/null +++ b/src/test/icu/pyt/test_010_database.py @@ -0,0 +1,85 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +"""Tests for ICU-based database collations.""" + +import pytest + + +def test_010_database(create_pg): + node1 = create_pg("node1") + + # This test requires a build configured --with-icu. Ask the server + # whether any ICU collation provider is available. + if ( + node1.safe_sql("SELECT count(*) > 0 FROM pg_collation WHERE collprovider = 'i'") + != "t" + ): + pytest.skip("ICU not supported by this build") + + # CREATE DATABASE cannot run inside a transaction block, so it is its own + # statement. + node1.safe_sql( + "CREATE DATABASE dbicu LOCALE_PROVIDER icu LOCALE 'C' " + "ICU_LOCALE 'en@colCaseFirst=upper' ENCODING 'UTF8' TEMPLATE template0" + ) + + node1.safe_sql( + """ +CREATE COLLATION upperfirst (provider = icu, locale = 'en@colCaseFirst=upper'); +CREATE TABLE icu (def text, en text COLLATE "en-x-icu", upfirst text COLLATE upperfirst); +INSERT INTO icu VALUES ('a', 'a', 'a'), ('b', 'b', 'b'), ('A', 'A', 'A'), ('B', 'B', 'B'); +""", + dbname="dbicu", + ) + + assert ( + node1.safe_sql("SELECT icu_unicode_version() IS NOT NULL", dbname="dbicu") + == "t" + ), "ICU unicode version defined" + + assert ( + node1.safe_sql("SELECT def FROM icu ORDER BY def", dbname="dbicu") + == "A\na\nB\nb" + ), "sort by database default locale" + + assert ( + node1.safe_sql( + 'SELECT def FROM icu ORDER BY def COLLATE "en-x-icu"', dbname="dbicu" + ) + == "a\nA\nb\nB" + ), "sort by explicit collation standard" + + assert ( + node1.safe_sql( + "SELECT def FROM icu ORDER BY en COLLATE upperfirst", dbname="dbicu" + ) + == "A\na\nB\nb" + ), "sort by explicit collation upper first" + + # Test that LOCALE='C' works for ICU + res = node1.sql( + "CREATE DATABASE dbicu1 LOCALE_PROVIDER icu LOCALE 'C' " + "TEMPLATE template0 ENCODING UTF8" + ) + assert res.error_message is None, "C locale works for ICU" + + # Test that LOCALE works for ICU locales if LC_COLLATE and LC_CTYPE + # are specified + res = node1.sql( + "CREATE DATABASE dbicu2 LOCALE_PROVIDER icu LOCALE '@colStrength=primary' " + "LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0 ENCODING UTF8" + ) + assert ( + res.error_message is None + ), "LOCALE works for ICU locales if LC_COLLATE and LC_CTYPE are specified" + + res = node1.sql( + "CREATE DATABASE dbicu3 LOCALE_PROVIDER builtin LOCALE 'C' TEMPLATE dbicu" + ) + assert ( + res.error_message is not None + ), "locale provider must match template: exit code not 0" + assert ( + "new locale provider (builtin) does not match locale provider " + "of the template database (icu)" in res.error_message + ), "locale provider must match template: error message" diff --git a/src/tools/pg_bsd_indent/meson.build b/src/tools/pg_bsd_indent/meson.build index 3d292e8febb..16c657f0a72 100644 --- a/src/tools/pg_bsd_indent/meson.build +++ b/src/tools/pg_bsd_indent/meson.build @@ -38,4 +38,9 @@ tests += { 't/001_pg_bsd_indent.pl', ], }, + 'pytest': { + 'tests': [ + 'pyt/test_001_pg_bsd_indent.py', + ], + }, } diff --git a/src/tools/pg_bsd_indent/pyt/test_001_pg_bsd_indent.py b/src/tools/pg_bsd_indent/pyt/test_001_pg_bsd_indent.py new file mode 100644 index 00000000000..fb3feb7c237 --- /dev/null +++ b/src/tools/pg_bsd_indent/pyt/test_001_pg_bsd_indent.py @@ -0,0 +1,67 @@ +# Copyright (c) 2017-2026, PostgreSQL Global Development Group + +"""Run pg_bsd_indent over a set of pre-fab test cases and check its output. + +Runs the build-dir program ``pg_bsd_indent`` over a set of pre-fab test cases +(taken from FreeBSD upstream) and compares its output against the expected +``*.0.stdout`` files. +""" + +import glob +import os +import subprocess + +import pytest + +# The input source files (*.0), expected outputs (*.0.stdout), profiles (*.pro) +# and type lists (*.list) all live in the module's tests/ directory, a sibling +# of pyt/. +TESTS_DIR = os.path.join(os.path.dirname(__file__), os.pardir, "tests") + +# Test basenames: every *.0 input file in tests/. +TEST_CASES = sorted( + os.path.basename(p)[:-2] for p in glob.glob(os.path.join(TESTS_DIR, "*.0")) +) + + +def test_version(pg_bin): + """pg_bsd_indent knows --version but not much else.""" + pg_bin.program_version_ok("pg_bsd_indent") + + +@pytest.mark.parametrize("test", TEST_CASES) +def test_001_pg_bsd_indent(pg_bin, test, tmp_path): + test_src = os.path.join(TESTS_DIR, f"{test}.0") + # Write the indented output under tmp_path, not into the source tree. + out_file = str(tmp_path / f"{test}.out") + pro_file = os.path.join(TESTS_DIR, f"{test}.pro") + + # Run pg_bsd_indent on the pre-fab test case. We run with cwd set to the + # tests directory so that *.pro files can reference *.list files by their + # bare name (e.g. types_from_file.pro uses -Utypes_from_file.list). We + # always pass -P.pro even when no such file exists; pg_bsd_indent + # tolerates a missing profile. + saved_cwd = os.getcwd() + os.chdir(TESTS_DIR) + try: + pg_bin.command_ok( + ["pg_bsd_indent", test_src, out_file, f"-P{pro_file}"], + f"pg_bsd_indent succeeds on {test}", + ) + finally: + os.chdir(saved_cwd) + + # Check the result matches the expected output. + with open(f"{test_src}.stdout", encoding="utf-8") as f: + expected = f.read() + with open(out_file, encoding="utf-8") as f: + actual = f.read() + + if expected != actual: + diff = subprocess.run( + ["diff", "-U3", f"{test_src}.stdout", out_file], + stdout=subprocess.PIPE, + text=True, + check=False, + ).stdout + pytest.fail(f"pg_bsd_indent output does not match for {test}\n{diff}") From 96d98342a61f29a25d020a2f1f5e99979e950629 Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Mon, 8 Jun 2026 08:26:54 -0400 Subject: [PATCH 15/18] python tests: add README for the pytest/session framework Document the Python port of the Perl TAP suite: layout, how to run the tests under meson and directly with pytest, the shared fixtures, and the PostgresServer/Session/PgBin framework classes. --- src/test/pytest/README.md | 274 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 src/test/pytest/README.md diff --git a/src/test/pytest/README.md b/src/test/pytest/README.md new file mode 100644 index 00000000000..c8d80a45496 --- /dev/null +++ b/src/test/pytest/README.md @@ -0,0 +1,274 @@ +# PostgreSQL Python tests (pytest) + +This tree holds the Python port of PostgreSQL's Perl TAP test suite. Tests are +written for [pytest](https://pytest.org) and run against a real PostgreSQL +server that the harness initializes, starts, and tears down for you. Queries go +through an **in-process libpq binding** (ctypes) rather than by spawning `psql`, +so a test can open many sessions, drive async and pipeline-mode traffic, and +inspect results without subprocess overhead. + +The framework deliberately mirrors the concepts of `PostgreSQL::Test::Cluster` +and `PostgreSQL::Test::Utils`, so a Perl `.pl` test maps fairly directly onto a +Python `test_*.py`. + +## Layout + +``` +src/test/pytest/ this directory -- the shared framework +├── pyproject.toml pytest config (also picked up from the repo root) +├── pgtap.py pytest plugin: emits TAP for the meson harness +├── pyt/ self-tests for the framework itself +├── libpq/ in-process libpq binding +│ ├── bindings.py ctypes declarations for the PQ* functions +│ ├── findlib.py locate/load libpq at runtime +│ ├── session.py Session: the connection + query API +│ ├── result.py ResultData: status, columns, rows, psql-style text +│ ├── constants.py enums (ExecStatusType, ConnStatusType, ...) +│ ├── pgnotify.py LISTEN/NOTIFY payload parsing +│ └── oids.py / errors.py type OIDs and exception types +└── pypg/ server + process management ("Cluster"/"Utils") + ├── server.py PostgresServer: init/start/stop/backup/replication + ├── command.py PgBin / CommandResult: run & assert on client programs + ├── fixtures.py the shared pytest fixtures (loaded as a plugin) + ├── util.py slurp_file, poll_until, TIMEOUT_DEFAULT + ├── regress.py pg_regress integration + └── ldapserver.py / kerberos.py / oauthserver.py / ssl_server.py +``` + +The actual tests live in `pyt/` subdirectories next to the code they cover, the +same way Perl TAP tests live in `t/`: + +``` +src/bin/psql/pyt/test_001_basic.py +src/bin/pg_rewind/pyt/test_001_basic.py +src/test/authentication/pyt/test_001_password.py +contrib//pyt/... +``` + +### Naming and discovery + +* Test files are `test_NNN_.py` (`test_001_basic.py`), matching the + `NNN_name.pl` numbering of the Perl originals. +* Test functions are `test_(...)`; pytest collects them automatically. +* Helper functions are prefixed with `_` so pytest does not collect them. +* `--import-mode=importlib` is set so that identically named files in different + directories (the many `test_001_basic.py`) do not collide. + +## Running the tests + +### With meson (the CI / harness path) + +The suite is wired into the meson build as test groups using the TAP protocol. +pytest is auto-detected: in the default `auto` mode the suite is enabled when a +usable `pytest` is found (preferring a `pytest` program on `PATH`, falling back +to `python -m pytest`). Force it on or off with `-Dpytest=enabled|disabled`, +and override the interpreter discovery with `-DPYTEST=...`. + +```bash +# build, then run a single pytest group (suite == the test_dir 'name'): +meson test -C --suite setup # once, to stage tmp_install +meson test -C --suite pg_rewind +meson test -C pytest # the framework self-tests +``` + +Under meson the harness sets `TESTLOGDIR`; the `pgtap` plugin then redirects +pytest's own chatter to `$TESTLOGDIR/pytest.log` and writes a clean TAP stream +(a `1..N` plan plus one `ok`/`not ok` per test) to stdout, which is what meson's +`protocol: 'tap'` consumes. The harness also prepends the temporary install's +`bin` to `PATH` (so `pg_config`, `initdb`, etc. resolve there) and adds this +directory to `PYTHONPATH`. + +### Directly with pytest (the developer path) + +When `TESTLOGDIR` is unset the plugin stays out of the way and pytest prints +normally. You only need `pg_config` (and the matching binaries) on `PATH` and +the framework importable. The repo-root `pyproject.toml` already sets +`pythonpath` and the required plugins, so from the repo root: + +```bash +# point at the build you want to test: +export PATH=/tmp_install/usr/local/pgsql/bin:$PATH + +pytest src/test/pytest/pyt/ # framework self-tests +pytest src/bin/pg_rewind/pyt/ -v # one suite, verbose +pytest src/test/pytest/pyt/test_libpq.py::test_pipeline # one test +``` + +`pytest` discovers `pyproject.toml` by walking up from the current directory, so +running from the repo root (or anywhere beneath it) picks up the right config: + +```toml +[tool.pytest.ini_options] +pythonpath = ["src/test/pytest"] # make libpq/ and pypg/ importable +addopts = ["-p", "pgtap", "-p", "pypg.fixtures", "--import-mode=importlib"] +python_files = ["test_*.py"] +``` + +### Requirements + +* Python 3 and `pytest` (`minversion = 7.0`). +* **No database driver** — `libpq` is bound directly via the stdlib `ctypes` + module, so psycopg/asyncpg are not needed. +* `pexpect` is an *optional* dependency, used only by tests that drive a real + terminal (e.g. psql tab-completion). Those tests `pytest.importorskip( + "pexpect")` and skip themselves when it is missing. +* External-service tests (LDAP/`slapd`, MIT Kerberos, OAuth, OpenSSL) skip + automatically when the supporting software or build option is absent. + +### Environment variables + +| Variable | Meaning | +|----------|---------| +| `PG_TEST_EXTRA` | Space-separated opt-in for expensive/unsafe suites (`ssl`, `ldap`, `kerberos`, ...). Tests that are not in the list `pytest.skip` themselves. | +| `PG_TEST_TIMEOUT_DEFAULT` | Default per-operation timeout in seconds (default `180`); backs `pypg.util.TIMEOUT_DEFAULT` and the polling helpers. | +| `TESTLOGDIR` | Set by the meson harness; switches the `pgtap` plugin into TAP-emitting mode. | + +## The session framework + +### Fixtures (`pypg.fixtures`, loaded via `-p pypg.fixtures`) + +These are the building blocks almost every test starts from. Servers and helper +processes are torn down automatically at the end of the test. + +| Fixture | Scope | What you get | +|---------|-------|--------------| +| `pg_config`, `bindir`, `libdir` | session | located `pg_config` and its reported dirs | +| `pg_bin` | session | a `PgBin` for running client programs that need no server | +| `create_pg` | function | factory: `create_pg(name="main", *, start=True, initdb_extra=None, allows_streaming=False, has_archiving=False, has_restoring=False)` → `PostgresServer` | +| `pg` | function | a single, started `PostgresServer` (`create_pg("main")`) | +| `conn` | function | a libpq `Session` on `pg`'s `postgres` database | +| `ldap_server`, `kerberos`, `oauth_server`, `ssl_server` | function | factories for the matching external services; skip when unavailable | + +A minimal test: + +```python +def test_oneval(conn): + assert conn.query_oneval("SELECT 1") == "1" + +def test_two_servers(create_pg): + primary = create_pg("primary", allows_streaming=True) + primary.backup("my_backup") + standby = create_pg("standby", start=False) + standby.init_from_backup(primary, "my_backup", has_streaming=True) + standby.start() + primary.wait_for_catchup("standby") +``` + +### `PostgresServer` (`pypg.server`) — the "Cluster" + +Created via the `create_pg` fixture. Highlights (see the source for the full +list and exact signatures): + +* **Lifecycle:** `init(...)`, `start()`, `stop(mode="fast")`, `restart()`, + `reload()`, `promote()`, `kill9()`, `teardown()`, `postmaster_pid()`. +* **Config:** `append_conf(text, filename="postgresql.conf")`, + `enable_archiving()`, `enable_streaming(root)`, `enable_restoring(root)`, + `set_standby_mode()`, `set_recovery_mode()`. +* **Queries (in-process libpq):** `session(dbname="postgres")` (cached), + `connect(...)` (uncached), `sql(query)` → `ResultData`, `safe_sql(query)` → + text (raises on error), `poll_query_until(query, expected="t", ...)`. +* **Backup & replication:** `backup()`, `backup_fs_cold()`, + `init_from_backup(...)`, `lsn(mode)`, `wait_for_catchup(...)`, + `wait_for_replay_catchup(...)`, `wait_for_subscription_sync(...)`, + `wait_for_event(...)`. +* **WAL:** `emit_wal(size)`, `advance_wal(n)`, `write_wal(...)`. +* **Logs:** `log_content()`, `log_position()`, `log_contains(pat, offset)`, + `wait_for_log(pat, offset)`, `log_check(...)`. +* **Auth assertions:** `connect_ok(...)`, `connect_fails(...)`. +* **Client-program assertions** (delegating to `PgBin`): `command_ok`, + `command_fails`, `command_like`, `command_fails_like`, `command_exit_is`, + `command_checks_all`, `issues_sql_like`, `issues_sql_unlike`. + +### `Session` (`libpq.session`) — the connection + +The in-process equivalent of a `psql` session. Returned by +`PostgresServer.session()` / `.connect()` and by the `conn` fixture. + +* **Synchronous:** `do(*sql)`, `query(sql)` → `ResultData`, `query_safe(sql)`, + `query_oneval(sql, missing_ok=False)`, `query_tuples(*sql)`. +* **Asynchronous:** `do_async(sql)`, `get_async_result()`, + `try_get_async_result()`, `wait_for_completion()`, + `wait_for_async_pattern(pattern, timeout)`. +* **Pipeline mode:** `enterPipelineMode()`, `exitPipelineMode()`, + `pipelineSync()`, `query_tuples_pipelined(*queries)`. +* **LISTEN/NOTIFY:** `get_notification()`, `get_all_notifications()`. +* **Notices / errors / stderr:** `get_notices()`, `clear_notices()`, + `get_stderr()`, `clear_stderr()`. +* **Lifecycle / introspection:** `wait_connect()`, `reconnect()`, `close()`, + `backend_pid()`, `conn_status()`, `connstr`, `conninfo_value(keyword)`. + +`query()` returns a `ResultData` dataclass: + +```python +@dataclass +class ResultData: + status: int # an ExecStatusType + error_message: Optional[str] + names: List[str] # column names + types: List[int] # column type OIDs + rows: List[List[Optional[str]]] # values as text, NULL -> None + psqlout: str # "-A -t" style rendering +``` + +```python +res = conn.query("SELECT n, s FROM (VALUES (1,'a'),(2,'b')) t(n,s) ORDER BY n") +assert res.names == ["n", "s"] +assert res.rows == [["1", "a"], ["2", "b"]] +assert res.psqlout == "1|a\n2|b" +``` + +### `PgBin` / `CommandResult` (`pypg.command`) — running client programs + +For exercising client executables (`pg_dump`, `pg_basebackup`, `psql`, ...): + +```python +@dataclass +class CommandResult: + returncode: int + stdout: str + stderr: str +``` + +`PgBin(bindir, extra_env=None)` runs a command and asserts on the outcome: +`result(cmd)`, `command_ok(cmd, msg)`, `command_fails(cmd, msg)`, +`command_exit_is(cmd, code, msg)`, `command_like(cmd, pattern, msg)`, +`command_fails_like(cmd, pattern, msg)`, +`command_checks_all(cmd, expected_ret, stdout_res, stderr_res, msg)`, plus the +`program_help_ok` / `program_version_ok` / `program_options_handling_ok` +boilerplate checks. + +### Utilities (`pypg.util`) + +`slurp_file(path, offset=0)`, `append_to_file(path, text)`, and +`poll_until(predicate, timeout=TIMEOUT_DEFAULT, interval=0.1)` — the building +block behind every `wait_for_*` / `poll_query_until` helper. + +## Per-suite fixtures (conftest.py) + +A `pyt/` directory may add its own `conftest.py` with fixtures specific to that +suite. Examples already in the tree: + +* **psql** (`src/bin/psql/pyt/conftest.py`): `interactive_psql` drives a real + psql under a pty via `pexpect` (`InteractivePsql.query_until(until, send)`), + `pytest.importorskip("pexpect")`-skipping when pexpect is absent. +* **pg_rewind** (`src/bin/pg_rewind/pyt/conftest.py`): a `rewind` fixture + yielding a `RewindTest` driver (`setup_cluster`, `start_primary`, + `create_standby`, `promote_standby`, `run_pg_rewind(mode)`, ...). +* **test_checksums** (`src/test/modules/test_checksums/pyt/conftest.py`): a + `checksums` helper for enabling/disabling and polling data-checksum state. + +Put fixtures that several suites share in `pypg/`; keep suite-specific ones in +that suite's `conftest.py`. + +## Writing a new test + +1. Create `…/pyt/test_NNN_.py` (and a `pyt/meson.build` adding the suite + to `tests` if the directory is new). +2. Start from the `pg` / `conn` / `create_pg` fixtures; reach for `pg_bin` to run + client programs. +3. Prefer the in-process `Session` over shelling out to `psql`. +4. Use `wait_for_*` / `poll_query_until` / `poll_until` instead of fixed sleeps. +5. Gate anything expensive or unsafe behind `PG_TEST_EXTRA`, and + `importorskip` / skip cleanly when an optional dependency is missing. +6. Run it directly with `pytest …/pyt/test_NNN_.py -v`, then confirm it + passes under `meson test`. From 3eb86bf24d08e35ef1129ad12d3606c9df6954b4 Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Fri, 12 Jun 2026 13:06:23 -0400 Subject: [PATCH 16/18] ci: run the pytest suite in CI Enable the pytest suite (-Dpytest=enabled) on all jobs. This needs pytest installed where the images do not already provide it: via MacPorts on macOS (plus pexpect for the interactive psql tests, which need a pty and so skip on Windows), via pip on the Windows VS image, and via pacman on MinGW. The AddressSanitizer job needs one accommodation: the suite loads libpq in-process via ctypes, and dlopening an ASan-instrumented libpq into an uninstrumented python aborts because the ASan runtime must come first in the link order. Preload the ASan runtime for the test step to satisfy that; it is a no-op for the already-instrumented server binaries. --- .github/workflows/pg-ci.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pg-ci.yml b/.github/workflows/pg-ci.yml index 8560e9389f6..8f1b49b39f5 100644 --- a/.github/workflows/pg-ci.yml +++ b/.github/workflows/pg-ci.yml @@ -88,6 +88,7 @@ env: -Dplperl=enabled -Dplpython=enabled -Dpltcl=enabled + -Dpytest=enabled -Dreadline=enabled -Dssl=openssl -Dtap_tests=enabled @@ -617,6 +618,15 @@ jobs: - name: Test world shell: *su_postgres_shell + # The pytest suite loads libpq in-process via ctypes. Here libpq is + # AddressSanitizer-instrumented, and ASan must come first in the link + # order; dlopening it into an otherwise uninstrumented python aborts + # with "ASan runtime does not come first". Preload the ASan runtime + # for the test run to satisfy that (a no-op for the already-instrumented + # server/client binaries). Scoped to this step so the build is + # unaffected; detect_leaks is already disabled via ASAN_OPTIONS. + env: + ADDITIONAL_SETUP: export LD_PRELOAD="$(gcc -print-file-name=libasan.so)" run: *meson_test_world_cmd - *linux_collect_cores_step @@ -659,6 +669,8 @@ jobs: openssl p5.34-io-tty p5.34-ipc-run + py312-pexpect + py312-pytest python312 tcl zstd @@ -815,6 +827,7 @@ jobs: -Dldap=enabled -Dplperl=enabled -Dplpython=enabled + -Dpytest=enabled -Dssl=openssl -Dtap_tests=enabled @@ -902,9 +915,11 @@ jobs: - name: Install dependencies shell: pwsh run: | - # meson is not preinstalled on windows-2022. Install via pip + # meson is not preinstalled on windows-2022. Install via pip. + # pytest enables the Python test suite (pexpect is omitted: it needs + # a pty, which Windows lacks, and the interactive tests importorskip). echo ::group::pip - python -m pip install --upgrade meson + python -m pip install --upgrade meson pytest if (!$?) { throw 'cmdfail' } echo ::endgroup:: @@ -1042,6 +1057,7 @@ jobs: ${MINGW_PACKAGE_PREFIX}-meson \ ${MINGW_PACKAGE_PREFIX}-perl \ ${MINGW_PACKAGE_PREFIX}-pkgconf \ + ${MINGW_PACKAGE_PREFIX}-python-pytest \ ${MINGW_PACKAGE_PREFIX}-readline \ ${MINGW_PACKAGE_PREFIX}-zlib \ ${MINGW_PACKAGE_PREFIX}-zstd From 5d9d957ac3c1ebb8007b0de17315319c39c40f35 Mon Sep 17 00:00:00 2001 From: Andrew Dunstan Date: Fri, 12 Jun 2026 13:06:24 -0400 Subject: [PATCH 17/18] ci: temporarily disable the Perl TAP tests While iterating on the Python pytest port, run only the pytest tests: the Perl TAP tests double the run time and disk usage (the macOS runner has been hitting "No space left on device"). tap_tests and pytest are independent meson test kinds, so -Dtap_tests=disabled skips the Perl tests while -Dpytest=enabled keeps the Python ones. The Linux meson jobs configure with MESON_COMMON_PG_CONFIG_ARGS and otherwise let features auto-detect, so they do not pick up the setting from MESON_COMMON_FEATURES; pass -Dtap_tests=disabled explicitly in their meson setup commands. This is a temporary CI-only change to be reverted before the work is finalized. --- .github/workflows/pg-ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pg-ci.yml b/.github/workflows/pg-ci.yml index 8f1b49b39f5..8bb2ef1bb91 100644 --- a/.github/workflows/pg-ci.yml +++ b/.github/workflows/pg-ci.yml @@ -91,7 +91,7 @@ env: -Dpytest=enabled -Dreadline=enabled -Dssl=openssl - -Dtap_tests=enabled + -Dtap_tests=disabled -Dzlib=enabled -Dzstd=enabled @@ -522,6 +522,7 @@ jobs: --pkg-config-path /usr/lib/i386-linux-gnu/pkgconfig/ \ -DPERL=perl5.40-i386-linux-gnu \ -Dlibnuma=disabled \ + -Dtap_tests=disabled \ build - name: Build @@ -608,6 +609,7 @@ jobs: -Duuid=e2fs \ --buildtype=debug \ -Dllvm=enabled \ + -Dtap_tests=disabled \ build - name: Build @@ -829,7 +831,7 @@ jobs: -Dplpython=enabled -Dpytest=enabled -Dssl=openssl - -Dtap_tests=enabled + -Dtap_tests=disabled defaults: run: From 18c46d8b1018571e5625669da8cef37371dd583a Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Tue, 16 Jun 2026 10:04:28 -0400 Subject: [PATCH 18/18] libpq: SQLSTATE-based error matching for query failures Extract the SQLSTATE from a failed result (PQresultErrorField, which bindings.py already declares) onto ResultData.sqlstate, carry it on QueryError, and add named QueryError subclasses (QueryCanceled, UniqueViolation, DeadlockDetected, ...) so a test can write `with pytest.raises(QueryCanceled):` instead of catching the generic QueryError and string-matching its message. query_safe raises the SQLSTATE-specific subclass via query_error_for(); every subclass remains catchable as QueryError / LibpqError, and an unmapped SQLSTATE still raises a plain QueryError. --- src/test/pytest/libpq/errors.py | 119 ++++++++++++++++++++++++++++++- src/test/pytest/libpq/result.py | 5 ++ src/test/pytest/libpq/session.py | 7 +- 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/test/pytest/libpq/errors.py b/src/test/pytest/libpq/errors.py index f61d0631b01..3b8f7540501 100644 --- a/src/test/pytest/libpq/errors.py +++ b/src/test/pytest/libpq/errors.py @@ -12,4 +12,121 @@ class PqConnectionError(LibpqError): class QueryError(LibpqError): - """Raised by the *_safe query helpers when a statement fails.""" + """Raised by the *_safe query helpers when a statement fails. + + ``sqlstate`` carries the five-character SQLSTATE from libpq when available + (None otherwise); ``sqlstate_class`` is its first two characters. These are + the stable, locale-independent way to assert on a specific error condition, + rather than matching against the human-readable message text. + """ + + def __init__(self, message, *, sqlstate=None): + super().__init__(message) + self.sqlstate = sqlstate + + @property + def sqlstate_class(self): + """The two-character SQLSTATE class, or None if no SQLSTATE is set.""" + if self.sqlstate and len(self.sqlstate) >= 2: + return self.sqlstate[:2] + return None + + +# Named QueryError subclasses for the SQLSTATEs tests most often assert on, so a +# test can write ``with pytest.raises(QueryCanceled):`` instead of catching the +# generic QueryError and then checking ``.sqlstate``. Each maps to its +# five-character SQLSTATE; query_error_for() picks the right class when raising. +class SyntaxErrorState(QueryError): + """42601 -- syntax_error.""" + + +class UndefinedTable(QueryError): + """42P01 -- undefined_table.""" + + +class UndefinedColumn(QueryError): + """42703 -- undefined_column.""" + + +class InsufficientPrivilege(QueryError): + """42501 -- insufficient_privilege.""" + + +class UniqueViolation(QueryError): + """23505 -- unique_violation.""" + + +class ForeignKeyViolation(QueryError): + """23503 -- foreign_key_violation.""" + + +class NotNullViolation(QueryError): + """23502 -- not_null_violation.""" + + +class CheckViolation(QueryError): + """23514 -- check_violation.""" + + +class SerializationFailure(QueryError): + """40001 -- serialization_failure.""" + + +class DeadlockDetected(QueryError): + """40P01 -- deadlock_detected.""" + + +class QueryCanceled(QueryError): + """57014 -- query_canceled.""" + + +class AdminShutdown(QueryError): + """57P01 -- admin_shutdown.""" + + +class CrashShutdown(QueryError): + """57P02 -- crash_shutdown.""" + + +class CannotConnectNow(QueryError): + """57P03 -- cannot_connect_now.""" + + +class ReadOnlySqlTransaction(QueryError): + """25006 -- read_only_sql_transaction.""" + + +class ObjectInUse(QueryError): + """55006 -- object_in_use.""" + + +# SQLSTATE -> exception subclass. Anything not listed raises a plain QueryError. +_SQLSTATE_EXCEPTIONS = { + "42601": SyntaxErrorState, + "42P01": UndefinedTable, + "42703": UndefinedColumn, + "42501": InsufficientPrivilege, + "23505": UniqueViolation, + "23503": ForeignKeyViolation, + "23502": NotNullViolation, + "23514": CheckViolation, + "40001": SerializationFailure, + "40P01": DeadlockDetected, + "57014": QueryCanceled, + "57P01": AdminShutdown, + "57P02": CrashShutdown, + "57P03": CannotConnectNow, + "25006": ReadOnlySqlTransaction, + "55006": ObjectInUse, +} + + +def query_error_for(message, sqlstate): + """Return a QueryError (or its SQLSTATE-specific subclass) for *sqlstate*. + + Used when a statement fails so callers can match on the specific condition + (e.g. ``pytest.raises(QueryCanceled)``) while still catching the base + QueryError/LibpqError when they want any failure. + """ + cls = _SQLSTATE_EXCEPTIONS.get(sqlstate or "", QueryError) + return cls(message, sqlstate=sqlstate) diff --git a/src/test/pytest/libpq/result.py b/src/test/pytest/libpq/result.py index 1bebbd0edce..e0d51a5a5c7 100644 --- a/src/test/pytest/libpq/result.py +++ b/src/test/pytest/libpq/result.py @@ -12,6 +12,9 @@ from .constants import ExecStatusType +# PG_DIAG_SQLSTATE field id from postgres_ext.h, for PQresultErrorField(). +_PG_DIAG_SQLSTATE = ord("C") + def _decode(raw): """Decode a libpq C string (bytes or None) to str/None.""" @@ -26,6 +29,7 @@ class ResultData: status: int error_message: Optional[str] = None + sqlstate: Optional[str] = None names: List[str] = field(default_factory=list) types: List[int] = field(default_factory=list) rows: List[List[Optional[str]]] = field(default_factory=list) @@ -46,6 +50,7 @@ def extract_result_data(lib, result, conn): res.error_message = _decode(lib.PQresultErrorMessage(result)) or _decode( lib.PQerrorMessage(conn) ) + res.sqlstate = _decode(lib.PQresultErrorField(result, _PG_DIAG_SQLSTATE)) return res if status == ExecStatusType.PGRES_COMMAND_OK: return res diff --git a/src/test/pytest/libpq/session.py b/src/test/pytest/libpq/session.py index 9111c5d2a96..f9fde0d1636 100644 --- a/src/test/pytest/libpq/session.py +++ b/src/test/pytest/libpq/session.py @@ -31,7 +31,7 @@ PQTRANS_INERROR, ) from .errors import PqConnectionError -from .errors import QueryError +from .errors import QueryError, query_error_for from .pgnotify import read_notification from .result import extract_result_data @@ -366,7 +366,10 @@ def query_safe(self, sql): res = self.query(sql) if res.error_message is not None: short = re.sub(r"\s+", " ", sql[:100]) - raise QueryError(f"query_safe failed on [{short}...]: {res.error_message}") + raise query_error_for( + f"query_safe failed on [{short}...]: {res.error_message}", + res.sqlstate, + ) return res.psqlout def query_oneval(self, sql, missing_ok=False):